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

220 lines
14 KiB
Swift

import AppKit
// MARK: - BoardUndoManager
/// The face a board's undo wears everywhere it is asked about: an `UndoManager` that owns no stack
/// and answers every question from the session's `HistoryProviding`.
///
/// ### Why an adapter exists at all
///
/// The command surface speaks one currency. Enablement, the composed menu title, and the crossing
/// itself are all `UndoManager` questions — `canUndo`, `undoMenuItemTitle`, `undo()` — and every
/// surface that offers ⌘Z asks them of *something*: the app's own Edit ▸ Undo/Redo rows read this
/// object through `FocusedValues.undoStack` (`UndoCommands.swift`), the board toolbar's pair carries
/// it as an explicit target (`BoardToolbar`), and AppKit itself still asks the window's delegate for
/// it (`HostedWindowController.windowWillReturnUndoManager`, wired and correct wherever the platform
/// is the one asking). One object answers all of them, which is what keeps them from disagreeing.
///
/// (Those first two used to be the same fact as the third: the rows and the toolbar pair were the
/// system's nil-target `undo:`/`redo:`, validated by `NSWindow` against whatever its delegate vended.
/// That route is unreachable on a SwiftUI window — the window latches an empty manager of its own
/// during creation, before any delegate of ours installs — so the surface became the app's own,
/// reading this object directly: 13-native-undo.md ▸ Rules ▸ the command-surface bullet, re-ruled
/// 2026-08-08. What it reads did not change, only how it gets here.)
///
/// The seam, though, must not be an `NSUndoManager`: a protocol that vended one could only ever have
/// had a single implementation, and the substrate stays behind `HistoryProviding` so a future
/// provider can bind without touching this class (`strategy/01-git-excision.md` ▸ Reversibility). So
/// *this* object is the translation — one per board session, over whichever provider that session
/// was composed with. (Once two providers shared the seam this way — a gitless board's native stack,
/// a Pro git board's git history, 12-editions.md ▸ The provider seam — with whichever bound
/// inheriting the whole command surface, enablement, dynamic titles, ⌘Z, the toolbar pair, by
/// binding and changing nothing here. App-managed git is gone now, 2026-08-08, and native is the
/// only one left.)
///
/// **And one per open card window**, over that window's own stack (13 ▸ Rules ▸ two levels, re-ruled
/// 2026-07-31 — `CardWindowUndo.manager`): the second level needs precisely the same translation, so
/// it gets the same object rather than a second one shaped like it. The name is now half-right and
/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not.
///
/// ### 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).
///
/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its
/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that
/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded
/// clear would silently make every close a no-net-change close.
///
/// ### 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 own rows over the focused stack, the board toolbar's
/// pair over the session's, a card window's over its own — 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 whatever provider a session
/// binds — a future one included — 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.
///
/// ### `nil` is a board with **no undo provider**, and it is a real state
///
/// **No board the app composes is one any more** (re-ruled 2026-07-31, twice): "the pair disabled
/// only under locks and on empty stacks — the provider follows the board, so boards without
/// app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every**
/// tier" (03-board-ui.md ▸ Toolbar ▸ Catalog). The repo-nested board was the last holder of this
/// state and no longer is: that rule was about *git*, and this stack never touches git. (The
/// distinction the quote draws collapsed entirely with the 2026-08-08 excision,
/// `strategy/01-git-excision.md`: every board is "without app-managed git" now, not just
/// repo-nested ones.)
///
/// The state stays modelled because the seam still admits it — a test binds a substrate-less
/// session through `AppModel.makeHistoryProvider` — and because an absent substrate is the honest
/// shape for one: every question below answers the empty way, so the Edit menu's rows, the
/// toolbar pair, and ⌘Z itself go quiet together through the same validation path a lock uses,
/// and nothing can accidentally accumulate in a stack that is not there.
///
/// ### Settable, once for exactly one event — now for none
///
/// **Add-git** (06-history-undo.md ▸ Rules ▸ Detection, retired) was the design's one sanctioned
/// mid-session mode flip: "clicking it flips the open board into git mode immediately", swapping
/// the substrate — the mode-none board's native stack discarded for the git trail seeded from the
/// root commit. The composition root wrote the new provider into this property rather than
/// rebuilding this object, so AppKit kept the identical manager it had already been handed by
/// `windowWillReturnUndoManager` and simply revalidated over a different stack.
///
/// App-managed git left the app entirely on 2026-08-08 (`strategy/01-git-excision.md`), and
/// add-git went with it: nothing left in the app ever assigns this property after composition. It
/// stays a `var` rather than a `let` for the same reason `HistoryProviding` stays a protocol — a
/// future substrate swap, mid-session or not, re-binds without re-plumbing this class.
var 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. False with no substrate at all, for the reason
/// `history` records.
public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true }
public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true }
// 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 a fact about *focus* and never about the substrate: "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 ..., the popover's rename field, and the settings sheet's text fields
/// (commit identity, credentials, remote URL) own ⌘Z/⇧⌘Z as field-local text undo while focused — a
/// reflexive undo over a typo must never become a tree checkout." (That list is 06's own since the
/// 2026-07-31 popover/sheet split; the rule is per *responder class*, so it covers each of them
/// wherever it is hosted, and the sheet's arrival adds nothing here.) 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
}
}