Build the HistoryProviding seam and the per-board native undo stack

The provider seam 12 promised: HistoryProviding speaks 13's vocabulary
— register a HistoryStep (bare 06 phrase plus undo/redo closures
returning applied or skipped), canUndo/canRedo, action names, clear —
and no UndoManager type appears anywhere in it, proven by a fake that
satisfies the seam with counters. The base provider wraps a private
UndoManager with groupsByEvent off so coalescing stays the Writer call
site's decision; undo re-registers the reversed step from inside the
undo, which makes a stale-skipped step vanish for free and the
crossing loop fall through to the next. BoardUndoManager adapts the
protocol to the responder chain — a stackless UndoManager subclass
answering from the provider — so Pro's git provider inherits menu
enablement, dynamic titles, and the nil-target toolbar pair by binding
the protocol. One stack per board session, born in beginSession,
cleared in the close flush; every window over the board answers it
through windowWillReturnUndoManager. Headless probes shaped the
routing: a real NSTextView's own manager wins natively, but a field
editor's does not — BoardUndoRouting answers the per-window text
manager while any NSText is first responder, so a search-field typo
never crosses a board step.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 13:47:27 -04:00
parent d61ce422a3
commit 93fad2ef1e
8 changed files with 1011 additions and 1 deletions
+43
View File
@@ -56,6 +56,26 @@ 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").
///
/// 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.
///
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var boardUndoManager: (() -> 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
/// `BoardUndoRouting`. Created on demand, per window, which is what AppKit itself would have
/// done for a window whose delegate answered nothing.
private lazy var textUndoManager = UndoManager()
/// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
/// instead of starting a second flush.
private var isFlushed = false
@@ -189,6 +209,29 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
return false
}
/// 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
/// 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
/// 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
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
return inherited
}
return BoardUndoRouting.undoManager(
isTextEditing: BoardUndoRouting.isTextEditing(window.firstResponder),
board: board,
textFallback: textUndoManager
)
}
func windowDidMove(_ notification: Notification) {
reportFrame()
previousDelegate?.windowDidMove?(notification)