fix: refuse transport changes mid-send; generation-guard rapid toggling; one-line "not found" row

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
2026-09-05 09:27:58 +04:00
co-authored by Claude Fable 5.1
parent bd11ba96c5
commit 6484fde530
+37 -9
View File
@@ -82,21 +82,20 @@ struct SettingsView: View {
model.chooseOneDriveFolder() model.chooseOneDriveFolder()
} }
} else { } else {
HStack(spacing: 8) { // One-line row, same shape as the normal path row: "Not found"
Text("No OneDrive folder found — sign in to OneDrive or choose a folder.") // where the path would be, Choose stays live. The explanation
.font(.caption) // moves to the caption below instead of wrapping this row.
.foregroundStyle(.secondary) folderValue(path: "Not found") {
.fixedSize(horizontal: false, vertical: true) model.chooseOneDriveFolder()
.frame(maxWidth: .infinity, alignment: .leading)
Button("Choose…") { model.chooseOneDriveFolder() }
} }
.frame(minHeight: 22)
} }
} }
GridRow { GridRow {
Text( 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) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -249,7 +248,18 @@ extension AppModel: SettingsWindowPresenting {
/// doesn't exist yet, and re-points the running watcher (folder + recordUncommented) /// 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 /// 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. /// 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) { func chooseTransport(_ value: SendTransport) {
guard !isSending else {
setStatus("Finish the current send first.")
return
}
guard value != transport else { return } guard value != transport else { return }
TransportSettings.setTransport(value) TransportSettings.setTransport(value)
setTransport(value) setTransport(value)
@@ -261,11 +271,17 @@ extension AppModel: SettingsWindowPresenting {
} }
setFolderURLs(outbox: folders.outbox, watch: folders.watch) setFolderURLs(outbox: folders.outbox, watch: folders.watch)
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
reconcileGeneration += 1
let generation = reconcileGeneration
Task { Task {
guard generation == self.reconcileGeneration else { return }
await watcher.setRecordUncommented(value == .airDrop) await watcher.setRecordUncommented(value == .airDrop)
guard generation == self.reconcileGeneration else { return }
do { do {
try await watcher.updateWatchFolder(folders.watch) try await watcher.updateWatchFolder(folders.watch)
} catch { } catch {
guard generation == self.reconcileGeneration else { return }
setStatus( setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." (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() { func chooseOneDriveFolder() {
guard !isSending else {
setStatus("Finish the current send first.")
return
}
let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser let start = resolvedOneDriveFolder ?? FileManager.default.homeDirectoryForCurrentUser
guard let url = chooseDirectory(startingAt: start) else { return } guard let url = chooseDirectory(startingAt: start) else { return }
TransportSettings.setOneDriveFolder(url) TransportSettings.setOneDriveFolder(url)
@@ -281,11 +303,17 @@ extension AppModel: SettingsWindowPresenting {
setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder()) setResolvedOneDriveFolder(OneDriveLocator.resolveOneDriveFolder())
guard transport == .oneDrive else { return } guard transport == .oneDrive else { return }
setFolderURLs(outbox: url, watch: url) setFolderURLs(outbox: url, watch: url)
reconcileGeneration += 1
let generation = reconcileGeneration
Task { Task {
guard generation == self.reconcileGeneration else { return }
do { do {
try await watcher.updateWatchFolder(url) try await watcher.updateWatchFolder(url)
guard generation == self.reconcileGeneration else { return }
setStatus("OneDrive folder set to \(url.lastPathComponent).") setStatus("OneDrive folder set to \(url.lastPathComponent).")
} catch { } catch {
guard generation == self.reconcileGeneration else { return }
setStatus( setStatus(
(error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder." (error as? ShotdeckError)?.errorDescription ?? "Could not switch the watch folder."
) )