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:
@@ -34,23 +34,37 @@ extension NSToolbarItem.Identifier {
|
||||
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
|
||||
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
|
||||
///
|
||||
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
|
||||
/// ### Undo and Redo carry the session's own manager, exactly as the menu's rows do
|
||||
///
|
||||
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
|
||||
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
|
||||
/// carry the same actions with the same nil target, so "matching their menu items" (03) is not a
|
||||
/// predicate written here — it is literally the same validation. Both reach the board window, whose
|
||||
/// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore
|
||||
/// enable exactly when that board has a step to cross and no read-only lock stands
|
||||
/// (13-native-undo.md ▸ Rules). **Every board has undo**, so there is no substrate-shaped
|
||||
/// disablement to write: 03's parenthetical about boards without undo was 06's *git* substrate,
|
||||
/// which a board once bound by being in git mode — on any tier since 12-editions.md ▸ PIVOT
|
||||
/// 2026-08-07, and under a Pro subscription before it, until app-managed git left the app entirely
|
||||
/// (`strategy/01-git-excision.md`, 2026-08-08) and there stopped being a substrate to bind.
|
||||
/// **The command surface is the app's** (13-native-undo.md ▸ Rules ▸ the command-surface bullet,
|
||||
/// re-ruled 2026-08-08): Edit ▸ Undo/Redo are the app's own rows reading the focused session's
|
||||
/// `BoardUndoManager` through the focus system (`UndoCommands.swift`), and the two items below carry
|
||||
/// an explicit target over **that same manager** — the session's, handed in by the window's host
|
||||
/// (`BoardWindowHost.configureWindow`). "Matching their menu items" (03) is therefore one *object*
|
||||
/// rather than one lookup: enablement is `canUndo`/`canRedo` on both surfaces, so a step landing, an
|
||||
/// empty stack, and the read-only lock reach the pair and the rows together and cannot disagree
|
||||
/// (`BoardUndoManager` answers all three, and is the only place any of them is decided).
|
||||
///
|
||||
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
||||
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
||||
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
|
||||
/// The pair was nil-target `undo:`/`redo:` until that re-ruling, resolving up the responder chain to
|
||||
/// the board window's `windowWillReturnUndoManager` — a route a SwiftUI window makes unreachable by
|
||||
/// latching an empty undo manager of its own before any delegate of ours installs (diagnosed
|
||||
/// 2026-08-07). Nothing about the *design* changed: the same manager, the same predicates, reached
|
||||
/// by being handed it instead of by looking it up.
|
||||
///
|
||||
/// **Every board has undo**, so there is no substrate-shaped disablement to write: 03's
|
||||
/// parenthetical about boards without undo was the *git* substrate, which a board once bound by being
|
||||
/// in git mode — on any tier since 12-editions.md ▸ PIVOT 2026-08-07, and under a Pro subscription
|
||||
/// before it, until app-managed git left the app entirely (`strategy/01-git-excision.md`,
|
||||
/// 2026-08-08) and there stopped being a substrate to bind.
|
||||
///
|
||||
/// **Toolbar clicks are never text-routed.** The menu rows route ⌘Z to the first responder's own
|
||||
/// manager while a text surface holds the keyboard (13 ▸ Undo routing); clicking a toolbar button is
|
||||
/// not a keystroke aimed at an editor, so these two act on the board stack unconditionally — which is
|
||||
/// also the only stack the item can see.
|
||||
///
|
||||
/// Their labels are the design's one exception to the menu-title rule: the *menu* titles are rewritten
|
||||
/// as the stack changes ("Undo Move Card"), which a toolbar label does not track, so these two are
|
||||
/// built from static labels (`ToolbarItemSpec.staticLabel`).
|
||||
@MainActor
|
||||
enum BoardToolbar {
|
||||
|
||||
@@ -78,12 +92,17 @@ enum BoardToolbar {
|
||||
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
|
||||
/// - session: the app's drag session, for the same guard the menu rows carry
|
||||
/// (`ZoomCommands.isEnabled`).
|
||||
/// - undo: **the board session's stack**, for the Undo/Redo pair — the same `BoardUndoManager`
|
||||
/// the Edit menu's rows read through the focus system (13-native-undo.md ▸ Rules ▸ the
|
||||
/// command-surface bullet, re-ruled 2026-08-08). `nil` for a window whose session has gone,
|
||||
/// which the pair reads as an empty stack: disabled, and crossing nothing.
|
||||
static func specs(
|
||||
store: BoardStore,
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
appearance: AppearanceStore,
|
||||
session: DragSession
|
||||
session: DragSession,
|
||||
undo: BoardUndoManager?
|
||||
) -> [ToolbarItemSpec] {
|
||||
[
|
||||
.mirroring(
|
||||
@@ -132,17 +151,28 @@ enum BoardToolbar {
|
||||
perform: { [weak zoom] in zoom?.step(.out) }
|
||||
)
|
||||
),
|
||||
// The one pair whose predicate is not a menu row's *expression* but the menu row's own
|
||||
// object: `canUndo`/`canRedo` on the session's manager, which is where the empty stack
|
||||
// and the read-only lock are both already decided (`BoardUndoManager`). Weak like every
|
||||
// other capture here — production hands these app-lived objects, and a spec must not be
|
||||
// what keeps a torn-down session's stack alive.
|
||||
.staticLabel(
|
||||
"Undo",
|
||||
identifier: .boardUndo,
|
||||
symbol: "arrow.uturn.backward",
|
||||
behavior: .responderAction(NSSelectorFromString("undo:"))
|
||||
behavior: .button(
|
||||
isEnabled: { [weak undo] in undo?.canUndo == true },
|
||||
perform: { [weak undo] in undo?.undo() }
|
||||
)
|
||||
),
|
||||
.staticLabel(
|
||||
"Redo",
|
||||
identifier: .boardRedo,
|
||||
symbol: "arrow.uturn.forward",
|
||||
behavior: .responderAction(NSSelectorFromString("redo:"))
|
||||
behavior: .button(
|
||||
isEnabled: { [weak undo] in undo?.canRedo == true },
|
||||
perform: { [weak undo] in undo?.redo() }
|
||||
)
|
||||
),
|
||||
.mirroring(
|
||||
menuTitle: "Show Trash",
|
||||
@@ -219,11 +249,19 @@ enum BoardToolbar {
|
||||
search: BoardSearchPresentation,
|
||||
zoom: BoardZoomStore,
|
||||
appearance: AppearanceStore,
|
||||
session: DragSession
|
||||
session: DragSession,
|
||||
undo: BoardUndoManager?
|
||||
) -> WindowToolbarController {
|
||||
let controller = WindowToolbarController(
|
||||
identifier: identifier,
|
||||
specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session),
|
||||
specs: specs(
|
||||
store: store,
|
||||
search: search,
|
||||
zoom: zoom,
|
||||
appearance: appearance,
|
||||
session: session,
|
||||
undo: undo
|
||||
),
|
||||
defaults: defaultItems
|
||||
)
|
||||
// Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement
|
||||
|
||||
Reference in New Issue
Block a user