Give card windows their own undo stacks and coarsen the close

Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+20 -7
View File
@@ -703,7 +703,7 @@ struct UndoCommandSurfaceTests {
defer: true
)
let controller = HostedWindowController()
controller.boardUndoManager = { manager }
controller.windowUndoManager = { manager }
controller.attach(to: window)
return (window, controller)
}
@@ -792,8 +792,11 @@ struct UndoCommandSurfaceTests {
#expect(undoRow.title == "Undo")
}
@Test("Every window over one board answers with that board's one stack")
func cardWindowsShareTheBoardsStack() throws {
@Test("A board window answers with the board's stack; a card window answers with its own")
func eachWindowAnswersWithItsOwnStack() throws {
// Realigned 2026-07-31 with the two-level model (13-native-undo.md Rules): this pinned the
// shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced
// "a card window owns its own stack ... and `window.undoManager` answers with it".
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
@@ -801,10 +804,12 @@ struct UndoCommandSurfaceTests {
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
// The board window and one of its card windows, wired the way their hosts wire them.
// The board window and one of its card windows, wired the way their hosts wire them
// (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`).
let card = CardWindowUndo()
let (boardWindow, boardController) = hostedWindow(session.undoManager)
defer { boardController.detach() }
let (cardWindow, cardController) = hostedWindow(session.undoManager)
let (cardWindow, cardController) = hostedWindow(card.manager)
defer { cardController.detach() }
let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:")
@@ -812,9 +817,17 @@ struct UndoCommandSurfaceTests {
session.history?.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow))
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
#expect(boardRow.title == "Undo Move 3 Cards")
#expect(cardRow.title == boardRow.title)
// **No fall-through** (06-history-undo.md Undo routing): the card window's own stack is
// empty, so its row is disabled and Z beeps it never reaches the board's step.
#expect(cardWindow.validateMenuItem(cardRow) == false)
#expect(cardRow.title == "Undo")
card.stack.register(StepLog().step("Comment"))
#expect(cardWindow.validateMenuItem(cardRow))
#expect(cardRow.title == "Undo Comment")
#expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it")
}
// MARK: The toolbar twins