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:
+93
-50
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user