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
128 lines
5.3 KiB
Swift
128 lines
5.3 KiB
Swift
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
|
|
}
|
|
}
|