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 } } /// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner /// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28) — /// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin. /// /// **It takes the one-shot's lifecycle**, deliberately: "a loss the user didn't notice is the harm, /// so it never auto-expires" is `OneShotBanner`'s "an error never evaporates unread", read for a row /// that reports something incomplete rather than something failed. Carrying its own `id` is the same /// consequence: two identical losses a minute apart are two rows, and dismissing one must not take /// the other with it. /// /// **It ranks below the true failures and above the ambient notices** — "an action that didn't /// happen outranks one that partially did" — which is why it is its own `BannerRow` case rather than /// an `OneShotBanner` with a `nil` error or a `InfoSignpost` with a heavier tone: neither of those /// vocabularies has a slot at loss's precedence, and bending one to fit would blur the reading that /// the class exists to make precise. public struct LossBanner: Identifiable, Sendable, Equatable { public let id: UUID public let message: String /// When the loss happened — the sort key for "newest first within a class". public let occurredAt: Date public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) { self.id = id self.message = message 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 seven 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`, `loss`, and `signpost`. Each reports something that already /// happened, so only the user can clear it. `loss` shares this lifecycle deliberately (settled /// 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the harm, so /// it never auto-expires" is the same reasoning that keeps a one-shot from evaporating unread, /// aimed at a row that isn't an error at all. /// - **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, the /// vanished root, and the writability probe — at open and, symmetrically, on every reconciling /// reload thereafter. 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) /// Content that didn't arrive though nothing failed — folders skipped from a Finder drop, the /// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it, /// above the ambient notices below it (settled 2026-07-28, see `LossBanner`). case loss(LossBanner) /// 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 let .loss(loss): "loss:\(loss.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, .loss: .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 .loss(loss): loss.message 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, loss rows, 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 .loss(loss): loss.id case let .signpost(signpost): signpost.id case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil } } /// **This row's buttons, in the order Tab visits them** — Cancel, then Dismiss. /// /// It exists because 10-accessibility.md ▸ Full Keyboard Access rules the banner's buttons in by /// name (2026-07-29): "'Every control' is literal and includes banner-row buttons — a Dismiss or /// Cancel on a banner must be a Tab stop … Cancel on an in-progress operation is exactly the /// control that cannot require a pointer". A claim about *which* controls a row has is then a /// fact about the row's data rather than about a view's `if` ladder, so it can be pinned /// headlessly and the strip can render straight from it (`BannerStripView`) — which is the same /// posture the rest of this type already takes ("the per-kind affordances hang off the row's /// data, not off separate views"). /// /// No row has both today: the two conditions are disjoint by construction (only an in-progress /// row cancels, and an in-progress row is never dismissable). The order is stated anyway, since /// it is the Tab order the moment one does. public var controls: [BannerRowControl] { var controls: [BannerRowControl] = [] if case let .inProgress(operation) = self, let cancel = operation.cancel { controls.append(.cancel(cancel)) } if let dismissID { controls.append(.dismiss(dismissID)) } return controls } } // MARK: - A row's buttons /// One button on a banner row — the strip's whole vocabulary of per-row controls, as data. /// /// **Identified by its label**, which is legitimate rather than lazy here: a row carries at most one /// of each kind, the label is the user-facing name of exactly that kind, and it is what both /// surfaces the ruling cares about need — the button's title (Cancel) or its accessibility label /// (Dismiss, whose face is an ✕ glyph). public enum BannerRowControl: Identifiable, Sendable { /// Stop the operation this row is reporting and remove its partial work — carried by cancelable /// in-progress rows only (`InProgressOperation`: "safe copies only"). case cancel(@MainActor @Sendable () -> Void) /// Clear this row, by the id `BannerCenter.dismiss(_:)` takes. case dismiss(UUID) public var label: String { switch self { case .cancel: "Cancel" case .dismiss: "Dismiss" } } public var id: String { label } } // 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, loss rows, 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:losses: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] = [] /// Newest first, like `oneShots` — content that didn't arrive though nothing failed, /// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see /// `LossBanner`). public private(set) var losses: [LossBanner] = [] /// 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 loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see /// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with. public func postLoss(_ message: String) { losses.insert(LossBanner(message: message), 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) } /// **The skipped undo step** (13-native-undo.md ▸ Rules ▸ staleness validation): an inverse found /// its target holding somebody else's newer value, so it was popped rather than applied and ⌘Z /// fell through to the next step. This is the row that says so. /// /// **A signpost, and no new class** — the vocabulary's answer rather than a compromise. 13 asks /// for an "info-tone banner", and 02-architecture.md § The banner surface gives the info tone /// exactly two halves: the pinned in-progress row with its spinner, and the passive signpost. /// Nothing is in flight here, so the passive half is the whole of the choice. It also reads /// right: unlike a `loss` row (warning tone, "content that didn't arrive though nothing failed"), /// **nothing was lost and nothing failed** — the file holds exactly what its most recent writer /// meant it to, the stack moved on to a step that did apply, and the user's ⌘Z did something. A /// row that ranks last and may collapse behind "+N more" is the honest weight for that: calm by /// design, nothing gated on seeing it instantly. And it is emphatically not a `oneShot`, which /// carries a `BoardWriteError` — a *failed* inverse posts one of those instead, and the two rows /// must stay distinguishable (`HistoryStepOutcome`). public func postSkippedStep(_ direction: HistoryDirection, subject: String) { postSignpost(Self.skippedStepMessage(direction, subject: subject)) } /// **The refused paste** (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29): the staged /// snapshot was missing or unreadable, so the paste produced **nothing**, and this is the row that /// says so — "the paste produces nothing, and a one-shot failure banner names it from the /// manifest's metadata". /// /// **A `oneShot`, not a loss row** — which is the pivot, and it is the vocabulary reading the /// event correctly rather than a reclassification for its own sake. The degraded paste *was* a loss /// row because the items landed and only their attachments did not: content that didn't arrive /// though nothing failed. Under refuse-don't-degrade nothing lands at all, which is exactly /// 02-architecture.md's definition of a one-shot — a write that did not happen — so the row /// carries a `BoardWriteError` like every other failure, ranks with the true failures, and says /// "Couldn't paste" rather than "Pasted … without". /// /// The retired member is `postDegradedPaste(_:)` and its `AttachmentLoss` payload: with the /// degraded materialization gone there is no partial arrival to account for, and the /// loss-accounting problem it existed to report — what didn't arrive, and whether the totals were /// honest — dissolves rather than being solved. **The loss class itself is untouched**: folder-drop /// skips still post one (`postSkippedFolders`), and the relocation, migration, displacement and /// remint notices are all still its. /// /// `title` is the offending entry's, from the manifest's own metadata, `nil` for an untitled item — /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so `actionPhrase(for:)` /// says "the item" instead. The staging path is what the error names as its `path`: it is the file /// that was not there, and naming it is what makes a bug report about this actionable. public func postRefusedPaste(title: String?, stagedAt path: String) { post(BoardWriteError( operation: .paste(title: title), path: path, reason: .clipboardContentGone )) } /// One card whose loose files were relocated into `attachments/` — what /// `relocatedLooseFilesMessage(for:)` names. /// /// `fileNames` are the names the files had **beside `index.md`**, not the Finder-renamed ones /// they may have landed under: those are the names the user or their agent wrote, and the one /// they would recognize in a sentence (`WriteOperation.importAttachment`'s own rule, read for /// the relocation). `title` is the card's as written, `nil` for an untitled one — "Untitled" is /// a rendering, never a value (03-board-ui.md § Card face). public struct Relocation: Sendable, Equatable { public let title: String? public let fileNames: [String] public init(title: String?, fileNames: [String]) { self.title = title self.fileNames = fileNames } } /// **The loose-file relocation** (01-storage-format.md § Fractal layout ▸ Rules, settled /// 2026-07-28): a file was sitting beside a card's `index.md`, the app moved it into that card's /// `attachments/`, and this is the row that says so — "surfacing a graceful warning-tone notice /// naming the card and files". /// /// **A loss row, though nothing was lost.** The class is the vocabulary's warning-tone, /// user-dismissed, never-expiring one — `LossBanner`'s "their future kin" — and this is exactly /// that shape read once more: the app did something to the user's files that they did not ask /// for, so it must be said out loud, it must not evaporate unread, and it must not rank as an /// error, because no action failed. `signpost` would be too quiet (it ranks last and may /// collapse behind "+N more"); `oneShot` would be a lie (it carries a `BoardWriteError`, and /// the write succeeded). The name of the class is about its *lifecycle and tone*, not about /// loss being the only thing it can report. /// /// A relocation that moved nothing posts nothing. public func postRelocatedLooseFiles(_ relocations: [Relocation]) { guard let message = Self.relocatedLooseFilesMessage(for: relocations) else { return } postLoss(message) } /// **The legacy tombstone migration** (01-storage-format.md § Deletion, resettled 2026-07-28: /// "Legacy `deleted:` keys migrate on load-and-write, never destroy … a graceful warning-tone /// notice"): a board written by an older version carried `deleted:` keys, the app moved the /// cards those keys named into `.trash/` and returned the lanes live, and this is the row that /// says so. /// /// **A loss row for `postRelocatedLooseFiles`' exact reason**, and it is the same shape of event: /// the app moved the user's folders on its own initiative, on a board it opened rather than on a /// gesture they made. That must be said out loud, must not evaporate unread, and must not rank /// as an error, because no action failed. /// /// **Cards only** (01-storage-format.md § Deletion, lane clause re-ruled 2026-07-29): the lane /// half of this notice retired with the lane migration itself — a lane's `deleted:` is inert now, /// nothing is written for it, and a row announcing an act the app did not perform would be worse /// than silence. /// /// `cards` holds the migrated cards' titles, in the order they were written, `nil` for an /// untitled one — "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so the /// phrasing layer decides what to call it. A migration that migrated nothing posts nothing. public func postMigratedTombstones(cards: [String?]) { guard let message = Self.migratedTombstonesMessage(cards: cards) else { return } postLoss(message) } /// One claimed board-root name whose squatter was moved aside — what /// `displacedClaimedNamesMessage(for:)` names. /// /// Both names are carried because the notice owes **old and new** (01-storage-format.md /// § Fractal layout ▸ Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice /// naming old and new"): the user needs to know which of their files moved *and* where to find /// it, and a sentence naming only one of the two would be half an answer. public struct Displacement: Sendable, Equatable { /// The claimed name the app took back — `.trash`, `CLAUDE.md`. public let name: String /// The Finder-ladder name the displaced node now has — `.trash 2`. public let movedTo: String public init(name: String, movedTo: String) { self.name = name self.movedTo = movedTo } } /// **The claimed-name displacement** (01-storage-format.md § Fractal layout ▸ Rules, ruled /// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it /// aside — preserved verbatim, never destroyed — and this is the row that says so. /// /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling /// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the /// user's that they did not ask it to move, so it must be said out loud, must not evaporate /// unread, and must not rank as an error, because nothing failed. /// /// A displacement that displaced nothing posts nothing. public func postDisplacedClaimedNames(_ displacements: [Displacement]) { guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return } postLoss(message) } /// **The duplicate-id remint** (01-storage-format.md § Fractal layout ▸ Rules, re-ruled /// 2026-07-29): two folders were carrying one id, the app gave the later one the fresh identity a /// copy should have had, and this is the row that says so — "Announced, not invisible: the /// relocation-style warning-tone notice names the repair … identity changed, so a line says so, /// but nothing waits on consent". /// /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, and the ruling asks for that /// class by name: the app renamed a folder of the user's that they did not ask it to rename, so it /// must be said out loud, must not evaporate unread, and must not rank as an error, because /// nothing failed. It is emphatically **not** a condition banner with a button — the user-gated /// Repair retired on 2026-07-29 and this row is what replaced it. /// /// `titles` are the reminted items' as the load found them, `nil` for an untitled one — "Untitled" /// is a rendering, never a value (03-board-ui.md § Card face). A remint that reminted nothing /// posts nothing. public func postRemintedDuplicateIDs(_ titles: [String?]) { guard let message = Self.remintedDuplicateIDsMessage(for: titles) else { return } postLoss(message) } /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its /// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a /// mixed drag proposes for its files only, and the drop imports the files while a one-shot /// banner names the skipped folders" — now a loss row (settled 2026-07-28): folders that never /// arrived are a non-failure loss, not a write failure. With the degraded paste retired /// (`postRefusedPaste`) this is the loss class's clearest remaining instance: the drop *did* land, /// and only the payload the attachment model cannot hold stayed behind. /// /// A drop with no skipped folders posts nothing — nothing was lost, so there is nothing to say. public func postSkippedFolders(count: Int) { guard count > 0 else { return } postLoss(Self.skippedFoldersMessage(count: count)) } /// Removes a dismissable row: a one-shot failure, a loss row, 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 } losses.removeAll { $0.id == id } signposts.removeAll { $0.id == id } } /// Removes every dismissable row — one-shots, losses, 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() losses.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 > **loss rows** > commit and attachment failures > **passive info /// rows**. Four 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. /// - **Loss rows slot between the true failures and the ambient notices** (settled 2026-07-28): /// "an action that didn't happen outranks one that partially did" is why a loss ranks below /// every one-shot write failure, while "content that didn't arrive though nothing failed" is /// still more consequential than a condition or a signpost that only reports ambient state — /// so a loss also ranks above `historySuspended` and the attachment one-shots that follow it. /// Concretely: non-attachment one-shots, then loss rows, then the commit-and-attachment class, /// then signposts. /// - **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 — `losses` /// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`). public nonisolated static func rows( lock: ReadOnlyLockReason?, breakage: BoardLoadError?, oneShots: [OneShotBanner], losses: [LossBanner], 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)) rows.append(contentsOf: newestFirst(losses).map(BannerRow.loss)) 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) } /// The same stable newest-first ordering as the overload above, for loss rows — the two classes /// share a lifecycle, and `postLoss` already maintains newest-first on insertion the way /// `post(_:)` does. private nonisolated static func newestFirst(_ losses: [LossBanner]) -> [LossBanner] { losses .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 match the commands the user pressed — **Delete**, Empty Trash — which is /// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to /// Trash' phrasing is reserved for the system Trash; board deletion says 'Delete'". A banner /// saying a card could not be *moved to the trash* would name the wrong one of the app's two /// trashes (the card window's attachment Remove is the other). 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 .paste(title): // **The command's own verb** (04-interactions.md ▸ Clipboard's own example sentence, // "Couldn't paste 'Fix login' — the copied content is gone"). The user pressed ⌘V, and // "Couldn't copy" — the operation the Writer would have run — would name a gesture they // never made. The item is named from the manifest's metadata, which is what the embedded // `index.md` is kept for now that it is never a materialization source. if let title { "Couldn't paste '\(title)'" } else { "Couldn't paste the item" } case let .delete(title): if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" } case let .purge(title): if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" } case let .migrateTombstone(title): // One sentence covering both shapes of the migration, because the user's mental model // of either is the same non-event: a board written by an older version being brought // up to date. It deliberately names neither "delete" nor "trash" — a card's migration // moves it into the trash and a lane's brings it *back*, so any verb specific enough // to describe one would be actively wrong about the other. if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" } case let .style(title): if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" } case let .resize(title): if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" } case let .rename(title): // The title here is the item's name *before* the edit — the one the user is still // looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example // sentence) identify the right row rather than a name that never landed. if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" } case let .duplicateBoard(title): if let title { "Couldn't duplicate '\(title)'" } else { "Couldn't duplicate the board" } case let .saveAsTemplate(title): // The command's own words (File ▸ Save as Template), because that is the button they // pressed and the board is still exactly as it was: nothing was saved *over*, and the // failure is about the copy in the templates folder, not about this board's own files. if let title { "Couldn't save '\(title)' as a template" } else { "Couldn't save the board as a template" } case let .importAttachment(filename): "Couldn't import '\(filename)'" case .listAttachments: "Couldn't read this card's attachments" case let .removeAttachment(filename): // **Finder's phrasing, deliberately** — and the one place in this app that is allowed // it. The naming constraint above reserves "move to the Trash" for the *system* Trash, // and this is the operation that uses it: the file really did go (or fail to go) where // Finder's own ⌘⌫ sends things, so saying anything else — "remove", "delete" — would // describe the board's own trash instead and promise the wrong recovery. "Couldn't move '\(filename)' to the Trash" case .renumberChildren: "Couldn't renumber cards" case let .relocateLooseFile(filename): // The verb matches the successful notice's ("Moved 'notes.txt' into attachments"), so // the failure reads as the same sentence negated rather than as a different event. // It stays in the ordinary one-shot precedence class rather than joining the attachment // imports at the bottom: the relocation is work the *app* started on its own, and a // failure the user did not provoke is exactly the one they have no other way to learn // about. "Couldn't move '\(filename)' into attachments" case .agentGuide: // **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and // most users will never have opened it — a filename here would name something they have // no relationship with. It says nothing about the board's own files because none were // touched, and nothing is lost: the board works exactly as well without the guide, which // is why every *refusal* to write it is a log line and only a real I/O failure gets here. "Couldn't update the agent guide" case let .displaceClaimedName(name): // **The name, quoted, and what the app wanted with it** — the failure's mirror of the // success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names // the *consequence* the user can act on rather than the mechanics: while the name is // held, the feature that needs it does not work, and the fix is theirs (move or rename // the thing sitting there) because the app has just demonstrated it cannot. "Couldn't move '\(name)' aside — Lanework needs that name" case let .repairDuplicateID(title): // **The failure's mirror of the success row** ("Repaired duplicate id — 'Fix login'"), in // the same words, so the two read as one sentence and its negation. It names the *defect* // rather than the mechanics ("couldn't rename a folder" would describe an act the user has // no model of) and stays graceful when the item is untitled, because the enum knows a // title and never a kind. if let title { "Couldn't repair the duplicate id of '\(title)'" } else { "Couldn't repair a duplicate id" } case let .toggleTask(title): // The user's word for it, not the file's: they ticked a box. The card is named where // the read that preceded the flip learned its title, so a body write that refused says // *which* card refused it — a card window is not always the frontmost thing on screen. if let title { "Couldn't tick the checkbox in '\(title)'" } else { "Couldn't tick the checkbox" } case let .editBody(title): // **Save**, because that is the word for what just failed: the Edit→Preview flip is the // effective Save button (05-card-window.md ▸ Edit), and the debounced tick is the same // act happening on its own. The keystrokes are still in the buffer — the banner says the // app could not put them on disk, not that they are gone. if let title { "Couldn't save '\(title)'" } else { "Couldn't save the card" } case let .rawSource(title): // **Apply**, because that is the button they pressed (05-card-window.md ▸ Raw source // outlet), and "source changes" because what failed to land is the whole file as they // typed it — not a save of the card's body, which is what "Couldn't save" would claim. // The buffer is still on screen: the banner says the app could not put those bytes on // disk, not that they are gone. if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" } // **The comment family names the card, because that is the title these operations carry** — // a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no // `order`"), and the card is the thing on the window in front of them. Each sentence is the // gesture's own word, the vocabulary's standing rule: they pressed ⌘↩, or Delete, or nothing // at all in the draft's case. case let .saveCommentDraft(title): // **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a // comment could not be saved would name one that does not exist yet. The typed text is // still in the composer — this says the app could not put it on disk. if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" } case let .postComment(title): // The Comment button's own word (05-card-window.md ▸ The comments column, "⌘↩ posts"). // The draft is untouched on disk, which is what makes this a retry rather than a loss. if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" } case let .editComment(title): if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" } case let .deleteComment(title): if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" } case .purgeCommentTrash: // No title, and no mention of a trash the user has never seen: `comments/.trash/` is // "never a UI surface", so the sentence is about the *card's* files being tidied — the // agent guide's posture, one level down. Nothing is lost either way; the next window // open sweeps again. "Couldn't tidy up deleted comments" } } /// 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 case let .staleTarget(message): message case let .invalidSource(error): // The loader's own reason, without its path: the path is always the card's own // `index.md`, and the banner has already named the card. In practice the raw-source // outlet raises this in its alert and never here — the store validates before it opens a // write bracket — so this line exists for a caller that reached the Writer directly. error.reason.description case .clipboardContentGone: // **04's own words** ("the copied content is gone"), and the whole of what can honestly be // said: the snapshot the pasteboard promised is not on disk, so there is no cause to // diagnose past that. It deliberately says nothing about *why* — a sweep that ran early, a // container the system reclaimed, an unmounted volume — because the user's recovery is the // same in every case, and 04 names it: ⌘C again. "the copied content is gone" } 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"). /// /// **The unwritable location gets two lines, not one shared one** (02 § Write-failure /// surfacing, settled): "which specific cause, not a shared line … the fixes being different /// acts". Ejecting a DMG or copying the board off it is not the same repair as a `chmod` or a /// Get Info panel, and a line that covered both would name neither. 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(.readOnlyVolume): "This board's volume is read-only — showing the last good view, read-only" case .unwritableLocation(.permissionDenied): "You don't have permission to change this folder — 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 skipped-folders line — 04-interactions.md's own example, "Folders can't be attached — 2 /// skipped", generalized over the count. `postSkippedFolders` never calls this at `count == 0`, /// so every real call already has something to report. /// /// **Singular stays "Folders can't be attached — 1 skipped"** rather than recasting the leading /// clause to "A folder can't be attached": the claim is always about the drag as a whole — *its* /// folders didn't make it in — and only the trailing count varies, which is one sentence shape /// for every count instead of two that would have to be kept in agreement with each other. public nonisolated static func skippedFoldersMessage(count: Int) -> String { "Folders can't be attached — \(count) skipped" } /// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved /// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on. /// /// **Plurals fold twice**, which is the design's word for it ("plurals fold"; "multiple files /// one card → 'Moved 3 files into attachments — Fix login'"): /// /// - **One card, one file** names the file *and* the card, which is the sentence the design /// wrote: both facts fit, so both are said. /// - **One card, several files** drops the filenames for their count. A banner is one line, and /// a list of names would be the first thing to truncate; the card is still named, which is /// what makes the notice actionable — the user knows exactly which `attachments/` to look in. /// - **Several cards** folds again, to two counts: "Moved 5 files into attachments — 3 cards" /// (settled here, the judgment 01 leaves to the implementation): the true total plus the true /// item count is the honest summary where a truncated list of titles would not /// be. This case is the whole-board sweep (a board opened after an agent scattered files /// across it), where naming three cards of eleven would read as a bug. /// /// The multi-card branch never has to spell a singular: two cards carry at least two files. /// /// `nil` when nothing moved — a relocation that relocated nothing is not news. Entries with no /// filenames are dropped first, so a caller need not filter its own list. public nonisolated static func relocatedLooseFilesMessage(for relocations: [Relocation]) -> String? { let cards = relocations.filter { !$0.fileNames.isEmpty } guard let only = cards.first else { return nil } let total = cards.reduce(0) { $0 + $1.fileNames.count } guard cards.count == 1 else { return "Moved \(total) files into attachments — \(cards.count) cards" } let subject = only.title.map { "'\($0)'" } ?? "an untitled card" guard total == 1, let name = only.fileNames.first else { return "Moved \(total) files into attachments — \(subject)" } return "Moved '\(name)' into attachments — \(subject)" } /// The legacy tombstone migration's line, written in `relocatedLooseFilesMessage`'s voice /// because it is the same kind of notice: the act first, the subject after an em dash, plurals /// folded, a sole item named. /// /// **One clause, since the lane half retired** (01-storage-format.md § Deletion, re-ruled /// 2026-07-29 — lanes ignore the key rather than migrating). The shapes: /// /// - **One card**: "Moved 'Fix login' to the trash — it carried an old deleted marker". /// - **Several**: "Moved 3 cards to the trash — they carried old deleted markers". /// /// **The tail names the cause**, and it is the whole explanation the row owes: the user did not /// delete anything just now, and without the clause the sentence would read as an action they /// had somehow just taken. /// /// `nil` when nothing migrated — a migration that migrated nothing is not news. public nonisolated static func migratedTombstonesMessage(cards: [String?]) -> String? { guard !cards.isEmpty else { return nil } let subject = cards.count == 1 ? sole(cards[0]) : "\(cards.count) cards" let tail = cards.count == 1 ? "it carried an old deleted marker" : "they carried old deleted markers" return "Moved \(subject) to the trash — \(tail)" } /// The claimed-name displacement's line — the relocation's own voice (the act first, the subject /// after an em dash), naming **old and new** as the ruling requires. /// /// - **One name**: "Renamed '.trash' to '.trash 2' — Lanework needs that name". The tail is the /// whole explanation the row owes: the user did not rename anything, and without it the /// sentence would read as an act they had somehow just taken. It says *needs the name* rather /// than anything about what was there, because what was there is the user's business and /// still exists, under the name the row just gave them. /// - **Several**: folded to a count in the relocation's idiom — "Renamed 2 items — Lanework /// needs those names". Two claimed names can be squatted at once (a board somebody unpacked /// over an old one), and a two-clause sentence would be longer than the row. /// /// `nil` when nothing moved — a displacement that displaced nothing is not news. public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? { guard let only = displacements.first else { return nil } guard displacements.count == 1 else { return "Renamed \(displacements.count) items — Lanework needs those names" } return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name" } /// The duplicate-id remint's line — **the design's own sentence**, verbatim /// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "posts 'Repaired duplicate /// id — 'Fix login''"). /// /// - **One**: "Repaired duplicate id — 'Fix login'". The relocation's idiom exactly — the act /// first, the subject after an em dash — and it names the *id* rather than the folder, because /// the folder name is a UUID no user has a relationship with. There is no explanatory tail: the /// act is its own explanation, and nothing about the user's content changed. /// - **Several**: folded to a count, the idiom's plural — "Repaired 3 duplicate ids". Several /// arrive together routinely (a hand-copied lane's cards all collide at once), and a sentence /// naming each would be longer than the row. /// /// `nil` when nothing was reminted — a heal that healed nothing is not news, which is also the /// vanished-duplicate race's whole outward appearance: silence. public nonisolated static func remintedDuplicateIDsMessage(for titles: [String?]) -> String? { guard let only = titles.first else { return nil } guard titles.count == 1 else { return "Repaired \(titles.count) duplicate ids" } return "Repaired duplicate id — \(sole(only))" } /// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation /// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the /// clause it sits in already says which level it is). private nonisolated static func sole(_ title: String?) -> String { guard let title else { return "an untitled item" } return "'\(title)'" } /// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix /// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …"). /// /// **The verb is the command the user pressed**, not the half of the step that declined: a step /// already undone sits on the redo stack reversed, so the closure ⇧⌘Z crosses is the one /// registered as `redo`, and a sentence naming the half would tell the user they pressed the /// other key (`HistoryDirection`). /// /// **`subject` is quoted whatever it names** — the item's title for a step with one target /// ("'Fix login'"), the step's own 06 phrase for a batch or an untitled item ("'Move 3 Cards'"). /// One sentence shape for both readings, chosen where the step is registered /// (`BoardStore.registerStep`) because that is the only place that knows how many items it named. /// /// **"changed outside Lanework"** is the design's own wording and stays literal: it is the whole /// explanation the row owes — the app did not decline out of caution, somebody else wrote to that /// item, and the reason the step is gone is that applying it would have thrown their edit away. public nonisolated static func skippedStepMessage(_ direction: HistoryDirection, subject: String) -> String { let verb = switch direction { case .undo: "Undo" case .redo: "Redo" } return "\(verb) skipped — '\(subject)' changed outside Lanework" } /// 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 } } }