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
|
/// **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.
|
/// 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 provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The
|
||||||
/// the Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default
|
/// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what
|
||||||
/// here because it is the substrate every tier can always fall back to; pro-m1 installs the git
|
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it
|
||||||
/// provider over this closure when the subscription is active (06-history-undo.md). Nothing in
|
/// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards,
|
||||||
/// this file is tier-conditional; the tier difference is which closure the root installs.
|
/// 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
|
/// 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
|
/// 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
|
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
|
||||||
/// nothing renders from it.
|
/// 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):
|
/// - **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
|
||||||
/// - **Free** — the native stack, on every board. There is no `HistoryStore` at all off Pro, so
|
/// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that
|
||||||
/// the absent git state *is* the tier test; nothing here reads a flag.
|
/// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board …
|
||||||
/// - **Pro, mode `git`** — the git provider: undo as forward restore commits over HEAD's
|
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
|
||||||
/// first-parent ancestry.
|
/// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as
|
||||||
/// - **Pro, mode `none` or `repoNested`** — **no provider**. "A board without git has no
|
/// forward restore commits over HEAD's first-parent ancestry (06).
|
||||||
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" — so Edit
|
/// - **Mode `none`** — the **native stack**, exactly as in the free tier. "Gitless boards bind
|
||||||
/// ▸ Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo
|
/// the native undo stack in every tier — an upgrade never removes undo" (12).
|
||||||
/// provider in the composed tier — under Pro, no-git and repo-nested boards, matching their menu
|
/// - **Mode `repoNested`** — **no provider**, the one no-undo case: a board inside somebody
|
||||||
/// items", 03-board-ui.md ▸ Toolbar). Not the native stack: on Pro, a mode-none board's edits
|
/// else's repository is one the app "leaves strictly alone … so they get **no undo**" (06
|
||||||
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second
|
/// ▸ Rules), and an in-memory stack there would be the app-managed undo journal that rule
|
||||||
/// substrate the design does not have.
|
/// 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`
|
/// 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
|
/// 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.
|
/// 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
|
@ObservationIgnored
|
||||||
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
|
||||||
guard let git else { return NativeHistoryProvider() }
|
guard let git else { return NativeHistoryProvider() }
|
||||||
guard git.mode == .git else { return nil }
|
switch git.mode {
|
||||||
return GitHistoryProvider(boardRoot: store.rootURL)
|
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
|
||||||
|
case .none: return NativeHistoryProvider()
|
||||||
|
case .repoNested: return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Sessions
|
// MARK: Sessions
|
||||||
@@ -335,13 +346,15 @@ public final class AppModel {
|
|||||||
/// Which implementation it is, is the tier's answer and nobody else's
|
/// Which implementation it is, is the tier's answer and nobody else's
|
||||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
/// (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
|
/// **`nil` is a board with no undo at all** — a repo-nested board under Pro, and nothing else
|
||||||
/// (06-history-undo.md ▸ Rules: "A board without git has **no undo/redo**"). The command
|
/// (06-history-undo.md ▸ Rules: boards inside an existing repository "get **no undo**").
|
||||||
/// surface disables through `undoManager`, which answers the empty way over an absent
|
/// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 — see
|
||||||
/// substrate.
|
/// `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
|
/// 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.
|
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter.
|
||||||
public var history: (any HistoryProviding)?
|
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`",
|
/// is every free-tier session ("The free tier ships exactly one mode: `none`",
|
||||||
/// 12-editions.md ▸ Tier matrix).
|
/// 12-editions.md ▸ Tier matrix).
|
||||||
///
|
///
|
||||||
/// **The consumer this is waiting for is the git `HistoryProviding` implementation** — the
|
/// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to
|
||||||
/// undo/redo card two cards further into pro-m1, which binds over `makeHistoryProvider` and
|
/// know whether the board has a repository to be an undo stack for, and it is the *mode*
|
||||||
/// reads exactly this to know whether it has a repository to be an undo stack for. Until it
|
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
|
||||||
/// lands both tiers bind the native stack, and this is a recorded fact with one reader: the
|
/// `none` the native stack, `repoNested` nothing. The popover's git section is the other
|
||||||
/// popover's git section.
|
/// reader.
|
||||||
///
|
///
|
||||||
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
|
/// `@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
|
/// 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)
|
provider.noteLanded(window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **Add-git binds undo too** (06 ▸ Rules ▸ Detection — the one commanded mid-session mode
|
// **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded
|
||||||
// flip). See `bindHistoryProvider(for:)` for the judgment call this records.
|
// 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
|
git.didAddGit = { [weak self] in
|
||||||
self?.bindHistoryProvider(for: ref)
|
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:
|
/// 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
|
/// "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
|
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
|
||||||
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting
|
/// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards
|
||||||
/// for the next open. Three reasons point the same way — the mode flip already carries the
|
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
|
||||||
/// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this
|
/// discard-and-reseed precedent applied".
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one
|
/// ### The discard is the whole of it — there is no migration
|
||||||
/// away — there is no un-add-git.
|
///
|
||||||
|
/// 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) {
|
func bindHistoryProvider(for ref: BoardWindowRef) {
|
||||||
guard var session = sessions[ref], session.history == nil,
|
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
|
||||||
let git = session.git, git.mode == .git else { return }
|
|
||||||
guard let history = makeHistoryProvider(session.store, session.tier, 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
|
session.history = history
|
||||||
sessions[ref] = session
|
sessions[ref] = session
|
||||||
session.store.history = history
|
session.store.history = history
|
||||||
@@ -1256,10 +1298,11 @@ public final class AppModel {
|
|||||||
///
|
///
|
||||||
/// ### Declining costs nothing, today least of all
|
/// ### Declining costs nothing, today least of all
|
||||||
///
|
///
|
||||||
/// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who
|
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
|
||||||
/// says Not Now loses exactly nothing that exists yet. The offer is built now because the
|
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
|
||||||
/// *mechanism* is what the design specifies and because a milestone that shipped the subscription
|
/// way — declining costs undo nothing at all, and costs a git board only the trail it would have
|
||||||
/// without it would leave a visible gap the moment the git provider lands.
|
/// 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 {
|
public func reopenOpenBoards() async {
|
||||||
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
|
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
|
||||||
let refs = sessions.keys.sorted { $0.path < $1.path }
|
let refs = sessions.keys.sorted { $0.path < $1.path }
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ public final class GitAutoCommitter {
|
|||||||
/// this engine clears the instant a window commits — so the moment of landing is the only moment
|
/// this engine clears the instant a window commits — so the moment of landing is the only moment
|
||||||
/// at which "that commit was the heal" is knowable at all.
|
/// at which "that commit was the heal" is knowable at all.
|
||||||
///
|
///
|
||||||
/// `nil` on every board with no undo provider bound (the free tier's committer does not exist;
|
/// `nil` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer
|
||||||
/// a Pro board's does, and a provider is bound beside it).
|
/// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where
|
||||||
|
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none boards
|
||||||
|
/// have a native stack and no committer; repo-nested boards have neither.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
|
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import os
|
|||||||
/// ### What it does not do yet
|
/// ### What it does not do yet
|
||||||
///
|
///
|
||||||
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
|
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
|
||||||
/// ranker. **The provider binding is not part of it** — both tiers still bind
|
/// ranker. **The provider binding reads `mode` and nothing else about a tier** — the composition
|
||||||
/// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the
|
/// root binds the git provider on mode `git`, the native stack on mode `none`, and nothing on a
|
||||||
/// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over
|
/// repo-nested board (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the
|
||||||
/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, the
|
/// board). Auto-commit, commit messages, branch controls, the identity fields, the
|
||||||
/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed
|
/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed
|
||||||
/// here now; remotes are pro-m2's and deliberately still absent.
|
/// here now; remotes are pro-m2's and deliberately still absent.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -141,9 +141,11 @@ public final class HistoryStore {
|
|||||||
/// never on any other path.
|
/// never on any other path.
|
||||||
///
|
///
|
||||||
/// It exists because the flip has a second consumer beyond the committer: the board's **undo
|
/// It exists because the flip has a second consumer beyond the committer: the board's **undo
|
||||||
/// substrate**. A session that composed on a mode-none board bound no provider at all
|
/// substrate**. A session that composed on a mode-none board bound the native stack
|
||||||
/// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip
|
/// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip
|
||||||
/// means the board now has a trail to be an undo stack over. What binding it means is
|
/// means the board now has a trail to be an undo stack over instead — "add-git swaps the
|
||||||
|
/// substrate mid-session … discards the in-session native stack and seeds the git trail from the
|
||||||
|
/// root commit" (13-native-undo.md). What that swap means is
|
||||||
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
|
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
|
||||||
/// keep that decision out of a git state that has no business knowing what a provider is.
|
/// keep that decision out of a git state that has no business knowing what a provider is.
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import AppKit
|
|||||||
/// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
|
/// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
|
||||||
/// up, disable and retitle with no code of the app's own.
|
/// up, disable and retitle with no code of the app's own.
|
||||||
///
|
///
|
||||||
/// The seam, though, must not be an `NSUndoManager`: the free tier's stack is one, Pro's is git
|
/// The seam, though, must not be an `NSUndoManager`: a gitless board's stack is one, a Pro git
|
||||||
/// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a
|
/// board's is git (12-editions.md ▸ The provider seam), and a protocol that vended one could only
|
||||||
/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the
|
/// ever have had a single implementation. So the substrate stays behind `HistoryProviding` and
|
||||||
/// translation — one per board session, over whichever provider that session was composed with. Pro
|
/// *this* object is the translation — one per board session, over whichever provider that session
|
||||||
/// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding
|
/// was composed with. A git board inherits the whole command surface (enablement, dynamic titles,
|
||||||
/// its provider and changing nothing here, which is what "a user subscribing (or lapsing) relearns
|
/// ⌘Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
|
||||||
/// nothing" (12) has to mean in code.
|
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
|
||||||
///
|
///
|
||||||
/// ### It deliberately keeps its inherited stack empty
|
/// ### It deliberately keeps its inherited stack empty
|
||||||
///
|
///
|
||||||
@@ -51,22 +51,24 @@ public final class BoardUndoManager: UndoManager {
|
|||||||
///
|
///
|
||||||
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
/// ### `nil` is a board with **no undo provider**, and it is a real state
|
||||||
///
|
///
|
||||||
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all — "the pair disabled
|
/// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of
|
||||||
/// on boards with no undo provider in the composed tier — under Pro, no-git and repo-nested
|
/// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards,
|
||||||
/// boards, matching their menu items" (03-board-ui.md ▸ Toolbar ▸ Catalog; 06-history-undo.md
|
/// under locks, and on empty stacks — the provider follows the board, so gitless boards bind
|
||||||
/// ▸ Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar
|
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog;
|
||||||
/// pair, and ⌘Z itself go quiet together, through the same validation path a lock uses. Modelling
|
/// 06-history-undo.md ▸ Rules). Every question below answers the empty way, so the Edit menu's
|
||||||
/// it as an absent substrate rather than as a substrate that always says no is the honest shape:
|
/// rows, the toolbar pair, and ⌘Z itself go quiet together, through the same validation path a
|
||||||
/// there is nothing there, and nothing can accidentally accumulate in it.
|
/// lock uses. Modelling it as an absent substrate rather than as a substrate that always says no
|
||||||
|
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it.
|
||||||
///
|
///
|
||||||
/// ### Settable, for exactly one event
|
/// ### Settable, for exactly one event
|
||||||
///
|
///
|
||||||
/// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip:
|
/// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip:
|
||||||
/// "clicking it flips the open board into git mode immediately — the popover flows straight into
|
/// "clicking it flips the open board into git mode immediately". 13's header says what that means
|
||||||
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session
|
/// here — the flip **swaps the substrate**: the mode-none board's native stack is discarded and
|
||||||
/// gains a commit trail, and a trail with a dead ⌘Z over it would read as a bug. The composition
|
/// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The
|
||||||
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit
|
/// composition root writes the new provider into this property rather than rebuilding this
|
||||||
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`.
|
/// object, so AppKit keeps the identical manager it has already been handed by
|
||||||
|
/// `windowWillReturnUndoManager` and simply revalidates over a different stack.
|
||||||
///
|
///
|
||||||
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
|
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
|
||||||
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
|
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ public enum HistoryStepOutcome: Equatable, Sendable {
|
|||||||
/// holds: move → move back ...; rename → restore title" — and both halves of that write are already
|
/// holds: move → move back ...; rename → restore title" — and both halves of that write are already
|
||||||
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
|
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
|
||||||
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
|
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
|
||||||
/// design's own terms, and deliberately says nothing about how a stack stores it: the free tier's
|
/// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's
|
||||||
/// stack is `NSUndoManager`-backed and Pro's is git (12-editions.md ▸ The provider seam), and neither
|
/// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md ▸ The provider
|
||||||
/// substrate appears here.
|
/// seam), and neither substrate appears here.
|
||||||
///
|
///
|
||||||
/// ### `name` is the 06 vocabulary, unprefixed
|
/// ### `name` is the 06 vocabulary, unprefixed
|
||||||
///
|
///
|
||||||
@@ -143,10 +143,11 @@ public struct HistoryStep {
|
|||||||
/// "One stack per board, owned by the board session. Not per-window: every window over a board
|
/// "One stack per board, owned by the board session. Not per-window: every window over a board
|
||||||
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
|
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
|
||||||
/// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
|
/// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
|
||||||
/// which implementation it gets: the free tier binds `NativeHistoryProvider` (two step stacks over
|
/// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31):
|
||||||
/// the inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as
|
/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
|
||||||
/// forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits
|
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
|
||||||
/// Pro's.
|
/// restore commits over HEAD's first-parent ancestry — 06-history-undo.md), a repo-nested board
|
||||||
|
/// binds none at all, and Teams inherits Pro's.
|
||||||
///
|
///
|
||||||
/// ### What this protocol deliberately does not say
|
/// ### What this protocol deliberately does not say
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import SwiftUI
|
|||||||
///
|
///
|
||||||
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
|
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
|
||||||
/// boards so the upgrade feels immediate" (12 ▸ The entitlement). The offer is raised **once**, from
|
/// boards so the upgrade feels immediate" (12 ▸ The entitlement). The offer is raised **once**, from
|
||||||
/// here, on the one outcome that means an active subscription just landed — and declining costs
|
/// here, on the one outcome that means an active subscription just landed — and declining costs a
|
||||||
/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git
|
/// gitless board nothing at all: the provider follows the board, so its undo is the same native
|
||||||
/// provider (`AppModel.makeHistoryProvider`).
|
/// stack before and after (`AppModel.makeHistoryProvider`). What a Not Now defers is the git trail
|
||||||
|
/// on the boards that have a repository.
|
||||||
struct ProSettingsSection: View {
|
struct ProSettingsSection: View {
|
||||||
|
|
||||||
@Environment(AppModel.self) private var appModel
|
@Environment(AppModel.self) private var appModel
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
|
|||||||
/// existing at all.
|
/// existing at all.
|
||||||
case free
|
case free
|
||||||
|
|
||||||
/// Lanework Pro — an active auto-renewable subscription. Binds the git history provider when
|
/// Lanework Pro — an active auto-renewable subscription. It is what puts git on the table; which
|
||||||
/// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md).
|
/// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
|
||||||
|
/// 2026-07-31 — `AppModel.makeHistoryProvider`): the git provider on a git board
|
||||||
|
/// (06-history-undo.md, 07-sync-collab.md), the same native stack the free tier uses on a gitless
|
||||||
|
/// one, nothing on a repo-nested one.
|
||||||
case pro
|
case pro
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+140
-13
@@ -44,6 +44,29 @@ private func makeGitBoard() async throws -> (fixture: WriterFixture, git: Histor
|
|||||||
return (fixture, git, ledger)
|
return (fixture, git, ledger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A synthetic native step that records its own crossing — `HistoryProviderTests`' fixture, file-private
|
||||||
|
/// there and here. What the binding suite needs it for is the *discard*: a step that would announce
|
||||||
|
/// itself loudly if the swap ever ran it.
|
||||||
|
@MainActor
|
||||||
|
private final class StepLog {
|
||||||
|
|
||||||
|
private(set) var crossings: [String] = []
|
||||||
|
|
||||||
|
func step(_ name: String) -> HistoryStep {
|
||||||
|
HistoryStep(
|
||||||
|
name: name,
|
||||||
|
undo: { [weak self] _ in
|
||||||
|
self?.crossings.append("undo \(name)")
|
||||||
|
return .applied
|
||||||
|
},
|
||||||
|
redo: { [weak self] _ in
|
||||||
|
self?.crossings.append("redo \(name)")
|
||||||
|
return .applied
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
|
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
|
||||||
let committer = try #require(git.committer)
|
let committer = try #require(git.committer)
|
||||||
@@ -873,6 +896,19 @@ struct GitUndoSessionTests {
|
|||||||
|
|
||||||
// MARK: - The provider binding
|
// MARK: - The provider binding
|
||||||
|
|
||||||
|
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md
|
||||||
|
/// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the
|
||||||
|
/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes
|
||||||
|
/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing.
|
||||||
|
///
|
||||||
|
/// ### The free tier's row is one cell wide, structurally
|
||||||
|
///
|
||||||
|
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
|
||||||
|
/// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free
|
||||||
|
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
|
||||||
|
/// 1.x board, **a repo-nested board**) works normally — files read and write as on any board,
|
||||||
|
/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first
|
||||||
|
/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("Git undo ▸ which board gets a provider")
|
@Suite("Git undo ▸ which board gets a provider")
|
||||||
struct GitUndoBindingTests {
|
struct GitUndoBindingTests {
|
||||||
@@ -913,8 +949,8 @@ struct GitUndoBindingTests {
|
|||||||
#expect(session.history is GitHistoryProvider)
|
#expect(session.history is GitHistoryProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pro on a mode-none board binds no provider at all — the pair disables")
|
@Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo")
|
||||||
func proOnAPlainBoardBindsNothing() throws {
|
func proOnAPlainBoardBindsTheNativeStack() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (model, tearDown) = try makeModel()
|
let (model, tearDown) = try makeModel()
|
||||||
@@ -924,17 +960,24 @@ struct GitUndoBindingTests {
|
|||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
|
|
||||||
|
// The board is Pro's — it composed a git state and detected a mode — and the mode is what
|
||||||
|
// chose the substrate: "gitless boards bind the native undo stack in every tier" (12).
|
||||||
|
#expect(session.git != nil, "Pro composes a git state even where there is no repository")
|
||||||
#expect(session.gitMode == .none)
|
#expect(session.gitMode == .none)
|
||||||
#expect(session.history == nil, "a board without git has no undo/redo")
|
#expect(session.history is NativeHistoryProvider)
|
||||||
#expect(session.undoManager.canUndo == false)
|
|
||||||
#expect(session.undoManager.canRedo == false)
|
// And it is a *working* stack, not a placeholder: the same command surface a free-tier
|
||||||
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
|
// session gets, which is what "a user subscribing relearns nothing" means here.
|
||||||
// And a crossing that somehow started still writes nothing.
|
let log = StepLog()
|
||||||
|
#expect(session.undoManager.canUndo == false, "empty, not absent")
|
||||||
|
session.history?.register(log.step("Move Card"))
|
||||||
|
#expect(session.undoManager.canUndo)
|
||||||
|
#expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
|
||||||
session.undoManager.undo()
|
session.undoManager.undo()
|
||||||
session.undoManager.redo()
|
#expect(log.crossings == ["undo Move Card"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Pro on a repo-nested board binds no provider either")
|
@Test("Pro on a repo-nested board binds no provider — the one no-undo case")
|
||||||
func proOnARepoNestedBoardBindsNothing() throws {
|
func proOnARepoNestedBoardBindsNothing() throws {
|
||||||
let outer = try WriterFixture()
|
let outer = try WriterFixture()
|
||||||
defer { outer.tearDown() }
|
defer { outer.tearDown() }
|
||||||
@@ -952,13 +995,46 @@ struct GitUndoBindingTests {
|
|||||||
let ref = try openBoard(model, at: boardRoot)
|
let ref = try openBoard(model, at: boardRoot)
|
||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
|
|
||||||
|
// 06 ▸ Rules: a board inside somebody else's repository is "left strictly alone … so they get
|
||||||
|
// **no undo**" — no app-managed undo journal, which an in-memory stack here would be.
|
||||||
#expect(session.gitMode == .repoNested)
|
#expect(session.gitMode == .repoNested)
|
||||||
#expect(session.history == nil, "the app leaves that repository strictly alone")
|
#expect(session.history == nil, "the app leaves that repository strictly alone")
|
||||||
#expect(session.undoManager.canUndo == false)
|
#expect(session.undoManager.canUndo == false)
|
||||||
|
#expect(session.undoManager.canRedo == false)
|
||||||
|
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
|
||||||
|
// And a crossing that somehow started still writes nothing.
|
||||||
|
session.undoManager.undo()
|
||||||
|
session.undoManager.redo()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Add-git binds the provider on the open session — the commanded mid-session flip")
|
@Test("The free tier's repo-nested board still binds the native stack — it never detects one")
|
||||||
func addGitBindsUndoLive() async throws {
|
func freeTierOnARepoNestedBoardIsNativeToo() throws {
|
||||||
|
let outer = try WriterFixture()
|
||||||
|
defer { outer.tearDown() }
|
||||||
|
let repoRoot = outer.root
|
||||||
|
_ = GitRepository.create(at: repoRoot)
|
||||||
|
let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
|
||||||
|
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
|
||||||
|
|
||||||
|
let (model, tearDown) = try makeModel()
|
||||||
|
defer { tearDown() }
|
||||||
|
model.currentTier = { .free }
|
||||||
|
|
||||||
|
let ref = try openBoard(model, at: boardRoot)
|
||||||
|
let session = try #require(model.session(for: ref))
|
||||||
|
|
||||||
|
// 12 ▸ The free tier and `.git` names this board by hand: "opening a board that has one …
|
||||||
|
// a repo-nested board … works normally … native undo runs". The tier composes no git state
|
||||||
|
// at all, so there is nothing here that *could* tell this board from a plain one — the inert
|
||||||
|
// posture made structural rather than remembered.
|
||||||
|
#expect(session.git == nil)
|
||||||
|
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
|
||||||
|
#expect(session.history is NativeHistoryProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")
|
||||||
|
func addGitSwapsTheSubstrate() async throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let (model, tearDown) = try makeModel()
|
let (model, tearDown) = try makeModel()
|
||||||
@@ -966,7 +1042,16 @@ struct GitUndoBindingTests {
|
|||||||
model.currentTier = { .pro }
|
model.currentTier = { .pro }
|
||||||
|
|
||||||
let ref = try openBoard(model, at: fixture.root)
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
#expect(try #require(model.session(for: ref)).history == nil)
|
let native = try #require(model.session(for: ref)?.history as? NativeHistoryProvider)
|
||||||
|
// The AppKit face AppKit already holds: the swap must not replace *this* object, or every
|
||||||
|
// window's `windowWillReturnUndoManager` answer would go stale.
|
||||||
|
let manager = try #require(model.session(for: ref)?.undoManager)
|
||||||
|
|
||||||
|
// A real in-session step, mid-flight when the flip arrives.
|
||||||
|
let log = StepLog()
|
||||||
|
native.register(log.step("Move Card"))
|
||||||
|
#expect(manager.canUndo)
|
||||||
|
#expect(manager.undoMenuItemTitle == "Undo Move Card")
|
||||||
|
|
||||||
let git = try #require(model.session(for: ref)?.git)
|
let git = try #require(model.session(for: ref)?.git)
|
||||||
#expect(await git.addGit())
|
#expect(await git.addGit())
|
||||||
@@ -974,7 +1059,49 @@ struct GitUndoBindingTests {
|
|||||||
let session = try #require(model.session(for: ref))
|
let session = try #require(model.session(for: ref))
|
||||||
#expect(session.gitMode == .git)
|
#expect(session.gitMode == .git)
|
||||||
#expect(session.history is GitHistoryProvider, "the flip carries undo through with it")
|
#expect(session.history is GitHistoryProvider, "the flip carries undo through with it")
|
||||||
#expect(session.undoManager.canUndo == false, "on a trail whose only commit is the root")
|
#expect(session.store.history is GitHistoryProvider, "and the Writer boundary registers there")
|
||||||
|
#expect(session.undoManager === manager, "the same manager, over a different stack")
|
||||||
|
|
||||||
|
// **The stack dies with the substrate** (13's header — the branch-switch discard-and-reseed
|
||||||
|
// precedent): no migration, and the discarded stack is cleared rather than merely dropped,
|
||||||
|
// so nothing holding a reference to it can cross a step against a board that now has a trail.
|
||||||
|
#expect(native.canUndo == false)
|
||||||
|
#expect(native.canRedo == false)
|
||||||
|
#expect(log.crossings.isEmpty, "the in-flight step never ran — it was discarded, not applied")
|
||||||
|
|
||||||
|
// **Seeded from the root commit**, which is the stack's floor and not a step (06 ▸ Rules), so
|
||||||
|
// ⌘Z is correctly empty the instant the flip lands — and the menu row says nothing.
|
||||||
|
#expect(try trail(at: fixture.root).count == 1)
|
||||||
|
#expect(manager.canUndo == false, "on a trail whose only commit is the root")
|
||||||
|
#expect(manager.canRedo == false)
|
||||||
|
#expect(manager.undoMenuItemTitle == "Undo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The swapped-in git provider is live — the next landed commit is a step on it")
|
||||||
|
func theSwappedProviderHearsTheCommitter() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let (model, tearDown) = try makeModel()
|
||||||
|
defer { tearDown() }
|
||||||
|
model.currentTier = { .pro }
|
||||||
|
|
||||||
|
let ref = try openBoard(model, at: fixture.root)
|
||||||
|
let git = try #require(model.session(for: ref)?.git)
|
||||||
|
#expect(await git.addGit())
|
||||||
|
|
||||||
|
let session = try #require(model.session(for: ref))
|
||||||
|
let provider = try #require(session.history as? GitHistoryProvider)
|
||||||
|
// `activateAutoCommit` remembered the session's wiring for exactly this — the committer
|
||||||
|
// add-git built reports landed commits to whatever provider the session now holds, which is
|
||||||
|
// the one the swap just installed.
|
||||||
|
let committer = try quickCommitter(git)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
||||||
|
committer.noteReloadLanded(sawForeignChange: true)
|
||||||
|
await commitAndSettle(committer, provider)
|
||||||
|
|
||||||
|
#expect(try trail(at: fixture.root).count == 2)
|
||||||
|
#expect(session.undoManager.canUndo, "the trail behind ⌘Z is the repository's, live")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -605,8 +605,9 @@ struct BoardSessionHistoryTests {
|
|||||||
defer { tearDown() }
|
defer { tearDown() }
|
||||||
|
|
||||||
// 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the
|
// 12-editions.md ▸ The entitlement: the tier is read at composition, once, and handed to the
|
||||||
// root that binds the provider. Both tiers bind the native stack until pro-m1 — what this
|
// root that binds the provider. What this pins is that the *argument arrives* — the matrix it
|
||||||
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
|
// feeds is `GitUndoBindingTests`', since after the re-ruling of 2026-07-31 the tier decides
|
||||||
|
// only whether a git state is composed at all, and the mode decides the substrate.
|
||||||
var seen: [Tier] = []
|
var seen: [Tier] = []
|
||||||
model.currentTier = { .pro }
|
model.currentTier = { .pro }
|
||||||
model.makeHistoryProvider = { _, tier, _ in
|
model.makeHistoryProvider = { _, tier, _ in
|
||||||
|
|||||||
Reference in New Issue
Block a user