Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter, Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned above the collapse (ratified mid-build), lock > breakage > one-shot write failures > commit+attachment, signposts last — with all user-facing phrasing owned here via exhaustive switches over the closed WriteOperation enum; free-form English survives only in diagnostics. performWrite posts its failures before rethrowing, so no one-shot can bypass the strip; refusals under lock post nothing. The lock vocabulary completes: vanishedRoot and unwritableLocation join bracketedReloadFailed, each with its own clearing rule (unwritable clears only on a reconciling reload's writability re-probe). The registry now owns root recovery: bookmark re-resolution absorbs renames transparently, a dead root locks read-only and re-arms FSEvents on the gone path so the root's return round-trips back through rootChanged, re-minting and re-keying on the way. DirtyBufferGuard is the one modal moment, retry / save a copy / discard, no fourth button. 36 new tests; full suite 333 tests in 62 suites green. Five findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -78,12 +78,28 @@ struct FileIdentity: Hashable {
|
||||
@MainActor
|
||||
public final class BoardStoreRegistry {
|
||||
|
||||
/// One open board: the shared store, the watcher feeding it, and how many windows are holding
|
||||
/// them open.
|
||||
/// One open board: the shared store, the watcher feeding it, how many windows are holding them
|
||||
/// open — and the two facts the root-recovery loop needs.
|
||||
private struct Entry {
|
||||
let store: BoardStore
|
||||
let watcher: FolderWatcher
|
||||
var referenceCount: Int
|
||||
|
||||
/// The board's **identity**, minted at acquire (02-architecture.md § Write-failure
|
||||
/// surfacing: "the registry's security-scoped bookmark is the identity, mid-session as much
|
||||
/// as across opens"). Re-minted on every absorbed rename, so it always names the folder
|
||||
/// where the board is now rather than where it was opened.
|
||||
///
|
||||
/// Optional because bookmark creation can fail outright — vanishingly unlikely for a folder
|
||||
/// just walked, and survivable: a board with no bookmark simply falls through to the
|
||||
/// path-reappearance half of the recovery loop, which is the same path a *dead* bookmark
|
||||
/// takes.
|
||||
var bookmark: Data?
|
||||
|
||||
/// Where the board was last known to live. The vanished-root case re-attaches the watcher
|
||||
/// here — FSEvents is content to watch a path that does not exist and reports its
|
||||
/// *creation* as a root change, which is exactly the return detection the lock needs.
|
||||
var lastKnownRoot: URL
|
||||
}
|
||||
|
||||
/// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry
|
||||
@@ -143,19 +159,32 @@ public final class BoardStoreRegistry {
|
||||
// become no-ops the moment `release` drops the watcher. Neither closure can form a cycle
|
||||
// with the object it calls into.
|
||||
let watcher = FolderWatcher(root: rootURL) { [weak store] event in
|
||||
// `.rootChanged` rides through like any other event. The store stubs it today; the
|
||||
// settled response — re-resolve the board's security-scoped bookmark and either
|
||||
// `reattach(to:)` at the new location or enter the vanished-root read-only lock
|
||||
// (02-architecture.md § Write-failure surfacing) — needs `BoardRegistry`'s bookmark and
|
||||
// belongs to the card that brings the two together. This registry is the natural place
|
||||
// for that seam, since it is the only object holding the store, the watcher, and (by
|
||||
// then) the record at once.
|
||||
// `.rootChanged` rides through like any other event; the store hands it straight back
|
||||
// here through `rootChangeDelegate`, below.
|
||||
store?.handleWatcherEvent(event)
|
||||
}
|
||||
store.watcherBrackets = (
|
||||
begin: { [weak watcher] in watcher?.beginBracket() },
|
||||
end: { [weak watcher] in watcher?.endBracket() }
|
||||
)
|
||||
// The recovery loop's entry point. It captures the **store**, not the entry's key: an
|
||||
// absorbed rename can re-key the entry (a delete-and-recreate at the same path changes file
|
||||
// identity), and a closure holding a stale key would quietly stop finding its own board on
|
||||
// the second root change. Object identity cannot go stale.
|
||||
store.rootChangeDelegate = { [weak self, weak store] in
|
||||
guard let self, let store else { return }
|
||||
recoverFromRootChange(of: store)
|
||||
}
|
||||
|
||||
// The bookmark is minted here — at acquire, once — because that is the moment the app
|
||||
// demonstrably has access to this folder (it just walked it). A bookmark minted later,
|
||||
// after the root has already gone missing, would be exactly the one that cannot be made.
|
||||
let bookmark = BoardRegistry.makeBookmark(for: rootURL)?.data
|
||||
if bookmark == nil {
|
||||
// Not a failure to open: the board loads, renders, and writes. Only the *rename*
|
||||
// half of root recovery is lost, and the path-reappearance half still works.
|
||||
Self.logger.error("no bookmark could be minted for this board; a mid-session move will not be absorbed")
|
||||
}
|
||||
|
||||
// Default debounce and latency: the numbers are settled in 02-architecture.md § Components
|
||||
// (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults.
|
||||
@@ -168,7 +197,13 @@ public final class BoardStoreRegistry {
|
||||
Self.logger.error("watcher stream failed to start; live reload is degraded for this board")
|
||||
}
|
||||
|
||||
entries[identity] = Entry(store: store, watcher: watcher, referenceCount: 1)
|
||||
entries[identity] = Entry(
|
||||
store: store,
|
||||
watcher: watcher,
|
||||
referenceCount: 1,
|
||||
bookmark: bookmark,
|
||||
lastKnownRoot: rootURL
|
||||
)
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -199,11 +234,117 @@ public final class BoardStoreRegistry {
|
||||
entry.watcher.stop()
|
||||
// Explicit rather than left to the weak captures: after this the store may still be alive in
|
||||
// whatever is closing, and brackets that quietly did nothing would be a lie about a watcher
|
||||
// that is gone.
|
||||
// that is gone. The root-change delegate goes for the same reason — a released store has no
|
||||
// entry to recover, and leaving the closure attached would invite it to look for one.
|
||||
entry.store.watcherBrackets = nil
|
||||
entry.store.rootChangeDelegate = nil
|
||||
entries[identity] = nil
|
||||
}
|
||||
|
||||
// MARK: - Root recovery
|
||||
|
||||
/// The whole response to a root-gone signal, in the one object that can give it: the store to
|
||||
/// relocate or lock, the watcher to re-attach, and the bookmark that decides which
|
||||
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
|
||||
/// file identity" and "A vanished board root locks the board read-only").
|
||||
///
|
||||
/// ### Three outcomes, tried in order
|
||||
///
|
||||
/// 1. **The bookmark resolves to a folder that is really there** — a rename or a move on the
|
||||
/// same volume. Absorbed transparently: relocate, re-attach, re-mint. **No banner, no lock,
|
||||
/// nothing was ever wrong.**
|
||||
/// 2. **The bookmark is dead, but the last-known path exists again** — a Finder undo, a
|
||||
/// remount, a folder recreated where the board used to be, arriving as the *creation* of a
|
||||
/// watched path. Treated as the root returning: the bookmark is re-minted from the path and
|
||||
/// the same absorption runs, and the reconciling reload's success is what clears the
|
||||
/// vanished-root lock through the store's own rule.
|
||||
/// 3. **Neither** — the root is truly vanished. The lock goes up and the watcher re-attaches at
|
||||
/// the last-known path anyway: FSEvents happily watches a path that does not exist and
|
||||
/// reports its creation as another `.rootChanged` (pinned in `FolderWatcherTests`), which is
|
||||
/// what arms outcome 2 for later. The re-attach's reconciling reload fails against the
|
||||
/// missing root, which is correct and harmless — a failed reload never replaces a good
|
||||
/// snapshot, and the lock is already standing.
|
||||
///
|
||||
/// ### Why outcome 1 checks the filesystem
|
||||
///
|
||||
/// Bookmark resolution is not a liveness test. `BoardRegistryTests` says as much where it
|
||||
/// refuses to build a dead-bookmark case by deleting a folder ("'ought to' is the filesystem's
|
||||
/// opinion"): a bookmark can resolve by its recorded *path* to something that is no longer
|
||||
/// there. Absorbing that would relocate the board onto a ghost and leave it with a reload
|
||||
/// failure instead of the honest vanished-root lock, so a resolution that does not exist on
|
||||
/// disk falls through to outcomes 2 and 3.
|
||||
///
|
||||
/// ### Re-entrancy
|
||||
///
|
||||
/// Every outcome ends in a fresh stream, and every future root change lands right back here.
|
||||
/// That is the design, not an accident: outcome 3 arms outcome 2, and a board that is renamed
|
||||
/// twice is absorbed twice. Nothing here holds state between calls beyond the entry itself, and
|
||||
/// the entry is looked up by store identity on each pass, so a re-key mid-loop cannot strand a
|
||||
/// later signal.
|
||||
private func recoverFromRootChange(of store: BoardStore) {
|
||||
guard let identity = entries.first(where: { $0.value.store === store })?.key,
|
||||
let entry = entries[identity] else {
|
||||
Self.logger.debug("root change for a store that is no longer registered — ignored")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. The rename-absorption path.
|
||||
if let bookmark = entry.bookmark,
|
||||
let resolution = BoardRegistry.resolve(bookmark),
|
||||
BoardRegistry.withScopedAccess(to: resolution.url, { FileManager.default.fileExists(atPath: $0.path) }) {
|
||||
Self.logger.debug("root change resolved through the bookmark — absorbing the move")
|
||||
absorb(newRoot: resolution.url, at: identity)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. The root returning at its last-known path — Finder undo, remount, recreation.
|
||||
if FileManager.default.fileExists(atPath: entry.lastKnownRoot.path) {
|
||||
Self.logger.debug("root reappeared at its last-known path — absorbing the return")
|
||||
absorb(newRoot: entry.lastKnownRoot, at: identity)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Truly vanished.
|
||||
store.enterVanishedRootLock()
|
||||
entry.watcher.reattach(to: entry.lastKnownRoot)
|
||||
}
|
||||
|
||||
/// Moves an entry's board to `newRoot`: the store's URLs, the watcher's stream, the entry's
|
||||
/// bookmark and last-known path, and — where it changed — the entry's key.
|
||||
///
|
||||
/// **Order matters.** `relocate(to:)` first, so the reconciling reload that `reattach(to:)`
|
||||
/// schedules walks the new root and lands a snapshot that agrees with the store's `rootURL`.
|
||||
/// Re-attaching first would leave a window in which the reload's tree and the Writer's URLs
|
||||
/// disagreed about where the board is.
|
||||
///
|
||||
/// **Re-keying.** A rename keeps the folder's file identity, so the key is usually unchanged
|
||||
/// and this is a no-op. A board deleted and recreated at the same path is a *different inode*
|
||||
/// under the same name, and its entry has to move to the new identity or the next
|
||||
/// `acquire`/`liveStore(for:)` would miss it and open a second store over the board already on
|
||||
/// screen — the exact bug the identity keying exists to prevent. An identity that cannot be
|
||||
/// read leaves the key alone: a stale key still finds the entry by store identity, where
|
||||
/// dropping the entry would strand a live watcher.
|
||||
private func absorb(newRoot: URL, at identity: FileIdentity) {
|
||||
guard var entry = entries[identity] else { return }
|
||||
|
||||
entry.store.relocate(to: newRoot)
|
||||
entry.watcher.reattach(to: newRoot)
|
||||
entry.lastKnownRoot = newRoot
|
||||
// Re-minted from where the board is now: a bookmark tracks moves, but a *stale* one is only
|
||||
// good for a while, and this is the moment the app has both access and certainty.
|
||||
if let refreshed = BoardRegistry.withScopedAccess(to: newRoot, { BoardRegistry.makeBookmark(for: $0) }) {
|
||||
entry.bookmark = refreshed.data
|
||||
}
|
||||
|
||||
guard let currentIdentity = FileIdentity(of: newRoot), currentIdentity != identity else {
|
||||
entries[identity] = entry
|
||||
return
|
||||
}
|
||||
entries[identity] = nil
|
||||
entries[currentIdentity] = entry
|
||||
Self.logger.debug("board re-keyed: the folder at this path is a different file than it was")
|
||||
}
|
||||
|
||||
/// The live store for a board, if any window has it open — **without** taking a reference.
|
||||
///
|
||||
/// This is the "is this board already open?" question: focusing an existing window rather than
|
||||
|
||||
Reference in New Issue
Block a user