Step 7 of strategy/01-git-excision.md, the companions. The agent guide bumps to v11: the Git section teaches repo-resident etiquette alone (stage only your own paths, commit your own changes, leave app-maintained files to the app) — existing boards heal to the new text on next open. README re-anchors: the four git feature bullets out, tiers say the complete Mac experience is free, and one bullet states the format's git-friendliness promise. The changelog drops the never-shipped git entries. DESIGN re-rules: 06 retired with Undo routing migrated to 13 (now the sole substrate's doc, seam kept open), 07 retired as written pending the ops-service workstream, 14 retired as superseded record, 12 carries the second pivot note, the index reflects all of it; the charter gets a pointer note (the anchors' full re-ruling stays with the user). InertGitTests renames to GitAgnosticStorageTests — the excision restores its original claim app-wide. And the sweep: ~70 comment sites across 36 files adjudicated against the keeper list, every present-tense description of the excised machinery made past tense or repointed, keepers untouched. 2,707 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
234 lines
13 KiB
Swift
234 lines
13 KiB
Swift
import Foundation
|
|
|
|
// MARK: - NativeHistoryProvider
|
|
|
|
/// The undo substrate of every board: one stack per board session (13-native-undo.md, whose header
|
|
/// lost its tier axis with the 2026-08-07 pivot and then its mode axis with the 2026-08-08 git
|
|
/// excision, `strategy/01-git-excision.md` — there is no other substrate left to be the board's
|
|
/// alternative to) — **and the substrate of every open card window's stack, on any board** (re-ruled 2026-07-31, the
|
|
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
|
|
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
|
|
/// the two it is; both need the same four-line grammar.
|
|
///
|
|
/// ### Two arrays, and why not `NSUndoManager`
|
|
///
|
|
/// This provider was an `NSUndoManager` for exactly one milestone, on the argument that the *command
|
|
/// surface* is the platform's — Edit ▸ Undo and Edit ▸ Redo are the system's own nil-target
|
|
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
|
|
/// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the
|
|
/// focused window hands back. All of that is still true, and none of it lives here: the retitling is
|
|
/// `BoardUndoManager.undoMenuItemTitle` composing `undoMenuTitle(forUndoActionName:)` over the bare
|
|
/// phrase this seam vends as a `String?`. The adapter is the `UndoManager`; the substrate never
|
|
/// needed to be one.
|
|
///
|
|
/// What forced the change is the staleness milestone's third outcome. `HistoryStepOutcome.failed`
|
|
/// means **the step stays put** — a disk error is retryable, so ⌘Z must still be able to reach the
|
|
/// step it just could not write. `NSUndoManager` pops a group before running it and offers no way to
|
|
/// put it back: a registration made while undoing lands on the *redo* stack by its own documented
|
|
/// rule, and one made after the crossing returns clears the redo stack outright. Either way a failed
|
|
/// undo would have quietly destroyed something. Two arrays express all three outcomes exactly, and
|
|
/// the grammar they have to implement is four lines long.
|
|
///
|
|
/// ### One `register` call is exactly one step
|
|
///
|
|
/// Nothing here groups, coalesces, or waits for the end of a run-loop turn — 13's "one gesture, one
|
|
/// undo step" is a property of the Writer call sites (a multi-card move registers *one* step with a
|
|
/// plural title), and the substrate's job is to not have opinions about it. This is what
|
|
/// `NSUndoManager`'s `groupsByEvent = false` was buying, as an absence rather than a setting.
|
|
///
|
|
/// ### Undo flips to redo by reversing
|
|
///
|
|
/// 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 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 {
|
|
|
|
/// The two stacks, top last. Both hold steps oriented for crossing — see the type's note.
|
|
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
|
|
|
|
public var canUndo: Bool { !undoSteps.isEmpty }
|
|
|
|
public var canRedo: Bool { !redoSteps.isEmpty }
|
|
|
|
/// The phrase the menu title is composed from, or `nil` when there is nothing to cross — and
|
|
/// also `nil` for a step registered without a name, which is the emptiness the adapter's `""`
|
|
/// contract is written against.
|
|
public var undoActionName: String? { name(of: undoSteps.last) }
|
|
|
|
public var redoActionName: String? { name(of: redoSteps.last) }
|
|
|
|
/// **The steps currently crossable by ⌘Z, oldest first** — read-only, and read by exactly one
|
|
/// caller: the card window's close, which folds the session's own stack into the one coarse board
|
|
/// step (13-native-undo.md ▸ Rules ▸ "Window close coarsens"; `CardWindowUndo`).
|
|
///
|
|
/// The undo stack is the honest input to that fold and the redo stack is not: a gesture the user
|
|
/// undid inside the window is a gesture whose effect is no longer on disk, so it must contribute
|
|
/// nothing to the session's net effect. Nothing here decides what a fold *means* — this is the
|
|
/// 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 — 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)
|
|
let dropped = redoSteps
|
|
redoSteps.removeAll()
|
|
retire(dropped)
|
|
}
|
|
|
|
public func undo() { cross(.undo) }
|
|
|
|
public func redo() { cross(.redo) }
|
|
|
|
/// Session teardown — once the add-git substrate swap and a branch reseed too, both gone with
|
|
/// app-managed git (`strategy/01-git-excision.md`, 2026-08-08) — 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() {
|
|
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
|
|
/// fall-through: "the step is skipped, not applied ... and ⌘Z falls through to the next step".
|
|
///
|
|
/// The loop's own exit is an empty stack, so a stack of nothing but stale steps empties itself
|
|
/// and stops rather than spinning. The other two outcomes each end the crossing after one step:
|
|
/// an applied step is the ⌘Z the user asked for, and a failed one leaves the stack exactly as it
|
|
/// found it (`HistoryStepOutcome.failed`).
|
|
private func cross(_ direction: HistoryDirection) {
|
|
while let step = pop(direction) {
|
|
switch step.undo(direction) {
|
|
case .applied:
|
|
push(step.reversed, onto: direction.opposite)
|
|
return
|
|
case .skipped:
|
|
// **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)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pop(_ direction: HistoryDirection) -> HistoryStep? {
|
|
direction == .undo ? undoSteps.popLast() : redoSteps.popLast()
|
|
}
|
|
|
|
private func push(_ step: HistoryStep, onto direction: HistoryDirection) {
|
|
if direction == .undo {
|
|
undoSteps.append(step)
|
|
} else {
|
|
redoSteps.append(step)
|
|
}
|
|
}
|
|
|
|
private func name(of step: HistoryStep?) -> String? {
|
|
guard let name = step?.name, !name.isEmpty else { return nil }
|
|
return name
|
|
}
|
|
}
|