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)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - BoardUndoManager
|
||||
|
||||
/// What AppKit is handed for a board's windows: an `UndoManager` that owns no stack and answers
|
||||
/// every question from the session's `HistoryProviding`.
|
||||
///
|
||||
/// ### Why an adapter exists at all
|
||||
///
|
||||
/// The responder chain speaks one currency. `NSWindow` implements `undo:`/`redo:` and validates
|
||||
/// those menu items itself, reading `canUndo`, `canRedo` and `undoMenuItemTitle` off whatever
|
||||
/// `UndoManager` the window's delegate hands back (`windowWillReturnUndoManager(_:)`) — which is
|
||||
/// exactly how the system's Edit ▸ Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
|
||||
/// up, disable and retitle with no code of the app's own.
|
||||
///
|
||||
/// The seam, though, must not be an `NSUndoManager`: base's stack is one, Pro's is git
|
||||
/// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a
|
||||
/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the
|
||||
/// translation — one per board session, over whichever provider that session was composed with. Pro
|
||||
/// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding
|
||||
/// its provider and changing nothing here, which is what "a user moving between editions relearns
|
||||
/// nothing" (12) has to mean in code.
|
||||
///
|
||||
/// ### It deliberately keeps its inherited stack empty
|
||||
///
|
||||
/// Every question is overridden, so anything that *did* register into this manager by accident (a
|
||||
/// stray AppKit client finding it through the window) would be invisible rather than half-live: it
|
||||
/// could never enable Undo, retitle it, or be crossed by ⌘Z. Nothing registers here today; the
|
||||
/// overrides are what make that safe rather than lucky.
|
||||
///
|
||||
/// `removeAllActions()` is deliberately **not** forwarded to `HistoryProviding.clear()`: AppKit
|
||||
/// clears a window's undo manager on paths this app does not control, and a card window closing must
|
||||
/// not empty the board's stack (13-native-undo.md ▸ Rules — the stack belongs to the *session*, and
|
||||
/// its one clearing point is that session's teardown).
|
||||
public final class BoardUndoManager: UndoManager {
|
||||
|
||||
/// The substrate this manager is a face for. Strong: the session owns both, and the manager is
|
||||
/// only ever reachable while the session that made it is alive.
|
||||
private let history: any HistoryProviding
|
||||
|
||||
public init(history: any HistoryProviding) {
|
||||
self.history = history
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Enablement
|
||||
|
||||
public override var canUndo: Bool { history.canUndo }
|
||||
|
||||
public override var canRedo: Bool { history.canRedo }
|
||||
|
||||
// MARK: Crossing
|
||||
|
||||
public override func undo() { history.undo() }
|
||||
|
||||
public override func redo() { history.redo() }
|
||||
|
||||
// MARK: Titles
|
||||
|
||||
/// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is
|
||||
/// nothing to cross, which is what its menu-title composition expects.
|
||||
public override var undoActionName: String { history.undoActionName ?? "" }
|
||||
|
||||
public override var redoActionName: String { history.redoActionName ?? "" }
|
||||
|
||||
/// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)`
|
||||
/// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app
|
||||
/// never spells the word "Undo" in a title.
|
||||
///
|
||||
/// The trim is for the nameless case: the pattern is `Undo %@`, so an empty action name would
|
||||
/// otherwise leave a trailing space where `NSUndoManager` itself answers a bare "Undo".
|
||||
public override var undoMenuItemTitle: String {
|
||||
undoMenuTitle(forUndoActionName: undoActionName).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
public override var redoMenuItemTitle: String {
|
||||
redoMenuTitle(forUndoActionName: redoActionName).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BoardUndoRouting
|
||||
|
||||
/// Which undo a hosted window answers with, given what holds the keyboard — 06-history-undo.md
|
||||
/// ▸ Undo routing, which 12 records as **edition-independent**: "focus decides text-undo vs
|
||||
/// board-undo; only the substrate behind board-undo differs".
|
||||
///
|
||||
/// ### Most of the rule is AppKit's, and is not here
|
||||
///
|
||||
/// "While a text-editing surface is focused ... ⌘Z/⇧⌘Z are that editor's own text undo" is the
|
||||
/// platform's own behaviour for a text view that vends a manager: `NSWindow.undo:` crosses the
|
||||
/// *first responder's* `undoManager`, and both of this app's real editors vend their own through the
|
||||
/// text-view delegate (`CardBodySurface`, `CardRawSourceView` — each with an `UndoManager` created
|
||||
/// per editor, emptied when the session ends). Those never reach this file.
|
||||
///
|
||||
/// ### What is here is the case AppKit gets wrong
|
||||
///
|
||||
/// A **field editor** — the shared `NSTextView` that appears inside every focused `NSTextField` and
|
||||
/// `NSSearchField` — vends no manager of its own to the window, so `undo:` falls through to the
|
||||
/// window's, and a reflexive ⌘Z over a typo in the search field or the board-rename field would
|
||||
/// cross a *board* step. 06 rules that out by name: "Control-class text fields route the same way
|
||||
/// (settled): the search field ... and the popover's text fields ... own ⌘Z/⇧⌘Z as field-local text
|
||||
/// undo while focused — a reflexive undo over a typo must never become a tree checkout." So the
|
||||
/// window-level answer is the board's stack **only when no text-editing surface holds the
|
||||
/// keyboard**, and a per-window text manager otherwise — which is also exactly what AppKit would
|
||||
/// have created for such a window on its own, so nothing about typing in a field changes.
|
||||
///
|
||||
/// Pure and free of windows on purpose: the decision is three lines that are invisible until they
|
||||
/// are wrong, and the responder it reads is the only part a test cannot conjure.
|
||||
public enum BoardUndoRouting {
|
||||
|
||||
/// Whether `responder` is a text-editing surface — every `NSTextView`, field editors included,
|
||||
/// which `NSText` is precisely the ancestor of. An `NSTextField` that has focus without editing
|
||||
/// is *not* one: AppKit installs the field editor the moment editing begins, and until then the
|
||||
/// board is what the keyboard is acting on.
|
||||
public static func isTextEditing(_ responder: NSResponder?) -> Bool {
|
||||
responder is NSText
|
||||
}
|
||||
|
||||
/// The manager a window's delegate should answer with.
|
||||
///
|
||||
/// `board` is `nil` for every window that is not showing a board — welcome, the restore
|
||||
/// bootstrap, the template chooser, and a board or card window whose session has already been
|
||||
/// torn down. Those get the text manager too, which is the platform default they had before this
|
||||
/// seam existed.
|
||||
public static func undoManager(
|
||||
isTextEditing: Bool,
|
||||
board: UndoManager?,
|
||||
textFallback: UndoManager
|
||||
) -> UndoManager {
|
||||
guard !isTextEditing, let board else { return textFallback }
|
||||
return board
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - HistoryStepOutcome
|
||||
|
||||
/// What happened when a step was asked to walk its write back (or forward again).
|
||||
///
|
||||
/// The second case is 13-native-undo.md ▸ Rules ▸ **staleness validation**, in the vocabulary the
|
||||
/// provider needs it in: "target folder gone, or the field no longer holding the step's after-value
|
||||
/// → the step is **skipped, not applied**: popped from the stack ... and ⌘Z falls through to the
|
||||
/// next step". The provider reads this answer and nothing else — the *predicate* (field-level,
|
||||
/// settled) and the info-tone banner that explains a skip both belong to the step, which is the only
|
||||
/// side that knows what it wrote and which board to say it on.
|
||||
public enum HistoryStepOutcome: Equatable, Sendable {
|
||||
|
||||
/// The step ran. Its mirror image joins the opposite stack.
|
||||
case applied
|
||||
|
||||
/// The step declined: the board no longer holds the value this step's write left, so applying
|
||||
/// its inverse would clobber somebody else's newer edit. Nothing ran, the step is dropped, and
|
||||
/// the crossing continues with the next one down.
|
||||
case skipped
|
||||
}
|
||||
|
||||
// MARK: - HistoryStep
|
||||
|
||||
/// One undoable step: a name, and the pair of actions that walk the board backwards and forwards
|
||||
/// across it.
|
||||
///
|
||||
/// ### An operation pair, not an `NSUndoManager` registration
|
||||
///
|
||||
/// 13-native-undo.md ▸ Rules puts every undoable change at the Writer boundary — "each Writer call
|
||||
/// site registers the inverse operation, computed from the pre-write snapshot the store already
|
||||
/// holds: move → move back ...; rename → restore title" — and both halves of that write are already
|
||||
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
|
||||
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
|
||||
/// design's own terms, and deliberately says nothing about how a stack stores it: base's stack is
|
||||
/// `NSUndoManager`-backed and Pro's is git (12-editions.md ▸ The provider seam), and neither
|
||||
/// substrate appears here.
|
||||
///
|
||||
/// ### `name` is the 06 vocabulary, unprefixed
|
||||
///
|
||||
/// "The 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's dynamic
|
||||
/// retitling — the same naming machinery both editions use" (13). What the step carries is the bare
|
||||
/// phrase — `"Move Card"`, `"Move 3 Cards"`, `"Rename Lane"` — in the vocabulary of
|
||||
/// 06-history-undo.md ▸ Commit messages, plural-folded by the same rule ("one gesture, one undo
|
||||
/// step — a multi-card move is one step with a plural title"). The **"Undo "/"Redo " prefix is
|
||||
/// never part of it**: the platform composes and localizes that (`BoardUndoManager`), and a step
|
||||
/// that spelled it would read "Undo Undo Move Card" in the Edit menu.
|
||||
///
|
||||
/// ### Both closures are `@MainActor`
|
||||
///
|
||||
/// Everything they touch — the store, the snapshot, the banners — is, and a step exists to be run
|
||||
/// from a menu command. Marking them says so at the seam instead of leaving each provider to
|
||||
/// rediscover it.
|
||||
public struct HistoryStep {
|
||||
|
||||
/// The menu phrase, unprefixed — see the type's note.
|
||||
public let name: String
|
||||
|
||||
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
|
||||
/// the write that just landed.
|
||||
public let undo: @MainActor () -> HistoryStepOutcome
|
||||
|
||||
/// Walks it forward again — the original write, replayed. Reached only after `undo` applied,
|
||||
/// because that is the only way a step reaches the redo stack.
|
||||
public let redo: @MainActor () -> HistoryStepOutcome
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
undo: @escaping @MainActor () -> HistoryStepOutcome,
|
||||
redo: @escaping @MainActor () -> HistoryStepOutcome
|
||||
) {
|
||||
self.name = name
|
||||
self.undo = undo
|
||||
self.redo = redo
|
||||
}
|
||||
|
||||
/// The same step read backwards — what a provider puts on the opposite stack once this one has
|
||||
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
|
||||
/// Card": the phrase names the *gesture*, not the direction.
|
||||
public var reversed: HistoryStep {
|
||||
HistoryStep(name: name, undo: redo, redo: undo)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
|
||||
/// The undo/redo substrate, behind one protocol boundary (12-editions.md ▸ The provider seam).
|
||||
///
|
||||
/// ### One per board session
|
||||
///
|
||||
/// "One stack per board, owned by the board session. Not per-window: every window over a board
|
||||
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
|
||||
/// ▸ Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
|
||||
/// which implementation it gets: base binds `NativeHistoryProvider` (an `NSUndoManager` stack over
|
||||
/// inverse `WriteOperation`s), Pro binds the git provider in pro-m1 (undo as forward restore commits
|
||||
/// over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits Pro's.
|
||||
///
|
||||
/// ### What this protocol deliberately does not say
|
||||
///
|
||||
/// - **No `NSUndoManager`, anywhere in the signature.** It is base's implementation detail, and a
|
||||
/// seam that vended one would be a seam only one provider could ever satisfy — the opposite of
|
||||
/// the reason the split exists at all ("base's native undo is the first proof the seam is real",
|
||||
/// 12). AppKit still needs an `UndoManager` to hand the responder chain; that adapter is
|
||||
/// `BoardUndoManager`, which sits *over* this protocol rather than inside it.
|
||||
/// - **No persistence promise.** Base's stack dies with the session (13); Pro's survives relaunch
|
||||
/// because git does (06). Both are honest implementations of these seven members.
|
||||
/// - **No routing.** Which surface ⌘Z reaches is focus's answer, not the substrate's
|
||||
/// (06 ▸ Undo routing, edition-independent) — `BoardUndoRouting`.
|
||||
@MainActor
|
||||
public protocol HistoryProviding: AnyObject {
|
||||
|
||||
/// Records one undoable step, on top of the undo stack, clearing the redo stack — the classic
|
||||
/// rule, and the one every substrate shares.
|
||||
///
|
||||
/// Called once per *gesture*, never once per write: "coalescing follows commit granularity ...
|
||||
/// a multi-card move is one step with a plural title; an Edit session is one step, registered at
|
||||
/// the Edit→Preview flip" (13 ▸ Rules).
|
||||
func register(_ step: HistoryStep)
|
||||
|
||||
/// Whether there is a step to cross. What the Edit menu's Undo row and the toolbar's Undo item
|
||||
/// enable on, through the same responder-chain answer.
|
||||
var canUndo: Bool { get }
|
||||
|
||||
var canRedo: Bool { get }
|
||||
|
||||
/// The name of the step ⌘Z would cross, or `nil` when there is none — the phrase the menu title
|
||||
/// is composed from ("Move 3 Cards" → "Undo Move 3 Cards").
|
||||
var undoActionName: String? { get }
|
||||
|
||||
var redoActionName: String? { get }
|
||||
|
||||
/// Crosses one step backwards. A stale step is skipped rather than applied, and the crossing
|
||||
/// falls through to the next one (13 ▸ Rules ▸ staleness validation); an empty stack does
|
||||
/// nothing.
|
||||
func undo()
|
||||
|
||||
func redo()
|
||||
|
||||
/// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board
|
||||
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
|
||||
func clear()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - NativeHistoryProvider
|
||||
|
||||
/// The base edition's undo substrate: one `NSUndoManager`-backed stack per board session
|
||||
/// (13-native-undo.md).
|
||||
///
|
||||
/// ### Why `NSUndoManager` at all, when the steps are ours
|
||||
///
|
||||
/// Because the *command surface* is the platform's. Edit ▸ Undo and Edit ▸ Redo are the system's own
|
||||
/// nil-target `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
|
||||
/// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the
|
||||
/// focused window hands back — "Undo Move 3 Cards" is `NSUndoManager`'s dynamic retitling, which 13
|
||||
/// names as the mechanism and 12 records as the one both editions share. Reimplementing the stack
|
||||
/// over two arrays would mean reimplementing that, badly.
|
||||
///
|
||||
/// It stays *behind* `HistoryProviding` regardless: nothing outside this file learns that base's
|
||||
/// stack is an `NSUndoManager`, which is what lets pro-m1 bind git to the same seam
|
||||
/// (12 ▸ The provider seam).
|
||||
///
|
||||
/// ### One `register` call is exactly one step
|
||||
///
|
||||
/// `groupsByEvent` is turned **off** and every registration is wrapped in its own group. The default
|
||||
/// (on) closes a group at the end of the run-loop turn, which would silently fold two gestures that
|
||||
/// happened to land in one event into a single ⌘Z — the opposite of 13's "one gesture, one undo
|
||||
/// step", and the coalescing decision belongs to the Writer call site (a multi-card move registers
|
||||
/// *one* step with a plural title), never to the run loop's timing.
|
||||
///
|
||||
/// ### Undo flips to redo by re-registering
|
||||
///
|
||||
/// A step that applies pushes its own mirror image back onto the manager from inside the undo — and
|
||||
/// `NSUndoManager` routes a registration made while it is undoing onto the **redo** stack, and vice
|
||||
/// versa. That single rule gives the whole classic dance (undo → redo → undo …) with no second stack
|
||||
/// of our own, and it is why a *skipped* step leaves nothing behind: it registers nothing, so the
|
||||
/// empty group is discarded and the step is simply gone (13: "popped from the stack ... and ⌘Z falls
|
||||
/// through to the next step").
|
||||
@MainActor
|
||||
public final class NativeHistoryProvider: HistoryProviding {
|
||||
|
||||
/// The stack. `private` and never vended: see the type's note.
|
||||
private let manager = UndoManager()
|
||||
|
||||
/// Set by the step just crossed when it declined to apply, read by the crossing loop below.
|
||||
/// A flag rather than a return value because the manager, not this object, calls the step.
|
||||
private var lastCrossingSkipped = false
|
||||
|
||||
public init() {
|
||||
// See "One `register` call is exactly one step", above.
|
||||
manager.groupsByEvent = false
|
||||
}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
|
||||
public var canUndo: Bool { manager.canUndo }
|
||||
|
||||
public var canRedo: Bool { manager.canRedo }
|
||||
|
||||
/// `NSUndoManager` answers `""` — not `nil` — for a stack with nothing on it *and* for a step
|
||||
/// registered without a name, so the emptiness check is the honest one.
|
||||
public var undoActionName: String? {
|
||||
guard canUndo, !manager.undoActionName.isEmpty else { return nil }
|
||||
return manager.undoActionName
|
||||
}
|
||||
|
||||
public var redoActionName: String? {
|
||||
guard canRedo, !manager.redoActionName.isEmpty else { return nil }
|
||||
return manager.redoActionName
|
||||
}
|
||||
|
||||
public func register(_ step: HistoryStep) {
|
||||
push(step)
|
||||
}
|
||||
|
||||
public func undo() {
|
||||
cross(manager.undo, while: { self.manager.canUndo })
|
||||
}
|
||||
|
||||
public func redo() {
|
||||
cross(manager.redo, while: { self.manager.canRedo })
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
manager.removeAllActions()
|
||||
}
|
||||
|
||||
// MARK: - The stack
|
||||
|
||||
/// Registers `step` as one group of its own, named for the menu.
|
||||
///
|
||||
/// The target is `self` and the payload rides in the closure, which is `NSUndoManager`'s
|
||||
/// block-based form: the manager references its target **unowned** (its own documented rule), so
|
||||
/// the provider owning the manager that references the provider is not a cycle.
|
||||
private func push(_ step: HistoryStep) {
|
||||
manager.beginUndoGrouping()
|
||||
manager.registerUndo(withTarget: self) { provider in
|
||||
provider.apply(step)
|
||||
}
|
||||
// Inside the group, deliberately: the name belongs to the group being closed, and on the
|
||||
// way back it is what retitles Redo.
|
||||
manager.setActionName(step.name)
|
||||
manager.endUndoGrouping()
|
||||
}
|
||||
|
||||
/// Runs one step's action and records what happened.
|
||||
private func apply(_ step: HistoryStep) {
|
||||
switch step.undo() {
|
||||
case .applied:
|
||||
// Lands on the opposite stack — see "Undo flips to redo by re-registering".
|
||||
push(step.reversed)
|
||||
case .skipped:
|
||||
lastCrossingSkipped = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline to apply — 13's
|
||||
/// fall-through: "the step is skipped, not applied ... and ⌘Z falls through to the next step".
|
||||
///
|
||||
/// The `while` guard is the manager's own emptiness, so a stack of nothing but stale steps
|
||||
/// empties itself and stops rather than spinning.
|
||||
private func cross(_ step: () -> Void, while more: () -> Bool) {
|
||||
repeat {
|
||||
guard more() else { return }
|
||||
lastCrossingSkipped = false
|
||||
step()
|
||||
} while lastCrossingSkipped
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user