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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -264,6 +264,13 @@ struct BoardWindowHost: View {
|
||||
}
|
||||
}
|
||||
|
||||
// This window's answer to "what does ⌘Z act on" (13-native-undo.md ▸ Rules; 06 ▸ Undo
|
||||
// routing) — the *session's* stack, read afresh on every ask so a torn-down board answers
|
||||
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
|
||||
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
|
||||
// up: `NSWindow` validates and crosses them against exactly this manager.
|
||||
windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager }
|
||||
|
||||
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
|
||||
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
|
||||
// card windows share that machinery and have no board to describe. It goes in after the
|
||||
|
||||
@@ -523,6 +523,15 @@ struct CardWindowHost: View {
|
||||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||||
)
|
||||
|
||||
// **The board's stack, not one of this window's own** (13-native-undo.md ▸ Rules: "not
|
||||
// per-window: every window over a board (board window, its card windows) shares the store
|
||||
// and shares the stack"). Same closure shape as the board window's, and deliberately the
|
||||
// same object: ⌘Z with a card window in front crosses the board step the user last made,
|
||||
// wherever they made it. The card's *text* surfaces are untouched by this — the body editor
|
||||
// and the raw-source editor each vend their own manager to the responder chain, which is
|
||||
// what keeps typing undo out of the board's stack (06-history-undo.md ▸ Undo routing).
|
||||
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
|
||||
|
||||
windowController.onAttach = { window in
|
||||
if let recordID,
|
||||
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user