The seams unbind — the provider is always native, and the git stack compiles dead
Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -36,79 +36,6 @@ 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.
|
||||
@@ -218,19 +145,18 @@ public struct InfoSignpost: Identifiable, Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// "In-progress operations are info rows"): long copy-shaped work — big-board Duplicate, template
|
||||
/// instantiation, large attachment imports — and whatever wholesale operation joins them.
|
||||
///
|
||||
/// `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.
|
||||
/// operation the banner layer has no enum for, and inventing one would mean a new 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.
|
||||
/// lost". An operation that cannot be abandoned halfway passes `nil`: unwinding it is a repair job,
|
||||
/// not a cancel.
|
||||
public struct InProgressOperation: Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public let label: String
|
||||
@@ -252,19 +178,16 @@ public struct InProgressOperation: Identifiable, Sendable {
|
||||
|
||||
/// One row in a window's banner strip.
|
||||
///
|
||||
/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
|
||||
/// 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`, `repositoryUnreadable`,
|
||||
/// `historySuspended`. They describe
|
||||
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
|
||||
/// ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
|
||||
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
|
||||
/// describes stops being true.
|
||||
/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something
|
||||
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
|
||||
/// `oneShot` in a second shape (settled 2026-07-31) — same tone, same rank, same lifecycle, a
|
||||
/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately
|
||||
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something
|
||||
/// that already happened, so only the user can clear it. `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.
|
||||
@@ -284,35 +207,18 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
/// the **whole** aggregate — one row either way, but its headline names the first defect and
|
||||
/// counts the rest rather than pretending the walk found only one.
|
||||
case reloadBreakage(BoardLoadFailure)
|
||||
/// **This board has a `.git` the app cannot open** (06-history-undo.md ▸ Rules, "A `.git` that
|
||||
/// isn't a valid repository still reads as git mode — and fails loudly", ruled 2026-07-31).
|
||||
/// Condition, error tone, standing at the breakage rank — the ruling's own class ("a standing
|
||||
/// breakage-class banner at detection").
|
||||
///
|
||||
/// **No payload**, which is the ruling read literally: the sentence is fixed
|
||||
/// (`BannerCenter.repositoryUnreadableMessage`, 06's words verbatim), and there is nothing to
|
||||
/// diagnose past "libgit2 will not open it" — a `reason` tail would be an invitation to leak
|
||||
/// developer prose into a line the design already wrote. `HistorySuspension` carries one because
|
||||
/// *its* tail genuinely varies ("disk full"); this row's cannot.
|
||||
///
|
||||
/// It heals rather than being dismissed, like every condition: "the banner clears when a later
|
||||
/// open or reload finds the repo readable."
|
||||
case repositoryUnreadable
|
||||
/// A write that did not happen. Dismissable, error tone.
|
||||
case oneShot(OneShotBanner)
|
||||
/// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull
|
||||
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
|
||||
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
|
||||
case gitFailure(GitFailureBanner)
|
||||
/// Content that didn't arrive though nothing failed — folders skipped from a Finder drop, the
|
||||
/// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
|
||||
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
|
||||
case loss(LossBanner)
|
||||
/// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
|
||||
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
|
||||
/// trail is degraded, which is a warning rather than an error. No producer today; the row waits
|
||||
/// for whatever substrate can stall.
|
||||
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.)
|
||||
/// (m5's copy-shaped work drives 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.)
|
||||
@@ -322,9 +228,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
switch self {
|
||||
case .readOnlyLock: "read-only-lock"
|
||||
case .reloadBreakage: "reload-breakage"
|
||||
case .repositoryUnreadable: "repository-unreadable"
|
||||
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)"
|
||||
@@ -334,15 +238,9 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
|
||||
public var tone: BannerTone {
|
||||
switch self {
|
||||
// A git operation that failed is an action that didn't happen, so it takes the failure
|
||||
// tone with the write failures it ranks beside — "never as a warning-tone loss row"
|
||||
// (02-architecture.md § The banner surface, settled 2026-07-31).
|
||||
// The unreadable repository takes the **error** tone rather than the history suspension's
|
||||
// warning, and the two sit either side of a real line: a suspension is history failing to
|
||||
// advance and retrying every debounce, while this is a repository the app cannot open at
|
||||
// all — nothing it does will fix it, and 06 asks for a "breakage-class" row that "fails
|
||||
// loudly". The reload breakage is the precedent the ruling names, and it is an error.
|
||||
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .oneShot, .gitFailure: .error
|
||||
case .readOnlyLock, .reloadBreakage, .oneShot: .error
|
||||
// A suspension is history failing to advance and retrying, which is degraded rather than
|
||||
// broken — the files are safe either way, so it takes the warning tone the loss row does.
|
||||
case .historySuspended, .loss: .warning
|
||||
case .inProgress, .signpost: .info
|
||||
}
|
||||
@@ -361,9 +259,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
switch self {
|
||||
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
|
||||
case let .reloadBreakage(error): BannerCenter.headline(for: error)
|
||||
case .repositoryUnreadable: BannerCenter.repositoryUnreadableMessage
|
||||
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
|
||||
@@ -377,10 +273,9 @@ 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, .repositoryUnreadable, .historySuspended, .inProgress: nil
|
||||
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,12 +359,11 @@ public enum BannerRowControl: Identifiable, Sendable {
|
||||
///
|
||||
/// ### What lives here and what does not
|
||||
///
|
||||
/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write
|
||||
/// and git-operation), loss rows, the history suspension, in-progress operations, and passive
|
||||
/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage — those
|
||||
/// are `BoardStore`'s truths, and copying them here would create a second place for them to be
|
||||
/// stale. `BoardStore.bannerRows` composes both halves through
|
||||
/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a
|
||||
/// 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.
|
||||
///
|
||||
@@ -480,10 +374,7 @@ 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. **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.
|
||||
/// suspension: their user-facing lines are here, not on the error types.
|
||||
///
|
||||
/// ### One center per window, not per board
|
||||
///
|
||||
@@ -502,37 +393,14 @@ 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`).
|
||||
public private(set) var losses: [LossBanner] = []
|
||||
|
||||
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
||||
/// The standing "history isn't advancing" condition, or `nil` when history is keeping up.
|
||||
public private(set) var historySuspension: HistorySuspension?
|
||||
|
||||
/// **The standing "this board's git repository can't be read" condition** (06-history-undo.md ▸
|
||||
/// Rules, ruled 2026-07-31) — `false` on every board whose repository opens, and on every board
|
||||
/// that has none.
|
||||
///
|
||||
/// It lives here rather than on `BoardStore` — where the lock and the breakage live — because it
|
||||
/// is not the store's truth: the fact belongs to the board's git state
|
||||
/// (`HistoryStore.isRepositoryUnreadable`, itself the committer's pause), and the store learns it
|
||||
/// through the same kind of seam the history suspension already arrives by
|
||||
/// (`BoardStore.noteRepositoryUnreadable(_:)`). One condition, one owner, no second copy to go
|
||||
/// stale.
|
||||
public private(set) var isRepositoryUnreadable = false
|
||||
|
||||
/// Work in flight, newest first for the same reason `oneShots` is.
|
||||
public private(set) var operations: [InProgressOperation] = []
|
||||
|
||||
@@ -552,19 +420,6 @@ 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.
|
||||
///
|
||||
@@ -804,32 +659,29 @@ 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 in either shape, a loss row, or a signpost.
|
||||
/// 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 }
|
||||
gitFailures.removeAll { $0.id == id }
|
||||
losses.removeAll { $0.id == id }
|
||||
signposts.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// Removes every dismissable row — both failure shapes, losses, and signposts alike. The strip's
|
||||
/// Removes every dismissable row — failures, losses, and signposts alike. The strip's
|
||||
/// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
|
||||
/// elsewhere (m6).
|
||||
public func dismissAllDismissableRows() {
|
||||
oneShots.removeAll()
|
||||
gitFailures.removeAll()
|
||||
losses.removeAll()
|
||||
signposts.removeAll()
|
||||
}
|
||||
|
||||
// MARK: History suspension
|
||||
|
||||
/// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's
|
||||
/// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
|
||||
/// covers the lock itself, which is deliberately *not* a banner).
|
||||
/// Raises (or refreshes) the "changes aren't being recorded to history" condition — for whatever
|
||||
/// substrate can stall while the files themselves are landing fine. No producer today.
|
||||
///
|
||||
/// 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
|
||||
@@ -838,39 +690,18 @@ public final class BannerCenter {
|
||||
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".
|
||||
/// Clears it, the moment history catches up. Idempotent — clearing a condition that is not
|
||||
/// standing is not an error, it is the ordinary shape of "it worked".
|
||||
public func clearHistorySuspension() {
|
||||
historySuspension = nil
|
||||
}
|
||||
|
||||
// MARK: The unreadable repository
|
||||
|
||||
/// **Raises the standing "this board's git repository can't be read" condition**
|
||||
/// (06-history-undo.md ▸ Rules, ruled 2026-07-31) — the detection-time probe's answer, and any
|
||||
/// later read that reaches the same conclusion.
|
||||
///
|
||||
/// Idempotent, and it deliberately records nothing about *when*: unlike the history suspension —
|
||||
/// whose `since` exists so a later "suspended for 4 minutes" reading could be built — there is
|
||||
/// nothing about this condition's duration a user could act on. The repository is unreadable or
|
||||
/// it is not.
|
||||
public func raiseRepositoryUnreadable() {
|
||||
isRepositoryUnreadable = true
|
||||
}
|
||||
|
||||
/// Clears it — "the banner clears when a later open or reload finds the repo readable" (06).
|
||||
/// Idempotent, `clearHistorySuspension()`'s rule: clearing a condition that is not standing is
|
||||
/// the ordinary shape of a repository that was fine all along.
|
||||
public func clearRepositoryUnreadable() {
|
||||
isRepositoryUnreadable = false
|
||||
}
|
||||
|
||||
// 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`.
|
||||
/// partial copy, nothing lost" (02, settled). Everything else passes `nil`.
|
||||
@discardableResult
|
||||
public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
|
||||
let operation = InProgressOperation(label: label, cancel: cancel)
|
||||
@@ -880,9 +711,9 @@ public final class BannerCenter {
|
||||
|
||||
/// **Relabels a running operation** — the same row, still spinning, now saying something else.
|
||||
///
|
||||
/// It exists for one sentence in 06-history-undo.md ▸ Interaction with external writers:
|
||||
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
|
||||
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
|
||||
/// It exists for the waiting state a long operation can fall into — contention outlasting a brief
|
||||
/// retry surfaces in the operation's *own* in-progress row ("waiting for another writer"),
|
||||
/// retrying on its cadence.
|
||||
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
|
||||
/// replacement — the operation has not restarted, it is explaining itself — so the id is stable
|
||||
/// and the view neither churns nor re-animates.
|
||||
@@ -937,25 +768,14 @@ 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.
|
||||
/// - **The failure rank is one class, ordered by recency** (settled 2026-07-31): "failures rank
|
||||
/// by what they are, not by which error vocabulary threw them". The rank held two row shapes
|
||||
/// while the git stack was wired, interleaved by `occurredAt`; the merge went with the second
|
||||
/// shape and the rank is ready to take another the day one arrives.
|
||||
///
|
||||
/// - **The unreadable repository stands in the breakage class, just under the reload breakage**
|
||||
/// (06-history-undo.md ▸ Rules, ruled 2026-07-31: "a standing breakage-class banner"). Under,
|
||||
/// and not over, because the two describe different things going wrong and one of them is
|
||||
/// about the user's content: a reload breakage means the board on screen is not the board on
|
||||
/// disk, while an unreadable repository leaves every file exactly as it is and pauses only the
|
||||
/// history over them. Both outrank every one-shot, which is what "breakage-class" buys.
|
||||
///
|
||||
/// `signposts`, `gitFailures` and `repositoryUnreadable` carry defaults: the first because its
|
||||
/// producer is m6's card window, the other two because a center that hosts no git surface (a card
|
||||
/// window's own) can never hold either. Every other class is spelled out at every call site —
|
||||
/// `losses` included, since a Finder drop that skipped folders already posts one
|
||||
/// (`postSkippedFolders`).
|
||||
/// `signposts` carries a default because its producer is m6's card window. 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: BoardLoadFailure?,
|
||||
@@ -963,9 +783,7 @@ public final class BannerCenter {
|
||||
losses: [LossBanner],
|
||||
suspension: HistorySuspension?,
|
||||
operations: [InProgressOperation],
|
||||
signposts: [InfoSignpost] = [],
|
||||
gitFailures: [GitFailureBanner] = [],
|
||||
repositoryUnreadable: Bool = false
|
||||
signposts: [InfoSignpost] = []
|
||||
) -> [BannerRow] {
|
||||
var rows: [BannerRow] = []
|
||||
|
||||
@@ -977,15 +795,9 @@ public final class BannerCenter {
|
||||
if let breakage {
|
||||
rows.append(.reloadBreakage(breakage))
|
||||
}
|
||||
if repositoryUnreadable {
|
||||
rows.append(.repositoryUnreadable)
|
||||
}
|
||||
|
||||
let ordered = newestFirst(oneShots, by: \.occurredAt)
|
||||
rows.append(contentsOf: failureRank(
|
||||
writes: ordered.filter { !$0.isAttachmentImport },
|
||||
git: newestFirst(gitFailures, by: \.occurredAt)
|
||||
))
|
||||
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
|
||||
|
||||
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
|
||||
|
||||
@@ -998,40 +810,13 @@ public final class BannerCenter {
|
||||
return rows
|
||||
}
|
||||
|
||||
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
|
||||
/// the git-operation failures, merged into the one precedence class they share — "failures rank
|
||||
/// by what they are, not by which error vocabulary threw them".
|
||||
///
|
||||
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
|
||||
/// write failure first, which is arbitrary and says so (`rows(...)`).
|
||||
private nonisolated static func failureRank(
|
||||
writes: [OneShotBanner],
|
||||
git: [GitFailureBanner]
|
||||
) -> [BannerRow] {
|
||||
var merged: [BannerRow] = []
|
||||
var writes = writes[...]
|
||||
var git = git[...]
|
||||
while let write = writes.first, let failure = git.first {
|
||||
if failure.occurredAt > write.occurredAt {
|
||||
merged.append(.gitFailure(failure))
|
||||
git = git.dropFirst()
|
||||
} else {
|
||||
merged.append(.oneShot(write))
|
||||
writes = writes.dropFirst()
|
||||
}
|
||||
}
|
||||
merged.append(contentsOf: writes.map(BannerRow.oneShot))
|
||||
merged.append(contentsOf: git.map(BannerRow.gitFailure))
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
|
||||
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
|
||||
/// `post…` maintains newest-first on insertion — so a tie renders in the order it was posted
|
||||
/// rather than in whatever order the sort happened to leave.
|
||||
///
|
||||
/// One function over a date key rather than one per class: the three dismissable classes order
|
||||
/// by exactly the same rule, and three copies of it were three places for it to drift.
|
||||
/// One function over a date key rather than one per class: the dismissable classes order
|
||||
/// by exactly the same rule, and a copy per class was a place for it to drift.
|
||||
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
|
||||
rows
|
||||
.enumerated()
|
||||
@@ -1056,39 +841,6 @@ 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").
|
||||
@@ -1521,25 +1273,8 @@ public final class BannerCenter {
|
||||
return "\(verb) skipped — '\(subject)' changed outside Lanework"
|
||||
}
|
||||
|
||||
/// **The unreadable repository's line — 06-history-undo.md ▸ Rules' own sentence, verbatim**
|
||||
/// (ruled 2026-07-31): "a standing breakage-class banner at detection ('This board's git
|
||||
/// repository can't be read — history is paused; Lanework leaves the repository untouched')".
|
||||
///
|
||||
/// A `static let` rather than a `headline(for:)` overload because the row carries nothing to
|
||||
/// compose from: three of its four sibling conditions take a payload and phrase around it, and
|
||||
/// this one is one fixed sentence. It stays here rather than on the git layer for the standing
|
||||
/// reason — "the banner owns all user-facing phrasing" — which is also why the git layer's own
|
||||
/// clause for the same state (`GitRepositoryPause.unreadable.explanation`, a fragment for
|
||||
/// failure tails) never reaches the strip.
|
||||
///
|
||||
/// The three clauses are the ruling's and each is load-bearing: what is wrong, what it costs
|
||||
/// (history is paused — not the board, which loads and edits normally), and the promise that
|
||||
/// makes waiting safe (the app will not try to repair a repository it cannot read).
|
||||
public nonisolated static let repositoryUnreadableMessage =
|
||||
"This board's git repository can't be read \u{2014} history is paused; Lanework leaves the repository untouched"
|
||||
|
||||
/// 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
|
||||
/// flush-before-overwrite guarantee are degraded — rather than the mechanics, and carries
|
||||
/// the diagnosis as its tail.
|
||||
public nonisolated static func headline(for suspension: HistorySuspension) -> String {
|
||||
let reason = trimmed(suspension.reason)
|
||||
|
||||
Reference in New Issue
Block a user