Files
lanework/Kanban/History/NativeHistoryProvider.swift
T
rzen 71664dab02 Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 19:39:50 -04:00

150 lines
7.1 KiB
Swift

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 behind at all: it is popped and never re-pushed, which is 13's
/// "popped from the stack ... and ⌘Z falls through to the next step".
@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] = []
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 }
/// 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.
public func register(_ step: HistoryStep) {
undoSteps.append(step)
retire(redoSteps)
redoSteps.removeAll()
}
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).
public func clear() {
retire(undoSteps)
retire(redoSteps)
undoSteps.removeAll()
redoSteps.removeAll()
}
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
// 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:
// 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()
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
}
}