Files
lanework/Kanban/History/BoardUndoManager.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

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

203 lines
12 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`: a gitless board's stack is one, a Pro git
/// board'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. A git board 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
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
///
/// **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 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.
///
/// ### `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 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, for exactly one event
///
/// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip:
/// "clicking it flips the open board into git mode immediately". 13's header says what that means
/// here — the flip **swaps the substrate**: the mode-none board's native stack is discarded and
/// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The
/// composition root writes the new provider into this property rather than rebuilding this
/// object, so AppKit keeps the identical manager it has already been handed by
/// `windowWillReturnUndoManager` and simply revalidates over a different stack.
///
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
/// composed" is about a subscription lapsing, which cannot change a running session's tier at
/// all — `BoardSession.tier` is a `let` with no setter. Mode can change, by explicit command,
/// and only in this one direction.)
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 **tier-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
}
}