Files
lanework/Kanban/App/UndoCommands.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

295 lines
14 KiB
Swift

import AppKit
import Observation
import SwiftUI
// MARK: - The focused stack
/// **The stack the frontmost window's ⌘Z crosses**, published into the focus system by whichever
/// host owns it: `BoardWindowHost` publishes its session's manager, `CardWindowHost` the window's own
/// (`CardWindowUndo.manager`).
///
/// One key serves both levels, and that is the two-level model's own shape rather than a shortcut
/// (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31): a card window's stack is the *same*
/// face type over the same seam, so a second key would only ask the rows to decide which of two
/// answers is in front — which is exactly the question the focus system already answers. Crossing
/// between the two levels is therefore impossible by construction here, which is 13 ▸ Undo routing's
/// "no fall-through" holding as a fact about the value rather than as a rule someone applies.
///
/// `FocusedBoardStoreKey` is the precedent and its note carries over verbatim: `focusedSceneValue`
/// rather than `focusedValue`, because the value is the *window's* and not any particular control's,
/// so it stays available whatever inside the window holds the keyboard — which is precisely what a
/// row that then routes on the first responder needs to read.
struct FocusedUndoStackKey: FocusedValueKey {
typealias Value = BoardUndoManager
}
extension FocusedValues {
var undoStack: BoardUndoManager? {
get { self[FocusedUndoStackKey.self] }
set { self[FocusedUndoStackKey.self] = newValue }
}
}
// MARK: - Routing
/// **What an Undo/Redo row answers with**, given the focused stack and what holds the keyboard —
/// 13-native-undo.md ▸ Undo routing's predicate, relocated from the window delegate to the command
/// layer (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08; the rule itself is
/// unchanged, only where it is enacted).
///
/// ### Why the rule had to move
///
/// The predicate used to be the platform's to apply: the system's nil-target `undo:`/`redo:` rows
/// resolve to `NSWindow`, which reads the manager its delegate hands back
/// (`windowWillReturnUndoManager`, still wired in `HostedWindowController` and still the right answer
/// wherever AppKit itself asks). On a SwiftUI window that hook is never consulted — `NSWindow` reads
/// and **permanently latches** an undo manager of its own during window creation, before any app code
/// can install a delegate (diagnosed 2026-08-07 by live probe) — so the whole command surface became
/// the app's own, and the routing came with it.
///
/// ### Pure, and free of both windows and SwiftUI
///
/// Everything a row needs is two references it is handed. The first responder is the one part a test
/// cannot conjure from the focus system, so it is a parameter rather than a read of `NSApp` — the
/// same posture `BoardUndoRouting` takes one layer down, and the reason that enum's decision is
/// reusable here verbatim rather than restated.
@MainActor
enum UndoCommandRouting {
/// The manager a row answers with — the focused stack, or, while a text surface holds the
/// keyboard, that responder's own manager.
///
/// The text case is where its typing undo actually registered: an editor that vends a manager
/// through its delegate (`CardBodySurface`, `CardRawSourceView`) hands back that one, and a field
/// editor — which vends none — resolves up the responder chain to the window's latched manager,
/// which is exactly where AppKit put its typing undo. Either way the answer is the responder's,
/// which is what "a reflexive undo over a typo must never become a board-level restore"
/// (13 ▸ Undo routing) means once the app is the one choosing.
///
/// `nil` where there is nothing to cross at all: no stack in front and no text surface focused, or
/// a text surface whose own manager is `nil` (a view in no window, with no delegate to ask). A row
/// over `nil` disables and keeps the bare verb.
static func routedManager(stack: BoardUndoManager?, firstResponder: NSResponder?) -> UndoManager? {
guard BoardUndoRouting.isTextEditing(firstResponder) else { return stack }
guard let text = firstResponder?.undoManager else { return nil }
return BoardUndoRouting.undoManager(isTextEditing: true, board: stack, textFallback: text)
}
// MARK: Titles
/// "Undo Move 3 Cards" — composed and localized by the platform over the bare 13-vocabulary
/// phrase, so the app never spells the verb (`BoardUndoManager.undoMenuItemTitle`).
///
/// The trim is for the nameless case on a manager that is *not* this app's adapter: a plain
/// `NSUndoManager` — a text view's, a field editor's — composes `Undo %@` over an empty name and
/// leaves the trailing space standing. The bare verb is also the answer for no manager at all,
/// which is a row with nothing in front of it rather than a row that lost its name.
static func undoTitle(of manager: UndoManager?) -> String {
title(manager?.undoMenuItemTitle, bare: "Undo")
}
static func redoTitle(of manager: UndoManager?) -> String {
title(manager?.redoMenuItemTitle, bare: "Redo")
}
private static func title(_ composed: String?, bare: String) -> String {
let trimmed = composed?.trimmingCharacters(in: .whitespaces) ?? ""
return trimmed.isEmpty ? bare : trimmed
}
// MARK: Enablement
/// Whether the row is live — the routed manager's own answer, which for this app's adapter is
/// "steps, and no read-only lock" (`BoardUndoManager.canUndo`, where 13 ▸ Rules' lock clause
/// lives) and for a text manager is the editor's session. `false` with no manager, which is the
/// same disablement an empty stack gets: exhausting what the row reaches never reaches anything
/// else (13 ▸ Undo routing, no fall-through).
static func canUndo(_ manager: UndoManager?) -> Bool {
manager?.canUndo == true
}
static func canRedo(_ manager: UndoManager?) -> Bool {
manager?.canRedo == true
}
}
// MARK: - The revalidation ticker
/// **What makes a rendered Undo/Redo row notice the world moved.**
///
/// The rows read three things that are not SwiftUI state: the routed manager's enablement, its
/// composed title, and — through the first responder — which of the two it is routing to at all.
/// `BoardUndoManager` re-derives from an `@Observable` provider, so a board or card step lands on the
/// row for free; a *text* manager is a plain `NSUndoManager` with no observation in it, and neither
/// is `NSApp.keyWindow?.firstResponder`. This is the subscription those two get.
///
/// A bumped counter rather than published state: nothing here knows what any row's answer is, only
/// that it may have changed, and the rows are cheap to re-derive. Reading `revision` inside a row's
/// `body` is what enrolls it.
///
/// ### The notifications, and why each one
///
/// `NSUndoManagerCheckpoint` with `object: nil` is the load-bearing one: it catches **every**
/// manager's stack changes, which is how typing into a field editor retitles the row that is routing
/// to it. The did-undo/did-redo pair covers a crossing that changes which direction is live, the
/// `NSText` editing pair covers the field editor arriving and leaving, and `NSMenu`'s did-begin-
/// tracking is a re-derive a moment before the Edit menu draws itself.
///
/// ### The known residual, which is cosmetic
///
/// Focus moving in or out of a text surface *without any typing* — clicking into an empty search
/// field, tabbing away from an untouched one — bumps nothing, because AppKit posts no editing
/// notification until an edit begins. A row rendered across that moment can therefore be titled or
/// enabled off the stack it is no longer routing to. Behaviour stays correct regardless: the action
/// re-derives the routed manager at fire time and beeps rather than crossing the wrong stack, which
/// is 13 ▸ Undo routing's own answer for an exhausted focus.
@MainActor
@Observable
final class UndoCommandTicker {
/// One instance, read by both rows: the rows differ in direction, never in when they are stale.
static let shared = UndoCommandTicker()
/// Bumped, never read for its value — a row reads it to subscribe, and the number itself means
/// nothing.
private(set) var revision = 0
private var observers: [any NSObjectProtocol] = []
private init() {
let names: [Notification.Name] = [
.NSUndoManagerCheckpoint,
.NSUndoManagerDidUndoChange,
.NSUndoManagerDidRedoChange,
NSText.didBeginEditingNotification,
NSText.didEndEditingNotification,
NSMenu.didBeginTrackingNotification,
]
observers = names.map { name in
NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in
// All six are posted on the main thread, and the synchronous path is the one that
// matters: a menu that has begun tracking is about to draw, so a hop onto the next
// turn would re-derive the row after the user is already looking at it. The
// asynchronous branch is the promise-keeping half, never the expected one.
if Thread.isMainThread {
MainActor.assumeIsolated { self?.bump() }
} else {
Task { @MainActor in self?.bump() }
}
}
}
}
private func bump() {
revision &+= 1
}
}
// MARK: - The rows
/// **Edit ▸ Undo and Edit ▸ Redo, as the app's own rows** (13-native-undo.md ▸ Rules ▸ the
/// command-surface bullet, ruled 2026-08-08).
///
/// `CommandGroup(replacing: .undoRedo)` takes the system's nil-target pair out of the menu, because
/// on a SwiftUI window they are unreachable: `NSWindow` latches an empty undo manager of its own
/// during window creation, before `HostedWindowController` can install the delegate that would have
/// vended the board's, so `windowWillReturnUndoManager` is never consulted and the rows validate
/// against a stack nothing ever registers into. Everything the pair used to get from the platform —
/// enablement, the dynamic title, the crossing — the rows below now ask for by name, off the same
/// `BoardUndoManager` that surface always meant to be reading.
///
/// The alternatives are recorded in 13 and both re-open decisions this app already made: registering
/// steps into the latched manager needs an `NSUndoManager` substrate, which `HistoryStepOutcome
/// .failed` rules out (`NativeHistoryProvider` ▸ two arrays), and intercepting nil-target `undo:`
/// from the responder chain is preempted by `NSWindow` handling the action itself, ahead of its own
/// delegate.
struct UndoRedoCommands: Commands {
var body: some Commands {
CommandGroup(replacing: .undoRedo) {
UndoMenuRow()
RedoMenuRow()
}
}
}
/// Edit ▸ Undo (⌘Z).
///
/// **The whole row routes, not just its action** (13 ▸ Rules ▸ the command-surface bullet, re-ruled
/// 2026-08-08). A row left titled and enabled off the board stack while a text field held the
/// keyboard would fail in one of two ways, and both are worse than a re-derived title: enabled, it
/// advertises a board step it will not cross once the action routes elsewhere; disabled — because
/// the board stack happened to be empty — it *swallows* ⌘Z outright, since a menu item owns its key
/// equivalent whether or not it is live and nothing downstream ever sees the chord. Routing title,
/// enablement and action together is what keeps 13 ▸ Undo routing's no-fall-through honest from the
/// user's side of the menu.
private struct UndoMenuRow: View {
@FocusedValue(\.undoStack) private var stack
var body: some View {
// Read for the subscription, never for the value — see `UndoCommandTicker`, which is what
// makes a text manager's stack changes and a focus move reach a row that has already
// rendered. It has to be read *in* `body`, which is the only scope SwiftUI tracks.
let _ = UndoCommandTicker.shared.revision
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.undoTitle(of: manager)) {
cross()
}
.keyboardShortcut("z", modifiers: .command)
.disabled(!UndoCommandRouting.canUndo(manager))
}
/// **Routed again here**, rather than closing over what the row rendered with: focus and stacks
/// both move between a menu's display and its click, and the manager the crossing reaches must be
/// the one that holds the keyboard *now*. The beep is 13 ▸ Undo routing's "exhausting a focused
/// editor's stack beeps; it never reaches board history", answering the window where the row's
/// rendered enablement was a moment stale.
private func cross() {
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
guard let manager, manager.canUndo else {
NSSound.beep()
return
}
manager.undo()
}
}
/// Edit ▸ Redo (⇧⌘Z) — `UndoMenuRow`'s twin in every respect, which is why its reasoning is not
/// repeated here.
private struct RedoMenuRow: View {
@FocusedValue(\.undoStack) private var stack
var body: some View {
let _ = UndoCommandTicker.shared.revision
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
Button(UndoCommandRouting.redoTitle(of: manager)) {
cross()
}
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!UndoCommandRouting.canRedo(manager))
}
private func cross() {
let manager = UndoCommandRouting.routedManager(
stack: stack,
firstResponder: NSApp.keyWindow?.firstResponder
)
guard let manager, manager.canRedo else {
NSSound.beep()
return
}
manager.redo()
}
}