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:
+126
-15
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user