Files
lanework/Kanban/History/HistoryProviding.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

384 lines
22 KiB
Swift

import Foundation
// MARK: - HistoryDirection
/// Which command the user pressed — ⌘Z or ⇧⌘Z.
///
/// **Not which half of a step is running.** A step that has already been undone goes onto the redo
/// stack *reversed* (`HistoryStep.reversed`), so the closure ⇧⌘Z crosses is the one registered as
/// `redo` and the closure a second ⌘Z crosses is the one registered as `undo` — the halves swap, and
/// a step cannot tell which it is by looking at itself. What it has to be told is the **command**,
/// because the one sentence a step ever says out loud names it: "Undo skipped — 'Fix login' changed
/// outside Lanework" (13-native-undo.md ▸ Rules), with "Redo skipped" as its mirror.
public enum HistoryDirection: Sendable, Equatable {
case undo
case redo
/// The stack a step lands on once it has been crossed this way.
public var opposite: HistoryDirection {
self == .undo ? .redo : .undo
}
}
// MARK: - HistoryStepOutcome
/// What happened when a step was asked to walk its write back (or forward again).
///
/// The three cases are three different fates for the *stack*, which is the only thing the provider
/// asks about:
///
/// - `.applied` — the write landed; the step's mirror image joins the opposite stack.
/// - `.skipped` — **stale** (13-native-undo.md ▸ Rules ▸ staleness validation): "target folder gone,
/// or the field no longer holding the step's after-value → the step is **skipped, not applied**:
/// popped from the stack ... and ⌘Z falls through to the next step". The step is dropped and the
/// crossing continues.
/// - `.failed` — the inverse was attempted and could not be written. The step **stays**, and the
/// crossing stops (see the case's own note).
///
/// The provider reads this answer and nothing else — the *predicate* (field-level, settled) and the
/// info-tone banner that explains a skip both belong to the step, which is the only side that knows
/// what it wrote and which board to say it on.
public enum HistoryStepOutcome: Equatable, Sendable {
/// The step ran. Its mirror image joins the opposite stack.
case applied
/// The step declined: the board no longer holds the value this step's write left, so applying
/// its inverse would clobber somebody else's newer edit. Nothing ran, the step is dropped, and
/// the crossing continues with the next one down.
case skipped
/// The step tried and could not: the Writer refused the inverse (a disk error, a permission
/// problem, a board that has gone read-only under the stack). Nothing landed.
///
/// **The step stays put and the crossing stops** — the one thing that distinguishes this from
/// `.skipped`, and the reason the case exists. 13 is silent here, so the posture is the honest
/// reading of its two rules: a *stale* step is one the board has moved past, so dropping it
/// loses nothing; a *failed* one is a step the user still means to cross, refused by a condition
/// that is usually momentary (a full disk, an unplugged volume), so popping it would spend their
/// only route back on a transient error. The failure has already banners itself as an ordinary
/// write failure (`BoardStore.performWrite` posts before it rethrows), which is exactly the
/// vocabulary 02-architecture.md § Write-failure surfacing gives it — and which is why a failure
/// must *not* also raise the info-tone skip row: two rows for one event would say the step is
/// both gone and retryable.
///
/// Stopping rather than falling through follows from the same reading: fall-through exists to
/// walk past steps the board no longer has a use for, and a disk that just refused one write is
/// not a reason to attempt N more.
case failed
}
// MARK: - HistoryStep
/// One undoable step: a name, and the pair of actions that walk the board backwards and forwards
/// across it.
///
/// ### An operation pair, not an `NSUndoManager` registration
///
/// 13-native-undo.md ▸ Rules puts every undoable change at the Writer boundary — "each Writer call
/// site registers the inverse operation, computed from the pre-write snapshot the store 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
/// 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 board's stack
/// is `NSUndoManager`-backed, and no substrate appears here. (Once two substrates shared this seam —
/// a gitless board's native stack, a Pro git board's git history, 12-editions.md ▸ The provider
/// seam — before app-managed git left the app entirely, 2026-08-08, `strategy/01-git-excision.md`.)
///
/// ### `name` is the 06 vocabulary, unprefixed
///
/// "The 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's dynamic
/// retitling — the same naming machinery both tiers use" (13). What the step carries is the bare
/// phrase — `"Move Card"`, `"Move 3 Cards"`, `"Rename Lane"` — in the vocabulary of
/// 06-history-undo.md ▸ Commit messages, plural-folded by the same rule ("one gesture, one undo
/// step — a multi-card move is one step with a plural title"). The **"Undo "/"Redo " prefix is
/// never part of it**: the platform composes and localizes that (`BoardUndoManager`), and a step
/// that spelled it would read "Undo Undo Move Card" in the Edit menu.
///
/// ### Both closures are `@MainActor`, and both are handed the direction
///
/// Everything they touch — the store, the snapshot, the banners — is `@MainActor`, and a step exists
/// to be run from a menu command. Marking them says so at the seam instead of leaving each provider
/// to rediscover it.
///
/// The `HistoryDirection` argument is **which command the user pressed**, not which half is running:
/// `reversed` swaps the two closures, so the half a ⌘Z crosses is the `redo` one as often as not,
/// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`).
public struct HistoryStep {
/// **What a step owes the world once it has left history for good** — run exactly once, by
/// whichever provider drops it.
///
/// 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 **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
/// "exactly once" a property of the object rather than of the bookkeeping around it.
///
/// A step with no retirement — every board gesture — carries `nil` and costs nothing.
@MainActor
public final class Retirement {
private var work: (@MainActor () -> Void)?
public init(_ work: @escaping @MainActor () -> Void) {
self.work = work
}
/// Runs the work, once. Later calls do nothing, which is what lets every provider report a
/// drop without first checking whether another one already has.
public func run() {
let work = self.work
self.work = nil
work?()
}
/// Whether the work is still owed — the assertion a test makes instead of watching disk.
public var isOwed: Bool { work != nil }
}
/// This step's identity, stable across `reversed` — which is what makes it usable as a key into
/// state held *beside* a stack (`CardWindowUndo`, whose fold has to find the raw write behind a
/// step that may have crossed any number of times).
public let id: UUID
/// 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?
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
/// the write that just landed.
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
/// Walks it forward again — the original write, replayed. Reached only after `undo` applied,
/// because that is the only way a step reaches the redo stack.
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
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, 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, backing: backing, retirement: retirement, undo: redo, redo: undo)
}
}
// MARK: - HistoryProviding
/// The undo/redo substrate, behind one protocol boundary (12-editions.md ▸ The provider seam).
///
/// ### One per board session — and one per open card window
///
/// "Two levels: one stack per board, one per open card window" (13-native-undo.md ▸ Rules, re-ruled
/// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its
/// implementation is what this protocol is a seam for. A **card window** owns a second stack for its
/// own gestures — always a `NativeHistoryProvider`, because a window's fine-grained inverses are
/// values-based whatever the board's substrate is (`CardWindowUndo`); what reaches this
/// seam from a window is the one coarse step its close registers.
///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets — **always `NativeHistoryProvider`** now (two step stacks over the
/// inverses registered at the Writer boundary), on every board there is
/// (`AppModel.makeHistoryProvider`). A git-mode board once bound a git provider instead — undo as
/// forward restore commits over HEAD's first-parent ancestry, 06-history-undo.md — between the
/// 2026-08-07 pivot (12-editions.md ▸ PIVOT 2026-08-07, when the tier stopped deciding whether git
/// was on the table at all) and the 2026-08-08 excision that removed the git provider outright
/// (`strategy/01-git-excision.md`).
///
/// ### What this protocol deliberately does not say
///
/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation
/// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the
/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof
/// the seam is real", 12 — written when native undo was the free tier's; the proof it named was
/// two working substrates, which the pivot left standing and the 2026-08-08 excision then narrowed
/// back to one, `strategy/01-git-excision.md` — the seam itself is what stays proved either way).
/// The command surface still speaks `UndoManager` — the menu rows, the toolbar pair and AppKit's
/// own asks all read one (13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled
/// 2026-08-08); that adapter is `BoardUndoManager`, which sits *over* this protocol rather than
/// inside it.
/// - **No persistence promise.** The native stack dies with the session (13); a git provider's once
/// survived relaunch because git does, before app-managed git was excised entirely (2026-08-08,
/// `strategy/01-git-excision.md`). Both were honest implementations of these seven members; only
/// native's promise is live to keep now.
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
/// (13-native-undo.md ▸ Undo routing) — `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you let go of
///
/// 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, while it existed, needed no
/// change for this rule at all.
///
/// ### 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 {
/// Records one undoable step, on top of the undo stack, clearing the redo stack — the classic
/// rule, and the one every substrate shares.
///
/// Called once per *gesture*, never once per write: "coalescing follows commit granularity ...
/// a multi-card move is one step with a plural title; an Edit session is one step, registered at
/// the Edit→Preview flip" (13 ▸ Rules).
func register(_ step: HistoryStep)
/// Whether there is a step to cross. What the Edit menu's Undo row and the toolbar's Undo item
/// enable on — both of them through one `BoardUndoManager` over this seam, which is what keeps
/// the two surfaces from being two answers (13-native-undo.md ▸ Rules ▸ the command-surface
/// bullet, re-ruled 2026-08-08).
var canUndo: Bool { get }
var canRedo: Bool { get }
/// The name of the step ⌘Z would cross, or `nil` when there is none — the phrase the menu title
/// is composed from ("Move 3 Cards" → "Undo Move 3 Cards").
var undoActionName: String? { get }
var redoActionName: String? { get }
/// Crosses one step backwards. A stale step is skipped rather than applied, and the crossing
/// falls through to the next one (13 ▸ Rules ▸ staleness validation); a step whose write *failed*
/// stays where it is and stops the crossing (`HistoryStepOutcome.failed`); an empty stack does
/// nothing.
func undo()
func redo()
/// 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 honest answer for any provider that
/// retires every step on arrival (which is what makes "purge rides the close flush" true over such
/// a substrate), 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> { [] }
}