02 ratified a warning-tone class for non-failure losses — content that didn't arrive though nothing failed: a degraded paste, folders skipped from a Finder drop, their future kin. Loss rows take the one-shot's dismissable-untimed lifecycle (a loss the user didn't notice is the harm) and rank below the true failures, above commit and attachment notices. BannerCenter grows LossBanner, postLoss, and the skipped-folders phrasing; the degraded-paste notice moves off its signpost onto the new class, ending its too-quiet ranking. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
711 lines
37 KiB
Swift
711 lines
37 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
|
|
}
|
|
}
|
|
|
|
/// 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) — a
|
|
/// degraded paste, folders skipped from a Finder drop, 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
|
|
/// (built), the vanished root (this milestone), the open-time writability probe (m4).
|
|
case readOnlyLock(ReadOnlyLockReason)
|
|
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
|
case reloadBreakage(BoardLoadError)
|
|
/// A write that did not happen. Dismissable, error tone.
|
|
case oneShot(OneShotBanner)
|
|
/// Content that didn't arrive though nothing failed — a degraded paste, folders skipped from a
|
|
/// Finder drop, their future kin. 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
/// One item a degraded paste could not bring its attachments with — what
|
|
/// `degradedPasteMessage(for:)` names.
|
|
///
|
|
/// `title` is the item's as written, `nil` for an untitled one: "Untitled" is a rendering, never
|
|
/// a value (03-board-ui.md § Card face), and the phrasing below says "the item" instead, exactly
|
|
/// as `actionPhrase(for:)` does for a failure whose title never got read.
|
|
public struct AttachmentLoss: Sendable, Equatable {
|
|
public let title: String?
|
|
public let attachments: Int
|
|
|
|
public init(title: String?, attachments: Int) {
|
|
self.title = title
|
|
self.attachments = attachments
|
|
}
|
|
}
|
|
|
|
/// **The degraded paste** (04-interactions.md ▸ Clipboard, settled): the staged snapshot was
|
|
/// missing or unreadable, so the paste fell back to the manifest's embedded `index.md` — content
|
|
/// intact, attachments absent — and this is the row that says so. "A degraded paste is loud,
|
|
/// never silent … the user never discovers an empty `attachments/` later."
|
|
///
|
|
/// **A loss row, not a `oneShot` and not a signpost** — the vocabulary's answer rather than a
|
|
/// compromise (settled 2026-07-28). 02-architecture.md's `oneShot` is *a write that did not
|
|
/// happen*, carrying a `BoardWriteError`, and nothing here failed — the items landed, whole but
|
|
/// for files that were never on the pasteboard's side of the transfer. This row first shipped as
|
|
/// a signpost, the vocabulary's other one-shot-lifecycle member at the time, and it read quieter
|
|
/// than 04's "loud" deserved: a signpost ranks last and may collapse behind "+N more", exactly
|
|
/// where a board already showing real trouble would bury it. The loss class exists to close that
|
|
/// gap — content that didn't arrive though nothing failed ranks below the true failures and
|
|
/// above the ambient notices, keeping the signpost's dismissable, untimed lifecycle without
|
|
/// inheriting its bottom-of-the-strip precedence.
|
|
///
|
|
/// An empty list posts nothing: a fallback that lost no attachments lost nothing at all, and a
|
|
/// banner announcing that would be noise.
|
|
public func postDegradedPaste(_ losses: [AttachmentLoss]) {
|
|
guard let message = Self.degradedPasteMessage(for: losses) 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, for the same reason the degraded paste is
|
|
/// one (settled 2026-07-28): folders that never arrived are a non-failure loss, not a write
|
|
/// failure.
|
|
///
|
|
/// 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 degraded paste already posts one (`postDegradedPaste`).
|
|
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**, Put Back, Delete
|
|
/// Immediately — 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 .delete(title):
|
|
if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
|
|
case let .restore(title):
|
|
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
|
case let .purge(title):
|
|
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
|
case let .style(title):
|
|
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
|
case let .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 .importAttachment(filename):
|
|
"Couldn't import '\(filename)'"
|
|
case .listAttachments:
|
|
"Couldn't read this card's attachments"
|
|
case .renumberChildren:
|
|
"Couldn't renumber cards"
|
|
}
|
|
}
|
|
|
|
/// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk
|
|
/// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs
|
|
/// translating, because its `description` is a fragment written for a developer.
|
|
private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String {
|
|
let text = switch reason {
|
|
case let .unreadable(message):
|
|
message
|
|
case let .uneditableFrontmatter(shape):
|
|
"this file's frontmatter can't be edited in place (\(shape.description))"
|
|
case let .io(message):
|
|
message
|
|
}
|
|
return trimmed(text)
|
|
}
|
|
|
|
/// The read-only lock's line. Each cause says the same two things — what is wrong, and that
|
|
/// what is on screen is still the last good view — because the lock's whole promise is that
|
|
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
|
|
/// lock's scope").
|
|
public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
|
|
switch lock {
|
|
case .bracketedReloadFailed:
|
|
"This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
|
case .vanishedRoot:
|
|
"This board's folder is gone — showing the last good view, read-only"
|
|
case .unwritableLocation:
|
|
"This board's location can't be written to — showing the last good view, read-only"
|
|
}
|
|
}
|
|
|
|
/// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus
|
|
/// the reassurance that the board is still the board.
|
|
///
|
|
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
|
/// `index.md` — is spelled as "This board" rather than shown as a lone dot.
|
|
public nonisolated static func headline(for breakage: BoardLoadError) -> String {
|
|
let reason = trimmed(breakage.reason.description)
|
|
let subject = breakage.path == "." || breakage.path.isEmpty
|
|
? "This board isn't loading"
|
|
: "'\(breakage.path)' isn't loading"
|
|
return "\(subject): \(reason) — showing the last good view"
|
|
}
|
|
|
|
/// The degraded paste's line — 04-interactions.md's own example sentence, "Pasted 'Fix login'
|
|
/// without its 3 attachments", generalized over the two axes it can vary on.
|
|
///
|
|
/// **It names exactly what was lost**, which is what the design asks for and what decides every
|
|
/// choice below: the count is real (never "some"), the singular and the plural are both spelled,
|
|
/// and a multi-item paste totals the attachments rather than listing every title — a banner is one
|
|
/// line, and "2 items" plus the true total is the honest summary where a truncated list would not
|
|
/// be. `nil` for an empty list: nothing was lost, so there is nothing to say.
|
|
///
|
|
/// The count is the item's `attachments/` as the snapshot listed it at copy time — the design's
|
|
/// own vocabulary for what a card carries (01-storage-format.md § Attachments). A stray file
|
|
/// sitting loose in the card folder is not in it and is not named here; see the report's
|
|
/// design-gap note.
|
|
public nonisolated static func degradedPasteMessage(for losses: [AttachmentLoss]) -> String? {
|
|
guard !losses.isEmpty else { return nil }
|
|
let total = losses.reduce(0) { $0 + $1.attachments }
|
|
guard total > 0 else { return nil }
|
|
|
|
guard losses.count == 1, let only = losses.first else {
|
|
return "Pasted \(losses.count) items without their \(total) attachments"
|
|
}
|
|
let subject = only.title.map { "'\($0)'" } ?? "the item"
|
|
let tail = total == 1 ? "its attachment" : "its \(total) attachments"
|
|
return "Pasted \(subject) without \(tail)"
|
|
}
|
|
|
|
/// 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 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 }
|
|
}
|
|
}
|