Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -107,6 +107,11 @@ extension BoardStore {
|
||||
let named = subject ?? name
|
||||
let step = HistoryStep(
|
||||
name: name,
|
||||
// **What this step holds in a trash, read off what it already declares** — the sweep's
|
||||
// gate (`HistoryStep.backing`, ruled 2026-07-31). Derived here rather than passed by each
|
||||
// call site because the expectations *are* the claim: a step whose undo needs a folder
|
||||
// present under `comments/.trash/` is a step whose undo is the move back out of it.
|
||||
backing: HistoryStep.backing(declaredBy: undoExpects),
|
||||
retirement: retirement,
|
||||
undo: { [weak self] direction in
|
||||
BoardStore.cross(self, direction, named, undoExpects, undo)
|
||||
@@ -130,9 +135,9 @@ extension BoardStore {
|
||||
// MARK: The window close's coarse step
|
||||
|
||||
/// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window
|
||||
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
|
||||
/// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state —
|
||||
/// deleted comments included — and whose redo reapplies the net effect").
|
||||
/// close ("the session's net effect registers on the board stack as one coarse step named
|
||||
/// 'Changes to ⟨card⟩', values-based, whose undo restores the card subtree to its session-start
|
||||
/// state — deleted comments included — and whose redo reapplies the net effect").
|
||||
///
|
||||
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
|
||||
/// whether there is a board to register it on is this method's:
|
||||
@@ -152,10 +157,11 @@ extension BoardStore {
|
||||
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
|
||||
///
|
||||
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the step leaves
|
||||
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
|
||||
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
|
||||
/// owes the purge.
|
||||
/// - Returns: whether the purge now has an owner — a live step holding it until the substrate
|
||||
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
|
||||
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
|
||||
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
|
||||
/// still owes the purge.
|
||||
@discardableResult
|
||||
func registerCardSession(
|
||||
_ window: CardWindowUndo,
|
||||
@@ -164,9 +170,18 @@ extension BoardStore {
|
||||
) -> Bool {
|
||||
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
|
||||
guard let net = window.netEffect() else { return false }
|
||||
// **The title the step is registered with, not the one the session opened on** (the phrase is
|
||||
// 13's "Changes to '⟨card⟩'", ruled 2026-07-31). Read once and spent twice, which is the
|
||||
// reason: the menu row and the skip banner must quote *one* name for one step, and a step
|
||||
// whose row said "Changes to 'Fix login'" while its banner said "'Fix log-in' changed outside
|
||||
// Lanework" would be two names for one card. Registration time is also the honest instant —
|
||||
// the row appears in the Edit menu the moment this returns, beside a board already showing
|
||||
// this title, and a session that renamed the card is a session whose rename is *in* the net
|
||||
// effect the row is offering to walk back.
|
||||
let title = Self.cardTitle(at: target, in: snapshot)
|
||||
registerStep(
|
||||
HistoryPhrase.cardSession,
|
||||
subject: Self.cardTitle(at: target, in: snapshot),
|
||||
HistoryPhrase.cardSession(title),
|
||||
subject: title,
|
||||
retiring: retiring,
|
||||
undoExpects: net.undoExpects,
|
||||
redoExpects: net.redoExpects,
|
||||
|
||||
@@ -167,8 +167,11 @@ public final class BoardUndoManager: UndoManager {
|
||||
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
|
||||
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
|
||||
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
|
||||
/// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text
|
||||
/// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the
|
||||
/// (settled): the search field ..., the popover's rename field, and the settings sheet's text fields
|
||||
/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a
|
||||
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
|
||||
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
|
||||
/// wherever it is hosted, and the sheet's arrival adds nothing here.) So the
|
||||
/// window-level answer is the board's stack **only when no text-editing surface holds the
|
||||
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
|
||||
/// have created for such a window on its own, so nothing about typing in a field changes.
|
||||
|
||||
@@ -101,6 +101,39 @@ public final class CardWindowUndo {
|
||||
writes[id] = write
|
||||
}
|
||||
|
||||
/// **Empties this window's fine stack, registering nothing** — the branch switch's settle step
|
||||
/// (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."
|
||||
///
|
||||
/// ### Closing the stack is not closing the window
|
||||
///
|
||||
/// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site.
|
||||
/// A window *close* owes the board one coarse step folded from this stack
|
||||
/// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the
|
||||
/// window is not closing — it stays open on the other side of the checkout and its next gesture
|
||||
/// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the
|
||||
/// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads
|
||||
/// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an
|
||||
/// empty fold exactly as it does for a session with no net change.
|
||||
///
|
||||
/// The raw writes go with the steps. They are keyed by step id and nothing on either stack names
|
||||
/// them any more, so keeping them would be a table that only grows — and a redone step's half of
|
||||
/// the fold, which is the one reason the table outlives the undo stack, has nothing left to be
|
||||
/// half of once both stacks are empty.
|
||||
///
|
||||
/// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()`
|
||||
/// runs each dropped step's retirement, and a window's fine steps carry none: the deferred
|
||||
/// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md ▸ Interaction with the
|
||||
/// trash), registered on the board's stack at close and never on this one.
|
||||
func discardSteps() {
|
||||
stack.clear()
|
||||
writes.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - The fold
|
||||
|
||||
/// **The session's net effect, or `nil` when there is none** — what the window's close registers
|
||||
@@ -111,7 +144,11 @@ public final class CardWindowUndo {
|
||||
///
|
||||
/// - **undo** — every live step's undo, newest first. Replaying the session backwards lands on the
|
||||
/// state it started from, deleted comments included: their backing is still in
|
||||
/// `comments/.trash/` because this step's own existence is what defers the purge.
|
||||
/// `comments/.trash/` because this step's own existence is what defers the purge — and, since
|
||||
/// the sweep gate (13 ▸ Interaction with the trash, ruled 2026-07-31), what keeps the *next*
|
||||
/// window open from sweeping it as residue. That claim needs nothing of its own here: the fold
|
||||
/// below carries each delete's `.present(.trashedComment(…))` into the coarse step's
|
||||
/// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them.
|
||||
/// - **redo** — every live step's redo, oldest first. The session, replayed.
|
||||
/// - **the undo's expectations** — the state the session's writes left, folded **last-write-wins**
|
||||
/// per field: what must still be true for the whole step to be safe to cross.
|
||||
|
||||
@@ -25,6 +25,10 @@ import Foundation
|
||||
/// destination clause a commit subject carries has no place in a title that has to stay short enough
|
||||
/// for a menu row.
|
||||
///
|
||||
/// **One deliberate exception, and it is the only one**: `cardSession(_:)` names its card
|
||||
/// ("Changes to 'Fix login'"), because the coarse close step is the one phrase whose *scope* is what
|
||||
/// distinguishes it — see that member.
|
||||
///
|
||||
/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed
|
||||
/// in eleven places is a vocabulary that drifts in eleven places.
|
||||
public enum HistoryPhrase {
|
||||
@@ -105,15 +109,37 @@ public enum HistoryPhrase {
|
||||
// MARK: The card-window session
|
||||
|
||||
/// **The coarse close step's phrase** — one card window's whole session, as the board's stack sees
|
||||
/// it: 13-native-undo.md ▸ Rules names it "Edit card 'Fix login'", so the menu title is the same
|
||||
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
|
||||
/// it: **"Changes to '⟨card⟩'"** (13-native-undo.md ▸ Rules, ruled 2026-07-31).
|
||||
///
|
||||
/// Deliberately *not* a new verb. The session is an edit to a card — the fine-grained things
|
||||
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
|
||||
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
|
||||
/// in a menu (06-history-undo.md ▸ Commit messages). It reads identically to a body-edit step
|
||||
/// because on the board's stack it *is* the card's edit.
|
||||
public static let cardSession = name(.edit, kind: .card)
|
||||
/// > "the session's net effect registers on the board stack as **one coarse step named
|
||||
/// > "Changes to '⟨card⟩'"** … the board row reads "Undo Changes to 'Fix login'": plural and
|
||||
/// > scope-flavoured, distinct from every fine verb, honest about folding many kinds; the fine
|
||||
/// > body-edit wording never leaks onto the board menu."
|
||||
///
|
||||
/// ### Why this one phrase carries its item
|
||||
///
|
||||
/// Every other phrase here drops the item clause a commit subject carries, because a verb plus a
|
||||
/// noun already says what the row is (the type's note). This step has no such verb: what it folds
|
||||
/// is a comment posted, a colour chosen, a paragraph rewritten, all at once, and a row that named
|
||||
/// any one of them would be lying about the other two — while enumerating them would be the
|
||||
/// "Mixed update" problem in a menu (06-history-undo.md ▸ Commit messages). So the *scope* is the
|
||||
/// phrase, and a scope is only legible when it names what it is the scope **of**.
|
||||
///
|
||||
/// ### It is not `Edit Card`, and that is the point
|
||||
///
|
||||
/// This member read `name(.edit, kind: .card)` until the ruling, and the fine body-edit step still
|
||||
/// does (`registerBodyEdit`) — two different steps on two different stacks, one row apart in the
|
||||
/// Edit menu, saying the same six characters. The board menu now says "Changes to 'Fix login'" and
|
||||
/// the window menu says "Edit Card": the coarse row names the session, the fine row names the
|
||||
/// gesture, and neither can be mistaken for the other.
|
||||
///
|
||||
/// - Parameter title: the card's title **as the step is registered** — `nil` renders the same
|
||||
/// placeholder the card's own window title bar renders ("Untitled" is a rendering, never a
|
||||
/// value — 03-board-ui.md § Card face), so a menu row and the window it came from name the card
|
||||
/// the same way.
|
||||
public static func cardSession(_ title: String?) -> String {
|
||||
"Changes to '\(title ?? "Untitled")'"
|
||||
}
|
||||
|
||||
// MARK: Composition
|
||||
|
||||
|
||||
@@ -111,10 +111,18 @@ public struct HistoryStep {
|
||||
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
|
||||
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
|
||||
/// deleted comments, so their backing lives as long as the step does — the purge runs when the
|
||||
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
|
||||
/// stale) or the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled
|
||||
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
|
||||
/// none of them itself — so the *provider* reports them, through this.
|
||||
/// coarse step leaves the board stack **cleanly** — undone-and-superseded, or dropped off the end
|
||||
/// — or when the board session ends" (13-native-undo.md ▸ Interaction with the trash, re-ruled
|
||||
/// 2026-07-31). A step is the only object that knows those moments, and it knows none of them
|
||||
/// itself — so the *provider* reports them, through this.
|
||||
///
|
||||
/// **A stale skip is deliberately not one of them** (the skip-purge decoupling, ruled
|
||||
/// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the
|
||||
/// skip banner says nothing was applied, and an irreversible purge riding that gesture would be
|
||||
/// surprise loss". Leaving the stack and leaving history for good stopped being the same event
|
||||
/// there, and this latch marks the second — which is why a substrate that keeps a skipped step's
|
||||
/// hold alive holds the *step* (`NativeHistoryProvider.strandedSteps`) rather than running this
|
||||
/// early and re-arming the sweep from somewhere else.
|
||||
///
|
||||
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
|
||||
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
|
||||
@@ -150,6 +158,26 @@ public struct HistoryStep {
|
||||
/// The menu phrase, unprefixed — see the type's note.
|
||||
public let name: String
|
||||
|
||||
/// **What this step holds as undo backing** — deleted content that exists on disk only because
|
||||
/// *this step's undo would move it back out*, and which is therefore not residue for as long as
|
||||
/// the step is crossable (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31).
|
||||
///
|
||||
/// > "`comments/.trash/` content referenced by a live coarse step on the board stack is a step's
|
||||
/// > **backing, not residue** — the open-time sweep consults the stack and skips owned content,
|
||||
/// > re-arming when the owning step leaves the stack (which is exactly when the deferred purge
|
||||
/// > wanted to run; one condition, two consumers). Reopening a window can therefore never destroy
|
||||
/// > its prior session's undo backing."
|
||||
///
|
||||
/// The pair with `retirement` is the whole mechanism: this says *what* is being held, that says
|
||||
/// *when the hold ends*, and the two are read by the two consumers the ruling names — the
|
||||
/// crash-residue sweep (`BoardStore.sweepCommentTrashResidue(inCard:)`, through
|
||||
/// `HistoryProviding.backedContent`) and the deferred purge itself.
|
||||
///
|
||||
/// **Declared by the step's own expectations rather than by hand** — see `backing(declaredBy:)`.
|
||||
/// Empty for every board gesture, which is all but a comment delete and the coarse step folding
|
||||
/// one.
|
||||
public let backing: Set<HistoryAnchor>
|
||||
|
||||
/// What this step owes when it leaves history — see `Retirement`. `nil` for every step that owes
|
||||
/// nothing, which is all but the card-window close step.
|
||||
public let retirement: Retirement?
|
||||
@@ -165,26 +193,58 @@ public struct HistoryStep {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
backing: Set<HistoryAnchor> = [],
|
||||
retirement: Retirement? = nil,
|
||||
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
|
||||
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.backing = backing
|
||||
self.retirement = retirement
|
||||
self.undo = undo
|
||||
self.redo = redo
|
||||
}
|
||||
|
||||
/// **The backing a step's own registration already names**: every target its **undo** expects to
|
||||
/// find *in a trash*.
|
||||
///
|
||||
/// Derived rather than passed, so no call site can forget it and no call site can say something
|
||||
/// its expectations contradict. The reading is exact: a step whose undo requires a folder to be
|
||||
/// present under `comments/.trash/` is a step whose undo *is* the move back out of it — which is
|
||||
/// the definition of backing, spelled in the currency the step already carries.
|
||||
///
|
||||
/// **`.trashedComment` is the whole vocabulary**, deliberately. The board's own `.trash/` holds
|
||||
/// the same relationship (a delete step's undo restores from it) and is deliberately absent: the
|
||||
/// board trash is a UI surface the user empties on purpose, with a confirm, and nothing sweeps it
|
||||
/// behind their back — so there is no consumer for the answer. `comments/.trash/` is the one
|
||||
/// trash the app purges on its own schedule (01-storage-format.md § Enhanced schema), which is
|
||||
/// exactly why it is the one that needs asking.
|
||||
public static func backing(declaredBy undoExpects: [HistoryExpectation]) -> Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for expectation in undoExpects where expectation.presence == .present {
|
||||
guard case .trashedComment = expectation.anchor else { continue }
|
||||
backing.insert(expectation.anchor)
|
||||
}
|
||||
return backing
|
||||
}
|
||||
|
||||
/// The same step read backwards — what a provider puts on the opposite stack once this one has
|
||||
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
|
||||
/// Card": the phrase names the *gesture*, not the direction.
|
||||
///
|
||||
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
|
||||
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
|
||||
/// the purge it defers must still be owed exactly once.
|
||||
/// **The identity, the backing and the retirement travel with it**, all three for one reason: a
|
||||
/// step that has crossed is the same step, so the fold that keyed state on it must still find that
|
||||
/// state, the content it holds must still be held, and the purge it defers must still be owed
|
||||
/// exactly once.
|
||||
///
|
||||
/// The backing claim is constant across the crossing rather than swapped with the closures, and
|
||||
/// that is the honest reading: an undone coarse step has *already* moved its comments back out of
|
||||
/// `comments/.trash/`, so there is nothing there to sweep — and its redo will put them back,
|
||||
/// after which its undo needs them again. A claim that lapsed while the step sat on the redo stack
|
||||
/// would be a claim that lapsed exactly when the step was still crossable.
|
||||
public var reversed: HistoryStep {
|
||||
HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
|
||||
HistoryStep(id: id, name: name, backing: backing, retirement: retirement, undo: redo, redo: undo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,14 +280,31 @@ public struct HistoryStep {
|
||||
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
|
||||
/// (06 ▸ Undo routing, tier-independent) — `BoardUndoRouting`.
|
||||
///
|
||||
/// ### One obligation every implementation shares: retire what you drop
|
||||
/// ### One obligation every implementation shares: retire what you let go of
|
||||
///
|
||||
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` — the
|
||||
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
|
||||
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
|
||||
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and — for a substrate
|
||||
/// that keeps no steps at all — the step handed to `register` itself. Nothing else in the app can
|
||||
/// observe that moment, and a step dropped in silence would defer its work forever.
|
||||
/// A step may owe work for as long as the substrate holds it and no longer (`HistoryStep.Retirement`
|
||||
/// — the deferred `comments/.trash/` purge). Only the substrate knows when it has stopped holding
|
||||
/// one, so **every implementation must call `retirement?.run()` on every step it lets go**: the redo
|
||||
/// stack it clears on a `register`, everything in `clear()`, and — for a substrate that keeps no
|
||||
/// steps at all — the step handed to `register` itself. Nothing else in the app can observe that
|
||||
/// moment, and a step dropped in silence would defer its work forever.
|
||||
///
|
||||
/// **Held is not the same as crossable**, since the skip-purge decoupling (13 ▸ Interaction with the
|
||||
/// trash, ruled 2026-07-31): a step a stale skip popped is crossable by nothing, and its hold
|
||||
/// nonetheless stands to the session's end, because "the skip banner says nothing was applied, and an
|
||||
/// irreversible purge riding that gesture would be surprise loss". A substrate that pops stale steps
|
||||
/// therefore has a place to put them (`NativeHistoryProvider.strandedSteps`); one that never keeps a
|
||||
/// step is untouched by the distinction, which is why the git provider needed no change.
|
||||
///
|
||||
/// ### And its mirror: say what you are still holding
|
||||
///
|
||||
/// The same fact, asked the other way round — `backedContent`. Retirement is the *moment* a hold
|
||||
/// ends; that is the *inventory* of holds standing right now, which is what a sweep needs before it
|
||||
/// removes anything (13 ▸ Interaction with the trash, ruled 2026-07-31: "one condition, two
|
||||
/// consumers"). The two must be answered off the same steps or the pair stops being one condition —
|
||||
/// which is the whole reason a stranded step is kept whole rather than filleted into a retirement
|
||||
/// here and a set of anchors there. It defaults to nothing, so a substrate that keeps no steps needs
|
||||
/// no line of code.
|
||||
@MainActor
|
||||
public protocol HistoryProviding: AnyObject {
|
||||
|
||||
@@ -262,4 +339,32 @@ public protocol HistoryProviding: AnyObject {
|
||||
/// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board
|
||||
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
|
||||
func clear()
|
||||
|
||||
/// **The undo backing every step this substrate still holds** — the union of `HistoryStep.backing`
|
||||
/// over everything it has not yet let go of.
|
||||
///
|
||||
/// Read by the crash-residue sweep before it removes anything
|
||||
/// (`BoardStore.sweepCommentTrashResidue(inCard:)`): "content referenced by a live coarse step on
|
||||
/// the board stack is a step's backing, not residue" (13 ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31). Both directions count, because both are live: a step sitting on the redo stack has
|
||||
/// not retired, and 13's own release condition — "undone-and-superseded" — is the moment it does.
|
||||
/// So does a step a stale skip stranded, which is crossable in *neither* direction and holding all
|
||||
/// the same: the skip-purge decoupling put its backing's release at the session's end, and a sweep
|
||||
/// that could not see it would take the release back (`NativeHistoryProvider.strandedSteps`).
|
||||
///
|
||||
/// A *point-in-time* answer, computed on demand rather than cached: the stacks are the truth, and
|
||||
/// a second copy of this could only ever be a stale one.
|
||||
var backedContent: Set<HistoryAnchor> { get }
|
||||
}
|
||||
|
||||
extension HistoryProviding {
|
||||
|
||||
/// **A substrate that keeps no steps holds no backing** — the git provider's honest answer
|
||||
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
|
||||
/// the close flush" true on Pro), and a test fake's.
|
||||
///
|
||||
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
|
||||
/// an implementation that *does* keep steps overrides it and every caller through `any
|
||||
/// HistoryProviding` sees the override.
|
||||
public var backedContent: Set<HistoryAnchor> { [] }
|
||||
}
|
||||
|
||||
@@ -39,8 +39,16 @@ import Foundation
|
||||
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
||||
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
||||
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
||||
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
||||
/// a skipped step leaves nothing crossable behind: it is popped and never re-pushed, which is 13's
|
||||
/// "popped from the stack ... and ⌘Z falls through to the next step".
|
||||
///
|
||||
/// ### A third collection, which is not a stack
|
||||
///
|
||||
/// What a stale skip leaves behind is not *nothing*, though, and that is the one place this type
|
||||
/// keeps state a stack cannot express: a skipped step that was holding undo backing goes on holding
|
||||
/// it until the session ends (`strandedSteps`; 13 ▸ Interaction with the trash, ruled 2026-07-31 —
|
||||
/// the skip-purge decoupling). Nothing can cross a stranded step and nothing can see one in the Edit
|
||||
/// menu; it is a hold outliving the history it belonged to.
|
||||
@MainActor
|
||||
public final class NativeHistoryProvider: HistoryProviding {
|
||||
|
||||
@@ -48,6 +56,26 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
private var undoSteps: [HistoryStep] = []
|
||||
private var redoSteps: [HistoryStep] = []
|
||||
|
||||
/// **Steps a stale skip popped that were still holding something** — off both stacks, uncrossable
|
||||
/// forever, and *not yet retired* (13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31):
|
||||
///
|
||||
/// > "the purge runs when the coarse step leaves the board stack **cleanly** — undone-and-superseded,
|
||||
/// > or dropped off the end — or when the board session ends; **a stale-skipped step's backing
|
||||
/// > instead survives to board-session end** ... the skip banner says nothing was applied, and an
|
||||
/// > irreversible purge riding that gesture would be surprise loss — the skip is exactly when the
|
||||
/// > user may want to inspect what the collision left."
|
||||
///
|
||||
/// **The whole step is kept, not a copy of what it was holding**, and that is the ruling's "one
|
||||
/// condition, two consumers" taken literally: the sweep's ownership question (`backedContent`) and
|
||||
/// the deferred purge's moment (`HistoryStep.Retirement`) are two readings of one object's hold,
|
||||
/// so they must not become two facts able to disagree. A skip that ran the retirement and re-armed
|
||||
/// the sweep from a side-table would be saying *released* to one consumer and *still held* to the
|
||||
/// other, for the same instant. Deferring the latch itself says it once.
|
||||
///
|
||||
/// Only steps that owe something are stranded — see `strand(_:)`.
|
||||
private var strandedSteps: [HistoryStep] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
@@ -73,16 +101,44 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// membership question, and the two arrays already answer it.
|
||||
public var pendingSteps: [HistoryStep] { undoSteps }
|
||||
|
||||
/// **What every step this substrate still holds is holding** — the sweep's gate
|
||||
/// (`HistoryProviding.backedContent`; 13-native-undo.md ▸ Interaction with the trash, ruled
|
||||
/// 2026-07-31).
|
||||
///
|
||||
/// All *three* collections, unlike `pendingSteps` above, and the difference is the question: that
|
||||
/// one asks what ⌘Z would cross *now*, this one asks what has not been released. A step on the
|
||||
/// redo stack has not retired — "undone-and-superseded" is a release condition precisely because
|
||||
/// being undone is not one — so its backing is still owed; and a stranded step is the case where
|
||||
/// the two questions come furthest apart, holding backing it will never again cross for
|
||||
/// (`strandedSteps`, the skip-purge decoupling ruled 2026-07-31). The readings differing is what
|
||||
/// makes them two members.
|
||||
///
|
||||
/// Leaving the stranded out would defeat the ruling by the back door: the step is off the stack,
|
||||
/// so the next window open's sweep would call its backing residue and purge exactly the content
|
||||
/// the skip was meant to leave standing.
|
||||
public var backedContent: Set<HistoryAnchor> {
|
||||
var backing: Set<HistoryAnchor> = []
|
||||
for step in undoSteps { backing.formUnion(step.backing) }
|
||||
for step in redoSteps { backing.formUnion(step.backing) }
|
||||
for step in strandedSteps { backing.formUnion(step.backing) }
|
||||
return backing
|
||||
}
|
||||
|
||||
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
||||
/// substrate shares.
|
||||
///
|
||||
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
|
||||
/// moment the app can see it.
|
||||
/// "undone-and-superseded" half of the deferred purge's release condition — a *clean* exit in the
|
||||
/// ruling's own word — and it is the only moment the app can see it.
|
||||
///
|
||||
/// `strandedSteps` is deliberately untouched: superseding is something that happens to a step on
|
||||
/// the redo stack, and a stranded step is on no stack to be superseded from. Its one exit is the
|
||||
/// session's end.
|
||||
public func register(_ step: HistoryStep) {
|
||||
undoSteps.append(step)
|
||||
retire(redoSteps)
|
||||
let dropped = redoSteps
|
||||
redoSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
public func undo() { cross(.undo) }
|
||||
@@ -92,17 +148,38 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
/// Session teardown, the add-git substrate swap, a branch reseed — every step goes, so every step
|
||||
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
|
||||
/// ends" (13 ▸ Interaction with the trash).
|
||||
///
|
||||
/// **This is where the stranded finally pay**, and the only place they can: "a stale-skipped
|
||||
/// step's backing instead survives to board-session end" (ruled 2026-07-31) names exactly this
|
||||
/// moment, and `AppModel`'s teardown reaches it with the store still alive — the `clear()` is a
|
||||
/// line above the store's release, for the reason the deferred purge needs it to be.
|
||||
public func clear() {
|
||||
retire(undoSteps)
|
||||
retire(redoSteps)
|
||||
let dropped = undoSteps + redoSteps + strandedSteps
|
||||
undoSteps.removeAll()
|
||||
redoSteps.removeAll()
|
||||
strandedSteps.removeAll()
|
||||
retire(dropped)
|
||||
}
|
||||
|
||||
/// **Off the stack first, then retired** — the order every caller above keeps, and the one
|
||||
/// `backedContent` depends on: a retirement is where the deferred purge runs, and a step that
|
||||
/// retired while still listed would be answering that it still holds the very content it is
|
||||
/// releasing.
|
||||
private func retire(_ steps: [HistoryStep]) {
|
||||
for step in steps { step.retirement?.run() }
|
||||
}
|
||||
|
||||
/// Keeps a skipped step's *hold* alive without keeping the step crossable — see `strandedSteps`.
|
||||
///
|
||||
/// **A step holding nothing is dropped outright**, which is every board gesture: with no backing
|
||||
/// to spare and no retirement to defer there is no consumer for the entry, and stranding it would
|
||||
/// be bookkeeping that only grows. The predicate is the pair the ruling's two consumers read, so
|
||||
/// it can never spare a step neither of them would ask about.
|
||||
private func strand(_ step: HistoryStep) {
|
||||
guard step.retirement != nil || !step.backing.isEmpty else { return }
|
||||
strandedSteps.append(step)
|
||||
}
|
||||
|
||||
// MARK: - The crossing
|
||||
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's
|
||||
@@ -119,9 +196,13 @@ public final class NativeHistoryProvider: HistoryProviding {
|
||||
push(step.reversed, onto: direction.opposite)
|
||||
return
|
||||
case .skipped:
|
||||
// Dropped for good — the third of the deferred purge's release conditions ("gone
|
||||
// stale"), and the reason a skip is reported here rather than merely counted.
|
||||
step.retirement?.run()
|
||||
// **Off the stack, but not released** — the skip-purge decoupling (13 ▸ Interaction
|
||||
// with the trash, ruled 2026-07-31). A stale skip is the one exit that is not clean:
|
||||
// "the skip banner says nothing was applied, and an irreversible purge riding that
|
||||
// gesture would be surprise loss — the skip is exactly when the user may want to
|
||||
// inspect what the collision left". So the step stops being crossable here and its
|
||||
// hold outlives it, to the session's end (`strandedSteps`).
|
||||
strand(step)
|
||||
continue
|
||||
case .failed:
|
||||
push(step, onto: direction)
|
||||
|
||||
Reference in New Issue
Block a user