Files
lanework/Kanban/History/NativeHistoryProvider.swift
T
rzen 1fd19dfb12 The undo command surface rebuilds — app-owned rows and explicit toolbar targets over FocusedValues
Edit ▸ Undo/Redo become the app's own replaced rows and the board toolbar
pair takes explicit targets, both reading the focused session's
BoardUndoManager through FocusedValues.undoStack (board windows publish the
session's manager, card windows their own) — the nil-target route died with
the SwiftUI window latch, 13-native-undo.md ▸ Rules ▸ command surface,
re-ruled 2026-08-08. The rows enact the routing predicate themselves: text
focus routes ⌘Z to the first responder's own manager, title and enablement
included, re-derived at fire time with a beep for the stale window.
NativeHistoryProvider turns @Observable so both surfaces re-derive on stack
changes; a checkpoint-notification ticker covers plain text managers.
.responderAction leaves ToolbarItemSpec with its only user;
windowWillReturnUndoManager stays wired for AppKit's own asks.

Live-probed on the fixture board (21/21): the row retitles to "Undo Add
Lane" and crosses via real ⌘Z key events, ⇧⌘Z redoes via a window-server
chord, the toolbar pair validates and fires, search-field and body-editor
⌘Z stay text undo with board stacks untouched, and a card window crosses
its own stack with no fall-through. 2698 unit tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 18:55:34 -04:00

252 lines
14 KiB
Swift

import Foundation
import Observation
// 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 were the system's own nil-target
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carried the same actions
/// (`BoardToolbar`), and both lit up, disabled and **retitled** from whatever `UndoManager` the
/// focused window handed back. The surface is the app's own now — replaced rows and explicit toolbar
/// targets, reading the focused `BoardUndoManager` through `FocusedValues` (13-native-undo.md ▸ Rules
/// ▸ the command-surface bullet, re-ruled 2026-08-08, the SwiftUI latch) — and the part of that
/// argument this file rests on is untouched either way: none of it lives here, and the *vocabulary*
/// is still the platform's, `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.
///
/// ### Observable, because the command surface is the app's own now
///
/// The two stacks below back every `canUndo`, `canRedo` and `undoActionName` any surface reads, and
/// since 2026-08-08 those surfaces are SwiftUI's and the toolbar's rather than `NSWindow`'s own
/// validation (13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled that day: the Edit
/// menu's rows read the focused `BoardUndoManager` through `FocusedValues`, and the toolbar pair
/// carries explicit targets over the same object). Both want
/// to re-derive when a step lands rather than when the user next moves the mouse, and both already
/// know how: the menu rows are views, and `WindowToolbarController.trackValidationState` re-arms
/// `withObservationTracking` over every predicate it was given. Making the *stacks* observed is what
/// lets that machinery reach through the adapter — one macro here, and no notification of anyone's
/// own anywhere above.
@MainActor
@Observable
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
}
}