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
+126 -15
View File
@@ -50,22 +50,46 @@ private func makeAppearance() -> AppearanceStore {
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
}
/// The board catalog, with the three collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
/// The board catalog, with the four collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, a drag session with nothing in flight, and no undo stack
/// which is the honest default for a suite whose subject is the *catalog*, and the Undo/Redo pair's
/// own tests pass one (`undoPairCrossesTheSessionsStack`).
@MainActor
private func boardSpecs(
store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil,
appearance: AppearanceStore? = nil,
session: DragSession = DragSession()
session: DragSession = DragSession(),
undo: BoardUndoManager? = nil
) -> [ToolbarItemSpec] {
BoardToolbar.specs(
store: store,
search: search,
zoom: zoom ?? makeZoom(),
appearance: appearance ?? makeAppearance(),
session: session
session: session,
undo: undo
)
}
/// A board stack with a real substrate behind it the shape `BoardWindowHost` hands the toolbar
/// (`AppModel`'s session composes exactly this pair).
@MainActor
private func makeUndo(isReadOnly: @escaping @MainActor () -> Bool = { false }) -> (BoardUndoManager, NativeHistoryProvider) {
let provider = NativeHistoryProvider()
return (BoardUndoManager(history: provider, isReadOnly: isReadOnly), provider)
}
/// One step that records its crossings `HistoryProviderTests`' synthetic fixture, in the one shape
/// this suite needs: what the toolbar pair must prove is that firing it *reaches* the stack, not what
/// the stack then does to disk.
@MainActor
private func countingStep(_ name: String, crossings: @escaping @MainActor () -> Void) -> HistoryStep {
HistoryStep(
name: name,
undo: { _ in crossings(); return .applied },
redo: { _ in crossings(); return .applied }
)
}
@@ -240,17 +264,104 @@ struct BoardToolbarTests {
#expect(specs.map(\.label) == [
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card'), which a toolbar
// label doesn't track". They are also the two items with no action of their own: nil target,
// responder-chain selectors, "matching their menu items" by using the same lookup.
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels the menu
// titles are rewritten dynamically ('Undo Move Card'), which a toolbar label doesn't
// track". They are ordinary buttons since the command surface became the app's own
// (13-native-undo.md Rules the command-surface bullet, re-ruled 2026-08-08) the label
// exception survived the mechanism that motivated it, because it was never about *how* the
// item fires.
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
guard case let .responderAction(selector) = spec.behavior else {
Issue.record("\(identifier.rawValue) must reach the responder chain like its menu row")
guard case .button = spec.behavior else {
Issue.record("\(identifier.rawValue) must carry an explicit target over the session's stack")
return
}
#expect(selector == NSSelectorFromString(spec.label.lowercased() + ":"))
#expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle")
}
}
/// **The pair's predicate is the menu rows' own object** (13-native-undo.md Rules the
/// command-surface bullet, re-ruled 2026-08-08): both surfaces read one `BoardUndoManager`, so
/// enablement here is `canUndo`/`canRedo` and nothing of the toolbar's own.
///
/// This replaces the nil-target pin the pair carried until that re-ruling the responder-chain
/// route it named is unreachable on a SwiftUI window, and what took its place is a predicate this
/// suite can read directly rather than one only `NSWindow` could answer.
@Test("Undo and Redo mirror the session's stack, and firing them crosses it")
func undoPairCrossesTheSessionsStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (manager, provider) = makeUndo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(!undo.isEnabled, "an empty stack dims it")
#expect(!redo.isEnabled)
var crossings: [String] = []
provider.register(countingStep("Move 3 Cards") { crossings.append("undo") })
#expect(undo.isEnabled, "a step on the stack lights it")
#expect(!redo.isEnabled, "and nothing has been crossed yet")
undo.activate()
#expect(crossings == ["undo"], "firing the item crosses the board's own stack")
#expect(!undo.isEnabled, "the stack is empty again")
#expect(redo.isEnabled, "and the crossed step is on the other one")
redo.activate()
#expect(crossings == ["undo", "undo"], "the synthetic step records both halves the same way")
#expect(undo.isEnabled)
}
/// The read-only lock disables the pair with every other mutating command, and it does it in the
/// one place it is decided `BoardUndoManager.isReadOnly` (13-native-undo.md Rules locks;
/// 02-architecture.md § "The lock's scope"). The stack is untouched, which is why the items come
/// back when it clears.
@Test("The read-only lock dims the pair, steps and all")
func theLockDimsThePair() throws {
final class Lock { var isOn = false }
let lock = Lock()
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (manager, provider) = makeUndo(isReadOnly: { lock.isOn })
provider.register(countingStep("Move Card") {})
provider.register(countingStep("Rename Lane") {})
provider.undo()
let specs = boardSpecs(store: store, undo: manager)
let undo = try #require(specs.spec(.boardUndo))
let redo = try #require(specs.spec(.boardRedo))
#expect(undo.isEnabled)
#expect(redo.isEnabled)
lock.isOn = true
#expect(!undo.isEnabled, "disabled with every other mutating command")
#expect(!redo.isEnabled)
#expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives")
lock.isOn = false
#expect(undo.isEnabled, "and resumes when the lock clears")
#expect(redo.isEnabled)
}
/// A window whose session has gone hands the catalog `nil`, and the pair reads that as an empty
/// stack rather than as an error the same quiet the adapter gives a board with no provider
/// (`BoardUndoManager.history`).
@Test("With no stack in front the pair is dim, and firing it does nothing")
func theUndoPairAbstainsWithNoStack() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let specs = boardSpecs(store: store, undo: nil)
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
let spec = try #require(specs.spec(identifier))
#expect(!spec.isEnabled)
spec.activate()
}
}
@@ -292,7 +403,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
// 03's three customization sentences: "right-click Customize Toolbar, drag to rearrange,
// system overflow and icon/text display options".
@@ -321,7 +432,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
#expect(search.focusField == nil, "nothing to focus until the item exists")
@@ -356,7 +467,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
let item = try #require(controller.toolbar(
controller.toolbar,
@@ -390,7 +501,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession(), undo: nil)
_ = controller.toolbar(
controller.toolbar,