Realign code with the 2026-07-31 findings-resolution rulings
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
This commit is contained in:
@@ -36,6 +36,79 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// **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.
|
||||
@@ -144,7 +217,7 @@ public struct InProgressOperation: Identifiable, Sendable {
|
||||
|
||||
/// One row in a window's banner strip.
|
||||
///
|
||||
/// The seven cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
||||
/// 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:
|
||||
///
|
||||
@@ -152,11 +225,13 @@ public struct InProgressOperation: Identifiable, Sendable {
|
||||
/// 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.
|
||||
/// - **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(_:)`).
|
||||
///
|
||||
@@ -173,6 +248,10 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
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`).
|
||||
@@ -192,6 +271,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
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)"
|
||||
@@ -201,7 +281,10 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
|
||||
public var tone: BannerTone {
|
||||
switch self {
|
||||
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
||||
// 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
|
||||
}
|
||||
@@ -221,6 +304,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
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
|
||||
@@ -234,6 +318,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
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
|
||||
@@ -300,11 +385,12 @@ public enum BannerRowControl: Identifiable, Sendable {
|
||||
///
|
||||
/// ### 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
|
||||
/// 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.
|
||||
///
|
||||
@@ -315,7 +401,10 @@ public enum BannerRowControl: Identifiable, Sendable {
|
||||
/// 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.
|
||||
/// 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
|
||||
///
|
||||
@@ -334,6 +423,17 @@ public final class BannerCenter {
|
||||
/// 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`).
|
||||
@@ -361,6 +461,19 @@ public final class BannerCenter {
|
||||
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) {
|
||||
@@ -575,20 +688,23 @@ public final class BannerCenter {
|
||||
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, 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.
|
||||
/// 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 — 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
|
||||
/// 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()
|
||||
}
|
||||
@@ -684,10 +800,17 @@ public final class BannerCenter {
|
||||
/// 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` carries a default because its producer is m6's card window and nothing posts one
|
||||
/// today; every other class has a live producer and is spelled out at every call site — `losses`
|
||||
/// included, since a Finder drop that skipped folders already posts one (`postSkippedFolders`).
|
||||
/// `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?,
|
||||
@@ -695,7 +818,8 @@ public final class BannerCenter {
|
||||
losses: [LossBanner],
|
||||
suspension: HistorySuspension?,
|
||||
operations: [InProgressOperation],
|
||||
signposts: [InfoSignpost] = []
|
||||
signposts: [InfoSignpost] = [],
|
||||
gitFailures: [GitFailureBanner] = []
|
||||
) -> [BannerRow] {
|
||||
var rows: [BannerRow] = []
|
||||
|
||||
@@ -708,10 +832,13 @@ public final class BannerCenter {
|
||||
rows.append(.reloadBreakage(breakage))
|
||||
}
|
||||
|
||||
let ordered = newestFirst(oneShots)
|
||||
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
||||
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).map(BannerRow.loss))
|
||||
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
|
||||
|
||||
if let suspension {
|
||||
rows.append(.historySuspended(suspension))
|
||||
@@ -722,31 +849,47 @@ public final class BannerCenter {
|
||||
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
|
||||
/// **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()
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
merged.append(contentsOf: writes.map(BannerRow.oneShot))
|
||||
merged.append(contentsOf: git.map(BannerRow.gitFailure))
|
||||
return merged
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.occurredAt == rhs.element.occurredAt
|
||||
lhs.element[keyPath: occurredAt] == rhs.element[keyPath: occurredAt]
|
||||
? lhs.offset < rhs.offset
|
||||
: lhs.element.occurredAt > rhs.element.occurredAt
|
||||
: lhs.element[keyPath: occurredAt] > rhs.element[keyPath: occurredAt]
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
@@ -764,6 +907,39 @@ public final class BannerCenter {
|
||||
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").
|
||||
@@ -853,6 +1029,14 @@ public final class BannerCenter {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user