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
+53 -1
View File
@@ -189,6 +189,25 @@ public final class AppModel {
/// reads the pasteboard once and collects every staged tree it no longer names.
public let clipboard = ClipboardStore()
// MARK: The provider seam
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
/// board session's undo stack is built by, called once per board as its session begins.
///
/// Base binds the native stack an `NSUndoManager` over inverse `WriteOperation`s
/// (13-native-undo.md) and that is the default here because it is the *shared* code's
/// implementation: both targets compile it, and Pro runs it too until pro-m1 replaces this
/// closure with the git provider (06-history-undo.md). Nothing in this file is
/// edition-conditional; the edition difference is which closure the root installs.
///
/// It takes the store because that is what a provider is a history *of*: the git provider needs
/// the board root it is a repository at, and the native one is about to need the same store's
/// snapshots to compute inverses from. A property rather than an initializer argument so a test
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore) -> any HistoryProviding = { _ in NativeHistoryProvider() }
// MARK: Sessions
/// One open board window and everything hanging off it.
@@ -201,6 +220,22 @@ public final class AppModel {
/// open-now flag without matching by identity a second time.
public let recordID: UUID
/// This board's undo/redo substrate **one stack per board session, never per window**
/// (13-native-undo.md Rules). It lives here for the store's reason exactly: the session is
/// what every window over this board shares, and "undo is board-local".
///
/// Which implementation it is, is the edition's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
public let history: any HistoryProviding
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
///
/// Built once with the session rather than per window, because a second adapter would be a
/// second answer to "what is this board's undo" and card windows share this one.
let undoManager: BoardUndoManager
/// This board's open card windows. The close flush's step 1 reads it; the card hosts
/// maintain it. Empty is the common case.
public var cardRefs: Set<CardWindowRef> = []
@@ -507,7 +542,18 @@ public final class AppModel {
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access)
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store)
sessions[ref] = BoardSession(
store: store,
recordID: recordID,
history: history,
undoManager: BoardUndoManager(history: history),
cardRefs: [],
access: access
)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
@@ -705,6 +751,12 @@ public final class AppModel {
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
// Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
// Cleared rather than merely dropped because the steps hold closures over the store
// this line is about to release, and a stack that outlived its board would be a
// retain cycle wearing an undo stack's clothes.
session.history.clear()
storeRegistry.release(session.store)
session.access?.stop()
}