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
This commit is contained in:
2026-08-08 18:55:34 -04:00
parent 78c32776d4
commit 1fd19dfb12
13 changed files with 926 additions and 201 deletions
+21 -34
View File
@@ -64,10 +64,6 @@ struct ToolbarItemSpec {
case button(isEnabled: () -> Bool, perform: () -> Void)
/// A toggle showing on-state Show Trash, Edit Body, Raw Source (03 Toolbar).
case toggle(isEnabled: () -> Bool, isOn: () -> Bool, setOn: (Bool) -> Void)
/// An action sent up the responder chain with no target of our own **Undo and Redo**, which
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather
/// than two (03 Toolbar; 06-history-undo.md).
case responderAction(Selector)
/// A search field in AppKit's own `NSSearchToolbarItem` the board's search
/// (03-board-ui.md Toolbar). The item owns the field's layout, so `focusedWidth` is a
/// preference rather than a constraint: it is the width the field takes *when it has the
@@ -88,8 +84,8 @@ struct ToolbarItemSpec {
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
/// opens the menu rather than polled the same freshness every other menu row in the app
/// gets (`validateMenuItem(_:)`) and `select(_:)` is a chosen row's whole action. The one
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows rather than
/// in the item's primary action.
case picker(
options: [(title: String, symbol: String?)],
selected: () -> Int?,
@@ -126,14 +122,14 @@ struct ToolbarItemSpec {
// MARK: State
/// The item's live enablement. Responder-chain items and the search item answer `true`: the
/// first is validated by the chain itself (which is the point of it), and the second has no
/// enablement of its own.
/// The item's live enablement. The search item and the picker answer `true`: neither has an
/// enablement of its own the field is a control rather than a command, and the picker's
/// choices are always available.
var isEnabled: Bool {
switch behavior {
case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .searchField, .picker: true
case .searchField, .picker: true
}
}
@@ -141,7 +137,7 @@ struct ToolbarItemSpec {
var isOn: Bool? {
switch behavior {
case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .searchField, .picker: nil
case .button, .searchField, .picker: nil
}
}
@@ -152,7 +148,7 @@ struct ToolbarItemSpec {
switch behavior {
case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .searchField, .picker: break
case .searchField, .picker: break
}
}
}
@@ -165,7 +161,9 @@ struct ToolbarItemSpec {
/// ### Why `NSToolbar` rather than SwiftUI's `.toolbar(id:)`
///
/// SwiftUI's customizable toolbar would answer most of 03's clauses, and it was the first choice.
/// Four requirements sent this to AppKit instead, and each is normative rather than aesthetic:
/// Four requirements sent this to AppKit instead, and each was normative rather than aesthetic. Three
/// of them still hold; the fourth is kept below because a retired reason is worth more written down
/// than deleted, and because the three that remain are what the answer now rests on:
///
/// - **F has to know whether the search item is installed.** "With the field removed from the
/// toolbar, invoking it surfaces the field transiently" (03) a decision that needs to read the
@@ -173,12 +171,16 @@ struct ToolbarItemSpec {
/// delegate callbacks); SwiftUI's toolbar API has no such query, and reaching around it into the
/// `NSToolbar` it happens to own means matching identifiers SwiftUI derives rather than ones this
/// app spells.
/// - **Undo and Redo have to reach the responder chain.** Their menu rows are the system's own
/// nil-target `undo:`/`redo:` and "matching their menu items" (03) is literal here: a toolbar
/// item with the same nil-target action validates and fires through exactly the same lookup, so
/// the pair enables and disables with the menu rows by construction rather than by agreement,
/// reading the board window's `BoardUndoManager` through `NSWindow`'s own validation
/// (13-native-undo.md). A SwiftUI `Button` cannot express that.
/// - ~~**Undo and Redo have to reach the responder chain.**~~ **Retired 2026-08-08** with the
/// mechanism it named (13-native-undo.md Rules the command-surface bullet, re-ruled that day):
/// the pair's nil-target `undo:`/`redo:` resolved to `NSWindow`, which reads the manager its
/// delegate vends and a SwiftUI window latches an empty manager of its own during creation,
/// before `HostedWindowController` installs, so that route reached a stack nothing registers into.
/// The pair now carries an **explicit target** over the focused session's `BoardUndoManager`
/// (`BoardToolbar`), which is the same object the app's own Edit Undo/Redo rows read through the
/// focus system "matching their menu items" (03) by sharing the manager rather than by sharing a
/// lookup. Nothing about that needs AppKit; the other three bullets are why this is still an
/// `NSToolbar`.
/// - **The search item is AppKit's own `NSSearchToolbarItem`**, hosting a real `NSSearchField` with
/// explicit first-responder control, settled in m5 for reasons `BoardSearchFieldController`
/// records (F must focus it from a menu item; Escape in an empty field must hand the keyboard
@@ -304,8 +306,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return makeButtonItem(spec)
case .toggle:
return makeToggleItem(spec)
case let .responderAction(selector):
return makeResponderItem(spec, selector: selector)
case let .searchField(focusedWidth, make, install):
return makeSearchItem(
spec,
@@ -333,19 +333,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item
}
/// **Undo and Redo**: no target, so AppKit resolves and validates the action up the responder
/// chain the same lookup their menu rows use, which is the whole of "matching their menu
/// items" (03 Toolbar). Deliberately *not* a `ValidatingToolbarItem`: the default validation
/// is precisely the behaviour wanted here.
private func makeResponderItem(_ spec: ToolbarItemSpec, selector: Selector) -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
item.isBordered = true
item.target = nil
item.action = selector
return item
}
/// A toggle button showing on-state. A hosted `NSButton` rather than a plain item because
/// `NSToolbarItem` has no state of its own, and 03 asks for one explicitly ("Show Trash (toggle
/// state matching the View menu checkmark)", "Edit Body is a single toggle button (on-state in