Files
lanework/Kanban/History/BoardUndoManager.swift
T
rzen 50669489cb Implement staleness validation and skip-with-banner
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 14:54:42 -04:00

163 lines
9.1 KiB
Swift

import AppKit
// MARK: - BoardUndoManager
/// What AppKit is handed for a board's windows: an `UndoManager` that owns no stack and answers
/// every question from the session's `HistoryProviding`.
///
/// ### Why an adapter exists at all
///
/// The responder chain speaks one currency. `NSWindow` implements `undo:`/`redo:` and validates
/// those menu items itself, reading `canUndo`, `canRedo` and `undoMenuItemTitle` off whatever
/// `UndoManager` the window's delegate hands back (`windowWillReturnUndoManager(_:)`) — which is
/// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
/// up, disable and retitle with no code of the app's own.
///
/// The seam, though, must not be an `NSUndoManager`: base's stack is one, Pro's is git
/// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a
/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the
/// translation — one per board session, over whichever provider that session was composed with. Pro
/// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding
/// its provider and changing nothing here, which is what "a user moving between editions relearns
/// nothing" (12) has to mean in code.
///
/// ### It deliberately keeps its inherited stack empty
///
/// Every question is overridden, so anything that *did* register into this manager by accident (a
/// stray AppKit client finding it through the window) would be invisible rather than half-live: it
/// could never enable Undo, retitle it, or be crossed by ⌘Z. Nothing registers here today; the
/// overrides are what make that safe rather than lucky.
///
/// `removeAllActions()` is deliberately **not** forwarded to `HistoryProviding.clear()`: AppKit
/// clears a window's undo manager on paths this app does not control, and a card window closing must
/// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and
/// its one clearing point is that session's teardown).
///
/// ### The read-only lock disables Undo and Redo here
///
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
/// disables Undo/Redo with the other mutating commands; **the stack itself survives the lock and
/// resumes when it clears**" (13 ▸ Rules). This is the right place for it and the only one: every
/// surface that offers ⌘Z — the Edit menu's nil-target row, the toolbar pair, a card window's
/// responder chain — validates through this object, so answering `false` here disables all of them
/// at once, exactly as the lock's other victims disable through menu validation (02-architecture.md
/// § "The lock's scope"). Putting it in the *provider* would have been the same answer in the wrong
/// place: the stack is not the thing that is locked, the board is, and a Pro session binding the git
/// provider must inherit the rule without reimplementing it.
public final class BoardUndoManager: UndoManager {
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
/// only ever reachable while the session that made it is alive.
private let history: any HistoryProviding
/// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
/// and Pro binds a different substrate behind the same one), and a closure is what lets the
/// composition root wire the board's own truth in without this file learning what a `BoardStore`
/// is. The default answers "writable", which is what a manager built without a board — a test of
/// the adapter's own grammar — should have.
private let isReadOnly: @MainActor () -> Bool
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
self.history = history
self.isReadOnly = isReadOnly
super.init()
}
// MARK: Enablement
/// **False under the lock, whatever the stack holds.** The steps are still there — this is an
/// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
/// would have crossed before it landed.
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
public override var canRedo: Bool { !isReadOnly() && history.canRedo }
// MARK: Crossing
/// Not gated on the lock, deliberately: `undo:` reaches a manager only through a menu item or
/// toolbar button that has already validated against `canUndo`, and a crossing that somehow
/// started anyway is refused one layer down by `performWrite` — which leaves the step on the
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
/// guard here would be a second answer to one question.
public override func undo() { history.undo() }
public override func redo() { history.redo() }
// MARK: Titles
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
/// nothing to cross, which is what its menu-title composition expects.
public override var undoActionName: String { history.undoActionName ?? "" }
public override var redoActionName: String { history.redoActionName ?? "" }
/// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
/// never spells the word "Undo" in a title.
///
/// The trim is for the nameless case: the pattern is `Undo %@`, so an empty action name would
/// otherwise leave a trailing space where `NSUndoManager` itself answers a bare "Undo".
public override var undoMenuItemTitle: String {
undoMenuTitle(forUndoActionName: undoActionName).trimmingCharacters(in: .whitespaces)
}
public override var redoMenuItemTitle: String {
redoMenuTitle(forUndoActionName: redoActionName).trimmingCharacters(in: .whitespaces)
}
}
// MARK: - BoardUndoRouting
/// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md
/// ▸ Undo routing, which 12 records as **edition-independent**: "focus decides text-undo vs
/// board-undo; only the substrate behind board-undo differs".
///
/// ### Most of the rule is AppKit's, and is not here
///
/// "While a text-editing surface is focused ... ⌘Z/⇧⌘Z are that editor's own text undo" is the
/// platform's own behaviour for a text view that vends a manager: `NSWindow.undo:` crosses the
/// *first responder's* `undoManager`, and both of this app's real editors vend their own through the
/// text-view delegate (`CardBodySurface`, `CardRawSourceView` — each with an `UndoManager` created
/// per editor, emptied when the session ends). Those never reach this file.
///
/// ### What is here is the case AppKit gets wrong
///
/// A **field editor** — the shared `NSTextView` that appears inside every focused `NSTextField` and
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
/// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text
/// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the
/// window-level answer is the board's stack **only when no text-editing surface holds the
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
/// have created for such a window on its own, so nothing about typing in a field changes.
///
/// Pure and free of windows on purpose: the decision is three lines that are invisible until they
/// are wrong, and the responder it reads is the only part a test cannot conjure.
public enum BoardUndoRouting {
/// Whether `responder` is a text-editing surface — every `NSTextView`, field editors included,
/// which `NSText` is precisely the ancestor of. An `NSTextField` that has focus without editing
/// is *not* one: AppKit installs the field editor the moment editing begins, and until then the
/// board is what the keyboard is acting on.
public static func isTextEditing(_ responder: NSResponder?) -> Bool {
responder is NSText
}
/// The manager a window's delegate should answer with.
///
/// `board` is `nil` for every window that is not showing a board — welcome, the restore
/// bootstrap, the template chooser, and a board or card window whose session has already been
/// torn down. Those get the text manager too, which is the platform default they had before this
/// seam existed.
public static func undoManager(
isTextEditing: Bool,
board: UndoManager?,
textFallback: UndoManager
) -> UndoManager {
guard !isTextEditing, let board else { return textFallback }
return board
}
}