The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1342 lines
78 KiB
Swift
1342 lines
78 KiB
Swift
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 one-shot failure class's second shape** (02-architecture.md § The banner surface, settled
|
|
/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation
|
|
/// failure** — the operation named in the user's words plus the underlying error, phrasing still
|
|
/// BannerCenter's — because failures rank by what they are, not by which error vocabulary threw
|
|
/// them".
|
|
///
|
|
/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git
|
|
/// operations stay off the closed `WriteOperation` vocabulary — only the banner tier learns the
|
|
/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a
|
|
/// checkout is not one. What the two shapes *do* share is everything the strip cares about — the
|
|
/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle — which is why
|
|
/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`).
|
|
///
|
|
/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is
|
|
/// the mirror of this one's: a failed restore used to post a `LossBanner` — the nearest class with a
|
|
/// free-form message — which read as warning-tone content-didn't-arrive when what actually happened
|
|
/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2)
|
|
/// pull/push … presents in the error tone at the failure rank, never as a warning-tone loss row."
|
|
public struct GitFailureBanner: Identifiable, Sendable, Equatable {
|
|
public let id: UUID
|
|
/// Which operation didn't happen — a closed vocabulary, so the sentence is composed here rather
|
|
/// than carried in from the git layer (`GitOperation`).
|
|
public let operation: GitOperation
|
|
/// The underlying error, verbatim — libgit2's own message, the free-form tail the phrasing rules
|
|
/// allow ("could not write to '…': Permission denied"). Never the banner's verb.
|
|
public let reason: String
|
|
/// When it failed — the sort key for "newest first within a class", shared with the write
|
|
/// failures it ranks beside.
|
|
public let occurredAt: Date
|
|
|
|
public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) {
|
|
self.id = id
|
|
self.operation = operation
|
|
self.reason = reason
|
|
self.occurredAt = occurredAt
|
|
}
|
|
}
|
|
|
|
/// **The git operations the banner can name** — the closed vocabulary behind `GitFailureBanner`,
|
|
/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner
|
|
/// owns all user-facing phrasing and localization from that vocabulary", and an operation added
|
|
/// without a sentence to say about it is a compile-time hole rather than a silent default
|
|
/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively).
|
|
///
|
|
/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2)
|
|
/// pull/push" — the first three are here because they have producers today
|
|
/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence
|
|
/// when the remote half ships.
|
|
///
|
|
/// No associated values: a branch switch's target is in the in-progress row the failure replaces
|
|
/// ("Switching to 'main'…"), and a failure that named it again would be the only member of this
|
|
/// vocabulary carrying a payload — a case can grow one the day a sentence genuinely needs it.
|
|
public enum GitOperation: Sendable, Equatable, CaseIterable {
|
|
case undo
|
|
case redo
|
|
case branchSwitch
|
|
|
|
/// **Add-git, when the form that asked has been dismissed** (06-history-undo.md ▸ Interaction with
|
|
/// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer
|
|
/// arrives, the failure falls back to the one-shot banner above — inline is the primary surface,
|
|
/// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback.
|
|
case addGit
|
|
|
|
/// The pair ⌘Z and ⇧⌘Z map to — so a caller that knows a `HistoryDirection` (the provider's
|
|
/// crossing) never has to spell the mapping itself, and never gets it backwards.
|
|
public static func restore(_ direction: HistoryDirection) -> GitOperation {
|
|
switch direction {
|
|
case .undo: .undo
|
|
case .redo: .redo
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 eight 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`, `gitFailure`, `loss`, and `signpost`. Each reports something
|
|
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
|
|
/// `oneShot` in a second shape (settled 2026-07-31) — same tone, same rank, same lifecycle, a
|
|
/// message where the other carries a `BoardWriteError`. `loss` shares the 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)
|
|
/// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull
|
|
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
|
|
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
|
|
case gitFailure(GitFailureBanner)
|
|
/// 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 .gitFailure(banner): "git-failure:\(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 {
|
|
// A git operation that failed is an action that didn't happen, so it takes the failure
|
|
// tone with the write failures it ranks beside — "never as a warning-tone loss row"
|
|
// (02-architecture.md § The banner surface, settled 2026-07-31).
|
|
case .readOnlyLock, .reloadBreakage, .oneShot, .gitFailure: .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 .gitFailure(banner): BannerCenter.headline(for: banner)
|
|
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 .gitFailure(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 failures in both shapes (write
|
|
/// and git-operation), 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:gitFailures:)`, 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. **And the git operations
|
|
/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin — "the operation
|
|
/// named in the user's words plus the underlying error, phrasing still BannerCenter's" — which is
|
|
/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row.
|
|
///
|
|
/// ### 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 failure class's git-shaped half, newest first like `oneShots` — a failed undo restore,
|
|
/// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see
|
|
/// `GitFailureBanner`).
|
|
///
|
|
/// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no
|
|
/// payload in common — one carries the closed `WriteOperation` vocabulary and a path, the other
|
|
/// a git operation and libgit2's message — and every reader of a write failure would have to
|
|
/// start asking whether it was one. They meet where the design says they meet, in the strip's
|
|
/// order: `rows(...)` merges them into one precedence class, newest first across both.
|
|
public private(set) var gitFailures: [GitFailureBanner] = []
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// **Records a git operation that didn't happen** — the failure class's second shape (settled
|
|
/// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly
|
|
/// (06-history-undo.md ▸ Interaction with external writers), a branch switch that could not run
|
|
/// (06 ▸ Branch switching), and the remote pair when pro-m2 wires them.
|
|
///
|
|
/// `reason` is the underlying error and nothing else — libgit2's own message, or the app's own
|
|
/// diagnosis of a repository it could not read. **Every user-facing word is composed here**
|
|
/// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's
|
|
/// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip.
|
|
public func postGitFailure(_ operation: GitOperation, reason: String) {
|
|
gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), 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))
|
|
}
|
|
|
|
/// **The mixed-kind drag that tried to leave the trash** (04-interactions.md ▸ The trash, ruled
|
|
/// 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — but
|
|
/// every out-of-trash drop target refuses the mixed payload, and the release surfaces a notice
|
|
/// explaining the rule … the refused drag ends like any refusal, rows staying put".
|
|
///
|
|
/// **A loss row**, with the relocation family and `postSkippedFolders`: nothing failed — no write
|
|
/// was attempted — and nothing is wrong with the board, but the gesture the user made did not
|
|
/// happen, which is exactly the warning-tone "didn't arrive" register. The wording is 04's own,
|
|
/// verbatim, and lives here because BannerCenter owns the phrasing (▸ Clipboard).
|
|
///
|
|
/// It is the *teaching* half of the guard: ⌘C/⌘X grey out silently (menu validation says no
|
|
/// before the gesture starts), while the drag has no such surface to say it in advance — so the
|
|
/// drop-time explanation "is where the rule teaches itself".
|
|
public func postMixedTrashDrag() {
|
|
postLoss(Self.mixedTrashDragMessage)
|
|
}
|
|
|
|
/// 04-interactions.md ▸ The trash's own sentence.
|
|
nonisolated static let mixedTrashDragMessage =
|
|
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
|
|
|
|
/// Removes a dismissable row: a one-shot failure in either shape, 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 }
|
|
gitFailures.removeAll { $0.id == id }
|
|
losses.removeAll { $0.id == id }
|
|
signposts.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// Removes every dismissable row — both failure shapes, 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()
|
|
gitFailures.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
|
|
}
|
|
|
|
/// **Relabels a running operation** — the same row, still spinning, now saying something else.
|
|
///
|
|
/// It exists for one sentence in 06-history-undo.md ▸ Interaction with external writers:
|
|
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
|
|
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
|
|
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
|
|
/// replacement — the operation has not restarted, it is explaining itself — so the id is stable
|
|
/// and the view neither churns nor re-animates.
|
|
///
|
|
/// An unknown id is a no-op: a completion racing a relabel is ordinary, not a bug.
|
|
public func updateOperation(_ id: UUID, label: String) {
|
|
guard let index = operations.firstIndex(where: { $0.id == id }) else { return }
|
|
let existing = operations[index]
|
|
operations[index] = InProgressOperation(id: existing.id, label: label, cancel: existing.cancel)
|
|
}
|
|
|
|
/// 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.
|
|
/// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31):
|
|
/// "failures rank by what they are, not by which error vocabulary threw them", so a failed
|
|
/// undo posted a second ago sits above a failed move from a minute ago and below one from a
|
|
/// second before it. Ties — two rows sharing a `Date` to the microsecond — put the write
|
|
/// failure first; the two shapes are posted from different call sites, so a tie is an
|
|
/// accident of the clock rather than an order anyone can read.
|
|
///
|
|
/// `signposts` and `gitFailures` carry defaults: the first because its producer is m6's card
|
|
/// window, the second because a center that hosts no git surface (a card window's own) can never
|
|
/// hold one. Every other class 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] = [],
|
|
gitFailures: [GitFailureBanner] = []
|
|
) -> [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, by: \.occurredAt)
|
|
rows.append(contentsOf: failureRank(
|
|
writes: ordered.filter { !$0.isAttachmentImport },
|
|
git: newestFirst(gitFailures, by: \.occurredAt)
|
|
))
|
|
|
|
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).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
|
|
}
|
|
|
|
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
|
|
/// the git-operation failures, merged into the one precedence class they share — "failures rank
|
|
/// by what they are, not by which error vocabulary threw them".
|
|
///
|
|
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
|
|
/// write failure first, which is arbitrary and says so (`rows(...)`).
|
|
private nonisolated static func failureRank(
|
|
writes: [OneShotBanner],
|
|
git: [GitFailureBanner]
|
|
) -> [BannerRow] {
|
|
var merged: [BannerRow] = []
|
|
var writes = writes[...]
|
|
var git = git[...]
|
|
while let write = writes.first, let failure = git.first {
|
|
if failure.occurredAt > write.occurredAt {
|
|
merged.append(.gitFailure(failure))
|
|
git = git.dropFirst()
|
|
} else {
|
|
merged.append(.oneShot(write))
|
|
writes = writes.dropFirst()
|
|
}
|
|
}
|
|
merged.append(contentsOf: writes.map(BannerRow.oneShot))
|
|
merged.append(contentsOf: git.map(BannerRow.gitFailure))
|
|
return merged
|
|
}
|
|
|
|
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
|
|
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
|
|
/// `post…` maintains newest-first on insertion — so a tie renders in the order it was posted
|
|
/// rather than in whatever order the sort happened to leave.
|
|
///
|
|
/// One function over a date key rather than one per class: the three dismissable classes order
|
|
/// by exactly the same rule, and three copies of it were three places for it to drift.
|
|
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
|
|
rows
|
|
.enumerated()
|
|
.sorted { lhs, rhs in
|
|
lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt]
|
|
? lhs.offset < rhs.offset
|
|
: lhs.element[keyPath: occurredAt] > rhs.element[keyPath: 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)"
|
|
}
|
|
|
|
/// The user-facing line for a git operation that didn't happen: what the app could not do, then
|
|
/// why — the write failure's shape exactly (settled 2026-07-31), because the two are one class.
|
|
///
|
|
/// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer
|
|
/// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the
|
|
/// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action
|
|
/// clause alone rather than trailing a dash into nothing.
|
|
public nonisolated static func headline(for failure: GitFailureBanner) -> String {
|
|
let action = actionPhrase(for: failure.operation)
|
|
let cause = trimmed(failure.reason)
|
|
return cause.isEmpty ? action : "\(action) — \(cause)"
|
|
}
|
|
|
|
/// **Exhaustive by construction — no `default`**, the `WriteOperation` rule applied to the git
|
|
/// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile.
|
|
///
|
|
/// The undo pair is named by **the command the user pressed** — the skipped-step row's rule
|
|
/// (`skippedStepMessage`), and for its reason: ⌘Z is the gesture, and "Couldn't restore an
|
|
/// earlier state" would describe machinery the user has no model of. The branch switch is named
|
|
/// by the control they used (the popover's branch picker) and stays plural — "branches" — rather
|
|
/// than naming the target, which the in-progress row this failure replaces already said
|
|
/// ("Switching to 'main'…") and which `GitOperation` deliberately doesn't carry.
|
|
private nonisolated static func actionPhrase(for operation: GitOperation) -> String {
|
|
switch operation {
|
|
case .undo: "Undo failed"
|
|
case .redo: "Redo failed"
|
|
case .branchSwitch: "Couldn't switch branches"
|
|
// Named by the control the user pressed, like the pair above — "Add Git" is the button, and
|
|
// the row is only ever seen by someone who pressed it and looked away.
|
|
case .addGit: "Couldn't add git to this board"
|
|
}
|
|
}
|
|
|
|
/// **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 .seedGitignore:
|
|
// **The agent guide's sentence, one file over**, and for its reasons: `.gitignore` is a
|
|
// courtesy the app writes once, most users will never open it, and nothing about the
|
|
// board's own files is at stake — a board without one simply excludes nothing, which is
|
|
// how every board behaved until 2026-07-31. It names the file rather than a role because
|
|
// this one *does* have a name users know from git, and "the ignore list" would be the
|
|
// app inventing a word for something already called something.
|
|
"Couldn't write this board's .gitignore"
|
|
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 }
|
|
}
|
|
}
|