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.
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user