Bind the undo provider to the board, not the tier

The 2026-07-31 re-ruling: gitless boards bind the native stack in every
tier — a Pro upgrade no longer removes undo from mode-none boards — and
Pro git boards bind the git provider; repo-nested stays the no-undo
case under Pro, while the free tier (which never runs detection) binds
native there too, per 12's inert posture. Add-git now swaps a live
native substrate mid-session: the in-flight stack is cleared with the
discarded provider, the git trail seeds from the root commit, and the
same BoardUndoManager instance keeps nil-target menu validation fresh.

2405 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:53:38 -04:00
parent 3bd6187b94
commit 2e4dde5655
9 changed files with 286 additions and 104 deletions
+93 -50
View File
@@ -278,11 +278,12 @@ 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 free tier binds the native stack a pair of step stacks over the inverses registered at
/// the Writer boundary (13-native-undo.md, `NativeHistoryProvider`) and that is the default
/// here because it is the substrate every tier can always fall back to; pro-m1 installs the git
/// provider over this closure when the subscription is active (06-history-undo.md). Nothing in
/// this file is tier-conditional; the tier difference is which closure the root installs.
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12 The
/// provider seam; 13-native-undo.md's header; 06 Rules): a board's substrate is decided by what
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it
/// 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.
///
/// 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
@@ -290,30 +291,40 @@ public final class AppModel {
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
///
/// ### The three answers, and the `nil` among them
/// ### The three answers, and the one `nil` among them
///
/// The closure is now the whole tier matrix, in three lines (12 Tier matrix; 06 Rules):
///
/// - **Free** the native stack, on every board. There is no `HistoryStore` at all off Pro, so
/// the absent git state *is* the tier test; nothing here reads a flag.
/// - **Pro, mode `git`** the git provider: undo as forward restore commits over HEAD's
/// first-parent ancestry.
/// - **Pro, mode `none` or `repoNested`** **no provider**. "A board without git has no
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" so Edit
/// Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo
/// provider in the composed tier under Pro, no-git and repo-nested boards, matching their menu
/// items", 03-board-ui.md Toolbar). Not the native stack: on Pro, a mode-none board's edits
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second
/// substrate the design does not have.
/// - **No `HistoryStore` at all** the free tier, where `HistoryStore.compose` returns `nil`
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
/// everywhere (any `.git` inert)" (13), and 12 The free tier and `.git` names the boards that
/// covers by hand "a formerly-subscribed user's board, a 1.x board, a repo-nested board
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
/// - **Mode `git`** (Pro only no other tier composes a git state) the git provider: undo as
/// forward restore commits over HEAD's first-parent ancestry (06).
/// - **Mode `none`** the **native stack**, exactly as in the free tier. "Gitless boards bind
/// the native undo stack in every tier an upgrade never removes undo" (12).
/// - **Mode `repoNested`** **no provider**, the one no-undo case: a board inside somebody
/// else's repository is one the app "leaves strictly alone so they get **no undo**" (06
/// Rules), and an in-memory stack there would be the app-managed undo journal that rule
/// forbids. Edit Undo/Redo and the toolbar pair disable there and nowhere else but under a
/// lock and on an empty stack (03-board-ui.md Toolbar Catalog).
///
/// The `HistoryStore` argument is what makes that 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.
///
/// ### 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.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
guard let git else { return NativeHistoryProvider() }
guard git.mode == .git else { return nil }
return GitHistoryProvider(boardRoot: store.rootURL)
switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none: return NativeHistoryProvider()
case .repoNested: return nil
}
}
// MARK: Sessions
@@ -335,13 +346,15 @@ public final class AppModel {
/// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
///
/// **`nil` is a board with no undo at all** under Pro, mode `none` and repo-nested boards
/// (06-history-undo.md Rules: "A board without git has **no undo/redo**"). The command
/// surface disables through `undoManager`, which answers the empty way over an absent
/// substrate.
/// **`nil` is a board with no undo at all** a repo-nested board under Pro, and nothing else
/// (06-history-undo.md Rules: boards inside an existing repository "get **no undo**").
/// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 see
/// `AppModel.makeHistoryProvider`). 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, binds a provider here on the board it flips
/// 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.
public var history: (any HistoryProviding)?
@@ -379,11 +392,11 @@ public final class AppModel {
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
/// 12-editions.md Tier matrix).
///
/// **The consumer this is waiting for is the git `HistoryProviding` implementation** the
/// undo/redo card two cards further into pro-m1, which binds over `makeHistoryProvider` and
/// reads exactly this to know whether it has a repository to be an undo stack for. Until it
/// lands both tiers bind the native stack, and this is a recorded fact with one reader: the
/// popover's git section.
/// **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*
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
/// `none` the native stack, `repoNested` nothing. The popover's git section is the other
/// reader.
///
/// `@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
@@ -799,8 +812,9 @@ public final class AppModel {
provider.noteLanded(window)
}
}
// **Add-git binds undo too** (06 Rules Detection the one commanded mid-session mode
// flip). See `bindHistoryProvider(for:)` for the judgment call this records.
// **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)
}
@@ -1014,25 +1028,53 @@ public final class AppModel {
)
}
/// **Binds the git provider onto an already-open session** add-git's one caller.
/// **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". It does not mention undo, so this is a
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting
/// for the next open. Three reasons point the same way 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" is a rule about
/// a **tier** lapsing, which cannot change a running session at all; and a board that visibly
/// starts accumulating commits while Z stays greyed out until it is closed and reopened would
/// read as a defect rather than as a policy.
/// 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".
///
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one
/// away there is no un-add-git.
/// ### 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" is a rule about a **tier** lapsing, which cannot change a running
/// session at all; 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], session.history == nil,
let git = session.git, git.mode == .git else { return }
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, session.tier, 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
@@ -1256,10 +1298,11 @@ public final class AppModel {
///
/// ### Declining costs nothing, today least of all
///
/// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who
/// says Not Now loses exactly nothing that exists yet. The offer is built now because the
/// *mechanism* is what the design specifies and because a milestone that shipped the subscription
/// without it would leave a visible gap the moment the git provider lands.
/// 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.
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 }