import Foundation // MARK: - NativeHistoryProvider /// The free tier's undo substrate: one stack per board session (13-native-undo.md) — **and the /// substrate of every open card window's stack, in either tier** (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 { var backing: Set = [] 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, 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() { 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 } }