diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift new file mode 100644 index 0000000..6f63725 --- /dev/null +++ b/Kanban/LiveStore/BannerCenter.swift @@ -0,0 +1,530 @@ +import Foundation +import Observation + +// MARK: - Tone + +/// The three kinds a banner row can be (02-architecture.md § The banner surface, "Tones, not +/// components"): one layout, one accessibility path, three colorings. The card window's +/// remote-change signpost (07-sync-collab.md) is this same component in `.info` — visually calm, +/// no error color — which is precisely why the distinction is a *tone* on a shared row rather +/// than three view types that would drift apart. +public enum BannerTone: Sendable, Equatable { + case error + case warning + case info +} + +// MARK: - Row payloads + +/// A one-shot write failure: it happened once, it is over, and it waits to be read +/// (02-architecture.md § The banner surface, "One-shots dismiss, conditions heal"). +/// +/// **No timeout, ever** — "an error never evaporates unread". The row leaves the strip only when +/// the user dismisses it, which is why it carries an id of its own rather than being identified by +/// its content: two identical failures a minute apart are two rows, and dismissing one must not +/// take the other with it. +public struct OneShotBanner: Identifiable, Sendable, Equatable { + public let id: UUID + public let error: BoardWriteError + /// When the failure happened — the sort key for "newest first within a class". + public let occurredAt: Date + + public init(id: UUID = UUID(), error: BoardWriteError, occurredAt: Date = Date()) { + self.id = id + self.error = error + self.occurredAt = occurredAt + } +} + +/// The standing condition "changes aren't being recorded to history" (02-architecture.md § +/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention"). +/// +/// `reason` is **diagnostic English, not the banner's verb** — the free-form tail the phrasing +/// rules allow ("disk full", "the repository is corrupt"). The user-facing sentence is +/// `BannerCenter.headline(for:)`'s, like every other row's. +/// +/// `since` exists because this is a condition, not an event: it started at some point and has been +/// true ever since. Nothing renders it today; it is what a later "suspended for 4 minutes" reading +/// would be built from, and recording it costs a `Date`. +public struct HistorySuspension: Sendable, Equatable { + public let reason: String + public let since: Date + + public init(reason: String, since: Date = Date()) { + self.reason = reason + self.since = since + } +} + +/// A calm notice that something happened elsewhere — the info tone's *passive* half. +/// +/// Its one producer-to-be is the card window's **remote-change signpost** (07-sync-collab.md): a +/// pull landed commits touching the very card being edited, the dirty buffer stays put and wins, +/// and the window says so without a modal or a merge UI. 02-architecture.md calls that "this same +/// component in the info tone", which is exactly what this is. +/// +/// **Ranks last and may collapse** (settled, § The banner surface): "calm by design, nothing gated +/// on seeing them instantly" — the opposite end of the strip from an in-progress row, whose spinner +/// may never hide. +/// +/// Dismissable, like a one-shot and unlike a condition: it reports something that already happened, +/// so there is nothing for it to heal into. +public struct InfoSignpost: Identifiable, Sendable, Equatable { + public let id: UUID + public let message: String + public let occurredAt: Date + + public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) { + self.id = id + self.message = message + self.occurredAt = occurredAt + } +} + +/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface, +/// "In-progress operations are info rows"): bracketed git operations ("Pulling…", "Switching to +/// 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment +/// imports). +/// +/// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed +/// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an +/// operation the banner layer has no enum for, and inventing one would mean a new git verb could +/// not describe itself without touching this file. The tradeoff is stated rather than hidden. +/// +/// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, +/// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing +/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a +/// repair job, not a cancel. +public struct InProgressOperation: Identifiable, Sendable { + public let id: UUID + public let label: String + public var isCancelable: Bool { cancel != nil } + /// `@MainActor @Sendable` rather than the plainer `@MainActor () -> Void` the sketch carried: + /// this value is stored in a `Sendable` struct, and a non-`Sendable` function type would make + /// that conformance a lie under strict concurrency. The isolation is unchanged — cancelling + /// runs on the main actor, where the operation's own state lives. + public let cancel: (@MainActor @Sendable () -> Void)? + + public init(id: UUID = UUID(), label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) { + self.id = id + self.label = label + self.cancel = cancel + } +} + +// MARK: - Rows + +/// One row in a window's banner strip. +/// +/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they +/// divide into three lifecycles that the view renders differently and that the ordering rule +/// treats as classes: +/// +/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe +/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin, +/// "a condition is never dismissed while it is still true". Each leaves when the thing it +/// describes stops being true. +/// - **One-shots dismiss**: `oneShot` and `signpost`. Each reports something that already happened, +/// so only the user can clear it. +/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps +/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`). +/// +/// **`Identifiable` by a synthetic string id**, not by content: the condition rows are singletons +/// per window (there is one lock, one breakage, one history suspension), so their ids are constant +/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost +/// animation, and no diffing surprise when a lock's cause changes underneath a standing row. +public enum BannerRow: Identifiable, Sendable { + /// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload + /// (built), the vanished root (this milestone), the open-time writability probe (m4). + case readOnlyLock(ReadOnlyLockReason) + /// A reload failed and the last good snapshot is still on screen. Condition, error tone. + case reloadBreakage(BoardLoadError) + /// A write that did not happen. Dismissable, error tone. + case oneShot(OneShotBanner) + /// History has stopped advancing. Condition, warning tone — the files are safe, only the undo + /// trail is degraded, which is a warning rather than an error. (m7's committer drives it.) + case historySuspended(HistorySuspension) + /// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse. + /// (m5's copy-shaped work and m7's git brackets drive it.) + case inProgress(InProgressOperation) + /// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window + /// drives it, as 07-sync-collab.md's remote-change signpost.) + case signpost(InfoSignpost) + + public var id: String { + switch self { + case .readOnlyLock: "read-only-lock" + case .reloadBreakage: "reload-breakage" + case let .oneShot(banner): "one-shot:\(banner.id.uuidString)" + case .historySuspended: "history-suspension" + case let .inProgress(operation): "operation:\(operation.id.uuidString)" + case let .signpost(signpost): "signpost:\(signpost.id.uuidString)" + } + } + + public var tone: BannerTone { + switch self { + case .readOnlyLock, .reloadBreakage, .oneShot: .error + case .historySuspended: .warning + case .inProgress, .signpost: .info + } + } + + /// Whether this row is pinned above the strip's collapse — true for in-progress rows and + /// nothing else (settled, 02-architecture.md § The banner surface: "a spinner may never hide + /// behind '+N more'"). The view reads this rather than re-deriving the case. + public var isPinned: Bool { + if case .inProgress = self { true } else { false } + } + + /// The user-facing line. Every word of it comes from `BannerCenter`'s phrasing statics except + /// an in-progress row's, whose label is its caller's (see `InProgressOperation`). + public var headline: String { + switch self { + case let .readOnlyLock(reason): BannerCenter.headline(for: reason) + case let .reloadBreakage(error): BannerCenter.headline(for: error) + case let .oneShot(banner): BannerCenter.headline(for: banner.error) + case let .historySuspended(suspension): BannerCenter.headline(for: suspension) + case let .inProgress(operation): operation.label + case let .signpost(signpost): signpost.message + } + } + + /// Only the rows reporting something that already happened carry a dismiss control — one-shot + /// failures and signposts. Conditions stand until they heal; in-progress rows complete, fail, or + /// are cancelled — neither is something a user can wave away. + public var dismissID: UUID? { + switch self { + case let .oneShot(banner): banner.id + case let .signpost(signpost): signpost.id + case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil + } + } +} + +// MARK: - BannerCenter + +/// The model behind one window's banner strip: the rows a window owns outright, plus the ordering +/// and phrasing rules the whole app renders through (02-architecture.md § The banner surface). +/// +/// ### What lives here and what does not +/// +/// A center holds the state nothing else does: dismissable one-shot write failures, the history +/// suspension, in-progress operations, and passive signposts. It deliberately does **not** hold the +/// read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them here +/// would create a second place for them to be stale. `BoardStore.bannerRows` composes both halves +/// through `rows(lock:breakage:oneShots:suspension:operations:signposts:)`, which is a *pure +/// function* precisely so the precedence rule can be tested without a store, a window, or a +/// filesystem. +/// +/// ### Phrasing lives here too +/// +/// "The banner owns all user-facing phrasing and localization from that vocabulary" (02 § +/// Write-failure surfacing). `BoardWriteError` carries a closed `WriteOperation` enum and a +/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no +/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time +/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history +/// suspension: their user-facing lines are here, not on the error types. +/// +/// ### One center per window, not per board +/// +/// The strip is "hosted by the window of origin": a card window's own save and attachment failures +/// belong to that window's center, and re-home to the board window's when it closes (m6's job). +/// `BoardStore` owns *the board window's* center, which is why it is a stored `let` there rather +/// than something injected — a board window's strip has exactly one lifetime, the store's. +@MainActor +@Observable +public final class BannerCenter { + + // MARK: State + + /// Newest first, which is the order the class renders in. Kept sorted on insertion rather than + /// at render time so that two failures sharing a timestamp — plausible inside one run loop + /// turn — still order deterministically. + public private(set) var oneShots: [OneShotBanner] = [] + + /// The standing "history isn't advancing" condition, or `nil` when commits are landing. + public private(set) var historySuspension: HistorySuspension? + + /// Work in flight, newest first for the same reason `oneShots` is. + public private(set) var operations: [InProgressOperation] = [] + + /// Passive notices, newest first. Empty until m6's card window starts posting the + /// remote-change signpost. + public private(set) var signposts: [InfoSignpost] = [] + + public init() {} + + // MARK: One-shots + + /// Records a write failure. **Every failed write lands here before it reaches its caller** — + /// `BoardStore.performWrite` posts before it rethrows — because the banner is how the one-way + /// flow stays honest: the action visibly did not happen, and the strip is the only place that + /// says why (02 § Write-failure surfacing). + public func post(_ error: BoardWriteError) { + oneShots.insert(OneShotBanner(error: error), at: 0) + } + + /// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first, + /// like the one-shots it shares a lifecycle with. + public func postSignpost(_ message: String) { + signposts.insert(InfoSignpost(message: message), at: 0) + } + + /// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an + /// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are + /// different promises and a row that offers one must never quietly do the other. + public func dismiss(_ id: UUID) { + oneShots.removeAll { $0.id == id } + signposts.removeAll { $0.id == id } + } + + /// Removes every dismissable row — one-shots and signposts alike. The strip's own "clear all" + /// affordance later; today it is what a window uses when it re-homes its rows elsewhere (m6). + public func dismissAllDismissableRows() { + oneShots.removeAll() + signposts.removeAll() + } + + // MARK: History suspension + + /// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's + /// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md + /// covers the lock itself, which is deliberately *not* a banner). + /// + /// A second call while already suspended keeps the original `since` and takes the newer + /// `reason`: the condition never stopped being true, so restarting its clock would misreport + /// how long history has been stalled, while the newest diagnosis is the useful one. + public func suspendHistory(reason: String) { + historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date()) + } + + /// Clears it, on the first successful commit. Idempotent — clearing a condition that is not + /// standing is not an error, it is the ordinary shape of "commit succeeded". + public func clearHistorySuspension() { + historySuspension = nil + } + + // MARK: In-progress operations + + /// Starts an info row with a spinner and hands back its id. + /// + /// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the + /// partial copy, nothing lost" (02, settled). Git brackets pass `nil`. + @discardableResult + public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID { + let operation = InProgressOperation(label: label, cancel: cancel) + operations.insert(operation, at: 0) + return operation.id + } + + /// Ends one — the row leaves the strip. + /// + /// **Completion and failure both come through here.** There is no `failOperation`: a failure is + /// `endOperation(id)` followed by `post(error)`, which is exactly 02's "failure swaps it for + /// the error row" expressed as two calls that already exist. Ending an unknown id is a no-op; + /// a close racing a completion is ordinary, not a bug. + public func endOperation(_ id: UUID) { + operations.removeAll { $0.id == id } + } + + // MARK: - Ordering + + /// The strip's rows, precedence-ordered — **the whole of the ordering rule, in one pure + /// function** (02 § The banner surface, "Concurrent conditions stack"). + /// + /// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage > + /// one-shot write failures > commit and attachment failures > **passive info rows**. Three + /// readings of it are worth stating because the code depends on them: + /// + /// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned + /// above everything: it is the only explanation the strip offers for a bracket's write lock + /// and for a close or quit deferring teardown, and a copy row's Cancel has to stay reachable + /// — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may + /// collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite + /// urgency. + /// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is + /// `.importAttachment`. It ranks in the commit-failure class rather than with its fellow + /// one-shots, so a fresh failed import sits below an older failed move. That is the design's + /// ordering read literally, and it is defensible: a failed import is the least destructive of + /// the failures (the drop was accepted, the partial copy was removed), so it yields its place + /// to failures that stopped the user's actual work. + /// - **Within the commit-and-attachment class the standing suspension leads**, then the + /// attachment one-shots newest first. A deliberate reading of "newest first within a class": + /// a condition and a one-shot are not comparable by recency in any way a user would read as + /// order — the condition's `since` is when it *started* being true, not when it happened — + /// so they are ordered by kind, and recency orders only the one-shots among themselves. + /// + /// `signposts` carries a default because its producer is m6's card window and nothing posts one + /// today; every other class has a live producer and is spelled out at every call site. + public nonisolated static func rows( + lock: ReadOnlyLockReason?, + breakage: BoardLoadError?, + oneShots: [OneShotBanner], + suspension: HistorySuspension?, + operations: [InProgressOperation], + signposts: [InfoSignpost] = [] + ) -> [BannerRow] { + var rows: [BannerRow] = [] + + rows.append(contentsOf: operations.map(BannerRow.inProgress)) + + if let lock { + rows.append(.readOnlyLock(lock)) + } + if let breakage { + rows.append(.reloadBreakage(breakage)) + } + + let ordered = newestFirst(oneShots) + rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot)) + + if let suspension { + rows.append(.historySuspended(suspension)) + } + rows.append(contentsOf: ordered.lazy.filter(\.isAttachmentImport).map(BannerRow.oneShot)) + + rows.append(contentsOf: signposts.map(BannerRow.signpost)) + return rows + } + + /// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run + /// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which + /// `post(_:)` already maintains newest-first — so a tie renders in the order it was posted + /// rather than in whatever order the sort happened to leave. + private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] { + banners + .enumerated() + .sorted { lhs, rhs in + lhs.element.occurredAt == rhs.element.occurredAt + ? lhs.offset < rhs.offset + : lhs.element.occurredAt > rhs.element.occurredAt + } + .map(\.element) + } + + // MARK: - Phrasing + + /// The user-facing line for a failed write: what the app could not do, then why. + /// + /// "Couldn't move 'Fix login' — disk full" is the design's own example and the shape every + /// case takes: an action clause the banner owns, an em dash, and the diagnostic cause from the + /// error's `reason` — the one place free-form English is allowed to survive. + public nonisolated static func headline(for error: BoardWriteError) -> String { + let action = actionPhrase(for: error.operation) + let cause = causePhrase(for: error.reason) + return cause.isEmpty ? action : "\(action) — \(cause)" + } + + /// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a + /// sentence here fails to compile, which is the settled contract ("a new Writer operation + /// without a banner rendering is a compile-time hole, not a silent default"). + /// + /// Titles are quoted where the operation carries one and the phrasing stays graceful where it + /// does not: the enum knows an item's title, never its *kind*, so an untitled failure says + /// "the item" rather than guessing "card" and being wrong about a lane. The trash verbs are + /// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash, + /// Put Back, Delete Immediately. + private nonisolated static func actionPhrase(for operation: WriteOperation) -> String { + switch operation { + case .createBoard: + "Couldn't create the board" + case .createLane: + "Couldn't create a lane" + case .createCard: + "Couldn't create a card" + case let .move(title): + if let title { "Couldn't move '\(title)'" } else { "Couldn't move the item" } + case let .reorder(title): + if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" } + case let .copy(title): + if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" } + case let .delete(title): + if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" } + case let .restore(title): + if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" } + case let .purge(title): + if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" } + case let .style(title): + if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" } + case let .importAttachment(filename): + "Couldn't import '\(filename)'" + case .listAttachments: + "Couldn't read this card's attachments" + case .renumberChildren: + "Couldn't renumber cards" + } + } + + /// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk + /// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs + /// translating, because its `description` is a fragment written for a developer. + private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String { + let text = switch reason { + case let .unreadable(message): + message + case let .uneditableFrontmatter(shape): + "this file's frontmatter can't be edited in place (\(shape.description))" + case let .io(message): + message + } + return trimmed(text) + } + + /// The read-only lock's line. Each cause says the same two things — what is wrong, and that + /// what is on screen is still the last good view — because the lock's whole promise is that + /// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The + /// lock's scope"). + public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String { + switch lock { + case .bracketedReloadFailed: + "This board couldn't be re-read after the last operation — showing the last good view, read-only" + case .vanishedRoot: + "This board's folder is gone — showing the last good view, read-only" + case .unwritableLocation: + "This board's location can't be written to — showing the last good view, read-only" + } + } + + /// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus + /// the reassurance that the board is still the board. + /// + /// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own + /// `index.md` — is spelled as "This board" rather than shown as a lone dot. + public nonisolated static func headline(for breakage: BoardLoadError) -> String { + let reason = trimmed(breakage.reason.description) + let subject = breakage.path == "." || breakage.path.isEmpty + ? "This board isn't loading" + : "'\(breakage.path)' isn't loading" + return "\(subject): \(reason) — showing the last good view" + } + + /// The suspended-history line. It names the *consequence* the user cares about — undo and the + /// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries + /// the diagnosis as its tail. + public nonisolated static func headline(for suspension: HistorySuspension) -> String { + let reason = trimmed(suspension.reason) + let line = "Changes aren't being recorded to history" + return reason.isEmpty ? line : "\(line) — \(reason)" + } + + /// Trims whitespace and a trailing period: these fragments are tails inside a longer line, and + /// a stray full stop mid-sentence is the tell of machine-assembled text. + private nonisolated static func trimmed(_ text: String) -> String { + var value = text.trimmingCharacters(in: .whitespacesAndNewlines) + while value.hasSuffix(".") { + value.removeLast() + } + return value + } +} + +// MARK: - Helpers + +private extension OneShotBanner { + /// Whether this failure is an attachment import — the one `WriteOperation` whose one-shot + /// ranks in the last precedence class rather than with the other one-shots. + var isAttachmentImport: Bool { + if case .importAttachment = error.operation { true } else { false } + } +} diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index d7d50cf..abc6b99 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -4,21 +4,47 @@ import os // MARK: - Vocabulary -/// Why a board is refusing writes — the read-only lock's cause, and the whole of its vocabulary -/// today. +/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the +/// vocabulary 02-architecture.md names. /// -/// 02-architecture.md names three ways a board enters the lock and only the first is built here. -/// `vanishedRoot` (§ Write-failure surfacing — a root that is gone, after bookmark re-resolution -/// found nothing) and `unwritableLocation` (§ — a read-only volume or a permission-denied folder, -/// probed at open) arrive with the cards that implement them. The banner turns a case of this into -/// user-facing phrasing (§ The banner surface); the store only owns the truth of it. +/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled +/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor +/// buffers kept but their debounced saves suspended — and differ only in cause and in **what +/// clears them**, which is the one thing this enum's cases are actually asked about (see +/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing +/// phrasing (§ The banner surface); the store only owns the truth of it. public enum ReadOnlyLockReason: Sendable, Equatable { /// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on /// screen may describe a tree that no longer exists — after a branch switch, a different branch /// entirely. Writes derived from it would land nonsense, so every write is refused until a /// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a /// bracketed operation locks the board read-only"). + /// + /// **Clears on the next successful reload, whatever its origin** — typically once the offending + /// file is fixed. case bracketedReloadFailed + + /// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted, + /// or the folder was deleted in Finder while the board was open (02-architecture.md § + /// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write + /// would land nowhere, so the last-good snapshot stays on screen, read-only. + /// + /// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if + /// the root is back, so success *is* the return signal. Pending dirty buffers then save + /// normally. + case vanishedRoot + + /// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a + /// read-only share) or a permission-denied folder (02-architecture.md § Write-failure + /// surfacing, "An unwritable board location enters the read-only lock at open"). Failing + /// loudly, specifically, *once* beats letting every gesture fail one at a time. + /// + /// **Clears only on a successful *reconciling* reload whose writability re-probe passes** — + /// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a + /// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on + /// every reconciling reload" — wake, activation) can tell that the permission or the mount + /// actually changed. + case unwritableLocation } /// The refusal `BoardStore.performWrite` throws when the board is locked read-only. @@ -185,11 +211,50 @@ public final class BoardStore { /// The board's selection, re-resolved against every snapshot this store applies. public private(set) var selection: Selection - /// The board root this store was opened on. Constant for now: absorbing a rename by - /// re-resolving the security-scoped bookmark (02-architecture.md § Write-failure surfacing) is - /// the registry's job and a later card's, and it will arrive together with `.rootChanged` - /// handling. - public let rootURL: URL + /// Where the board is **now**. Follows the folder: a rename or a move absorbed through + /// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window + /// keys, Reveal in Finder — re-derives at the new location (02-architecture.md § + /// Write-failure surfacing, "A renamed or moved board root follows its file identity"). + /// + /// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in + /// Finder should be visible in the title bar without anything else being told. + /// + /// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this + /// by exactly one reload during an absorption. That is not a second source of truth: the + /// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the + /// snapshot at the new root, after which the two agree again. + public private(set) var rootURL: URL + + // MARK: Banners + + /// The board window's banner strip, as a model (02-architecture.md § The banner surface). + /// + /// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the + /// window of origin", and a board window's strip has exactly one lifetime — this store's. A + /// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply + /// failures, and re-homes those rows here when it closes; injecting a shared center would + /// erase precisely that distinction. + /// + /// It holds only what nothing else does — one-shot write failures, the history suspension, + /// in-progress operations, passive signposts. The lock and the reload breakage stay this + /// store's own state and are composed in at render time by `bannerRows`. + public let banners = BannerCenter() + + /// The rows the board window's strip renders, in precedence order. + /// + /// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and + /// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The + /// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own. + public var bannerRows: [BannerRow] { + BannerCenter.rows( + lock: readOnlyLock, + breakage: reloadFailure, + oneShots: banners.oneShots, + suspension: banners.historySuspension, + operations: banners.operations, + signposts: banners.signposts + ) + } // MARK: Wiring @@ -200,6 +265,18 @@ public final class BoardStore { @ObservationIgnored public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)? + /// What to do when the watched root changes identity — injected for the same reason the + /// brackets are: the response needs the board's **security-scoped bookmark**, and this store + /// does not own one (the registry does, along with the watcher that must be re-attached and the + /// last-known path that arms the return detection). A store that reached for a bookmark it did + /// not hold could not be built or tested without a registry. + /// + /// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what + /// every other failure path here does, and which is exactly the shape unit tests and any + /// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop. + @ObservationIgnored + public var rootChangeDelegate: (@MainActor () -> Void)? + // MARK: Reload machinery /// Monotonic id of the most recently *started* reload — and therefore also the number of tree @@ -295,16 +372,22 @@ public final class BoardStore { requestReload(origin) case .rootChanged: - // Stubbed on purpose, and the stub is the whole of the honest answer today: the settled - // response is to re-resolve the board's security-scoped bookmark and either - // `reattach(to:)` the watcher at the new location — a rename absorbed with no banner and - // no lock — or enter the vanished-root read-only lock (02-architecture.md § - // Write-failure surfacing). Both halves need the registry's bookmark, which this store - // deliberately does not own yet, and a guess here would be a *wrong* guess: treating a - // rename as a vanish would lock a board that is merely somewhere else. The card that - // brings the bookmark replaces this case; until then a root change simply leaves the - // last-good snapshot on screen, which is the same thing every failure path does. - Self.logger.debug("rootChanged ignored — bookmark re-resolution is not built yet") + // Delegated, never guessed at. The settled response is to re-resolve the board's + // security-scoped bookmark and either `reattach(to:)` the watcher at the new location — + // a rename absorbed with no banner and no lock — or enter the vanished-root read-only + // lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this + // store does not own, and a guess here would be a *wrong* guess: treating a rename as a + // vanish would lock a board that is merely somewhere else. + // + // No reload is started either way. The delegate's two outcomes both end in one — + // `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root + // lock's eventual clearance when the root returns — and a reload fired from here would + // walk a path that just stopped being the board. + guard let rootChangeDelegate else { + Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)") + return + } + rootChangeDelegate() } } @@ -350,26 +433,26 @@ public final class BoardStore { outcome = .failure(error) } await barrier?() - await self?.apply(outcome, generation: generation) + await self?.apply(outcome, generation: generation, origin: origin) } } /// Lands one walk's result and starts whatever it uncovered. - private func apply(_ outcome: Result, generation: Int) { + private func apply(_ outcome: Result, generation: Int, origin: WatchOrigin) { reloadInFlight = false // The stale-apply guard. Serialization means this should not trigger today, but "only the // newest result applies" is the rule the wholesale floor and every future overlapping-load // change are written against, so it is enforced rather than assumed. if generation == reloadGeneration { - land(outcome, generation: generation) + land(outcome, generation: generation, origin: origin) } startPendingReload() resumeQuiescenceWaitersIfQuiet() } - private func land(_ outcome: Result, generation: Int) { + private func land(_ outcome: Result, generation: Int, origin: WatchOrigin) { // Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale // operation gets exactly one reload to prove itself, and a second failure after it is // ordinary per-file breakage again. @@ -385,10 +468,10 @@ public final class BoardStore { case let .success(result): snapshot = result.model loadWarnings = result.warnings - // One success clears both conditions — transient breakage self-heals and a locked board - // unlocks without the user doing anything but fixing the file. + // Breakage always heals on a success — it *is* the claim "the last reload failed", and + // this one did not. reloadFailure = nil - readOnlyLock = nil + clearLockIfDisproved(by: origin) selection = selection.resolved(against: result.model) case let .failure(error): @@ -396,7 +479,13 @@ public final class BoardStore { // replaces a good snapshot, and a selection over a snapshot that did not change has // nothing to re-resolve against. reloadFailure = error - if endsWholesaleOperation { + // `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished + // mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit + // that the bracket's final reload becomes a no-op there rather than a redundant + // failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break + // the clearing rules — the vanished root's lock must not clear on a reload that never + // proves the root came back. + if endsWholesaleOperation, readOnlyLock == nil { readOnlyLock = .bracketedReloadFailed } Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)") @@ -409,6 +498,88 @@ public final class BoardStore { startReload(origin) } + // MARK: - The lock's clearing rules + + /// Clears the read-only lock if this successful reload actually disproved its cause. + /// + /// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure + /// surfacing): + /// + /// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The + /// first says "the tree could not be re-read after a wholesale change" and the second says + /// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin + /// asked for it, so any success clears them. + /// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever; + /// loading proves nothing about writing. It clears only when a **reconciling** reload — wake, + /// activation, a stream re-creation — re-probes writability and finds it changed ("Writability + /// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears + /// the lock without ceremony"). + /// + /// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid + /// permission question asked of the filesystem, which is what makes it answer correctly for + /// both halves of the case — a read-only *mount* and a permission-denied *folder*. + /// + /// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not + /// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring + /// a lock from a probe here would be a policy decision this milestone was not asked to make. + private func clearLockIfDisproved(by origin: WatchOrigin) { + switch readOnlyLock { + case nil: + break + case .bracketedReloadFailed, .vanishedRoot: + readOnlyLock = nil + case .unwritableLocation: + guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return } + Self.logger.debug("writability re-probe passed — the unwritable-location lock clears") + readOnlyLock = nil + } + } + + // MARK: - Root identity and explicit locks + + /// Points this store at the board's new location, absorbing a rename or a move. + /// + /// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else + /// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its + /// file identity"): the board the app has open is the *file*, not the path string, so this is + /// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**. + /// + /// It deliberately starts **no reload**. The caller follows this with the watcher's + /// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new + /// root; a reload fired from here would be a second walk racing that one for no gain. Until it + /// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see + /// `rootURL`'s note. + public func relocate(to newRoot: URL) { + guard newRoot != rootURL else { return } + Self.logger.debug("board root relocated; Writer URLs now derive from the new location") + rootURL = newRoot + } + + /// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution + /// found nothing and the last-known path is not there either. + /// + /// Overwrites whatever lock was standing: a root that is gone is the most current and most + /// specific truth about why writes are refused, and its clearing rule (a successful reload, + /// which can only happen if the root came back) is strictly the safer one to be holding. + public func enterVanishedRootLock() { + Self.logger.error("board root vanished — entering the read-only lock") + readOnlyLock = .vanishedRoot + } + + /// Raises the unwritable-location read-only lock — the open flow's call, after probing the + /// root's writability (02 § "An unwritable board location enters the read-only lock at open"). + /// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the + /// producer. + /// + /// Does **not** overwrite a standing lock: a board that is already locked for a vanished root + /// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both + /// of those clear on a success that would then re-probe anyway. + public func enterUnwritableLock() { + guard readOnlyLock == nil else { return } + Self.logger.error("board location is not writable — entering the read-only lock") + readOnlyLock = .unwritableLocation + } + // MARK: - Write gate /// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces @@ -418,6 +589,13 @@ public final class BoardStore { /// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is /// why this method's only jobs are the gate and the bracket. /// + /// **A failure posts to the banner before it is rethrown** (02-architecture.md § Write-failure + /// surfacing): the strip is how the one-way flow keeps its honesty — the action visibly did not + /// happen, and the banner is the only thing that says why — so no call site is trusted to + /// remember, and a `try?` at some future call site cannot make a failure silent. The refusal + /// below is deliberately *not* posted: the lock row is already standing, and a second row per + /// refused gesture would bury it under echoes of itself. + /// /// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is locked read-only — checked /// *before* the bracket opens, so a refusal costs no suspended watcher and no owed reload. /// Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped @@ -438,7 +616,14 @@ public final class BoardStore { // `defer`, not a trailing call: a Writer operation that fails partway has still touched disk, // and an unbalanced bracket would leave the watcher suspended for the rest of the session. defer { watcherBrackets?.end() } - return try operation() + // `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and + // the Writer's typed error is lost on the way to the banner. + do throws(BoardWriteError) { + return try operation() + } catch { + banners.post(error) + throw error + } } /// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo @@ -474,7 +659,18 @@ public final class BoardStore { wholesaleReloadFloor = reloadGeneration + 1 watcherBrackets?.end() } - try operation() + do { + try operation() + } catch let error as BoardWriteError { + // Same honesty rule as `performWrite`, applied to the one error type the banner has + // phrasing for. A wholesale operation is usually git's (m7), whose own failure + // vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history + // condition, the in-progress row swapping for an error — is the committer's to drive; + // but a `BoardWriteError` escaping here is an ordinary failed write and may no more + // bypass the strip than one from `performWrite`. + banners.post(error) + throw error + } } // MARK: - Selection diff --git a/Kanban/LiveStore/BoardStoreRegistry.swift b/Kanban/LiveStore/BoardStoreRegistry.swift index a459fa3..d7f2880 100644 --- a/Kanban/LiveStore/BoardStoreRegistry.swift +++ b/Kanban/LiveStore/BoardStoreRegistry.swift @@ -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 diff --git a/Kanban/LiveStore/DirtyBufferGuard.swift b/Kanban/LiveStore/DirtyBufferGuard.swift new file mode 100644 index 0000000..e2eec26 --- /dev/null +++ b/Kanban/LiveStore/DirtyBufferGuard.swift @@ -0,0 +1,132 @@ +import Foundation +import Observation +import os + +/// The **one modal moment on the write-failure path** (02-architecture.md § Write-failure +/// surfacing), as a mechanism: closing a window — or the board, or quitting — with a dirty buffer +/// that cannot be written. +/// +/// ### Why this one case is allowed to be modal +/// +/// Everything else on the write path is non-modal by construction, because everything else is on +/// disk: a failed move visibly did not happen, a failed save leaves the keystrokes in the buffer +/// and the banner standing while the debounced save retries on its own cadence. Nothing is lost +/// while the window stays open. **Closing is the moment that stops being true** — the buffer is the +/// only place that text exists, and the window is about to go away. So the close stops and asks: +/// retry, save a copy elsewhere, or discard. Failing silently here would be the one place this +/// design loses a user's work. +/// +/// ### What this type is, and is not +/// +/// It is the *state machine* for that moment and nothing else: it does not own the buffer, does not +/// know what a card is, and does not present anything. Two closures supply everything specific — +/// `attemptSave` flushes the dirty buffer to its real home (the debounced save's target) and +/// `writeCopy` writes the buffer's current text wherever the user pointed. Presentation is +/// `View.dirtyBufferAlert(_:copyDestination:)` in `Kanban/UI/`. +/// +/// ### Who will call it +/// +/// - **m6's editor sessions** — the card window's body Edit buffer and its raw-source buffer, each +/// closing with a flush (05-card-window.md: "Leaving Edit flushes the debounce ... window +/// close"). +/// - **m4's board-close flush** — closing a board window flushes pending debounced work before the +/// store tears down (02 § Windows, "Close flushes"), and a flush that cannot land is this moment +/// arriving at the board level. +/// +/// Both are the same shape, which is why the mechanism is generic and arrives before either +/// caller: a buffer, a save that can fail, and a close that must not proceed until the text is +/// somewhere. +@MainActor +@Observable +public final class DirtyBufferGuard { + + /// Whether a close is currently held up. + /// + /// Deliberately two cases. There is no `.saving` or `.retrying`: `attemptSave` is synchronous + /// (it is a file write, and the buffer is already in memory), so there is no interval during + /// which a third state could be observed — inventing one would only invite a spinner over a + /// moment that does not exist. + public enum Phase: Equatable { + /// Nothing is holding the close: either the buffer is safe, or none was ever dirty. + case idle + /// The save failed. The modal must present, carrying this error's phrasing, and the close + /// must not proceed until one of `retry()`, `saveCopy(to:)`, or `discard()` returns it to + /// `.idle`. + case blocked(BoardWriteError) + } + + public private(set) var phase: Phase = .idle + + /// Flushes the dirty buffer to its real home — the same write the debounced save performs. + private let attemptSave: @MainActor () throws(BoardWriteError) -> Void + + /// Writes the buffer's current text to an arbitrary user-picked URL — "save a copy elsewhere". + /// + /// Untyped `throws` on purpose: this writes outside the board, to a destination the user chose + /// through a save panel, so its failures are `Foundation`'s (`Data.write`, `NSError` from the + /// panel's URL) rather than the Writer's closed vocabulary. Forcing them into `BoardWriteError` + /// would mean inventing a `WriteOperation` case for a file that is not part of any board. + private let writeCopy: @MainActor (URL) throws -> Void + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "dirty-buffer") + + public init( + attemptSave: @escaping @MainActor () throws(BoardWriteError) -> Void, + writeCopy: @escaping @MainActor (URL) throws -> Void + ) { + self.attemptSave = attemptSave + self.writeCopy = writeCopy + } + + /// The close flow's gate: tries the save and answers whether the close may proceed. + /// + /// - Returns: `true` when the buffer landed and the window may close. `false` when it did not — + /// `phase` is `.blocked` and the alert must present. + /// + /// The caller decides *whether* to call this: a clean buffer has nothing to flush, and asking + /// this type about it would mean teaching it what "dirty" means for text it does not own. + public func beginClose() -> Bool { + attempt() + } + + /// Re-attempts the save from the alert's "Try Again". Same act as `beginClose()`, named for the + /// button that calls it — a retry after the user freed some disk or reconnected a volume is the + /// case this whole moment exists to make possible. + /// + /// - Returns: `true` when the buffer landed and the close may resume. + @discardableResult + public func retry() -> Bool { + attempt() + } + + /// Writes the buffer's text to a destination the user picked, and unblocks on success. + /// + /// **The text is safe elsewhere, so the close may proceed** — the buffer's real home is still + /// unwritten, and that is the trade the user just made knowingly. A failure rethrows and leaves + /// the phase blocked: the modal stays up, because the text is still nowhere but memory. + public func saveCopy(to url: URL) throws { + try writeCopy(url) + Self.logger.debug("dirty buffer saved as a copy; the close may proceed") + phase = .idle + } + + /// The user chose to lose the text. Unblocks unconditionally — this is the one branch with + /// nothing to verify, and second-guessing an explicit discard would be its own kind of + /// dishonesty. + public func discard() { + Self.logger.debug("dirty buffer discarded at the user's request") + phase = .idle + } + + private func attempt() -> Bool { + do throws(BoardWriteError) { + try attemptSave() + } catch { + Self.logger.error("dirty buffer could not be saved: \(error.description, privacy: .public)") + phase = .blocked(error) + return false + } + phase = .idle + return true + } +} diff --git a/Kanban/UI/BannerStripView.swift b/Kanban/UI/BannerStripView.swift new file mode 100644 index 0000000..2f6215f --- /dev/null +++ b/Kanban/UI/BannerStripView.swift @@ -0,0 +1,273 @@ +import SwiftUI + +/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a +/// stack of rows (02-architecture.md § The banner surface). +/// +/// ### Tones, not components +/// +/// There is one row layout and three colorings. An error, a warning, and an info row differ in +/// symbol and tint and in nothing else — which is what lets the card window's remote-change +/// signpost (07-sync-collab.md) be "this same component in the info tone" rather than a second +/// thing that drifts. The per-kind affordances hang off the row's data, not off separate views: a +/// dismiss control appears exactly where `BannerRow.dismissID` is non-`nil`, a spinner and its +/// optional Cancel exactly where the row is `.inProgress`. +/// +/// ### The collapse rule +/// +/// "Beyond three rows the remainder collapse behind a '+N more' disclosure" — because the strip is +/// window furniture above the board, and a board that has gone badly wrong must not disappear +/// under its own error messages. Precedence ordering is what makes three the right number to show: +/// `BannerCenter.rows(...)` has already put the lock, the breakage, and the newest unread failure +/// at the top, so the collapsed remainder is always the least urgent tail. +/// +/// **In-progress rows are exempt, and do not count toward the budget** (settled, 02): they are the +/// strip's only explanation for a bracket's write lock and for a close or quit deferring teardown, +/// and a copy row's Cancel has to stay reachable — "a spinner may never hide behind '+N more'". +/// They are safe to pin because there are few at once and each clears itself. So the strip is +/// always *every* pinned row, then at most three of the rest. +/// +/// ### Deliberately self-contained +/// +/// It takes rows and two callbacks; it reaches for no store and no environment. The board window +/// (m4) hosts it over the lane area, the card window (m6) hosts its own, and the re-homing rule — +/// a card window's condition moving to the board window's strip when it closes — is a question of +/// which `BannerCenter` a row came from, never of this view. +public struct BannerStripView: View { + + private let rows: [BannerRow] + private let onDismiss: (UUID) -> Void + + /// How many rows show before the disclosure takes over. + private static let collapseThreshold = 3 + + @State private var isExpanded = false + + public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) { + self.rows = rows + self.onDismiss = onDismiss + } + + public var body: some View { + if !rows.isEmpty { + VStack(spacing: 1) { + ForEach(pinnedRows) { row in + BannerRowView(row: row, onDismiss: onDismiss) + } + ForEach(visibleCollapsibleRows) { row in + BannerRowView(row: row, onDismiss: onDismiss) + } + if hiddenCount > 0 { + disclosure + } + } + .background(.quaternary) + } + } + + /// The rows that always show, in precedence order — in-progress rows, which `rows(...)` has + /// already placed at the head, so rendering them first preserves that order rather than + /// imposing a second one. + private var pinnedRows: [BannerRow] { + rows.filter(\.isPinned) + } + + private var collapsibleRows: [BannerRow] { + rows.filter { !$0.isPinned } + } + + /// Everything collapsible when expanded or short enough, the first three otherwise. `prefix` + /// and not a filter — precedence order is the whole point, so what shows is always a prefix of + /// the ordered list. + private var visibleCollapsibleRows: [BannerRow] { + isExpanded || collapsibleRows.count <= Self.collapseThreshold + ? collapsibleRows + : Array(collapsibleRows.prefix(Self.collapseThreshold)) + } + + private var hiddenCount: Int { + max(0, collapsibleRows.count - Self.collapseThreshold) + } + + private var disclosure: some View { + Button { + isExpanded.toggle() + } label: { + HStack(spacing: 6) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .imageScale(.small) + Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more") + Spacer(minLength: 0) + } + .font(.callout) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(.background.secondary) + } +} + +// MARK: - One row + +/// A single banner row. Everything kind-specific is a branch on the row's data; the layout is one +/// `HStack` for all five cases. +private struct BannerRowView: View { + + let row: BannerRow + let onDismiss: (UUID) -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + leading + + Text(row.headline) + .font(.callout) + // Wrapping, never truncating: the cause tail ("— disk full") is the half that says + // what to do about it, and a strip that hid it would be decoration. + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + trailingControls + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(row.tone.fill) + // One element per row, tone included: a VoiceOver user must hear *that* this is an error + // before hearing what the error is, and colour cannot carry that. The dismiss and Cancel + // buttons survive as custom actions of the combined element rather than as separate stops + // (10-accessibility.md; the announce-on-appear path arrives with that milestone). + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(row.tone.accessibilityPrefix): \(row.headline)")) + } + + @ViewBuilder + private var leading: some View { + if case .inProgress = row { + // The spinner replaces the symbol rather than joining it: an in-progress row's state + // *is* "still going", and two glyphs saying so would be noise. + ProgressView() + .controlSize(.small) + } else { + Image(systemName: row.tone.symbolName) + .foregroundStyle(row.tone.accent) + .imageScale(.medium) + .accessibilityHidden(true) + } + } + + @ViewBuilder + private var trailingControls: some View { + if case let .inProgress(operation) = row, let cancel = operation.cancel { + // Cancel appears on safe copies only (02, settled): it means "remove the partial copy, + // nothing lost". Git brackets pass no closure and therefore get no button. + Button("Cancel", action: cancel) + .buttonStyle(.link) + .font(.callout) + } + + if let dismissID = row.dismissID { + Button { + onDismiss(dismissID) + } label: { + Image(systemName: "xmark") + .imageScale(.small) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss") + .help("Dismiss") + } + } +} + +// MARK: - Tone rendering + +private extension BannerTone { + var symbolName: String { + switch self { + case .error: "exclamationmark.triangle.fill" + case .warning: "exclamationmark.circle.fill" + case .info: "info.circle.fill" + } + } + + var accent: Color { + switch self { + case .error: .red + case .warning: .orange + case .info: .secondary + } + } + + /// A wash, not a slab: the strip sits above the board and must read as furniture rather than as + /// a second window. Info is deliberately the calmest of the three — "visually calm, no error + /// colour" is the settled description of the signpost that shares this tone. + var fill: some ShapeStyle { + switch self { + case .error: AnyShapeStyle(Color.red.opacity(0.12)) + case .warning: AnyShapeStyle(Color.orange.opacity(0.12)) + case .info: AnyShapeStyle(.background.secondary) + } + } + + /// What VoiceOver says before the headline. "Status" rather than "Info" because that is the + /// word the platform uses for a non-alarming state announcement. + var accessibilityPrefix: String { + switch self { + case .error: "Error" + case .warning: "Warning" + case .info: "Status" + } + } +} + +// MARK: - Previews + +private func previewError( + _ operation: WriteOperation, + _ reason: BoardWriteError.Reason = .io(message: "the disk is full") +) -> BoardWriteError { + BoardWriteError(operation: operation, path: "/Users/x/Boards/Work/lane/index.md", reason: reason) +} + +#Preview("Single error") { + BannerStripView( + rows: [.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login"))))], + onDismiss: { _ in } + ) + .frame(width: 520) +} + +#Preview("Stacked tones") { + BannerStripView( + rows: [ + .inProgress(InProgressOperation(label: "Pulling…")), + .readOnlyLock(.vanishedRoot), + .oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))), + .historySuspended(HistorySuspension(reason: "the repository is corrupt")), + .signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")), + ], + onDismiss: { _ in } + ) + .frame(width: 520) +} + +/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest, +/// and "+3 more" counts only what actually collapsed. +#Preview("Collapse") { + BannerStripView( + rows: [ + .inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})), + .readOnlyLock(.bracketedReloadFailed), + .reloadBreakage(BoardLoadError(path: "todo/index.md", reason: .missingOrder)), + .oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))), + .oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))), + .oneShot(OneShotBanner(error: previewError(.renumberChildren))), + .oneShot(OneShotBanner(error: previewError(.importAttachment(filename: "photo.png"), + .unreadable(message: "the source file could not be read")))), + ], + onDismiss: { _ in } + ) + .frame(width: 520) +} diff --git a/Kanban/UI/DirtyBufferAlert.swift b/Kanban/UI/DirtyBufferAlert.swift new file mode 100644 index 0000000..987e5c0 --- /dev/null +++ b/Kanban/UI/DirtyBufferAlert.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// Presents `DirtyBufferGuard`'s blocked phase as the app's one modal moment on the write-failure +/// path (02-architecture.md § Write-failure surfacing). +/// +/// ### The three choices, and why there is no fourth +/// +/// Retry, save a copy elsewhere, discard. There is deliberately **no Cancel** — no "keep the window +/// open and think about it": the close is already stopped, so a fourth button would only mean +/// "stop asking", which is the silent failure this alert exists to prevent. Dismissing the alert +/// without choosing leaves the phase blocked and the alert comes back; the only ways out are the +/// three that put the text somewhere or knowingly let it go. +/// +/// ### The copy destination +/// +/// `copyDestination` supplies the URL. In a real window that is an `NSSavePanel` (or a +/// `fileExporter`) run from the button; here it is a closure so the flow is testable and so this +/// modifier stays free of file-picking machinery. Returning `nil` means the user backed out of the +/// panel — the phase stays blocked and the alert returns, which is the honest outcome. +/// +/// ### Callers +/// +/// m6's editor sessions (the card window's body and raw-source buffers) and m4's board-close +/// flush. Both attach this to the window that is trying to close. +public extension View { + func dirtyBufferAlert( + _ bufferGuard: DirtyBufferGuard, + copyDestination: @escaping @MainActor () -> URL? + ) -> some View { + modifier(DirtyBufferAlertModifier(bufferGuard: bufferGuard, copyDestination: copyDestination)) + } +} + +private struct DirtyBufferAlertModifier: ViewModifier { + + let bufferGuard: DirtyBufferGuard + let copyDestination: @MainActor () -> URL? + + func body(content: Content) -> some View { + content.alert( + "Your changes couldn't be saved", + isPresented: Binding( + // A getter over the phase and a setter that does nothing: SwiftUI writes `false` + // when the alert is dismissed by any route it manages, and honouring that would + // close the window with the text still nowhere but memory. Only the three buttons + // move the phase, so only they can take the alert down. + get: { bufferGuard.phase != .idle }, + set: { _ in } + ), + presenting: blockingError + ) { _ in + Button("Try Again") { + bufferGuard.retry() + } + Button("Save a Copy…") { + guard let url = copyDestination() else { return } + // A failed copy rethrows into a still-blocked phase, so the alert simply returns — + // the same place the user already was, with nothing lost. There is no second error + // surface to build here: this *is* the error surface. + try? bufferGuard.saveCopy(to: url) + } + Button("Discard Changes", role: .destructive) { + bufferGuard.discard() + } + } message: { error in + Text("\(BannerCenter.headline(for: error))\n\nSave a copy somewhere else, or discard the changes to close.") + } + } + + private var blockingError: BoardWriteError? { + if case let .blocked(error) = bufferGuard.phase { error } else { nil } + } +} diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift new file mode 100644 index 0000000..2d27c4b --- /dev/null +++ b/KanbanTests/BannerCenterTests.swift @@ -0,0 +1,493 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BannerCenter` is two things, and this suite is two suites: an **ordering rule** that is a pure +/// function over six row classes, and the **product's voice** — every sentence a user reads when a +/// write fails. The first is tested the way pure functions are, with no store and no filesystem; +/// the second is tested for the properties phrasing has to have (non-empty, distinct per +/// operation, titles quoted when known and graceful when not) rather than by pinning literals that +/// would turn every copy edit into a failing test. +/// +/// The third part — that a failed write *reaches* the strip at all — needs a real store and a real +/// Writer failure, because the claim is about `performWrite`'s wiring rather than about the center. +/// Those tests are at the bottom, over `WriterTestSupport`'s fixtures like every other suite here. + +// MARK: - Fixtures + +/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +/// A board with one lane whose frontmatter is readable but **uneditable** — the whole-frontmatter +/// flow mapping. It loads and renders fine; any app write to it refuses loudly, which is the +/// cheapest genuine Writer failure there is (no permissions to fiddle with, no disk to fill). +@MainActor +private func makeBoardWithUneditableLane() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.uneditable) + return fixture +} + +private func error( + _ operation: WriteOperation, + _ reason: BoardWriteError.Reason = .io(message: "the disk is full") +) -> BoardWriteError { + BoardWriteError(operation: operation, path: "/Boards/Work/lane/index.md", reason: reason) +} + +/// One representative error per `WriteOperation` case. Spelled out rather than derived so that a +/// new case added to the enum shows up here as a missing entry the moment anyone looks — the +/// switch in `actionPhrase(for:)` is the compile-time guard; this is the reading guard. +private let everyOperation: [WriteOperation] = [ + .createBoard, + .createLane, + .createCard, + .move(title: "Fix login"), + .reorder(title: "Fix login"), + .copy(title: "Fix login"), + .delete(title: "Fix login"), + .restore(title: "Fix login"), + .purge(title: "Fix login"), + .style(title: "Fix login"), + .importAttachment(filename: "photo.png"), + .listAttachments, + .renumberChildren, +] + +/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one. +private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [ + (.move(title: "Fix login"), .move(title: nil)), + (.reorder(title: "Fix login"), .reorder(title: nil)), + (.copy(title: "Fix login"), .copy(title: nil)), + (.delete(title: "Fix login"), .delete(title: nil)), + (.restore(title: "Fix login"), .restore(title: nil)), + (.purge(title: "Fix login"), .purge(title: nil)), + (.style(title: "Fix login"), .style(title: nil)), +] + +// MARK: - Ordering + +@MainActor +@Suite("BannerCenter ▸ ordering") +struct BannerCenterOrderingTests { + + @Test("Every class present produces the settled precedence order") + func everyClassOrdersByPrecedence() { + let move = OneShotBanner(error: error(.move(title: "Fix login")), occurredAt: Date(timeIntervalSince1970: 100)) + let attachment = OneShotBanner( + error: error(.importAttachment(filename: "photo.png")), + occurredAt: Date(timeIntervalSince1970: 200) + ) + let operation = InProgressOperation(label: "Pulling…") + let signpost = InfoSignpost(message: "This card changed on the remote") + + let rows = BannerCenter.rows( + lock: .vanishedRoot, + breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder), + oneShots: [attachment, move], + suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)), + operations: [operation], + signposts: [signpost] + ) + + // in-progress (pinned) > read-only lock > reload breakage > one-shot write failures > + // commit and attachment failures > passive info rows. The two info classes sit at opposite + // ends of the strip. + #expect(rows.map(\.id) == [ + "operation:\(operation.id.uuidString)", + "read-only-lock", + "reload-breakage", + "one-shot:\(move.id.uuidString)", + "history-suspension", + "one-shot:\(attachment.id.uuidString)", + "signpost:\(signpost.id.uuidString)", + ]) + #expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info]) + #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false], + "a spinner may never hide behind '+N more' — nothing else is pinned") + } + + @Test("An attachment failure ranks below other one-shots even when it is newer") + func attachmentFailuresRankLast() { + let attachment = OneShotBanner( + error: error(.importAttachment(filename: "photo.png")), + occurredAt: Date(timeIntervalSince1970: 900) + ) + let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1)) + + let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [attachment, style], suspension: nil, operations: []) + + #expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"], + "precedence class outranks recency; recency only orders within a class") + } + + @Test("One-shots order newest first within their class") + func oneShotsAreNewestFirst() { + let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1)) + let middle = OneShotBanner(error: error(.move(title: "B")), occurredAt: Date(timeIntervalSince1970: 2)) + let newest = OneShotBanner(error: error(.move(title: "C")), occurredAt: Date(timeIntervalSince1970: 3)) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: [middle, oldest, newest], + suspension: nil, + operations: [] + ) + + #expect(rows.map(\.id) == [ + "one-shot:\(newest.id.uuidString)", + "one-shot:\(middle.id.uuidString)", + "one-shot:\(oldest.id.uuidString)", + ]) + } + + @Test("Failures sharing a timestamp keep the order they were posted in") + func tiesAreStable() { + let center = BannerCenter() + // Posted in one run loop turn: `Date()` may well hand both the same value, and the strip + // must not shuffle between renders because of it. + center.post(error(.move(title: "First"))) + center.post(error(.move(title: "Second"))) + center.post(error(.move(title: "Third"))) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: center.oneShots, + suspension: nil, + operations: [] + ) + #expect(rows.map(\.headline).map { $0.contains("'Third'") } == [true, false, false]) + #expect(rows.count == 3) + } + + @Test("Nothing standing is an empty strip") + func quietBoardHasNoRows() { + #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty) + } +} + +// MARK: - Lifecycles + +@MainActor +@Suite("BannerCenter ▸ lifecycles") +struct BannerCenterLifecycleTests { + + @Test("One-shots dismiss individually; conditions carry no dismiss control at all") + func oneShotsDismissIndividually() throws { + let center = BannerCenter() + center.post(error(.move(title: "First"))) + center.post(error(.style(title: "Second"))) + #expect(center.oneShots.count == 2) + + let doomed = try #require(center.oneShots.first) + center.dismiss(doomed.id) + + #expect(center.oneShots.count == 1) + #expect(center.oneShots.first?.id != doomed.id, "dismissing one must not take its neighbour") + + // The condition rows have no id to dismiss with — the API shape *is* the rule ("conditions + // heal", 02 § The banner surface), and this is where it is stated as a test. + let rows = BannerCenter.rows( + lock: .bracketedReloadFailed, + breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex), + oneShots: center.oneShots, + suspension: HistorySuspension(reason: "disk full"), + operations: [InProgressOperation(label: "Pulling…")] + ) + #expect(rows.filter { $0.dismissID != nil }.count == 1, "only the one-shot may be dismissed") + } + + @Test("Dismissing an in-progress operation's id does nothing — dismiss is not cancel") + func dismissDoesNotEndOperations() { + let center = BannerCenter() + let id = center.beginOperation(label: "Importing…", cancel: nil) + center.dismiss(id) + #expect(center.operations.count == 1) + } + + @Test("Suspending history raises a warning row; clearing it takes the row away") + func historySuspensionIsAHealingCondition() throws { + let center = BannerCenter() + #expect(center.historySuspension == nil) + + center.suspendHistory(reason: "the repository is corrupt") + let suspension = try #require(center.historySuspension) + #expect(suspension.reason == "the repository is corrupt") + + let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []) + #expect(rows.count == 1) + #expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded") + #expect(rows[0].headline.contains("history")) + #expect(rows[0].dismissID == nil) + + center.clearHistorySuspension() + #expect(center.historySuspension == nil) + #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty) + } + + @Test("Re-suspending keeps the original start and takes the newer diagnosis") + func resuspendingKeepsTheClock() throws { + let center = BannerCenter() + center.suspendHistory(reason: "disk full") + let first = try #require(center.historySuspension) + + center.suspendHistory(reason: "the repository is corrupt") + let second = try #require(center.historySuspension) + + #expect(second.since == first.since, "the condition never stopped being true") + #expect(second.reason == "the repository is corrupt") + } + + @Test("Beginning an operation shows an info row; ending it clears the row") + func operationsCompleteByLeaving() { + let center = BannerCenter() + let id = center.beginOperation(label: "Pulling…", cancel: nil) + + var rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations) + #expect(rows.count == 1) + #expect(rows[0].tone == .info) + #expect(rows[0].headline == "Pulling…") + guard case let .inProgress(operation) = rows[0] else { + Issue.record("expected an in-progress row") + return + } + #expect(!operation.isCancelable, "git brackets get no Cancel — settled") + + center.endOperation(id) + rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations) + #expect(rows.isEmpty) + } + + @Test("Copy-shaped work carries Cancel, and cancelling is the caller's closure") + func cancelableOperationsRunTheirClosure() throws { + let center = BannerCenter() + let cancelled = Box() + let id = center.beginOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true }) + + let operation = try #require(center.operations.first) + #expect(operation.isCancelable) + operation.cancel?() + #expect(cancelled.value) + + center.endOperation(id) + #expect(center.operations.isEmpty) + } + + @Test("A signpost is a calm, dismissable row that ranks last and may collapse") + func signpostsRankLastAndDismiss() throws { + let center = BannerCenter() + center.postSignpost("This card changed on the remote — your edits still win") + center.post(error(.move(title: "Fix login"))) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: center.oneShots, + suspension: nil, + operations: center.operations, + signposts: center.signposts + ) + #expect(rows.count == 2) + #expect(rows.last?.tone == .info, "same tone as an in-progress row, opposite end of the strip") + #expect(rows.last?.isPinned == false, "calm by design — nothing is gated on seeing it instantly") + + let dismissID = try #require(rows.last?.dismissID) + center.dismiss(dismissID) + #expect(center.signposts.isEmpty) + #expect(center.oneShots.count == 1, "dismissing a signpost leaves the failure standing") + } + + @Test("A failed operation is an end plus a post — the row swaps for the error") + func failureSwapsTheRow() { + let center = BannerCenter() + let id = center.beginOperation(label: "Importing 'photo.png'…", cancel: nil) + center.endOperation(id) + center.post(error(.importAttachment(filename: "photo.png"), .unreadable(message: "the source file could not be read"))) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: center.oneShots, + suspension: nil, + operations: center.operations + ) + #expect(rows.count == 1) + #expect(rows[0].tone == .error) + #expect(rows[0].headline == "Couldn't import 'photo.png' — the source file could not be read") + } +} + +/// A one-field reference cell, so a `@Sendable` cancel closure has somewhere to record that it ran. +@MainActor +private final class Box { + var value = false +} + +// MARK: - Phrasing + +@MainActor +@Suite("BannerCenter ▸ phrasing") +struct BannerCenterPhrasingTests { + + @Test("Every write operation has its own non-empty headline") + func everyOperationSaysSomethingDistinct() { + let headlines = everyOperation.map { BannerCenter.headline(for: error($0)) } + + for (operation, headline) in zip(everyOperation, headlines) { + #expect(!headline.isEmpty, "\(operation) has no headline") + #expect(headline.hasPrefix("Couldn't "), "\(operation) does not name what failed") + #expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice") + } + #expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong") + } + + @Test("Titles are quoted when known and the phrasing stays graceful when they are not") + func titlesAreQuotedOrGracefullyAbsent() { + for (withTitle, withoutTitle) in titledOperations { + let named = BannerCenter.headline(for: error(withTitle)) + let anonymous = BannerCenter.headline(for: error(withoutTitle)) + + #expect(named.contains("'Fix login'"), "\(withTitle) does not quote the title it carries") + #expect(!anonymous.contains("''"), "\(withoutTitle) rendered an empty pair of quotes") + #expect(anonymous.contains("the item"), "\(withoutTitle) should fall back to the kind-free noun") + #expect(named != anonymous) + } + } + + @Test("The cause tail comes from the error's reason and nowhere else") + func causeTailCarriesTheDiagnosis() { + #expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full"))) + == "Couldn't move 'Fix login' — the disk is full") + + // A trailing full stop in a diagnostic message must not survive into the middle of a line. + #expect(BannerCenter.headline(for: error(.createCard, .io(message: "no space left on device."))) + == "Couldn't create a card — no space left on device") + + // The uneditable-frontmatter reason is the one whose own `description` is written for a + // developer, so the banner translates it rather than quoting it. + let uneditable = BannerCenter.headline(for: error(.style(title: "Odd"), .uneditableFrontmatter(.keyWithoutOwnLine))) + #expect(uneditable.hasPrefix("Couldn't restyle 'Odd' — ")) + #expect(uneditable.contains("frontmatter")) + } + + @Test("Every lock reason says what is wrong and that the view is still the last good one") + func lockHeadlinesReassure() { + let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot, .unwritableLocation] + let headlines = reasons.map(BannerCenter.headline(for:)) + + for headline in headlines { + #expect(headline.contains("last good view")) + #expect(headline.contains("read-only")) + } + #expect(Set(headlines).count == reasons.count) + } + + @Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it") + func breakageHeadlineNamesThePath() { + let headline = BannerCenter.headline( + for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4)) + ) + #expect(headline.contains("'todo/fix-login/index.md'")) + #expect(headline.contains("line 4")) + #expect(headline.contains("showing the last good view")) + + // The board's own index.md reports as "." — a lone dot in the product's voice would be a + // bug report, not a sentence. + let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex)) + #expect(!rootHeadline.contains("'.'")) + #expect(rootHeadline.hasPrefix("This board isn't loading")) + } + + @Test("The suspended-history line names the consequence, then the diagnosis") + func suspensionHeadlineNamesTheConsequence() { + #expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full")) + == "Changes aren't being recorded to history — the disk is full") + #expect(BannerCenter.headline(for: HistorySuspension(reason: "")) + == "Changes aren't being recorded to history") + } +} + +// MARK: - Store integration + +@MainActor +@Suite("BannerCenter ▸ store integration") +struct BannerCenterStoreTests { + + @Test("A failed write lands exactly one one-shot in the store's banners, and still throws") + func failedWritePostsOnce() throws { + let fixture = try makeBoardWithUneditableLane() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // A genuine Writer refusal: the lane's frontmatter is a whole-frontmatter flow mapping, so + // the surgical editor cannot address it and the write fails loudly rather than corrupting + // the file (01-storage-format.md § Frontmatter). + var thrown: BoardWriteError? + do { + try store.performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane2), operation: .style(title: nil)) { _ in } + } + Issue.record("expected the write to fail") + } catch let failure as BoardWriteError { + thrown = failure + } + + let error = try #require(thrown) + #expect(store.banners.oneShots.count == 1, "the banner is posted once, not per layer") + #expect(store.banners.oneShots.first?.error == error, "the strip carries the same failure the caller saw") + + // And it is the whole of what the strip shows: a healthy board with one unread failure. + #expect(store.bannerRows.count == 1) + #expect(store.bannerRows[0].tone == .error) + #expect(store.bannerRows[0].headline == "Couldn't restyle 'Odd' — this file's frontmatter can't be edited in place (a top-level key has no line of its own)") + #expect(store.bannerRows[0].dismissID != nil) + } + + @Test("A write refused by the read-only lock posts nothing — the lock row already stands") + func refusalUnderLockPostsNothing() throws { + let fixture = try makeBoardWithUneditableLane() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + + do { + try store.performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in } + } + Issue.record("expected the store to refuse the write") + } catch let refusal as BoardStoreWriteRefusal { + #expect(refusal == .readOnlyLocked(.vanishedRoot)) + } + + #expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write; the lock row is the message") + #expect(store.bannerRows.count == 1) + guard case .readOnlyLock(.vanishedRoot) = store.bannerRows[0] else { + Issue.record("expected the lock row alone") + return + } + } + + @Test("The store's rows compose its own conditions with the center's") + func bannerRowsComposeBothHalves() throws { + let fixture = try makeBoardWithUneditableLane() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.enterUnwritableLock() + store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full"))) + store.banners.suspendHistory(reason: "the disk is full") + store.banners.beginOperation(label: "Duplicating…", cancel: nil) + store.banners.postSignpost("This card changed on the remote") + + #expect(store.bannerRows.map(\.id) == [ + "operation:\(store.banners.operations[0].id.uuidString)", + "read-only-lock", + "one-shot:\(store.banners.oneShots[0].id.uuidString)", + "history-suspension", + "signpost:\(store.banners.signposts[0].id.uuidString)", + ]) + } +} diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 21aeaf5..9dd310b 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -486,21 +486,40 @@ struct BoardStoreTests { // MARK: Root changes - @Test("A root change is accepted and leaves the last good snapshot alone") - func rootChangeIsAStubToday() async throws { + @Test("A root change with no delegate wired leaves the last good snapshot alone") + func rootChangeWithoutADelegateIsANoOp() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lastGood = store.snapshot - // Documented no-op until the registry's bookmark arrives: re-resolve-or-lock needs an - // identity this store does not own yet, and guessing would lock a board that merely moved. + // Re-resolve-or-lock needs a bookmark this store does not own, so the response is + // delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no + // delegate, the honest answer is the same one every failure path gives: keep the last good + // snapshot. Guessing here would lock a board that had merely moved. store.handleWatcherEvent(.rootChanged) await store.awaitQuiescence() #expect(store.snapshot == lastGood) #expect(store.reloadFailure == nil) #expect(store.readOnlyLock == nil) - #expect(store.reloadGeneration == 0, "a root change schedules no reload today") + #expect(store.reloadGeneration == 0, "a root change schedules no reload of its own") + } + + @Test("A root change with a delegate hands over and starts no reload of its own") + func rootChangeIsDelegated() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + var calls = 0 + store.rootChangeDelegate = { calls += 1 } + store.handleWatcherEvent(.rootChanged) + await store.awaitQuiescence() + + #expect(calls == 1) + // The delegate's two outcomes both end in a reload — at the re-resolved root, or on the + // root's return. One fired from here would walk a path that just stopped being the board. + #expect(store.reloadGeneration == 0) } } diff --git a/KanbanTests/DirtyBufferGuardTests.swift b/KanbanTests/DirtyBufferGuardTests.swift new file mode 100644 index 0000000..490acd5 --- /dev/null +++ b/KanbanTests/DirtyBufferGuardTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Testing +@testable import Kanban + +/// The close-with-a-dirty-buffer state machine (02-architecture.md § Write-failure surfacing, "The +/// one modal moment on the write-failure path"). +/// +/// Everything specific is a closure, so these tests are about one thing only: **whether the close +/// may proceed**. Each of the four exits — the save that just works, the retry that works on the +/// second try, the copy saved elsewhere, and the deliberate discard — has to leave the guard +/// `.idle`, and the failing save has to leave it `.blocked` carrying the error the alert will +/// phrase. + +// MARK: - Support + +/// Stands in for an editor's dirty buffer: some text, a save that can be made to fail, and a record +/// of where things actually went. +@MainActor +private final class FakeBuffer { + var text: String + /// Non-`nil` makes the next save (and every save after it) fail. + var saveFailure: BoardWriteError? + /// Set when `writeCopy` should refuse — a save panel pointed at a full disk. + var copyFails = false + + private(set) var saveAttempts = 0 + private(set) var savedText: String? + private(set) var copies: [URL: String] = [:] + + init(text: String = "the paragraph that exists nowhere else") { + self.text = text + } + + func makeGuard() -> DirtyBufferGuard { + DirtyBufferGuard( + attemptSave: { [self] () throws(BoardWriteError) in + saveAttempts += 1 + if let saveFailure { + throw saveFailure + } + savedText = text + }, + writeCopy: { [self] url in + if copyFails { + throw CocoaError(.fileWriteOutOfSpace) + } + copies[url] = text + } + ) + } +} + +private let diskFull = BoardWriteError( + operation: .style(title: "Fix login"), + path: "/Boards/Work/todo/fix-login/index.md", + reason: .io(message: "the disk is full") +) + +private func copyDestination() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("dirty-buffer-\(UUID().uuidString).md") +} + +// MARK: - Tests + +@MainActor +@Suite("DirtyBufferGuard") +struct DirtyBufferGuardTests { + + @Test("A save that lands never blocks — the close proceeds with no modal at all") + func successfulSaveNeverBlocks() { + let buffer = FakeBuffer() + let bufferGuard = buffer.makeGuard() + + #expect(bufferGuard.beginClose()) + #expect(bufferGuard.phase == .idle) + #expect(buffer.savedText == "the paragraph that exists nowhere else") + #expect(buffer.saveAttempts == 1) + } + + @Test("A failing save blocks the close and carries the error the alert will phrase") + func failingSaveBlocks() { + let buffer = FakeBuffer() + buffer.saveFailure = diskFull + let bufferGuard = buffer.makeGuard() + + #expect(!bufferGuard.beginClose()) + #expect(bufferGuard.phase == .blocked(diskFull)) + + guard case let .blocked(error) = bufferGuard.phase else { + Issue.record("expected the blocked phase") + return + } + #expect(BannerCenter.headline(for: error) == "Couldn't restyle 'Fix login' — the disk is full") + } + + @Test("Retrying after the cause is fixed unblocks the close") + func retrySucceedsAndCloses() { + let buffer = FakeBuffer() + buffer.saveFailure = diskFull + let bufferGuard = buffer.makeGuard() + #expect(!bufferGuard.beginClose()) + + // A retry while the disk is still full stays blocked — the alert returns, which is the + // honest outcome and the reason there is no "close anyway" button. + #expect(!bufferGuard.retry()) + #expect(bufferGuard.phase == .blocked(diskFull)) + + buffer.saveFailure = nil + #expect(bufferGuard.retry()) + #expect(bufferGuard.phase == .idle) + #expect(buffer.savedText == "the paragraph that exists nowhere else") + #expect(buffer.saveAttempts == 3) + } + + @Test("Saving a copy elsewhere writes the text and unblocks the close") + func saveCopyWritesAndUnblocks() throws { + let buffer = FakeBuffer() + buffer.saveFailure = diskFull + let bufferGuard = buffer.makeGuard() + #expect(!bufferGuard.beginClose()) + + let destination = copyDestination() + try bufferGuard.saveCopy(to: destination) + + #expect(bufferGuard.phase == .idle, "the text is safe somewhere; the close may proceed") + #expect(buffer.copies[destination] == "the paragraph that exists nowhere else") + // The buffer's real home is still unwritten — that is the trade the user knowingly made. + #expect(buffer.savedText == nil) + } + + @Test("A copy that itself fails leaves the close blocked") + func failedCopyStaysBlocked() { + let buffer = FakeBuffer() + buffer.saveFailure = diskFull + buffer.copyFails = true + let bufferGuard = buffer.makeGuard() + #expect(!bufferGuard.beginClose()) + + #expect(throws: (any Error).self) { + try bufferGuard.saveCopy(to: copyDestination()) + } + #expect(bufferGuard.phase == .blocked(diskFull), "the text is still nowhere but memory") + } + + @Test("Discarding unblocks the close and writes nothing anywhere") + func discardUnblocks() { + let buffer = FakeBuffer() + buffer.saveFailure = diskFull + let bufferGuard = buffer.makeGuard() + #expect(!bufferGuard.beginClose()) + + bufferGuard.discard() + + #expect(bufferGuard.phase == .idle) + #expect(buffer.savedText == nil) + #expect(buffer.copies.isEmpty) + } +} diff --git a/KanbanTests/RootRecoveryTests.swift b/KanbanTests/RootRecoveryTests.swift new file mode 100644 index 0000000..651c526 --- /dev/null +++ b/KanbanTests/RootRecoveryTests.swift @@ -0,0 +1,280 @@ +import Foundation +import Testing +@testable import Kanban + +/// What happens to an open board when its folder moves, disappears, or comes back — the settled +/// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end +/// against a **real FSEvents stream, a real registry, and a real bookmark**. +/// +/// A fake would prove nothing here. The three claims under test are all claims about the +/// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the +/// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is +/// what distinguishes "this board loads fine" from "this board can be written to". Every one of +/// them would be assumed rather than tested against a double. +/// +/// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for +/// something is generous** (poll for seconds — a slow machine must not fail a correctness test) +/// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing +/// at all — the writability clearing rule — drives the store's inbound door directly instead, so +/// its ordering is exact rather than merely likely. + +// MARK: - Support + +/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +/// A small board: two lanes, one card. Enough that a reload landing at a new root has something +/// recognisable in it. +@MainActor +private func makeBoard(at root: URL) throws { + let manager = FileManager.default + try manager.createDirectory(at: root, withIntermediateDirectories: true) + + func write(_ relativePath: String, _ text: String) throws { + let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) + try manager.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(text.utf8).write(to: folder.appendingPathComponent("index.md")) + } + + try write("", Item.board) + try write(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try write(Ident.lane2, Item.rich(order: "2048", title: "Doing")) +} + +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try makeBoard(at: fixture.root) + return fixture +} + +/// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an +/// agent or an editor does. +private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws { + let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md")) +} + +private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] { + (snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value) +} + +/// The one shape a path comparison may take here. A bookmark resolves to the canonical location +/// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one +/// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly. +private func canonical(_ url: URL) -> String { + url.resolvingSymlinksInPath().standardizedFileURL.path +} + +/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is +/// not a bounded-latency promise. A root change in particular can take several seconds to surface. +@MainActor +private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async { + let start = ContinuousClock.now + while ContinuousClock.now - start < deadline { + if condition() { return } + try? await Task.sleep(for: .milliseconds(25)) + } +} + +/// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test +/// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live. +@MainActor +private func settle() async { + try? await Task.sleep(for: .milliseconds(400)) +} + +// MARK: - Tests + +@MainActor +@Suite("Root recovery") +struct RootRecoveryTests { + + // MARK: Rename absorption + + @Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact") + func renameIsAbsorbed() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let renamed = fixture.root + .deletingLastPathComponent() + .appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true) + // Registered first so it runs *last*: the store is released — and its watcher stopped — + // before the folder it is watching is removed. + defer { try? FileManager.default.removeItem(at: renamed) } + + let registry = BoardStoreRegistry() + let store = try registry.acquire(fixture.root) + defer { registry.release(store) } + await settle() + + // A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not + // the string that names it. + try FileManager.default.moveItem(at: fixture.root, to: renamed) + + // The done-when: the store's URLs re-derived, and a reload actually ran at the new root + // (`snapshot.rootURL` is the root the last successful walk used). + await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) } + #expect(canonical(store.rootURL) == canonical(renamed)) + #expect(canonical(store.snapshot.rootURL) == canonical(renamed)) + + // Nothing was ever wrong, and the strip says so. + #expect(store.readOnlyLock == nil) + #expect(store.reloadFailure == nil) + #expect(store.bannerRows.isEmpty) + #expect(store.snapshot.lanes.count == 2, "the board is still the board") + + // The entry followed too: identity did not change, so the same store answers for the new + // path and a window opening it would share rather than duplicate. + #expect(registry.liveStore(for: renamed) === store) + #expect(registry.openBoardCount == 1) + + // And the wiring survived the move: a foreign edit at the *new* path reloads. + try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048") + await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") } + #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"]) + #expect(store.bannerRows.isEmpty) + } + + // MARK: Vanish and return + + @Test("A vanished root locks the board read-only, and the root's return clears it") + func vanishAndReturn() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let registry = BoardStoreRegistry() + let store = try registry.acquire(fixture.root) + defer { registry.release(store) } + let lastGood = store.snapshot + await settle() + + // The folder is deleted in Finder while the board is open. Bookmark re-resolution finds + // nothing that exists, and the last-known path is gone too. + try FileManager.default.removeItem(at: fixture.root) + + await waitUntil { store.readOnlyLock == .vanishedRoot } + #expect(store.readOnlyLock == .vanishedRoot) + + // Wait for the vanished state to *settle* before recreating anything, and not for tidiness: + // entering the lock re-attaches the watcher at the missing path, and that re-attach owes a + // debounced reconciling reload. Recreating the folder inside that 200 ms window would let + // the reload find the root already back and clear the lock without a root change ever being + // delivered — a legitimate recovery, but a different one from the one under test here. + // Waiting for that reload to land and fail pins the sequence to the real-world shape: the + // root comes back later, and its *creation* is what recovers the board. + await waitUntil { store.reloadFailure != nil } + await store.awaitQuiescence() + #expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root") + + // The last-good snapshot is still on screen — that is the whole point of the lock. + #expect(store.snapshot == lastGood) + #expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock") + + // And the strip leads with the lock. (The re-attach's reconciling reload fails against the + // missing root, so a breakage row stands behind it; the *lock* is what comes first.) + guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else { + Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))") + return + } + + // Every write is refused — nothing would land anywhere. + do { + try store.performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in } + } + Issue.record("expected the locked board to refuse the write") + } catch let refusal as BoardStoreWriteRefusal { + #expect(refusal == .readOnlyLocked(.vanishedRoot)) + } + #expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write") + + // The root returns: a Finder undo, a remount, a folder recreated where the board was. Built + // aside and moved into place in one step, so the path appears as a whole board rather than + // as a directory that is filled in over several reload debounces. + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true) + try makeBoard(at: staging) + try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072") + try FileManager.default.moveItem(at: staging, to: fixture.root) + + // FSEvents was left watching the path precisely so this creation would be reported. + await waitUntil { store.readOnlyLock == nil } + #expect(store.readOnlyLock == nil, "a successful reload proves the root came back") + + await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") } + #expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"]) + #expect(store.reloadFailure == nil) + #expect(store.bannerRows.isEmpty) + + // The recreated folder is a different file than the one that was deleted, so the entry had + // to be re-keyed — otherwise the next window to open this board would get a second store + // over a board already on screen. + await waitUntil { registry.liveStore(for: fixture.root) === store } + #expect(registry.liveStore(for: fixture.root) === store) + #expect(registry.openBoardCount == 1) + + // And writes are live again. + try store.performWrite { () throws(BoardWriteError) -> Void in + try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in } + } + #expect(store.banners.oneShots.isEmpty) + } + + // MARK: The writability clearing rule + + @Test("The unwritable-location lock clears only on a reconciling reload whose probe passes") + func unwritableLockClearsOnlyOnAReconcilingProbe() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still + // reads perfectly. That is the whole difficulty of this case: the board loads fine. + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) + store.enterUnwritableLock() + #expect(store.readOnlyLock == .unwritableLocation) + + // A foreign reload succeeds — and clears nothing. Loading proves nothing about writing, + // which is exactly why this lock's clearing rule is not the other two's. + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + #expect(store.reloadFailure == nil, "an unwritable root still reads") + #expect(store.readOnlyLock == .unwritableLocation) + + // Neither does a reconciling one while the permission is still what it was. + store.handleWatcherEvent(.treeChanged(.reconciling)) + await store.awaitQuiescence() + #expect(store.readOnlyLock == .unwritableLocation) + + // The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event + // the board would act on — so the lock stands until the next reconciling sweep (wake, app + // activation) re-probes. + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + #expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes") + + store.handleWatcherEvent(.treeChanged(.reconciling)) + await store.awaitQuiescence() + #expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony") + #expect(store.bannerRows.isEmpty) + } + + @Test("A reconciling reload that finds the root unwritable does not raise the lock by itself") + func theProbeOnlyClears() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) + + store.handleWatcherEvent(.treeChanged(.reconciling)) + await store.awaitQuiescence() + + // Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a + // policy decision this layer has not been asked to make — recorded as a test so the + // asymmetry is deliberate rather than forgotten. + #expect(store.readOnlyLock == nil) + } + +}