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
+13 -14
View File
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window that has nothing to flush.
var onCloseRequested: (() -> Void)?
/// This window's board undo stack, asked for afresh every time AppKit wants it the board
/// window's and its card windows' shared answer (13-native-undo.md Rules: "one stack per
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the
/// session's manager").
/// **The stack this window's Z crosses**, asked for afresh every time AppKit wants it
/// 13-native-undo.md Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
///
/// A closure rather than a stored manager for two reasons: the session does not exist yet when
/// the window attaches, and it stops existing at teardown while the window is still closing
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a
/// window that outlived it by a run-loop turn.
/// A closure rather than a stored manager for two reasons: a board window's session does not
/// exist yet when the window attaches, and it stops existing at teardown while the window is
/// still closing answering `nil` then is what keeps a torn-down board's stack from being
/// reachable through a window that outlived it by a run-loop turn.
///
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var boardUndoManager: (() -> UndoManager?)?
var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board 06-history-undo.md Undo routing, via
@@ -215,16 +214,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
}
/// The window-level half of 06-history-undo.md Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it.
///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a board: `responds(to:)` reports this method whatever the
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no board
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?()
let board = windowUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {