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:
+90
-585
@@ -344,11 +344,12 @@ public final class AppModel {
|
||||
/// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `beginSession` is the only caller.
|
||||
///
|
||||
/// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall): the answer is
|
||||
/// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped
|
||||
/// consults it any more** — `HistoryStore.compose` and `makeHistoryProvider` both lost the axis.
|
||||
/// The seam is kept unchanged, mechanics and all, because the entitlement is correct for
|
||||
/// whatever the next base/Pro split turns out to gate; what it does not do is decide undo.
|
||||
/// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall, and the 2026-08-08
|
||||
/// ruling then excised git entirely): the answer is still read and still recorded on the session
|
||||
/// (`BoardSession.tier`), and **nothing consults it any more** — `makeHistoryProvider` lost the
|
||||
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
|
||||
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
|
||||
/// what it does not do is decide undo.
|
||||
@ObservationIgnored
|
||||
public var currentTier: @MainActor () -> Tier = { .free }
|
||||
|
||||
@@ -357,68 +358,39 @@ public final class AppModel {
|
||||
/// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a
|
||||
/// board session's undo stack is built by, called once per board as its session begins.
|
||||
///
|
||||
/// **The provider follows the board — and, since PIVOT 2026-08-07, the board alone**
|
||||
/// (re-ruled 2026-07-31 — 12 ▸ The provider seam; 13-native-undo.md's header; 06 ▸ Rules; then
|
||||
/// 12 ▸ PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the
|
||||
/// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one — it still decided
|
||||
/// whether git was on the table at all — and the pivot took it out of that one too: "every tier
|
||||
/// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced
|
||||
/// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made
|
||||
/// subscribing *remove* undo from a mode-none board — an upgrade that takes a feature away.
|
||||
/// **Every board gets the native stack, and the seam has one answer**
|
||||
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 — the app-managed
|
||||
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
|
||||
/// decides this any more: not its tier (12 ▸ PIVOT 2026-08-07 took the last row the tier decided),
|
||||
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board
|
||||
/// nobody has done anything special to and a board living in a user's git repo bind the same
|
||||
/// stack, which was already true before this ruling and is now true by construction.
|
||||
///
|
||||
/// It takes the store because that is what a provider is a history *of*: the git provider needs
|
||||
/// the board root it is a repository at, and the native one's steps are computed from the same
|
||||
/// store's snapshots. A property rather than an initializer argument so a test
|
||||
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||
/// nothing renders from it.
|
||||
/// ### Why it is still a seam
|
||||
///
|
||||
/// ### The two answers, and the two `nil`s that are no longer among them
|
||||
/// Deliberately kept, and the excision plan says so in as many words (▸ Reversibility: "the seams
|
||||
/// the git stack plugged into … are all nil-safe/default-native and are **kept**, so a future
|
||||
/// provider — journal, ops service, or even git again — re-binds without re-plumbing"). Two things
|
||||
/// it buys today: a test binds a fake substrate — or a substrate-less board — without a second
|
||||
/// `AppModel` initializer, and the day a second provider exists it arrives as a different default
|
||||
/// here rather than as a branch threaded through the session.
|
||||
///
|
||||
/// - **Mode `git`** — the git provider: undo as forward restore commits over HEAD's first-parent
|
||||
/// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only — no other
|
||||
/// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so
|
||||
/// much as a `stat`.
|
||||
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**. "Boards
|
||||
/// without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in
|
||||
/// **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31 twice; 12 ▸ The
|
||||
/// provider seam; 13's header). `unverifiable` joins the same branch structurally — a denial
|
||||
/// can never be told apart from a repository actually being there, so it takes `repoNested`'s
|
||||
/// posture, undo included (06 ▸ Rules ▸ Detection). This is where a board nobody opted git in
|
||||
/// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's
|
||||
/// header, read through 12 ▸ PIVOT 2026-08-07).
|
||||
/// - **No `HistoryStore` at all** — the argument stays optional for the seam's own reason (a test
|
||||
/// binds a git-stateless board through it), and no session composes one: `compose` returns a
|
||||
/// store for every board there is. It answers the native stack, which is what it always
|
||||
/// answered for the free tier that used to arrive this way.
|
||||
/// It takes the store because that is what a provider is a history *of*: the native stack's steps
|
||||
/// are computed from that store's snapshots, and a provider that needed the board root would find
|
||||
/// it there too. `@ObservationIgnored` because nothing renders from it.
|
||||
///
|
||||
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
|
||||
/// stance "concerns *git*, and this stack never touches git — memory-only, journal-free,
|
||||
/// session-scoped — so what repo-nested denies is app-managed history, never ⌘Z" (13's header).
|
||||
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
|
||||
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
|
||||
/// so subscribing *removed* undo from exactly the boards it left alone. Edit ▸ Undo/Redo and the
|
||||
/// toolbar pair now disable only under a lock and on an empty stack.
|
||||
///
|
||||
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
|
||||
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
|
||||
///
|
||||
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
|
||||
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
|
||||
/// question about its repository, and a root that had to ask the disk itself would be a second
|
||||
/// detection.
|
||||
/// **The production closure never answers `nil`**, and the optionality is the seam's rather than a
|
||||
/// board's: `nil` means "no undo at all", which no board the app composes is
|
||||
/// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes
|
||||
/// a test able to bind one).
|
||||
///
|
||||
/// ### Consumers
|
||||
///
|
||||
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
|
||||
/// one event that changes a board's answer under an open session — add-git's commanded flip,
|
||||
/// which swaps `none`'s native stack for `git`'s trail.
|
||||
/// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that
|
||||
/// used to — add-git's commanded mid-session mode flip — went with the git stack.
|
||||
@ObservationIgnored
|
||||
public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in
|
||||
guard let git else { return NativeHistoryProvider() }
|
||||
switch git.mode {
|
||||
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
||||
case .none, .repoNested, .unverifiable: return NativeHistoryProvider()
|
||||
}
|
||||
public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in
|
||||
NativeHistoryProvider()
|
||||
}
|
||||
|
||||
// MARK: Sessions
|
||||
@@ -440,23 +412,21 @@ public final class AppModel {
|
||||
/// window). It lives here for the store's reason exactly: the session is what every window
|
||||
/// over this board shares, and "undo is board-local".
|
||||
///
|
||||
/// Which implementation it is, is the board's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam, read through ▸ PIVOT 2026-08-07) — see
|
||||
/// `AppModel.makeHistoryProvider`.
|
||||
/// Which implementation it is comes from one place — see `AppModel.makeHistoryProvider`,
|
||||
/// which since the 2026-08-08 git excision has exactly one answer for every board.
|
||||
///
|
||||
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
|
||||
/// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git,
|
||||
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
|
||||
/// provider. What keeps the optionality is the seam rather than a board: a test binds a
|
||||
/// substrate-less session through `makeHistoryProvider`, and a store with no session at all
|
||||
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
|
||||
/// `undoManager`, which answers the empty way over an absent substrate.
|
||||
/// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps
|
||||
/// the optionality is the seam rather than a board: a test binds a substrate-less session
|
||||
/// through `makeHistoryProvider`, and a store with no session at all registers nothing
|
||||
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
|
||||
/// answers the empty way over an absent substrate.
|
||||
///
|
||||
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
|
||||
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips —
|
||||
/// native out, git in, the in-session steps discarded with it
|
||||
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter,
|
||||
/// and since PIVOT 2026-08-07 it has no say in this either.
|
||||
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned
|
||||
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git
|
||||
/// stack. It stays a `var` because a second provider is a live possibility
|
||||
/// (`strategy/01-git-excision.md` ▸ Reversibility) and because nothing is bought by tightening
|
||||
/// it; a tier lapse could never touch it — `tier` has no setter, and since PIVOT 2026-08-07 it
|
||||
/// has no say in this either.
|
||||
public var history: (any HistoryProviding)?
|
||||
|
||||
/// **The tier this board composed under** (12-editions.md ▸ The entitlement) — recorded,
|
||||
@@ -467,46 +437,14 @@ public final class AppModel {
|
||||
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
|
||||
/// mid-session is a fact about the *next* open and about nothing already on screen.
|
||||
///
|
||||
/// **Nothing git-shaped reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07), so the
|
||||
/// substrate, the committer, the branch controls and the History surfaces are all decided by
|
||||
/// the board's mode alone. It stays recorded because the entitlement's machinery stays built
|
||||
/// and correct for whatever the re-decided base/Pro split gates, and because the fact a board
|
||||
/// opened under is a composition-time answer the way the provider binding is: whatever asks
|
||||
/// next must get what this board opened with, never what the entitlement says at render time.
|
||||
/// **Nothing reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07) and then left the app
|
||||
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
|
||||
/// this. It stays recorded because the entitlement's machinery stays built and correct for
|
||||
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
|
||||
/// a composition-time answer the way the provider binding is: whatever asks next must get what
|
||||
/// this board opened with, never what the entitlement says at render time.
|
||||
public let tier: Tier
|
||||
|
||||
/// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
|
||||
/// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the
|
||||
/// add-git action the popover offers on a board that has none.
|
||||
///
|
||||
/// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md):
|
||||
/// `HistoryStore.compose` runs detection for every board in every tier and returns a store.
|
||||
/// It used to answer `nil` under the free tier — the inert-`.git` posture made structural
|
||||
/// rather than remembered — and that posture is retired. The optionality survives for the
|
||||
/// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`.
|
||||
///
|
||||
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
|
||||
/// settled at composition and cannot change under an open session. What can change is the
|
||||
/// mode *inside* it, by add-git alone — the one commanded mid-session flip 06 allows.
|
||||
public let git: HistoryStore?
|
||||
|
||||
/// The mode this board is being edited in, `none` when there is no git state at all — which
|
||||
/// no session the app composes is any more (see `git` above), and which a board nobody has
|
||||
/// added git to detects anyway.
|
||||
///
|
||||
/// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to
|
||||
/// know whether the board has a repository to be an undo stack for, and it is the *mode* and
|
||||
/// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT
|
||||
/// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack.
|
||||
/// The popover's git section is the other reader — and the one place the two gitless modes
|
||||
/// still differ, since add-git is offered on one and explained away on the other.
|
||||
///
|
||||
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
|
||||
/// type's isolation, and everything that asks a session what mode it is in is main-actor
|
||||
/// work anyway (a menu, a popover, a provider being composed).
|
||||
@MainActor
|
||||
public var gitMode: BoardGitMode { git?.mode ?? .none }
|
||||
|
||||
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
|
||||
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
|
||||
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
|
||||
@@ -903,115 +841,15 @@ public final class AppModel {
|
||||
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
|
||||
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
|
||||
//
|
||||
// **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the
|
||||
// paywall). This line used to sit on the same line as the git state it gated; the gate is
|
||||
// gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled.
|
||||
// **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the paywall;
|
||||
// `strategy/01-git-excision.md`, ruled 2026-08-08 — git left the app). This line used to sit
|
||||
// on the same line as the git state it gated; both the gate and the state are gone, and what
|
||||
// is left is a dormant fact kept for the base/Pro split still to be ruled.
|
||||
let tier = currentTier()
|
||||
// **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board
|
||||
// open") — unconditionally, in every tier. One `stat` per open, freshly, so a board that
|
||||
// gained or lost a `.git` since its last open opens in the mode it now has. Until the
|
||||
// 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought
|
||||
// the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with
|
||||
// the posture, and every open now pays what Pro's opens always paid.
|
||||
//
|
||||
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
|
||||
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
|
||||
//
|
||||
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
|
||||
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
|
||||
// disk itself would be a second detection able to disagree with this one.
|
||||
//
|
||||
// **The ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo
|
||||
// machinery — the EchoLedger — lets the auto-committer classify every observed change, per
|
||||
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
|
||||
// callers (the add-git surface, unit tests), and a session that took that default would hand
|
||||
// the committer a ledger nothing ever writes to: every commit this app made would classify
|
||||
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
|
||||
let git = HistoryStore.compose(boardRoot: store.rootURL, ledger: store.echoes)
|
||||
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
|
||||
// below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with
|
||||
// the board session and dies at close/quit ... standard macOS behavior". On a git-mode board
|
||||
// it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo
|
||||
// survives relaunch) — the seam's whole point.
|
||||
let history = makeHistoryProvider(store, git)
|
||||
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal
|
||||
// layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
|
||||
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
|
||||
// load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git
|
||||
// flips the mode mid-session, which this closure picks up for free by asking the git state
|
||||
// at the moment of each load rather than at composition.
|
||||
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
|
||||
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
|
||||
// ▸ Rules ▸ Auto-commit). Called on every session and not only on git-mode ones, because
|
||||
// add-git can flip a board mid-session and the committer it builds then must land in
|
||||
// exactly this shape — `activateAutoCommit` remembers the wiring for that.
|
||||
git.activateAutoCommit { [weak store] committer in
|
||||
guard let store else { return }
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
// **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled
|
||||
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
|
||||
// awaits a snapshot generation covering its changed paths before the committer runs —
|
||||
// the commit's subject can never be outrun by its own reload". Both halves of that
|
||||
// await are reads of the store the composer is already diffing, which is why they are
|
||||
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
|
||||
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
|
||||
//
|
||||
// The generation the gate counts is `landedReloads` — completed *walks* rather than
|
||||
// applied snapshots — because a value-equal reload skips the assignment and its
|
||||
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
|
||||
// anything to change (`BoardStore.landedReloads`).
|
||||
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
|
||||
committer.landedReloads = { [weak store] in store?.landedReloads }
|
||||
// 02-architecture.md ▸ Write-failure surfacing, through the strip the board window
|
||||
// already renders: a genuine commit failure means "your edits are saved, history has
|
||||
// stopped advancing", which is exactly what the standing suspension row says. Lock
|
||||
// contention and a held repository never reach here — neither is a failure.
|
||||
committer.reportFailure = { [weak store] failure in
|
||||
store?.banners.suspendHistory(reason: failure.message)
|
||||
}
|
||||
committer.reportRecovery = { [weak store] in
|
||||
store?.banners.clearHistorySuspension()
|
||||
}
|
||||
// **The corrupt-`.git` loud failure's standing row** (06-history-undo.md ▸ Rules,
|
||||
// ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
|
||||
// and says so on the strip, "announced per 10-accessibility.md" — and the same seam
|
||||
// heals it, since the paused engine's own 15 s re-read is what notices a repository
|
||||
// repaired in a terminal. Distinct from the suspension above: that row is history
|
||||
// failing to advance and retrying, this one is there being nothing to advance into.
|
||||
committer.reportRepositoryUnreadable = { [weak store] unreadable in
|
||||
store?.noteRepositoryUnreadable(unreadable)
|
||||
}
|
||||
store.commitSeam = .binding(to: committer)
|
||||
// **The undo stack's ear on the committer** — every commit this engine lands, and
|
||||
// which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry,
|
||||
// live; ▸ Heal commits are transparent to undo). Bound here rather than in
|
||||
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
|
||||
// `activateAutoCommit` remembers on its behalf.
|
||||
committer.reportLanded = { [weak self, ref] window in
|
||||
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
|
||||
provider.noteLanded(window)
|
||||
}
|
||||
}
|
||||
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
|
||||
// mid-session mode flip; 13-native-undo.md's header — "discards the in-session native
|
||||
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
|
||||
git.didAddGit = { [weak self] in
|
||||
self?.bindHistoryProvider(for: ref)
|
||||
}
|
||||
// **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers,
|
||||
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
|
||||
// when that form has been dismissed before the answer arrived — "inline is the primary
|
||||
// surface, never a silence trap". The banner enumeration is the same one branch switch and
|
||||
// undo restore post into, one row per failure.
|
||||
git.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.addGit, reason: failure.message)
|
||||
}
|
||||
// **The detection-time answer, published once** (06 ▸ Rules: "a standing breakage-class
|
||||
// banner **at detection**"). The probe ran inside `compose` above — before this session
|
||||
// existed, and therefore before the seam that carries its transitions was wired — so a
|
||||
// board that opened into an unreadable repository raises its row here rather than
|
||||
// waiting for the first debounce to rediscover what composition already knows.
|
||||
store.noteRepositoryUnreadable(git.isRepositoryUnreadable)
|
||||
// the board session and dies at close/quit ... standard macOS behavior".
|
||||
let history = makeHistoryProvider(store)
|
||||
// **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the
|
||||
// store is that boundary — every app-mediated mutation goes out through one of its write
|
||||
// methods — so it is the store that computes each inverse and registers it. What it cannot
|
||||
@@ -1024,7 +862,6 @@ public final class AppModel {
|
||||
recordID: recordID,
|
||||
history: history,
|
||||
tier: tier,
|
||||
git: git,
|
||||
// The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the
|
||||
// other mutating commands while the board refuses writes, and the stack survives to
|
||||
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
|
||||
@@ -1036,183 +873,19 @@ public final class AppModel {
|
||||
cardRefs: [],
|
||||
access: access
|
||||
)
|
||||
wireGitUndo(history, store: store, git: git, ref: ref)
|
||||
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
|
||||
refreshRecents()
|
||||
}
|
||||
|
||||
// MARK: - The git provider's wiring
|
||||
|
||||
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing
|
||||
/// at all for any other substrate.
|
||||
///
|
||||
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
|
||||
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
|
||||
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
|
||||
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider
|
||||
/// stays a thing that knows about commits, and the model stays the only object that knows what a
|
||||
/// window is.
|
||||
private func wireGitUndo(
|
||||
_ history: (any HistoryProviding)?,
|
||||
store: BoardStore,
|
||||
git: HistoryStore?,
|
||||
ref: BoardWindowRef
|
||||
) {
|
||||
guard let provider = history as? GitHistoryProvider, let git else { return }
|
||||
|
||||
provider.flushPendingCommit = { [weak git] in
|
||||
await git?.committer?.flushNow()
|
||||
}
|
||||
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||
// The stage-around the settle released comes back with the committer: a card window still open
|
||||
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
|
||||
provider.resumeCommitting = { [weak self, weak git] in
|
||||
git?.committer?.start()
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a
|
||||
// one-shot banner failure naming the operation and the error, the tree left as it was") —
|
||||
// now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled
|
||||
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
|
||||
// this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't
|
||||
// happen, not content that didn't arrive.
|
||||
//
|
||||
// The closure passes the *direction* and libgit2's own message and stops there — "Undo
|
||||
// failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
|
||||
provider.reportFailure = { [weak store] direction, failure in
|
||||
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
|
||||
}
|
||||
provider.runBracketed = { [weak store] subject, work in
|
||||
guard let store else { return await work() }
|
||||
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
|
||||
// own subject — the sentence the trail now carries, spoken once when the reload lands.
|
||||
try? await store.performWholesale(announcing: subject) { await work() }
|
||||
}
|
||||
provider.settleSessions = { [weak self, weak provider] paths in
|
||||
guard let self, let provider else { return .proceed }
|
||||
let gate = self.settleGate(for: ref) { [weak provider] folder in
|
||||
// The card's uncommitted on-disk saves are reverted by the restore itself, which
|
||||
// compares this folder against the working tree rather than against HEAD — see
|
||||
// `GitRestoreOperation.plan`.
|
||||
provider?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
let outcome = await gate.settle(touching: paths)
|
||||
// **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it
|
||||
// was, sessions and their staging included.
|
||||
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
|
||||
return outcome
|
||||
}
|
||||
provider.seed()
|
||||
|
||||
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
|
||||
}
|
||||
|
||||
/// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the
|
||||
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
|
||||
/// interrupted switch recognizable as this app's.
|
||||
///
|
||||
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
|
||||
/// same board's git session seen from two sides — and because both must be re-wired on exactly the
|
||||
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
|
||||
private func wireBranchSwitching(
|
||||
git: HistoryStore,
|
||||
store: BoardStore,
|
||||
provider: GitHistoryProvider,
|
||||
ref: BoardWindowRef
|
||||
) {
|
||||
guard let switcher = git.switcher else { return }
|
||||
let recordID = sessions[ref]?.recordID
|
||||
|
||||
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
|
||||
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||
// As on the restore path: what the settle released is a session that has not ended, and the
|
||||
// window is still open on the other side of the checkout.
|
||||
switcher.resumeCommitting = { [weak self, weak git] in
|
||||
git?.committer?.start()
|
||||
self?.resumeCardSessionStaging(for: ref)
|
||||
}
|
||||
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
|
||||
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
|
||||
// starts empty".
|
||||
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
|
||||
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
|
||||
switcher.runBracketed = { [weak store] announcement, work in
|
||||
guard let store else { return await work() }
|
||||
try? await store.performWholesale(announcing: announcement) { await work() }
|
||||
}
|
||||
switcher.beginProgress = { [weak store] label in
|
||||
store?.banners.beginOperation(label: label) ?? UUID()
|
||||
}
|
||||
switcher.updateProgress = { [weak store] id, label in
|
||||
store?.banners.updateOperation(id, label: label)
|
||||
}
|
||||
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
||||
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
|
||||
// an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from
|
||||
// the operation alone).
|
||||
switcher.reportFailure = { [weak store] failure in
|
||||
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
|
||||
}
|
||||
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The
|
||||
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
|
||||
// "A branch switch was interrupted — the previous state is restored" is the app tidying up
|
||||
// after itself, with nothing for the user to do — the loss class's own register.
|
||||
switcher.reportRecovery = { [weak store] message in
|
||||
store?.banners.postLoss(message)
|
||||
}
|
||||
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
|
||||
// record — a store-level test — simply carries no stamp, and recovery then has nothing to
|
||||
// recognize, which is the honest answer for a board the app has no state for.
|
||||
switcher.readStamp = { [weak self] in
|
||||
guard let self, let recordID else { return nil }
|
||||
return self.boardRegistry.gitOperationStamp(id: recordID)
|
||||
}
|
||||
switcher.writeStamp = { [weak self] stamp in
|
||||
guard let self, let recordID else { return }
|
||||
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
|
||||
}
|
||||
switcher.settleSessions = { [weak self, weak switcher] in
|
||||
guard let self, let switcher else { return .proceed }
|
||||
let gate = self.settleGate(
|
||||
for: ref,
|
||||
message: SessionSettleStep.branchSwitchMessage
|
||||
) { [weak switcher] folder in
|
||||
switcher?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
|
||||
let outcome = await gate.settleAll()
|
||||
// The switch's flush runs next and must find a tree it can settle whole — see
|
||||
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
|
||||
//
|
||||
// **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the
|
||||
// same `.proceed`, the same seam, for the same reason one rung up — what a window is
|
||||
// holding describes the branch being left. "Cancel keeps the current branch and the
|
||||
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
|
||||
// as it releases nothing.
|
||||
if outcome == .proceed {
|
||||
self.releaseCardSessionStaging(for: ref)
|
||||
self.discardCardWindowUndoStacks(for: ref)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
|
||||
// committer's start, which is where a pause first becomes knowable, and before anything the
|
||||
// user does can land on top of a half-finished checkout.
|
||||
Task { await switcher.recoverInterruptedOperation() }
|
||||
}
|
||||
|
||||
/// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production.
|
||||
///
|
||||
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
|
||||
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the
|
||||
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
|
||||
/// that closure straight at the alert — so the *composition* around the gate, which is what
|
||||
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
|
||||
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
|
||||
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
|
||||
/// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing
|
||||
/// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel
|
||||
/// over real card windows without a modal on screen.
|
||||
///
|
||||
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
|
||||
/// rather than API: production never assigns it.
|
||||
@@ -1222,18 +895,20 @@ public final class AppModel {
|
||||
}
|
||||
|
||||
/// **The save-or-discard step for one board**, built from its open card windows
|
||||
/// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
/// (`SessionSettleGate`).
|
||||
///
|
||||
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
||||
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
||||
///
|
||||
/// **No production caller today** — the git restore and branch switch were the two, and both went
|
||||
/// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it
|
||||
/// composes, for the gate's own reason: it is what the next wholesale operation binds to.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - message: what the step says it is about. The two callers describe different consequences —
|
||||
/// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to
|
||||
/// both without giving either the other's wording.
|
||||
/// - message: what the step says it is about. Different operations describe different
|
||||
/// consequences, and a step that described the wrong one would be a worse modal than none.
|
||||
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
|
||||
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
|
||||
/// into its plan; the switch reverts before it flushes).
|
||||
/// gate can reconcile that folder's already-written bytes its own way.
|
||||
func settleGate(
|
||||
for ref: BoardWindowRef,
|
||||
message: String = SessionSettleStep.message,
|
||||
@@ -1272,62 +947,6 @@ public final class AppModel {
|
||||
)
|
||||
}
|
||||
|
||||
/// **Swaps the board's undo substrate onto an already-open session** — add-git's one caller.
|
||||
///
|
||||
/// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git:
|
||||
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
||||
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
|
||||
/// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards
|
||||
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
|
||||
/// discard-and-reseed precedent applied".
|
||||
///
|
||||
/// ### The discard is the whole of it — there is no migration
|
||||
///
|
||||
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
|
||||
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
|
||||
/// in-memory inverse operations against a board that has just acquired a commit trail, and
|
||||
/// replaying one after the swap would walk the board back across a change the root commit already
|
||||
/// records as the baseline. The branch-switch precedent says the same thing about the same
|
||||
/// question — "the undo/redo stack does not survive a switch. It is discarded and reseeded from
|
||||
/// the new HEAD's first-parent ancestry … redo starts empty" (06 ▸ Branch switching) — so the old
|
||||
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
|
||||
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: ⌘Z is
|
||||
/// correctly empty the instant the flip lands.
|
||||
///
|
||||
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
|
||||
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
|
||||
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
|
||||
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
|
||||
/// change on this board rides. Nothing here posts a banner, and nothing should.
|
||||
///
|
||||
/// ### Why live rather than at the next open
|
||||
///
|
||||
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
|
||||
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
|
||||
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
|
||||
/// the provider it composed" was a rule about a **tier** lapsing, which never could change a
|
||||
/// running session — and since PIVOT 2026-08-07 no longer changes the *next* open either, since
|
||||
/// a git board composes the git provider in every tier; and a board that visibly starts
|
||||
/// accumulating commits while ⌘Z answers from a
|
||||
/// stack the repository knows nothing about would read as a defect rather than as a policy.
|
||||
///
|
||||
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
|
||||
/// `none`, and flips to `.git` before it fires `didAddGit`.
|
||||
func bindHistoryProvider(for ref: BoardWindowRef) {
|
||||
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
|
||||
guard let history = makeHistoryProvider(session.store, git) else { return }
|
||||
guard history !== session.history else { return }
|
||||
// Before the reassignment, while `session.history` is still the substrate being replaced: the
|
||||
// in-flight native steps go with it, and any closure that outlives this line finds an empty
|
||||
// stack rather than inverses against a pre-repository board.
|
||||
session.history?.clear()
|
||||
session.history = history
|
||||
sessions[ref] = session
|
||||
session.store.history = history
|
||||
session.undoManager.history = history
|
||||
wireGitUndo(history, store: session.store, git: git, ref: ref)
|
||||
}
|
||||
|
||||
/// Registers a card window with its board's session, so the close flush can find it.
|
||||
///
|
||||
/// A card window whose board has no session is a card window with no board — the ownership rule
|
||||
@@ -1340,112 +959,32 @@ public final class AppModel {
|
||||
}
|
||||
sessions[ref.board]?.cardRefs.insert(ref)
|
||||
cardSessions[ref] = session
|
||||
// **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the
|
||||
// stage-around opens here — with the window — rather than at the body's first Edit→Preview
|
||||
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
|
||||
// in an interim commit.
|
||||
setCardSession(true, for: ref)
|
||||
}
|
||||
|
||||
func unregisterCardWindow(_ ref: CardWindowRef) {
|
||||
sessions[ref.board]?.cardRefs.remove(ref)
|
||||
cardSessions[ref] = nil
|
||||
// **The close flush's release** — and, for a window that left without its session ending (a
|
||||
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
|
||||
// card folder excluded from staging forever. Both are the same line because both mean the same
|
||||
// thing: this window is no longer holding its folder back.
|
||||
setCardSession(false, for: ref)
|
||||
}
|
||||
|
||||
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
|
||||
/// reason: this is the seam table's third column, written only here.
|
||||
@ObservationIgnored
|
||||
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
|
||||
|
||||
/// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the
|
||||
/// committer "stages around the whole open card folder").
|
||||
/// **A wholesale operation's settle empties every open card window's fine undo stack.**
|
||||
///
|
||||
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the
|
||||
/// committer knows what staging is, and only the app model knows which board a card window belongs
|
||||
/// to and how to reach its committer. A board with no committer — any board nobody has added git
|
||||
/// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) — records
|
||||
/// nothing, which is the same `nil` every other git seam takes.
|
||||
/// The rule the branch switch established, kept as a rule about *any* operation that replaces the
|
||||
/// tree under an open window: pre-operation steps describe a state that has gone, so Save All and
|
||||
/// Discard alike end with every window's stack empty — the board-stack discard-and-reseed
|
||||
/// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which
|
||||
/// is why this hangs off a `.proceed` at the call site rather than off the ask.
|
||||
///
|
||||
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
|
||||
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
|
||||
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
|
||||
/// both containers, which is exactly why the body save uses it too.
|
||||
/// **The board stack is not touched here**, and it is not an omission: an operation that replaces
|
||||
/// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*.
|
||||
/// Doing it here would be the same discard at the wrong moment.
|
||||
///
|
||||
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
|
||||
/// and closing an already-closed session reaches a committer that has nothing to remove.
|
||||
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
|
||||
guard isOpen else {
|
||||
// **The token goes whether or not there is anyone left to tell.** A card window's own
|
||||
// teardown can land after its board's, and a token kept past the board it names would
|
||||
// outlive everything that could ever release it.
|
||||
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
|
||||
sessions[ref.board]?.git?.committer?.endCardSession(token)
|
||||
return
|
||||
}
|
||||
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
|
||||
let token = cardSessionTokens[ref] ?? UUID()
|
||||
cardSessionTokens[ref] = token
|
||||
let cardID = ref.cardIdentity
|
||||
committer.beginCardSession(token) { [weak store = session.store] in
|
||||
guard let store,
|
||||
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
|
||||
return path.folder(under: store.rootURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore
|
||||
/// vs open Edit sessions) — every open card window on this board, unconditionally.
|
||||
/// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff
|
||||
/// leaves a window's steps describing a state that is still mostly there; what protects them is
|
||||
/// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a
|
||||
/// wholesale one.
|
||||
///
|
||||
/// ### Why unconditionally, rather than through the modal
|
||||
///
|
||||
/// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves
|
||||
/// the session hasn't committed" — and a window that is merely open, with a comment posted an hour
|
||||
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
|
||||
/// that window is still holding its whole folder out of every commit, so leaving it held would
|
||||
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
|
||||
/// sessions settled the restore runs on a settled tree … it cannot fail dirty".
|
||||
///
|
||||
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
|
||||
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
|
||||
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
|
||||
/// why this runs *after* the gate has answered rather than before it.
|
||||
func releaseCardSessionStaging(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
setCardSession(false, for: cardRef)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The branch switch's settle empties every open card window's fine undo stack**
|
||||
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
|
||||
///
|
||||
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
|
||||
/// > the branch being left — Save All and Discard alike end with every window's stack empty, the
|
||||
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
|
||||
/// > their cards onto the new branch with fresh stacks."
|
||||
///
|
||||
/// ### Why here, beside the staging release
|
||||
///
|
||||
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
|
||||
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
|
||||
/// are true of a session whose branch is about to be replaced under it. Running them from one
|
||||
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
|
||||
/// two call sites have to keep in step (`wireBranchSwitching`).
|
||||
///
|
||||
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
|
||||
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
|
||||
/// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same
|
||||
/// discard, one level up, at the wrong moment — before the checkout that decides what to reseed
|
||||
/// *from*.
|
||||
///
|
||||
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
|
||||
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
|
||||
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
|
||||
/// per-step question rather than a wholesale one.
|
||||
/// **No production caller today** — it went with the branch switch (`strategy/01-git-excision.md`)
|
||||
/// — and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason.
|
||||
///
|
||||
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
|
||||
/// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two
|
||||
@@ -1457,18 +996,6 @@ public final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the
|
||||
/// operation behind the settle has finished with the tree.
|
||||
///
|
||||
/// A window that is still open after a restore or a branch switch is still a session, and its
|
||||
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
|
||||
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
|
||||
func resumeCardSessionStaging(for ref: BoardWindowRef) {
|
||||
for cardRef in sessions[ref]?.cardRefs ?? [] {
|
||||
setCardSession(true, for: cardRef)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Launch failures
|
||||
|
||||
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
|
||||
@@ -1629,11 +1156,10 @@ public final class AppModel {
|
||||
///
|
||||
/// ### Declining costs nothing, today least of all
|
||||
///
|
||||
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
|
||||
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
|
||||
/// way — declining costs undo nothing at all, and costs a git board only the trail it would have
|
||||
/// gained at its next open. The offer exists because "subscribe takes effect at each board's next
|
||||
/// open" (12 ▸ The entitlement) needs one, not because anything breaks without it.
|
||||
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
|
||||
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
|
||||
/// "subscribe takes effect at each board's next open" (12 ▸ The entitlement) needs one, not
|
||||
/// because anything breaks without it.
|
||||
public func reopenOpenBoards() async {
|
||||
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
|
||||
let refs = sessions.keys.sorted { $0.path < $1.path }
|
||||
@@ -1681,15 +1207,6 @@ public final class AppModel {
|
||||
},
|
||||
endCardSession: { [weak self] cardRef in
|
||||
await self?.cardSessions[cardRef]?.endSession()
|
||||
// **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸
|
||||
// Auto-commit). The unregister does release it — that is what closes a window on its
|
||||
// own — but it arrives from the *window's* teardown, which this sequence waits for
|
||||
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
|
||||
// was slow to disappear would then flush with the folder still staged around and leave
|
||||
// a settled session uncommitted, which is precisely what "nothing settled is ever left
|
||||
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
|
||||
// releasing what the session held is too. Idempotent with the unregister.
|
||||
self?.setCardSession(false, for: cardRef)
|
||||
},
|
||||
dismissCardWindow: { [weak self] cardRef in
|
||||
self?.windowDismisser?(value: cardRef)
|
||||
@@ -1705,17 +1222,10 @@ public final class AppModel {
|
||||
// window of its own — the raw-source buffer is the candidate — so that the order
|
||||
// relative to `committerFlush` is already decided when one arrives.
|
||||
//
|
||||
// **`committerFlush` is filled now** (06-history-undo.md ▸ Rules ▸ Auto-commit: "Board
|
||||
// window close and app quit flush the pipeline — any pending editor save, then the
|
||||
// pending auto-commit — before teardown; nothing settled is ever left unsaved or
|
||||
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
|
||||
// session *and released its stage-around*, so each session — body, comments, purge and
|
||||
// all — lands in exactly one commit. `nil` on every board with no committer, which is
|
||||
// every board nobody has added git to (12 ▸ PIVOT 2026-08-07 — the tier no longer
|
||||
// decides; opt-in per board still does).
|
||||
committerFlush: { [weak self] in
|
||||
await self?.sessions[ref]?.git?.committer?.flushNow()
|
||||
},
|
||||
// **`committerFlush` stays nil too**, and now for want of a committer rather than by
|
||||
// policy: the auto-committer it drove went with the git stack
|
||||
// (`strategy/01-git-excision.md`). The slot is 02's ordering statement — pending editor
|
||||
// saves, then whatever records history — and it keeps that place for the successor.
|
||||
recordClose: { [weak self] in
|
||||
guard let self, let session = sessions[ref] else { return }
|
||||
let counts = Self.liveCounts(of: session.store.snapshot)
|
||||
@@ -1734,11 +1244,6 @@ public final class AppModel {
|
||||
},
|
||||
tearDown: { [weak self] in
|
||||
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
|
||||
// The committer dies with the session it commits for, `history.clear()`'s reason
|
||||
// exactly: its debounce holds a closure over the store this line is about to release,
|
||||
// and a timer that outlived its board would fire against a repository nobody is
|
||||
// looking at. Its pending work has already been flushed by `committerFlush` above.
|
||||
session.git?.stopAutoCommit()
|
||||
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
|
||||
// ▸ Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
|
||||
// Cleared rather than merely dropped because the steps hold closures over the store
|
||||
|
||||
@@ -162,8 +162,8 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
},
|
||||
discard: { [self] in
|
||||
// The buffer goes back to what disk says; the *disk* goes back to the target state as
|
||||
// part of the restore itself, which reconciles this card's folder against the working
|
||||
// tree rather than against HEAD (`GitRestoreOperation.plan`).
|
||||
// part of the wholesale operation itself, which is the only party that knows what it
|
||||
// is reconciling this card's folder towards (`SessionSettleGate`).
|
||||
body.discardBuffer()
|
||||
rawSourceCancel?()
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import Foundation
|
||||
|
||||
// MARK: - Vocabulary
|
||||
|
||||
/// What the user chose at the save-or-discard step (06-history-undo.md ▸ Branch switching: "**Save
|
||||
/// All** ends every session with its normal commit …, **Discard** reverts buffers and uncommitted
|
||||
/// saves to HEAD, **Cancel** keeps the current branch and the sessions").
|
||||
/// What the user chose at the save-or-discard step: **Save All** ends every session with its normal
|
||||
/// save, **Discard** reverts buffers and leaves the wholesale operation to reconcile what already
|
||||
/// reached disk, **Cancel** keeps everything exactly as it is.
|
||||
public enum SessionSettleChoice: Sendable, Equatable {
|
||||
case saveAll
|
||||
case discard
|
||||
@@ -21,10 +21,9 @@ public enum SessionSettleOutcome: Sendable, Equatable {
|
||||
/// The user chose Cancel. "Cancel keeps everything" — nothing was written, nothing reverted.
|
||||
case cancelled
|
||||
|
||||
/// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer
|
||||
/// that fails validation cancels the whole switch with focus on the offending window, nothing
|
||||
/// half-switched" (06 ▸ Branch switching). The payload is that window's session id, already
|
||||
/// focused by the gate.
|
||||
/// **Save All met a raw-source buffer that would not validate.** Since Apply validates, a buffer
|
||||
/// that fails validation cancels the whole operation with focus on the offending window, nothing
|
||||
/// half-done. The payload is that window's session id, already focused by the gate.
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@@ -79,7 +78,7 @@ public struct SettleableSession {
|
||||
///
|
||||
/// Matched component-wise against the paths a wholesale operation would write, which is what
|
||||
/// makes the match survive a lane move: a card's own folder component never changes, only the
|
||||
/// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason).
|
||||
/// lane above it (`SessionSettleGate.path(_:isInsideFolderNamed:)`).
|
||||
public let cardFolderName: String
|
||||
|
||||
/// Whether this session is holding state a wholesale tree operation would disturb: unsaved
|
||||
@@ -91,10 +90,9 @@ public struct SettleableSession {
|
||||
/// the raw buffer. `false` means the raw buffer failed validation — the whole operation is off.
|
||||
public let saveAll: @MainActor () -> Bool
|
||||
|
||||
/// **Discard** for this one session: revert the buffer and leave raw source without writing. The
|
||||
/// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's
|
||||
/// folder against the working tree rather than against HEAD for exactly that reason
|
||||
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
||||
/// **Discard** for this one session: revert the buffer and leave raw source without writing. What
|
||||
/// already reached disk is reconciled by the wholesale operation behind the gate, which is the
|
||||
/// only party that knows the state it is writing towards.
|
||||
public let discard: @MainActor () -> Void
|
||||
|
||||
public init(
|
||||
@@ -117,23 +115,22 @@ public struct SettleableSession {
|
||||
/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md ▸ Rules
|
||||
/// ▸ Undo restore vs open Edit sessions; ▸ Branch switching).
|
||||
///
|
||||
/// ### One machinery, two callers, by design
|
||||
/// ### One machinery, every caller, by design
|
||||
///
|
||||
/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same
|
||||
/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch
|
||||
/// save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same
|
||||
/// rationale)." So this object is written for both from the start; the undo provider is its first
|
||||
/// caller and the branch controls will be its second, passing the paths a checkout would write
|
||||
/// instead of the paths a restore would.
|
||||
/// The design never described a gate per operation: it described one save-or-discard step — Save All
|
||||
/// / Discard / Cancel — and handed it to whatever wants to move the tree wholesale under an open
|
||||
/// editor. So this object is written for all of them. **Nothing calls it today** (`strategy/
|
||||
/// 01-git-excision.md` ▸ What is deleted: the git restore and branch switch were its two callers and
|
||||
/// went with the stack); it is kept because it is the settled answer to a question the next wholesale
|
||||
/// operation will ask on its first day.
|
||||
///
|
||||
/// ### The diff decides whether it appears at all
|
||||
/// ### The paths decide whether it appears at all
|
||||
///
|
||||
/// "A restore materializes only the diff between the current tree and the target state, so a card
|
||||
/// whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms
|
||||
/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all."
|
||||
/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a
|
||||
/// yes/no: a modal that appeared on every ⌘Z because *some* window somewhere was in Edit would be a
|
||||
/// different, much worse feature.
|
||||
/// An operation that materializes only a diff leaves a card whose open Edit session the diff doesn't
|
||||
/// touch simply unaffected — that card's uncommitted ~700 ms saves continue undisturbed. That is
|
||||
/// `settle(touching:)`'s first line, and it is why the gate takes paths rather than a yes/no: a modal
|
||||
/// that appeared on every gesture because *some* window somewhere was in Edit would be a different,
|
||||
/// much worse feature. `settleAll()` is the other shape, for an operation that moves everything.
|
||||
///
|
||||
/// ### Why the ask is a closure
|
||||
///
|
||||
@@ -174,17 +171,16 @@ public struct SessionSettleGate {
|
||||
await decide(over: Self.reached(by: paths, among: sessions()))
|
||||
}
|
||||
|
||||
/// **Settles every open session, whatever the operation writes** — the branch switch's gate
|
||||
/// (06 ▸ Branch switching).
|
||||
/// **Settles every open session, whatever the operation writes** — the gate for an operation that
|
||||
/// replaces the tree wholesale rather than materializing a diff.
|
||||
///
|
||||
/// The path filter above is the *restore's* narrowing and belongs to it alone: "a restore
|
||||
/// materializes only the diff between the current tree and the target state, so a card whose open
|
||||
/// Edit session the diff doesn't touch is simply unaffected". A branch switch has no such
|
||||
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out — "an
|
||||
/// unsettled raw buffer … its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
|
||||
/// onto the new branch's card" — is about the buffer belonging to the old branch, not about
|
||||
/// whether the checkout happened to rewrite that card. So this asks about every session that is
|
||||
/// holding something, and about no path at all.
|
||||
/// The path filter above is a diff-shaped operation's narrowing and belongs to it alone: a card
|
||||
/// whose open Edit session the diff doesn't touch is simply unaffected. A wholesale replacement
|
||||
/// has no such property, and the raw-source hazard is the reason it cannot borrow one — an
|
||||
/// unsettled raw buffer's Apply later writes the *entire* pre-operation `index.md` byte-for-byte
|
||||
/// over whatever now stands there, which is about the buffer describing a state that has gone, not
|
||||
/// about whether the operation happened to rewrite that card. So this asks about every session
|
||||
/// that is holding something, and about no path at all.
|
||||
public func settleAll() async -> SessionSettleOutcome {
|
||||
await decide(over: sessions())
|
||||
}
|
||||
@@ -225,9 +221,21 @@ public struct SessionSettleGate {
|
||||
) -> [SettleableSession] {
|
||||
guard !paths.isEmpty else { return [] }
|
||||
return sessions.filter { session in
|
||||
paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) }
|
||||
paths.contains { path($0, isInsideFolderNamed: session.cardFolderName) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a
|
||||
/// prefix of another's cannot be dragged into a step it has no business in.
|
||||
///
|
||||
/// Moved here from the git history walk when the git stack was unbound (`strategy/
|
||||
/// 01-git-excision.md` ▸ Sequencing ▸ Seam unbind); the walk was its only other caller.
|
||||
///
|
||||
/// `dropLast()` because the *containing* folders are the question: a path that ends in the folder
|
||||
/// name is a file called that, not a file inside it.
|
||||
static func path(_ path: String, isInsideFolderNamed name: String) -> Bool {
|
||||
path.split(separator: "/").dropLast().contains { $0 == name }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The presented step
|
||||
@@ -236,12 +244,11 @@ public struct SessionSettleGate {
|
||||
///
|
||||
/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s
|
||||
/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to
|
||||
/// text no commit protects, and there is no non-modal shape for a question whose three answers are
|
||||
/// text no save protects, and there is no non-modal shape for a question whose three answers are
|
||||
/// mutually exclusive and immediate.
|
||||
///
|
||||
/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed
|
||||
/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other
|
||||
/// answers write.
|
||||
/// The default is **Cancel**, deliberately: a Return pressed reflexively at a dialog nobody read must
|
||||
/// be the answer that changes nothing, and both other answers write.
|
||||
public enum SessionSettleStep {
|
||||
|
||||
public static let title = "Unsaved card edits"
|
||||
@@ -251,12 +258,12 @@ public enum SessionSettleStep {
|
||||
Save them, discard the changes, or cancel.
|
||||
"""
|
||||
|
||||
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
|
||||
/// consequence a user is deciding about is not the same one — a restore would change the cards
|
||||
/// being edited, while a switch takes every card to a different branch — and a step that described
|
||||
/// the wrong operation would be a worse modal than no wording at all.
|
||||
public static let branchSwitchMessage = """
|
||||
Switching branches would replace the cards you are editing. \
|
||||
/// The same three buttons, asked for an operation that replaces the tree rather than editing part
|
||||
/// of it. **One sentence differs, deliberately**: the consequence a user is deciding about is not
|
||||
/// the same one, and a step that described the wrong operation would be a worse modal than no
|
||||
/// wording at all. Kept unused beside the gate, for the gate's reason.
|
||||
public static let wholesaleReplacementMessage = """
|
||||
This would replace the cards you are editing. \
|
||||
Save them, discard the changes, or cancel.
|
||||
"""
|
||||
|
||||
|
||||
@@ -64,15 +64,15 @@ public struct ChangeNarrationRequest: Sendable {
|
||||
public let snapshot: BoardModel?
|
||||
|
||||
/// **The last-committed half** of the "last-committed vs. current" diff — supplied by the caller
|
||||
/// by contract; the narrator itself never reads this from anywhere. The git stack supplied it from
|
||||
/// HEAD's tree (`GitHeadSnapshot`), the former git-backed supplier; the journal will supply it from
|
||||
/// memory/snapshots.
|
||||
/// by contract; the narrator itself never reads this from anywhere. The retired git substrate
|
||||
/// materialized it from HEAD's tree; the journal will supply it from memory/snapshots.
|
||||
///
|
||||
/// `nil` when the caller has nothing to diff against — the git stack's case was an unborn HEAD
|
||||
/// (where `isRootCommit` already says everything) or a HEAD whose tree did not load as a board. The
|
||||
/// git stack read it from the repository rather than carrying it forward from the last commit the
|
||||
/// app made, because the app is not the only writer and because launch catch-up has no carried
|
||||
/// value to offer: see `GitHeadSnapshot` for the whole of that argument.
|
||||
/// `nil` when the caller has nothing to diff against — a first window (where `isRootCommit`
|
||||
/// already says everything), or a stored state that did not load as a board.
|
||||
///
|
||||
/// **A supplier reads it rather than carrying it forward** from the last window it described, and
|
||||
/// deliberately: the app is not the only writer, and catch-up after a period of not watching has
|
||||
/// no carried value to offer.
|
||||
public let previousSnapshot: BoardModel?
|
||||
|
||||
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it — the one
|
||||
@@ -92,8 +92,7 @@ public struct ChangeNarrationRequest: Sendable {
|
||||
/// comments' own `created`, folder name on ties" (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31),
|
||||
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
|
||||
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
|
||||
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
|
||||
/// function of values.
|
||||
/// it once, off disk, and the engine stays a pure function of values.
|
||||
///
|
||||
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
|
||||
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
|
||||
@@ -141,7 +140,7 @@ public protocol ChangeNarrating: Sendable {
|
||||
|
||||
// MARK: - The wired composer
|
||||
|
||||
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
|
||||
/// **The semantic composer**, and the seam's default.
|
||||
///
|
||||
/// A one-line conformance over `ChangeNarrator`, deliberately: the vocabulary is worth a file of
|
||||
/// its own and nothing about it should have to know that a protocol exists. The type stays because
|
||||
|
||||
@@ -24,10 +24,10 @@ import Foundation
|
||||
/// ### Purity, and where the impurity went
|
||||
///
|
||||
/// Nothing in here reads a file, opens a repository, or asks what time it is. Both snapshots and the
|
||||
/// guide's text arrive as values on `ChangeNarrationRequest`; `GitAutoCommitter` resolves them once per
|
||||
/// flush, the last-committed one through `GitHeadSnapshot`. That is what makes the whole vocabulary —
|
||||
/// every subject form, every fold, every trash reading — testable with two snapshots and no
|
||||
/// repository at all.
|
||||
/// guide's text arrive as values on `ChangeNarrationRequest`, resolved once per window by whoever is
|
||||
/// driving the narration — the previous snapshot included. That is what makes the whole vocabulary —
|
||||
/// every subject form, every fold, every trash reading — testable with two snapshots and no store at
|
||||
/// all.
|
||||
///
|
||||
/// ### The changed-path list is a filter, not a hint
|
||||
///
|
||||
@@ -93,6 +93,14 @@ enum ChangeNarrator {
|
||||
/// says *added* without a second sentence shape for it.
|
||||
static let absentValuePlaceholder = "(none)"
|
||||
|
||||
/// **The subject the one snapshot-less window gets** (06 ▸ Rules ▸ Abnormal repo states): a first
|
||||
/// window with no previous snapshot to diff against describes the whole board as arriving, never a
|
||||
/// folded diff-from-empty.
|
||||
///
|
||||
/// Defined here rather than borrowed from a substrate, so the narrator stays a pure function of
|
||||
/// two snapshots (`strategy/01-git-excision.md` ▸ What is harvested).
|
||||
static let rootSubject = "Initial board state"
|
||||
|
||||
// MARK: - Entry point
|
||||
|
||||
/// One request in, one whole message out — a subject, and a body when there is more to say.
|
||||
@@ -100,7 +108,7 @@ enum ChangeNarrator {
|
||||
// **The one commit with a subject of its own** (06 ▸ Rules ▸ Abnormal repo states): "it
|
||||
// commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
|
||||
// no last-committed snapshot to diff against."
|
||||
guard !request.isRootCommit else { return GitRepository.initialCommitSubject }
|
||||
guard !request.isRootCommit else { return rootSubject }
|
||||
return assemble(events(for: request), request: request)
|
||||
}
|
||||
|
||||
@@ -910,7 +918,7 @@ enum ChangeNarrator {
|
||||
/// inside a thread at all.
|
||||
///
|
||||
/// Shared with the flush that reads each of those folders' `created`
|
||||
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
|
||||
/// (`ChangeNarrationRequest.commentTimestamps`), so the key a timestamp is *filed*
|
||||
/// under and the key it is *looked up* by have one definition. Everything about where a thread
|
||||
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
|
||||
/// classification consumed.
|
||||
@@ -1281,9 +1289,9 @@ enum ChangeNarrator {
|
||||
/// Whether a path could make the *snapshot* differ at all — every path the model speaks for,
|
||||
/// plus attachments, whose names it carries.
|
||||
///
|
||||
/// The flush asks it before it materializes HEAD's tree: a window of nothing but strays and
|
||||
/// the agent guide composes path-shaped events, and reading two whole boards to describe a
|
||||
/// changed `.gitignore` would be work with no reader (`GitAutoCommitter.composition`).
|
||||
/// The flush asks it before it materializes the previous snapshot: a window of nothing but
|
||||
/// strays and the agent guide composes path-shaped events, and reading two whole boards to
|
||||
/// describe a changed `.gitignore` would be work with no reader.
|
||||
static func mightAffectSnapshot(_ path: String) -> Bool {
|
||||
isModelSilent(path) || path.split(separator: "/").contains(Substring(attachmentsFolder))
|
||||
}
|
||||
|
||||
@@ -160,8 +160,7 @@ extension BoardStore {
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the substrate
|
||||
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
|
||||
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
|
||||
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
|
||||
/// still owes the purge.
|
||||
/// already. `false` means nothing was registered and the caller still owes the purge.
|
||||
@discardableResult
|
||||
func registerCardSession(
|
||||
_ window: CardWindowUndo,
|
||||
|
||||
@@ -363,9 +363,9 @@ public protocol HistoryProviding: AnyObject {
|
||||
|
||||
extension HistoryProviding {
|
||||
|
||||
/// **A substrate that keeps no steps holds no backing** — the git provider's honest answer
|
||||
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
|
||||
/// the close flush" true on Pro), and a test fake's.
|
||||
/// **A substrate that keeps no steps holds no backing** — the honest answer for any provider that
|
||||
/// retires every step on arrival (which is what makes "purge rides the close flush" true over such
|
||||
/// a substrate), and a test fake's.
|
||||
///
|
||||
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
|
||||
/// an implementation that *does* keep steps overrides it and every caller through `any
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -244,21 +244,6 @@ public enum BoardAnnouncer {
|
||||
public var breakageBefore: BoardLoadFailure?
|
||||
public var breakageAfter: BoardLoadFailure?
|
||||
|
||||
/// **The unreadable-repository condition before and after** (06-history-undo.md ▸ Rules,
|
||||
/// ruled 2026-07-31: the standing breakage-class banner, "announced per
|
||||
/// 10-accessibility.md"). Booleans rather than a payload for the row's own reason — the
|
||||
/// sentence is fixed — and a pair rather than a single flag for the lock's: what is
|
||||
/// announced is the *transition*, in either direction.
|
||||
///
|
||||
/// No reload ever sets these. The condition is detected at board open and healed by the
|
||||
/// paused engine's own re-read, neither of which is a reload — so its producer is
|
||||
/// `BoardStore.noteRepositoryUnreadable(_:)`, exactly as the writability probe's lock is
|
||||
/// `announceLockChange(from:)`'s. They live on this value anyway because the ladder is where
|
||||
/// "one sentence, chosen by precedence" is decided, and a second announcer would be a second
|
||||
/// voice.
|
||||
public var repositoryUnreadableBefore = false
|
||||
public var repositoryUnreadableAfter = false
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@@ -319,14 +304,6 @@ public enum BoardAnnouncer {
|
||||
if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
|
||||
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage))
|
||||
}
|
||||
// Last of the raised conditions, matching the strip's own precedence: the two above it
|
||||
// describe the board's files, this one describes the history over them.
|
||||
if facts.repositoryUnreadableAfter, !facts.repositoryUnreadableBefore {
|
||||
return AccessibilityPhrases.bannerLabel(
|
||||
tone: .error,
|
||||
headline: BannerCenter.repositoryUnreadableMessage
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -339,9 +316,6 @@ public enum BoardAnnouncer {
|
||||
if facts.breakageBefore != nil, facts.breakageAfter == nil {
|
||||
return AccessibilityPhrases.reloadBreakageCleared
|
||||
}
|
||||
if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter {
|
||||
return AccessibilityPhrases.repositoryUnreadableCleared
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,16 +158,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
/// `UserDefaults`.
|
||||
public var remoteLocationWarned: Bool
|
||||
|
||||
/// **A bracketed git operation this app started and has not finished** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board
|
||||
/// registry) before touching the repo").
|
||||
///
|
||||
/// `nil` for every board that is not mid-operation, which is every board almost all of the time:
|
||||
/// the stamp is written immediately before the repository is touched and cleared as soon as the
|
||||
/// operation is over, so finding one at open means the app died in between. See
|
||||
/// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`.
|
||||
public var gitOperationStamp: GitOperationStamp?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
bookmark: Data,
|
||||
@@ -182,8 +172,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
pushOnCommit: Bool = false,
|
||||
remoteLocationWarned: Bool = false,
|
||||
icon: String? = nil,
|
||||
iconColor: String? = nil,
|
||||
gitOperationStamp: GitOperationStamp? = nil
|
||||
iconColor: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.bookmark = bookmark
|
||||
@@ -199,7 +188,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
self.remoteLocationWarned = remoteLocationWarned
|
||||
self.icon = icon
|
||||
self.iconColor = iconColor
|
||||
self.gitOperationStamp = gitOperationStamp
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
@@ -227,7 +215,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
case iconColor
|
||||
case pushOnCommit
|
||||
case remoteLocationWarned
|
||||
case gitOperationStamp
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -246,11 +233,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
|
||||
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
|
||||
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
|
||||
// Tolerant twice over: absent on every record written before this key existed, and absent
|
||||
// again — rather than fatal — if a future build's stamp `Kind` is one this build cannot name.
|
||||
// A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the
|
||||
// pause-and-defer stance rather than to a quarantined registry.
|
||||
gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp)
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
@@ -269,7 +251,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
|
||||
try container.encodeIfPresent(iconColor, forKey: .iconColor)
|
||||
try container.encode(pushOnCommit, forKey: .pushOnCommit)
|
||||
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
|
||||
try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp)
|
||||
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
|
||||
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
|
||||
}
|
||||
@@ -599,22 +580,6 @@ public final class BoardRegistry {
|
||||
update(id) { $0.remoteLocationWarned = true }
|
||||
}
|
||||
|
||||
/// **Records — or clears — the bracketed git operation this app is about to run**
|
||||
/// (`GitOperationStamp`).
|
||||
///
|
||||
/// It saves the file synchronously like every other setter here, and that is load-bearing rather
|
||||
/// than incidental: the whole value of the stamp is that it is on disk *before* the repository is
|
||||
/// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes
|
||||
/// atomically, so the file a next launch reads is either the old one or this one.
|
||||
public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) {
|
||||
update(id) { $0.gitOperationStamp = stamp }
|
||||
}
|
||||
|
||||
/// The stamp this board is carrying, if any — read once per session, at open.
|
||||
public func gitOperationStamp(id: UUID) -> GitOperationStamp? {
|
||||
record(id: id)?.gitOperationStamp
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// Every known board, most recently opened first, each classified by whether its bookmark
|
||||
|
||||
@@ -288,10 +288,9 @@ public final class BoardStore: HealHost {
|
||||
/// still refresh the card window's thread and board search's comment index. Keying either on
|
||||
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
|
||||
/// optimization.
|
||||
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
|
||||
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
|
||||
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
|
||||
/// counter would spin out its whole deadline on any value-equal landing.
|
||||
/// - **Any covering gate** — a consumer waiting for a walk that started after its writes hit
|
||||
/// disk. A completed walk covers them whether or not the tree turned out to differ, so a gate
|
||||
/// waiting on the applied counter would spin out its whole deadline on any value-equal landing.
|
||||
///
|
||||
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
|
||||
/// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
|
||||
@@ -471,41 +470,10 @@ public final class BoardStore: HealHost {
|
||||
losses: banners.losses,
|
||||
suspension: banners.historySuspension,
|
||||
operations: banners.operations,
|
||||
signposts: banners.signposts,
|
||||
gitFailures: banners.gitFailures,
|
||||
repositoryUnreadable: banners.isRepositoryUnreadable
|
||||
signposts: banners.signposts
|
||||
)
|
||||
}
|
||||
|
||||
/// **The unreadable repository, raised or healed** (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) — the
|
||||
/// session's one call for a condition that is the *git state's* truth rather than this store's
|
||||
/// (`HistoryStore.isRepositoryUnreadable`, wired in `AppModel.beginSession`).
|
||||
///
|
||||
/// It does two things because the ruling asks for two: the row stands on the strip, and it is
|
||||
/// **announced** — "announced per 10-accessibility.md", which makes a standing banner "an
|
||||
/// accessibility element … announced when it appears and when it clears". This condition never
|
||||
/// arrives on a reload (it is detected at open and healed by the paused engine's own re-read), so
|
||||
/// it takes `announceLockChange(from:)`'s path exactly: through `BoardAnnouncer`'s ladder rather
|
||||
/// than posting directly, so the sentence a user hears and the sentence they read off the row are
|
||||
/// one string.
|
||||
///
|
||||
/// Idempotent, and silent when nothing changed: the 15 s re-read that keeps confirming an
|
||||
/// unreadable repository must not say so every 15 s.
|
||||
public func noteRepositoryUnreadable(_ unreadable: Bool) {
|
||||
guard banners.isRepositoryUnreadable != unreadable else { return }
|
||||
if unreadable {
|
||||
Self.logger.error("this board's git repository could not be opened — history is paused")
|
||||
banners.raiseRepositoryUnreadable()
|
||||
} else {
|
||||
banners.clearRepositoryUnreadable()
|
||||
}
|
||||
var facts = BoardAnnouncer.ReloadFacts()
|
||||
facts.repositoryUnreadableBefore = !unreadable
|
||||
facts.repositoryUnreadableAfter = unreadable
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
}
|
||||
|
||||
// MARK: Wiring
|
||||
|
||||
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
|
||||
@@ -569,18 +537,6 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public weak var history: (any HistoryProviding)?
|
||||
|
||||
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Auto-commit), or `nil` on every board there is no committer for — which is every free-tier
|
||||
/// board and every Pro board without a repository at its root.
|
||||
///
|
||||
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
|
||||
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
|
||||
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
|
||||
/// did before this milestone — which is what makes the free tier's inert posture structural
|
||||
/// rather than conditional.
|
||||
@ObservationIgnored
|
||||
public var commitSeam: HistoryCommitSeam?
|
||||
|
||||
// MARK: The open card windows
|
||||
|
||||
/// **Which of this board's cards have a card window open right now** — one fact, kept for one
|
||||
@@ -761,28 +717,6 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
||||
|
||||
/// **Where git path history reaches the loader** (01-storage-format.md ▸ Fractal layout
|
||||
/// ▸ Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) — `nil` on every
|
||||
/// board the app manages no git for, which is every free-tier board and every Pro board without
|
||||
/// a repo at its root.
|
||||
///
|
||||
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
|
||||
/// its own ranker, so that a load never answers from a history that has moved since the last one
|
||||
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
|
||||
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
|
||||
/// over at composition never could.
|
||||
///
|
||||
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
|
||||
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
|
||||
///
|
||||
/// **The board's first load predates this** — `init` runs inside `BoardStoreRegistry.acquire`,
|
||||
/// before a session exists to compose the git state that supplies it — so an opening board's
|
||||
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
|
||||
/// Deliberate, and the narrow cost of composing the git state where the design puts it
|
||||
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
|
||||
@ObservationIgnored
|
||||
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
|
||||
|
||||
// MARK: - Init
|
||||
@@ -904,10 +838,6 @@ public final class BoardStore: HealHost {
|
||||
let generation = reloadGeneration
|
||||
let root = rootURL
|
||||
let barrier = loadBarrier
|
||||
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
|
||||
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
||||
// break a tie for. `nil` everywhere the app manages no git.
|
||||
let historyRanker = makeIdentityHistoryRanker?()
|
||||
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
|
||||
// decision stands for the session, so a reload sees the board the user chose to open.
|
||||
let skipping = skippedPaths
|
||||
@@ -928,7 +858,6 @@ public final class BoardStore: HealHost {
|
||||
outcome = .success(try BoardLoader.load(
|
||||
boardRoot: root,
|
||||
skipping: skipping,
|
||||
historyRanker: historyRanker,
|
||||
memo: memo,
|
||||
counter: counter
|
||||
))
|
||||
@@ -982,13 +911,6 @@ public final class BoardStore: HealHost {
|
||||
facts.lockBefore = readOnlyLock
|
||||
facts.breakageBefore = reloadFailure
|
||||
|
||||
// **Whether this reload revealed anything the app does not vouch for** — the one bit the
|
||||
// auto-committer's flush-before-overwrite gate turns on (06 ▸ Rules ▸ Flush-before-overwrite).
|
||||
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
|
||||
// the app certainly did not write, and the safe direction is to let the next app write commit
|
||||
// what is there before overwriting it.
|
||||
var sawForeignChange = false
|
||||
|
||||
switch outcome {
|
||||
case let .success(result):
|
||||
// **What changed, who changed it, and what it cost the cursor** — all three computed
|
||||
@@ -1020,7 +942,6 @@ public final class BoardStore: HealHost {
|
||||
includingTrash: shownTrash
|
||||
)
|
||||
facts.diff = verdicts.foreign
|
||||
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
|
||||
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
|
||||
// "deleted *externally*", which would be a lie about an app-mediated delete — whose
|
||||
// own command already chose a successor (04-interactions.md ▸ The map's ⌫ rule) and
|
||||
@@ -1170,7 +1091,6 @@ public final class BoardStore: HealHost {
|
||||
if endsWholesaleOperation, readOnlyLock == nil {
|
||||
readOnlyLock = .bracketedReloadFailed
|
||||
}
|
||||
sawForeignChange = true
|
||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -1181,18 +1101,6 @@ public final class BoardStore: HealHost {
|
||||
facts.lockAfter = readOnlyLock
|
||||
facts.breakageAfter = reloadFailure
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
|
||||
// **The auto-commit debounce, armed by every landing** (06 ▸ Rules ▸ Auto-commit; ▸
|
||||
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
|
||||
// any change and get auto-committed on the same debounce").
|
||||
//
|
||||
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
|
||||
// over, so the committer never races the loader for the same files. **Unconditional on what
|
||||
// changed**, equally deliberately — a reload lands whether or not the snapshot moved, and the
|
||||
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
|
||||
// strays or only `CLAUDE.md` still commits (06 ▸ Commit messages ▸ Non-snapshot files commit
|
||||
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
|
||||
commitSeam?.reloadDidLand(sawForeignChange)
|
||||
}
|
||||
|
||||
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
||||
@@ -1448,24 +1356,11 @@ public final class BoardStore: HealHost {
|
||||
if let readOnlyLock {
|
||||
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||
}
|
||||
// **Flush-before-overwrite** (06-history-undo.md ▸ Rules), before the bracket rather than
|
||||
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
|
||||
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
|
||||
// the window holds a change the app does not vouch for — see `GitAutoCommitter.noteWillWrite`
|
||||
// for the gate, and for the two costs it is recorded as carrying.
|
||||
commitSeam?.willWrite()
|
||||
watcherBrackets?.begin()
|
||||
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
|
||||
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
|
||||
//
|
||||
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
|
||||
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
|
||||
// the last moment they still describe a completed write nothing has classified yet
|
||||
// (`EchoLedger.outstandingEntries`). A partway failure harvests too — bytes that reached disk
|
||||
// are bytes the next commit will carry, whoever they belong to.
|
||||
defer {
|
||||
watcherBrackets?.end()
|
||||
commitSeam?.writeBracketDidClose()
|
||||
}
|
||||
// **The receipt seam** (02-architecture.md ▸ Components ▸ EchoLedger). Binding the ledger
|
||||
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
|
||||
@@ -1535,25 +1430,21 @@ public final class BoardStore: HealHost {
|
||||
try operation()
|
||||
} catch let error as BoardWriteError {
|
||||
// Same honesty rule as `performWrite`, applied to the one error type the banner has
|
||||
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
|
||||
// vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
|
||||
// condition, the in-progress row swapping for an error — is the committer's to drive;
|
||||
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
|
||||
// bypass the strip than one from `performWrite`.
|
||||
// phrasing for. A wholesale operation may carry a failure vocabulary of its own, whose
|
||||
// surfacing is that operation's to drive; but a `BoardWriteError` escaping here is an
|
||||
// ordinary failed write and may no more bypass the strip than one from `performWrite`.
|
||||
banners.post(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// The same bracket over work that **awaits** — the undo restore (06-history-undo.md) and, next,
|
||||
/// the branch switch.
|
||||
/// The same bracket over work that **awaits**.
|
||||
///
|
||||
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
|
||||
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
|
||||
/// git operation is a detached libgit2 task the main actor must not block on
|
||||
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
|
||||
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
|
||||
/// to guess which one a trailing closure meant.
|
||||
/// wholesale operation can be a detached task the main actor must not block on. Both keep the
|
||||
/// bracket, the reload floor and the completion phrase in one place; the distinct argument label
|
||||
/// is what keeps overload resolution from having to guess which one a trailing closure meant.
|
||||
///
|
||||
/// The refusal, the ordering and the arming are the synchronous version's, unchanged — see its
|
||||
/// doc comment for all three.
|
||||
|
||||
@@ -318,22 +318,24 @@ public final class EchoLedger: Sendable {
|
||||
|
||||
/// **Every receipt the ledger holds right now, with its heal mark — read, never consumed.**
|
||||
///
|
||||
/// Pro's auto-committer's one call (`GitAutoCommitter.harvest`), and it has to be a copy rather
|
||||
/// than a read at commit time for an ordering reason worth stating here: receipts are *consumed*
|
||||
/// by the landing reload that classifies them ("one write, one echo"), and the committer asks its
|
||||
/// question a debounce later — by which time the receipt for the user's own card edit is long
|
||||
/// gone, and reading the live ledger would attribute the user's own work to `Lanework External`.
|
||||
/// So the committer copies at the close of each write bracket, when a receipt describes a
|
||||
/// completed write and nothing has yet had a chance to retire it, and re-applies the satisfaction
|
||||
/// rule against disk itself (`CommitAttribution`).
|
||||
/// **The attribution surface** — the seam a provenance consumer harvests through, and it has to be
|
||||
/// a copy rather than a read at attribution time for an ordering reason worth stating here:
|
||||
/// receipts are *consumed* by the landing reload that classifies them ("one write, one echo"), and
|
||||
/// a consumer asks its question a debounce later — by which time the receipt for the user's own
|
||||
/// card edit is long gone, and reading the live ledger would attribute the user's own work to an
|
||||
/// external writer. So a consumer copies at the close of each write bracket, when a receipt
|
||||
/// describes a completed write and nothing has yet had a chance to retire it, and re-applies the
|
||||
/// satisfaction rule against disk itself.
|
||||
///
|
||||
/// Nothing is retired here, which is what makes this safe to call on every bracket: the
|
||||
/// announcer's consumption still decides what speaks, and the committer's copy still decides what
|
||||
/// each commit is authored by.
|
||||
// The harvest speaks `HarvestedReceipt` — committer-side vocabulary from
|
||||
// Git/CommitAttribution.swift, which the phone target doesn't compile (the mobile MVP has
|
||||
// no git). The recording side above is what BoardWriter needs on every platform; this copy
|
||||
// has exactly one caller and it is Mac-only.
|
||||
/// announcer's consumption still decides what speaks, and a harvested copy still decides what each
|
||||
/// batch is attributed to.
|
||||
///
|
||||
/// **Kept with no live consumer** (`strategy/01-git-excision.md` ▸ What is kept): the harvest is
|
||||
/// the foundation the foreign-change journal will be built on, so the surface stays even though
|
||||
/// the git consumer that drove it has gone.
|
||||
// Mac-only because `HarvestedReceipt` is, and the phone target doesn't compile it. The recording
|
||||
// side above is what BoardWriter needs on every platform; this copy is Mac's alone.
|
||||
#if os(macOS)
|
||||
func outstandingEntries() -> [String: HarvestedReceipt] {
|
||||
receipts.withLock { store in
|
||||
|
||||
@@ -1546,7 +1546,7 @@ public struct LoadResult: Sendable {
|
||||
/// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
|
||||
///
|
||||
/// A caller that ignores it gets a cold walk every time, which is exactly what
|
||||
/// `TemplateEngine`, `GitHeadSnapshot` and every first load do.
|
||||
/// `TemplateEngine` and every first load do.
|
||||
public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()
|
||||
|
||||
/// **The typed defect stream** — everything this walk found that is pending *work*
|
||||
|
||||
@@ -432,13 +432,4 @@ enum AccessibilityPhrases {
|
||||
/// Reload breakage clearing — the board is reading its files again, which is a smaller claim
|
||||
/// than the lock's and is deliberately phrased as one.
|
||||
static let reloadBreakageCleared = "The board is loading again"
|
||||
|
||||
/// The unreadable repository clearing (06-history-undo.md ▸ Rules, ruled 2026-07-31: "the banner
|
||||
/// clears when a later open or reload finds the repo readable").
|
||||
///
|
||||
/// Stated as the regained capability, `readOnlyLockCleared`'s rule: what the user was waiting on
|
||||
/// is history advancing again, not libgit2 changing its mind about a folder. It stays the
|
||||
/// smaller claim of the two — nothing about editing the board was ever blocked by this
|
||||
/// condition, which is precisely what its own row says.
|
||||
static let repositoryUnreadableCleared = "History is recording again"
|
||||
}
|
||||
|
||||
@@ -212,14 +212,12 @@ public final class CardBodyEditSession {
|
||||
}
|
||||
|
||||
/// **Throws the buffer away and takes disk's word for it** — the Discard branch of the
|
||||
/// save-or-discard step (06-history-undo.md ▸ Branch switching: "Discard reverts buffers and
|
||||
/// uncommitted saves to HEAD").
|
||||
/// save-or-discard step (13-native-undo.md ▸ Undo routing; `SessionSettleGate`).
|
||||
///
|
||||
/// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation
|
||||
/// behind the step's to undo, because only that operation knows which state it is restoring to
|
||||
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder
|
||||
/// against the working tree for exactly this reason). Splitting it that way is what keeps the two
|
||||
/// halves from being two answers able to disagree: one pass writes the card's files, once.
|
||||
/// It reverts the *buffer* and ends the session; any uncommitted on-disk saves are the wholesale
|
||||
/// operation behind the step's to reconcile, because only that operation knows which state it is
|
||||
/// restoring to. Splitting it that way is what keeps the two halves from being two answers able to
|
||||
/// disagree: one pass writes the card's files, once.
|
||||
///
|
||||
/// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer
|
||||
/// would write the discarded text back over the restored card a moment later.
|
||||
|
||||
Reference in New Issue
Block a user