Skip to content

Commit afad2ef

Browse files
committed
Autosave entry changes in unlocked database view
1 parent 0ac8284 commit afad2ef

7 files changed

Lines changed: 94 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Added: Save new credentials and generate strong passwords directly from AutoFill, with offline-safe queueing for Dropbox-backed databases.
1414

1515
### Fixes
16+
- Autosave entry create/edit/delete changes immediately after they are staged, remove the confusing unlocked-screen save button, and show retry-only save UI when an autosave fails
1617
- Remove the baked white background from the Dropbox provider glyph so it renders cleanly in dark mode
1718
- Show provider-specific cloud sync status during unlock, and add focused unlock coverage for cloud sync success, fallback, and failure paths
1819
- Fix Xcode Cloud post-clone bootstrap so clean CI machines no longer require a developer team xcconfig value just to generate the project

KeeForge/App/KeeForgeApp.swift

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,6 @@ struct DatabaseNavigationView: View {
271271
.navigationDestination(for: KPEntry.self) { entry in
272272
EntryDetailView(entryID: entry.id, viewModel: viewModel)
273273
}
274-
.toolbar {
275-
ToolbarItem(placement: .topBarTrailing) {
276-
DatabaseSaveToolbarButton(viewModel: viewModel)
277-
}
278-
}
279274
.safeAreaInset(edge: .top, spacing: 0) {
280275
VStack(spacing: 8) {
281276
if let bannerText = viewModel.cloudSyncBannerText {
@@ -294,8 +289,8 @@ struct DatabaseNavigationView: View {
294289
)
295290
}
296291

297-
if viewModel.isDirty {
298-
UnsavedChangesBanner()
292+
if viewModel.isDirty && viewModel.isSaving == false {
293+
UnsavedChangesBanner(viewModel: viewModel)
299294
}
300295

301296
if viewModel.isReadOnly {
@@ -373,28 +368,6 @@ struct DatabaseNavigationView: View {
373368
}
374369
}
375370

376-
private struct DatabaseSaveToolbarButton: View {
377-
@Bindable var viewModel: DatabaseViewModel
378-
379-
var body: some View {
380-
Button {
381-
Task {
382-
await viewModel.saveHandlingError()
383-
}
384-
} label: {
385-
if viewModel.isSaving {
386-
ProgressView()
387-
.controlSize(.small)
388-
.frame(width: 24, height: 24)
389-
} else {
390-
Image(systemName: "square.and.arrow.down")
391-
}
392-
}
393-
.disabled(viewModel.isDirty == false || viewModel.isSaving)
394-
.accessibilityIdentifier("database.save")
395-
}
396-
}
397-
398371
private struct ReadOnlyRibbon: View {
399372
var body: some View {
400373
Text("Read-only mode — toggle in the database list to enable editing.")
@@ -408,13 +381,25 @@ private struct ReadOnlyRibbon: View {
408381
}
409382

410383
private struct UnsavedChangesBanner: View {
384+
@Bindable var viewModel: DatabaseViewModel
385+
411386
var body: some View {
412387
HStack(spacing: 8) {
413388
Circle()
414389
.fill(Color.orange)
415390
.frame(width: 8, height: 8)
416-
Text("Unsaved changes")
391+
Text("Changes not saved")
417392
.font(.caption.weight(.medium))
393+
394+
Spacer(minLength: 12)
395+
396+
Button("Retry Save") {
397+
Task {
398+
await viewModel.saveHandlingError()
399+
}
400+
}
401+
.font(.caption.weight(.semibold))
402+
.disabled(viewModel.isSaving)
418403
}
419404
.frame(maxWidth: .infinity, alignment: .leading)
420405
.padding(.horizontal, 16)

KeeForge/Views/EntryEditView.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ struct EntryEditView: View {
1414
@State private var showDeleteConfirmation = false
1515
@State private var showPasswordGenerator = false
1616
@State private var editingErrorMessage: String?
17+
@State private var isSubmitting = false
1718

1819
init(
1920
formViewModel: EntryEditViewModel,
@@ -104,6 +105,7 @@ struct EntryEditView: View {
104105
Button("Delete Entry", role: .destructive) {
105106
showDeleteConfirmation = true
106107
}
108+
.disabled(isSubmitting)
107109
.accessibilityIdentifier("entry-edit.delete")
108110
}
109111
}
@@ -116,14 +118,15 @@ struct EntryEditView: View {
116118
Button("Cancel") {
117119
cancelTapped()
118120
}
121+
.disabled(isSubmitting)
119122
.accessibilityIdentifier("entry-edit.cancel")
120123
}
121124

122125
ToolbarItem(placement: .confirmationAction) {
123126
Button("Save") {
124127
saveTapped()
125128
}
126-
.disabled(formViewModel.canSave == false)
129+
.disabled(formViewModel.canSave == false || isSubmitting)
127130
.accessibilityIdentifier("entry-edit.save")
128131
}
129132
}
@@ -216,7 +219,12 @@ struct EntryEditView: View {
216219
)
217220
}
218221

219-
onComplete(.finished)
222+
isSubmitting = true
223+
Task { @MainActor in
224+
await databaseViewModel.saveHandlingError()
225+
isSubmitting = false
226+
onComplete(.finished)
227+
}
220228
} catch {
221229
editingErrorMessage = error.localizedDescription
222230
}
@@ -227,7 +235,12 @@ struct EntryEditView: View {
227235

228236
do {
229237
try databaseViewModel.deleteEntry(entryID, sendToRecycleBin: sendToRecycleBin)
230-
onComplete(.deleted)
238+
isSubmitting = true
239+
Task { @MainActor in
240+
await databaseViewModel.saveHandlingError()
241+
isSubmitting = false
242+
onComplete(.deleted)
243+
}
231244
} catch {
232245
editingErrorMessage = error.localizedDescription
233246
}

KeeForge/Views/EntryListView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@ struct EntryListView: View {
5353
? "The entry will be moved to the recycle bin."
5454
: "This entry will be removed immediately and cannot be restored from KeeForge."),
5555
primaryButton: .destructive(Text(action.sendToRecycleBin ? "Delete" : "Delete Permanently")) {
56-
try? viewModel.deleteEntry(action.entryID, sendToRecycleBin: action.sendToRecycleBin)
56+
do {
57+
try viewModel.deleteEntry(action.entryID, sendToRecycleBin: action.sendToRecycleBin)
58+
Task {
59+
await viewModel.saveHandlingError()
60+
}
61+
} catch {
62+
viewModel.presentSaveError(error)
63+
}
5764
},
5865
secondaryButton: .cancel()
5966
)

KeeForge/Views/GroupListView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,14 @@ struct GroupListView: View {
145145
? "The entry will be moved to the recycle bin."
146146
: "This entry will be removed immediately and cannot be restored from KeeForge."),
147147
primaryButton: .destructive(Text(action.sendToRecycleBin ? "Delete" : "Delete Permanently")) {
148-
try? viewModel.deleteEntry(action.entryID, sendToRecycleBin: action.sendToRecycleBin)
148+
do {
149+
try viewModel.deleteEntry(action.entryID, sendToRecycleBin: action.sendToRecycleBin)
150+
Task {
151+
await viewModel.saveHandlingError()
152+
}
153+
} catch {
154+
viewModel.presentSaveError(error)
155+
}
149156
},
150157
secondaryButton: .cancel()
151158
)

KeeForgeUITests/EntryEditUITests.swift

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ final class EntryEditUITests: KeeForgeUITestCase {
1313
if name.contains("testSaveConflictOffersReloadAndConflictCopy") {
1414
app.launchEnvironment["UI_TEST_LOCAL_SAVE_CONFLICT_COUNT"] = "2"
1515
}
16+
if name.contains("testLockWhileDirtyPromptsConfirmationThenLocks") {
17+
app.launchEnvironment["UI_TEST_LOCAL_SAVE_CONFLICT_COUNT"] = "1"
18+
}
1619
}
1720

1821
func testCreateEntrySavesAndShowsInList() {
@@ -23,7 +26,6 @@ final class EntryEditUITests: KeeForgeUITestCase {
2326
username: "ui-created-user",
2427
password: "created-password-123"
2528
)
26-
persistDatabaseChanges()
2729
lockAndReopenVault()
2830

2931
openEntry(named: createdEntryTitle)
@@ -44,9 +46,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
4446
XCTAssertTrue(titleField.waitForExistence(timeout: 5))
4547
replaceText(in: titleField, with: editedDiscordTitle)
4648
app.buttons["entry-edit.save"].tap()
47-
48-
XCTAssertTrue(waitForUnsavedIndicator(isPresent: true))
49-
persistDatabaseChanges()
49+
XCTAssertTrue(waitForElementToDisappear(app.buttons["entry-edit.save"], timeout: 10))
5050
tapBackButton()
5151
tapBackButton()
5252
lockAndReopenVault()
@@ -68,12 +68,10 @@ final class EntryEditUITests: KeeForgeUITestCase {
6868
XCTAssertTrue(deleteButton.waitForExistence(timeout: 5))
6969
deleteButton.tap()
7070
app.alerts.buttons["Delete"].tap()
71-
72-
XCTAssertTrue(waitForUnsavedIndicator(isPresent: true))
73-
persistDatabaseChanges()
71+
waitForAutosaveAttempt()
7472

7573
XCTAssertFalse(entry(named: "Twitter").exists)
76-
tapBackButton()
74+
lockAndReopenVault()
7775

7876
openGroup(named: "Recycle Bin")
7977
XCTAssertTrue(revealElement(entry(named: "Twitter")), "Twitter entry was not moved into the recycle bin")
@@ -127,8 +125,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
127125
useButton.tap()
128126

129127
app.buttons["entry-edit.save"].tap()
130-
XCTAssertTrue(waitForUnsavedIndicator(isPresent: true))
131-
persistDatabaseChanges()
128+
XCTAssertTrue(waitForElementToDisappear(app.buttons["entry-edit.save"], timeout: 10))
132129
lockAndReopenVault()
133130

134131
openEntry(named: generatedPasswordEntryTitle)
@@ -160,7 +157,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
160157
openGroup(named: "Social")
161158
openEntry(named: "Discord")
162159
editCurrentEntryTitle(to: firstConflictDiscordTitle)
163-
triggerDatabaseSaveConflict()
160+
XCTAssertTrue(waitForSaveConflictAlert())
164161

165162
let reloadButton = app.buttons["save-conflict.reload"]
166163
let saveAsCopyButton = app.buttons["save-conflict.save-as-copy"]
@@ -176,7 +173,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
176173
openGroup(named: "Social")
177174
openEntry(named: "Discord")
178175
editCurrentEntryTitle(to: secondConflictDiscordTitle)
179-
triggerDatabaseSaveConflict()
176+
XCTAssertTrue(waitForSaveConflictAlert())
180177

181178
XCTAssertTrue(saveAsCopyButton.waitForExistence(timeout: 5))
182179
saveAsCopyButton.tap()
@@ -253,6 +250,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
253250
let saveButton = app.buttons["entry-edit.save"]
254251
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Entry editor save button was not visible", file: file, line: line)
255252
saveButton.tap()
253+
XCTAssertTrue(waitForElementToDisappear(saveButton, timeout: 10), "Entry editor did not dismiss after autosave", file: file, line: line)
256254
}
257255

258256
private func editCurrentEntryTitle(to title: String, file: StaticString = #filePath, line: UInt = #line) {
@@ -267,24 +265,7 @@ final class EntryEditUITests: KeeForgeUITestCase {
267265
let saveButton = app.buttons["entry-edit.save"]
268266
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Entry editor save button was not visible", file: file, line: line)
269267
saveButton.tap()
270-
XCTAssertTrue(waitForUnsavedIndicator(isPresent: true), "Unsaved indicator did not appear after editing", file: file, line: line)
271-
}
272-
273-
private func triggerDatabaseSaveConflict(file: StaticString = #filePath, line: UInt = #line) {
274-
let saveButton = app.buttons["database.save"]
275-
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Database save button was not visible", file: file, line: line)
276-
saveButton.tap()
277-
278-
let alert = app.alerts["Save Conflict"]
279-
XCTAssertTrue(alert.waitForExistence(timeout: 10), "Save conflict alert did not appear", file: file, line: line)
280-
}
281-
282-
private func persistDatabaseChanges(file: StaticString = #filePath, line: UInt = #line) {
283-
let saveButton = app.buttons["database.save"]
284-
XCTAssertTrue(saveButton.waitForExistence(timeout: 5), "Database save button was not visible", file: file, line: line)
285-
XCTAssertTrue(saveButton.isEnabled, "Database save button was disabled unexpectedly", file: file, line: line)
286-
saveButton.tap()
287-
XCTAssertFalse(waitForUnsavedIndicator(isPresent: true, timeout: 10), "Unsaved indicator did not clear after saving", file: file, line: line)
268+
XCTAssertTrue(waitForElementToDisappear(saveButton, timeout: 10), "Entry editor did not dismiss after autosave", file: file, line: line)
288269
}
289270

290271
private func lockAndReopenVault(file: StaticString = #filePath, line: UInt = #line) {
@@ -332,12 +313,35 @@ final class EntryEditUITests: KeeForgeUITestCase {
332313
return indicator.exists == isPresent
333314
}
334315

316+
private func waitForSaveConflictAlert(timeout: TimeInterval = 10) -> Bool {
317+
app.alerts["Save Conflict"].waitForExistence(timeout: timeout)
318+
}
319+
320+
private func waitForElementToDisappear(
321+
_ element: XCUIElement,
322+
timeout: TimeInterval = 5
323+
) -> Bool {
324+
let deadline = Date().addingTimeInterval(timeout)
325+
326+
repeat {
327+
if element.exists == false {
328+
return true
329+
}
330+
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
331+
} while Date() < deadline
332+
333+
return element.exists == false
334+
}
335+
336+
private func waitForAutosaveAttempt(timeout: TimeInterval = 2) {
337+
RunLoop.current.run(until: Date().addingTimeInterval(timeout))
338+
}
339+
335340
private func tapBackButton(file: StaticString = #filePath, line: UInt = #line) {
336341
let navigationBar = app.navigationBars.firstMatch
337342
XCTAssertTrue(navigationBar.waitForExistence(timeout: 5), "Navigation bar was not visible", file: file, line: line)
338343

339344
let excludedIdentifiers = Set([
340-
"database.save",
341345
"entry-list.add-entry",
342346
"lock.button",
343347
"sort.menu",

KeeForgeUITests/KeeForgeUITestCase.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,17 @@ class KeeForgeUITestCase: XCTestCase {
181181
file: file,
182182
line: line
183183
)
184-
databaseRow.tap()
184+
XCTAssertTrue(
185+
revealElement(databaseRow),
186+
"Database row was not hittable",
187+
file: file,
188+
line: line
189+
)
190+
if databaseRow.isHittable {
191+
databaseRow.tap()
192+
} else {
193+
databaseRow.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
194+
}
185195

186196
XCTAssertTrue(
187197
passwordField.waitForExistence(timeout: timeout),

0 commit comments

Comments
 (0)