The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
538 lines
27 KiB
Swift
538 lines
27 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 standing condition "changes aren't being recorded to history" (02-architecture.md §
|
|
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
|
|
///
|
|
/// `reason` is **diagnostic English, not the banner's verb** — the free-form tail the phrasing
|
|
/// rules allow ("disk full", "the repository is corrupt"). The user-facing sentence is
|
|
/// `BannerCenter.headline(for:)`'s, like every other row's.
|
|
///
|
|
/// `since` exists because this is a condition, not an event: it started at some point and has been
|
|
/// true ever since. Nothing renders it today; it is what a later "suspended for 4 minutes" reading
|
|
/// would be built from, and recording it costs a `Date`.
|
|
public struct HistorySuspension: Sendable, Equatable {
|
|
public let reason: String
|
|
public let since: Date
|
|
|
|
public init(reason: String, since: Date = Date()) {
|
|
self.reason = reason
|
|
self.since = since
|
|
}
|
|
}
|
|
|
|
/// A calm notice that something happened elsewhere — the info tone's *passive* half.
|
|
///
|
|
/// Its one producer-to-be is the card window's **remote-change signpost** (07-sync-collab.md): a
|
|
/// pull landed commits touching the very card being edited, the dirty buffer stays put and wins,
|
|
/// and the window says so without a modal or a merge UI. 02-architecture.md calls that "this same
|
|
/// component in the info tone", which is exactly what this is.
|
|
///
|
|
/// **Ranks last and may collapse** (settled, § The banner surface): "calm by design, nothing gated
|
|
/// on seeing them instantly" — the opposite end of the strip from an in-progress row, whose spinner
|
|
/// may never hide.
|
|
///
|
|
/// Dismissable, like a one-shot and unlike a condition: it reports something that already happened,
|
|
/// so there is nothing for it to heal into.
|
|
public struct InfoSignpost: Identifiable, Sendable, Equatable {
|
|
public let id: UUID
|
|
public let message: String
|
|
public let occurredAt: Date
|
|
|
|
public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
|
|
self.id = id
|
|
self.message = message
|
|
self.occurredAt = occurredAt
|
|
}
|
|
}
|
|
|
|
/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
|
|
/// "In-progress operations are info rows"): bracketed git operations ("Pulling…", "Switching to
|
|
/// 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment
|
|
/// imports).
|
|
///
|
|
/// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed
|
|
/// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an
|
|
/// operation the banner layer has no enum for, and inventing one would mean a new git verb could
|
|
/// not describe itself without touching this file. The tradeoff is stated rather than hidden.
|
|
///
|
|
/// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports,
|
|
/// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing
|
|
/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a
|
|
/// repair job, not a cancel.
|
|
public struct InProgressOperation: Identifiable, Sendable {
|
|
public let id: UUID
|
|
public let label: String
|
|
public var isCancelable: Bool { cancel != nil }
|
|
/// `@MainActor @Sendable` rather than the plainer `@MainActor () -> Void` the sketch carried:
|
|
/// this value is stored in a `Sendable` struct, and a non-`Sendable` function type would make
|
|
/// that conformance a lie under strict concurrency. The isolation is unchanged — cancelling
|
|
/// runs on the main actor, where the operation's own state lives.
|
|
public let cancel: (@MainActor @Sendable () -> Void)?
|
|
|
|
public init(id: UUID = UUID(), label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) {
|
|
self.id = id
|
|
self.label = label
|
|
self.cancel = cancel
|
|
}
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
/// One row in a window's banner strip.
|
|
///
|
|
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
|
/// divide into three lifecycles that the view renders differently and that the ordering rule
|
|
/// treats as classes:
|
|
///
|
|
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
|
|
/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
|
|
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
|
|
/// describes stops being true.
|
|
/// - **One-shots dismiss**: `oneShot` and `signpost`. Each reports something that already happened,
|
|
/// so only the user can clear it.
|
|
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
|
|
/// it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
|
|
///
|
|
/// **`Identifiable` by a synthetic string id**, not by content: the condition rows are singletons
|
|
/// per window (there is one lock, one breakage, one history suspension), so their ids are constant
|
|
/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost
|
|
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
|
|
public enum BannerRow: Identifiable, Sendable {
|
|
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload
|
|
/// (built), the vanished root (this milestone), the open-time writability probe (m4).
|
|
case readOnlyLock(ReadOnlyLockReason)
|
|
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
|
case reloadBreakage(BoardLoadError)
|
|
/// A write that did not happen. Dismissable, error tone.
|
|
case oneShot(OneShotBanner)
|
|
/// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
|
|
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
|
|
case historySuspended(HistorySuspension)
|
|
/// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
|
|
/// (m5's copy-shaped work and m7's git brackets drive it.)
|
|
case inProgress(InProgressOperation)
|
|
/// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window
|
|
/// drives it, as 07-sync-collab.md's remote-change signpost.)
|
|
case signpost(InfoSignpost)
|
|
|
|
public var id: String {
|
|
switch self {
|
|
case .readOnlyLock: "read-only-lock"
|
|
case .reloadBreakage: "reload-breakage"
|
|
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
|
|
case .historySuspended: "history-suspension"
|
|
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
|
|
case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
|
|
}
|
|
}
|
|
|
|
public var tone: BannerTone {
|
|
switch self {
|
|
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
|
case .historySuspended: .warning
|
|
case .inProgress, .signpost: .info
|
|
}
|
|
}
|
|
|
|
/// Whether this row is pinned above the strip's collapse — true for in-progress rows and
|
|
/// nothing else (settled, 02-architecture.md § The banner surface: "a spinner may never hide
|
|
/// behind '+N more'"). The view reads this rather than re-deriving the case.
|
|
public var isPinned: Bool {
|
|
if case .inProgress = self { true } else { false }
|
|
}
|
|
|
|
/// The user-facing line. Every word of it comes from `BannerCenter`'s phrasing statics except
|
|
/// an in-progress row's, whose label is its caller's (see `InProgressOperation`).
|
|
public var headline: String {
|
|
switch self {
|
|
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
|
|
case let .reloadBreakage(error): BannerCenter.headline(for: error)
|
|
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
|
|
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
|
|
case let .inProgress(operation): operation.label
|
|
case let .signpost(signpost): signpost.message
|
|
}
|
|
}
|
|
|
|
/// Only the rows reporting something that already happened carry a dismiss control — one-shot
|
|
/// failures and signposts. Conditions stand until they heal; in-progress rows complete, fail, or
|
|
/// are cancelled — neither is something a user can wave away.
|
|
public var dismissID: UUID? {
|
|
switch self {
|
|
case let .oneShot(banner): banner.id
|
|
case let .signpost(signpost): signpost.id
|
|
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BannerCenter
|
|
|
|
/// The model behind one window's banner strip: the rows a window owns outright, plus the ordering
|
|
/// and phrasing rules the whole app renders through (02-architecture.md § The banner surface).
|
|
///
|
|
/// ### What lives here and what does not
|
|
///
|
|
/// A center holds the state nothing else does: dismissable one-shot write failures, the history
|
|
/// suspension, in-progress operations, and passive signposts. It deliberately does **not** hold the
|
|
/// read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them here
|
|
/// would create a second place for them to be stale. `BoardStore.bannerRows` composes both halves
|
|
/// through `rows(lock:breakage:oneShots:suspension:operations:signposts:)`, which is a *pure
|
|
/// function* precisely so the precedence rule can be tested without a store, a window, or a
|
|
/// filesystem.
|
|
///
|
|
/// ### Phrasing lives here too
|
|
///
|
|
/// "The banner owns all user-facing phrasing and localization from that vocabulary" (02 §
|
|
/// Write-failure surfacing). `BoardWriteError` carries a closed `WriteOperation` enum and a
|
|
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
|
|
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
|
|
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
|
|
/// suspension: their user-facing lines are here, not on the error types.
|
|
///
|
|
/// ### One center per window, not per board
|
|
///
|
|
/// The strip is "hosted by the window of origin": a card window's own save and attachment failures
|
|
/// belong to that window's center, and re-home to the board window's when it closes (m6's job).
|
|
/// `BoardStore` owns *the board window's* center, which is why it is a stored `let` there rather
|
|
/// than something injected — a board window's strip has exactly one lifetime, the store's.
|
|
@MainActor
|
|
@Observable
|
|
public final class BannerCenter {
|
|
|
|
// MARK: State
|
|
|
|
/// Newest first, which is the order the class renders in. Kept sorted on insertion rather than
|
|
/// at render time so that two failures sharing a timestamp — plausible inside one run loop
|
|
/// turn — still order deterministically.
|
|
public private(set) var oneShots: [OneShotBanner] = []
|
|
|
|
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
|
public private(set) var historySuspension: HistorySuspension?
|
|
|
|
/// Work in flight, newest first for the same reason `oneShots` is.
|
|
public private(set) var operations: [InProgressOperation] = []
|
|
|
|
/// Passive notices, newest first. Empty until m6's card window starts posting the
|
|
/// remote-change signpost.
|
|
public private(set) var signposts: [InfoSignpost] = []
|
|
|
|
public init() {}
|
|
|
|
// MARK: One-shots
|
|
|
|
/// Records a write failure. **Every failed write lands here before it reaches its caller** —
|
|
/// `BoardStore.performWrite` posts before it rethrows — because the banner is how the one-way
|
|
/// flow stays honest: the action visibly did not happen, and the strip is the only place that
|
|
/// says why (02 § Write-failure surfacing).
|
|
public func post(_ error: BoardWriteError) {
|
|
oneShots.insert(OneShotBanner(error: error), at: 0)
|
|
}
|
|
|
|
/// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
|
|
/// like the one-shots it shares a lifecycle with.
|
|
public func postSignpost(_ message: String) {
|
|
signposts.insert(InfoSignpost(message: message), at: 0)
|
|
}
|
|
|
|
/// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
|
|
/// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
|
|
/// different promises and a row that offers one must never quietly do the other.
|
|
public func dismiss(_ id: UUID) {
|
|
oneShots.removeAll { $0.id == id }
|
|
signposts.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// Removes every dismissable row — one-shots and signposts alike. The strip's own "clear all"
|
|
/// affordance later; today it is what a window uses when it re-homes its rows elsewhere (m6).
|
|
public func dismissAllDismissableRows() {
|
|
oneShots.removeAll()
|
|
signposts.removeAll()
|
|
}
|
|
|
|
// MARK: History suspension
|
|
|
|
/// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's
|
|
/// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
|
|
/// covers the lock itself, which is deliberately *not* a banner).
|
|
///
|
|
/// A second call while already suspended keeps the original `since` and takes the newer
|
|
/// `reason`: the condition never stopped being true, so restarting its clock would misreport
|
|
/// how long history has been stalled, while the newest diagnosis is the useful one.
|
|
public func suspendHistory(reason: String) {
|
|
historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
|
|
}
|
|
|
|
/// Clears it, on the first successful commit. Idempotent — clearing a condition that is not
|
|
/// standing is not an error, it is the ordinary shape of "commit succeeded".
|
|
public func clearHistorySuspension() {
|
|
historySuspension = nil
|
|
}
|
|
|
|
// MARK: In-progress operations
|
|
|
|
/// Starts an info row with a spinner and hands back its id.
|
|
///
|
|
/// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
|
|
/// partial copy, nothing lost" (02, settled). Git brackets pass `nil`.
|
|
@discardableResult
|
|
public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
|
|
let operation = InProgressOperation(label: label, cancel: cancel)
|
|
operations.insert(operation, at: 0)
|
|
return operation.id
|
|
}
|
|
|
|
/// Ends one — the row leaves the strip.
|
|
///
|
|
/// **Completion and failure both come through here.** There is no `failOperation`: a failure is
|
|
/// `endOperation(id)` followed by `post(error)`, which is exactly 02's "failure swaps it for
|
|
/// the error row" expressed as two calls that already exist. Ending an unknown id is a no-op;
|
|
/// a close racing a completion is ordinary, not a bug.
|
|
public func endOperation(_ id: UUID) {
|
|
operations.removeAll { $0.id == id }
|
|
}
|
|
|
|
// MARK: - Ordering
|
|
|
|
/// The strip's rows, precedence-ordered — **the whole of the ordering rule, in one pure
|
|
/// function** (02 § The banner surface, "Concurrent conditions stack").
|
|
///
|
|
/// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
|
|
/// one-shot write failures > commit and attachment failures > **passive info rows**. Three
|
|
/// readings of it are worth stating because the code depends on them:
|
|
///
|
|
/// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
|
|
/// above everything: it is the only explanation the strip offers for a bracket's write lock
|
|
/// and for a close or quit deferring teardown, and a copy row's Cancel has to stay reachable
|
|
/// — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
|
|
/// collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
|
|
/// urgency.
|
|
/// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
|
|
/// `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
|
|
/// one-shots, so a fresh failed import sits below an older failed move. That is the design's
|
|
/// ordering read literally, and it is defensible: a failed import is the least destructive of
|
|
/// the failures (the drop was accepted, the partial copy was removed), so it yields its place
|
|
/// to failures that stopped the user's actual work.
|
|
/// - **Within the commit-and-attachment class the standing suspension leads**, then the
|
|
/// attachment one-shots newest first. A deliberate reading of "newest first within a class":
|
|
/// a condition and a one-shot are not comparable by recency in any way a user would read as
|
|
/// order — the condition's `since` is when it *started* being true, not when it happened —
|
|
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
|
|
///
|
|
/// `signposts` carries a default because its producer is m6's card window and nothing posts one
|
|
/// today; every other class has a live producer and is spelled out at every call site.
|
|
public nonisolated static func rows(
|
|
lock: ReadOnlyLockReason?,
|
|
breakage: BoardLoadError?,
|
|
oneShots: [OneShotBanner],
|
|
suspension: HistorySuspension?,
|
|
operations: [InProgressOperation],
|
|
signposts: [InfoSignpost] = []
|
|
) -> [BannerRow] {
|
|
var rows: [BannerRow] = []
|
|
|
|
rows.append(contentsOf: operations.map(BannerRow.inProgress))
|
|
|
|
if let lock {
|
|
rows.append(.readOnlyLock(lock))
|
|
}
|
|
if let breakage {
|
|
rows.append(.reloadBreakage(breakage))
|
|
}
|
|
|
|
let ordered = newestFirst(oneShots)
|
|
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
|
|
|
if let suspension {
|
|
rows.append(.historySuspended(suspension))
|
|
}
|
|
rows.append(contentsOf: ordered.lazy.filter(\.isAttachmentImport).map(BannerRow.oneShot))
|
|
|
|
rows.append(contentsOf: signposts.map(BannerRow.signpost))
|
|
return rows
|
|
}
|
|
|
|
/// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run
|
|
/// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which
|
|
/// `post(_:)` already maintains newest-first — so a tie renders in the order it was posted
|
|
/// rather than in whatever order the sort happened to leave.
|
|
private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] {
|
|
banners
|
|
.enumerated()
|
|
.sorted { lhs, rhs in
|
|
lhs.element.occurredAt == rhs.element.occurredAt
|
|
? lhs.offset < rhs.offset
|
|
: lhs.element.occurredAt > rhs.element.occurredAt
|
|
}
|
|
.map(\.element)
|
|
}
|
|
|
|
// MARK: - Phrasing
|
|
|
|
/// The user-facing line for a failed write: what the app could not do, then why.
|
|
///
|
|
/// "Couldn't move 'Fix login' — disk full" is the design's own example and the shape every
|
|
/// case takes: an action clause the banner owns, an em dash, and the diagnostic cause from the
|
|
/// error's `reason` — the one place free-form English is allowed to survive.
|
|
public nonisolated static func headline(for error: BoardWriteError) -> String {
|
|
let action = actionPhrase(for: error.operation)
|
|
let cause = causePhrase(for: error.reason)
|
|
return cause.isEmpty ? action : "\(action) — \(cause)"
|
|
}
|
|
|
|
/// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a
|
|
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
|
|
/// without a banner rendering is a compile-time hole, not a silent default").
|
|
///
|
|
/// Titles are quoted where the operation carries one and the phrasing stays graceful where it
|
|
/// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
|
|
/// "the item" rather than guessing "card" and being wrong about a lane. The trash verbs are
|
|
/// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash,
|
|
/// Put Back, Delete Immediately.
|
|
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
|
|
switch operation {
|
|
case .createBoard:
|
|
"Couldn't create the board"
|
|
case .createLane:
|
|
"Couldn't create a lane"
|
|
case .createCard:
|
|
"Couldn't create a card"
|
|
case let .move(title):
|
|
if let title { "Couldn't move '\(title)'" } else { "Couldn't move the item" }
|
|
case let .reorder(title):
|
|
if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
|
|
case let .copy(title):
|
|
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
|
|
case let .delete(title):
|
|
if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" }
|
|
case let .restore(title):
|
|
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
|
case let .purge(title):
|
|
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
|
case let .style(title):
|
|
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
|
case let .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 .importAttachment(filename):
|
|
"Couldn't import '\(filename)'"
|
|
case .listAttachments:
|
|
"Couldn't read this card's attachments"
|
|
case .renumberChildren:
|
|
"Couldn't renumber cards"
|
|
}
|
|
}
|
|
|
|
/// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk
|
|
/// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs
|
|
/// translating, because its `description` is a fragment written for a developer.
|
|
private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String {
|
|
let text = switch reason {
|
|
case let .unreadable(message):
|
|
message
|
|
case let .uneditableFrontmatter(shape):
|
|
"this file's frontmatter can't be edited in place (\(shape.description))"
|
|
case let .io(message):
|
|
message
|
|
}
|
|
return trimmed(text)
|
|
}
|
|
|
|
/// The read-only lock's line. Each cause says the same two things — what is wrong, and that
|
|
/// what is on screen is still the last good view — because the lock's whole promise is that
|
|
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
|
|
/// lock's scope").
|
|
public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
|
|
switch lock {
|
|
case .bracketedReloadFailed:
|
|
"This board couldn't be re-read after the last operation — showing the last good view, read-only"
|
|
case .vanishedRoot:
|
|
"This board's folder is gone — showing the last good view, read-only"
|
|
case .unwritableLocation:
|
|
"This board's location can't be written to — showing the last good view, read-only"
|
|
}
|
|
}
|
|
|
|
/// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus
|
|
/// the reassurance that the board is still the board.
|
|
///
|
|
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
|
/// `index.md` — is spelled as "This board" rather than shown as a lone dot.
|
|
public nonisolated static func headline(for breakage: BoardLoadError) -> String {
|
|
let reason = trimmed(breakage.reason.description)
|
|
let subject = breakage.path == "." || breakage.path.isEmpty
|
|
? "This board isn't loading"
|
|
: "'\(breakage.path)' isn't loading"
|
|
return "\(subject): \(reason) — showing the last good view"
|
|
}
|
|
|
|
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
|
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
|
/// the diagnosis as its tail.
|
|
public nonisolated static func headline(for suspension: HistorySuspension) -> String {
|
|
let reason = trimmed(suspension.reason)
|
|
let line = "Changes aren't being recorded to history"
|
|
return reason.isEmpty ? line : "\(line) — \(reason)"
|
|
}
|
|
|
|
/// Trims whitespace and a trailing period: these fragments are tails inside a longer line, and
|
|
/// a stray full stop mid-sentence is the tell of machine-assembled text.
|
|
private nonisolated static func trimmed(_ text: String) -> String {
|
|
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
while value.hasSuffix(".") {
|
|
value.removeLast()
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private extension OneShotBanner {
|
|
/// Whether this failure is an attachment import — the one `WriteOperation` whose one-shot
|
|
/// ranks in the last precedence class rather than with the other one-shots.
|
|
var isAttachmentImport: Bool {
|
|
if case .importAttachment = error.operation { true } else { false }
|
|
}
|
|
}
|