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 /// /// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of /// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards, /// under locks, and on empty stacks — the provider follows the board, so gitless boards bind /// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog; /// 06-history-undo.md ▸ Rules). 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. Modelling it as an absent substrate rather than as a substrate that always says no /// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it. /// /// ### 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 } }