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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user