From 6484fde53017cba82881b2f3b39f45840accdbda Mon Sep 17 00:00:00 2001 From: kua-agent Date: Sat, 5 Sep 2026 09:27:58 +0400 Subject: [PATCH] fix: refuse transport changes mid-send; generation-guard rapid toggling; one-line "not found" row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAJOR: chooseTransport/chooseOneDriveFolder now refuse (status "Finish the current send first.") while isSending is true, closing off the send-vs- transport-switch race at the UI entry point (SendController.swift's commit in this same series is the structural fix underneath). MINOR: both functions spawned unstructured `Task { }` calls to watcher.setRecordUncommented/updateWatchFolder; rapid toggling could apply an earlier, superseded call's folder/flag after a later one had already won. Fixed with a monotonically increasing `reconcileGeneration` counter (AppModel.swift) bumped synchronously before each Task starts; the Task checks its own snapshot against the live value before every mutating step (not just once via Task.isCancelled), so the LAST choice always wins. Verified via ONEDRIVE-SELFTEST's new rapid-toggle sub-step (this branch's PickerSelfTest.swift commit), which proves the watcher ends up watching the folder from the last chooseTransport call. Design fix (Ben, panel-08 review): the "No OneDrive folder found — sign in to OneDrive or choose a folder." value text wrapped over four lines, making that row tall and ragged next to Choose…. The value column now reads exactly "Not found" (secondary colour, one line, same as the truncated-path style), and the explanation moves to the caption below: "No OneDrive folder found. Sign in to OneDrive, or choose a folder." When a folder IS resolved the caption is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp --- Sources/Shotdeck/SettingsView.swift | 46 +++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/Sources/Shotdeck/SettingsView.swift b/Sources/Shotdeck/SettingsView.swift index 5291120..9c8ba42 100644 --- a/Sources/Shotdeck/SettingsView.swift +++ b/Sources/Shotdeck/SettingsView.swift @@ -82,21 +82,20 @@ struct SettingsView: View { model.chooseOneDriveFolder() } } else { - HStack(spacing: 8) { - Text("No OneDrive folder found — sign in to OneDrive or choose a folder.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - Button("Choose…") { model.chooseOneDriveFolder() } + // One-line row, same shape as the normal path row: "Not found" + // where the path would be, Choose… stays live. The explanation + // moves to the caption below instead of wrapping this row. + folderValue(path: "Not found") { + model.chooseOneDriveFolder() } - .frame(minHeight: 22) } } GridRow { Text( - "The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive." + model.resolvedOneDriveFolder != nil + ? "The PDF is saved here and this same folder is watched for the marked-up copy. On the iPad open it from Files > OneDrive." + : "No OneDrive folder found. Sign in to OneDrive, or choose a folder." ) .font(.caption) .foregroundStyle(.secondary) @@ -249,7 +248,18 @@ extension AppModel: SettingsWindowPresenting { /// doesn't exist yet, and re-points the running watcher (folder + recordUncommented) /// at the new state. Switching back to AirDrop restores its own stored overrides /// untouched, since AirDrop and OneDrive folder settings are stored under separate keys. + /// Refuses while a send is in flight (send() snapshots its own folder/transport, but + /// switching mid-send is still confusing UX — nothing to gain by allowing it). + /// The async reconcile below is generation-guarded: `reconcileGeneration` is bumped + /// synchronously before the Task starts, and the Task checks its own snapshot against + /// the live value before every mutating step, so rapid toggling (this function or + /// chooseOneDriveFolder, in any order) always lets the LAST choice win instead of an + /// earlier, superseded call applying its stale folder/flag after a later one already won. func chooseTransport(_ value: SendTransport) { + guard !isSending else { + setStatus("Finish the current send first.") + return + } guard value != transport else { return } TransportSettings.setTransport(value) setTransport(value) @@ -261,11 +271,17 @@ extension AppModel: SettingsWindowPresenting { } setFolderURLs(outbox: folders.outbox, watch: folders.watch) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) + + reconcileGeneration += 1 + let generation = reconcileGeneration Task { + guard generation == self.reconcileGeneration else { return } await watcher.setRecordUncommented(value == .airDrop) + guard generation == self.reconcileGeneration else { return } do { try await watcher.updateWatchFolder(folders.watch) } catch { + guard generation == self.reconcileGeneration else { return } setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." ) @@ -273,7 +289,13 @@ extension AppModel: SettingsWindowPresenting { } } + /// Refuses while a send is in flight, same reasoning as chooseTransport. See + /// chooseTransport's doc comment for the generation-guard mechanism shared here. func chooseOneDriveFolder() { + guard !isSending else { + setStatus("Finish the current send first.") + return + } let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser guard let url = chooseDirectory(startingAt: start) else { return } TransportSettings.setOneDriveFolder(url) @@ -281,11 +303,17 @@ extension AppModel: SettingsWindowPresenting { setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) guard transport == .oneDrive else { return } setFolderURLs(outbox: url, watch: url) + + reconcileGeneration += 1 + let generation = reconcileGeneration Task { + guard generation == self.reconcileGeneration else { return } do { try await watcher.updateWatchFolder(url) + guard generation == self.reconcileGeneration else { return } setStatus("OneDrive folder set to \(url.lastPathComponent).") } catch { + guard generation == self.reconcileGeneration else { return } setStatus( (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." )