Implement staleness validation and skip-with-banner
Every crossing validates its expectations before writing: each step
carries per-item HistoryExpectations — folder, effective ancestor-walked
liveness, and exactly the fields the gesture set — and a mismatch pops
the step, posts the signpost ('Undo skipped — Fix login changed outside
Lanework'), and falls through to the next. Validation reads disk, not
the in-memory snapshot: the snapshot is by construction one reload
behind every app write, so a rapid second undo would false-skip against
the pre-state — disk is what current can honestly mean at press time.
Stale and failed part ways: a stale step is one the board moved past,
so dropping it loses nothing; a failed one is refused by a usually
momentary condition, so it stays put and the crossing stops with only
performWrite's own error row — which forced the provider off
NSUndoManager onto two plain arrays, since a popped group cannot be put
back. The read-only lock disables Undo/Redo through the adapter while
the stack survives to resume on clear. Delete and restore validate
presence alone — a machine timestamp is not a decision — and a
malformed field matches nothing, since it is a shape the app never
writes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -2,126 +2,117 @@ import Foundation
|
||||
|
||||
// MARK: - NativeHistoryProvider
|
||||
|
||||
/// The base edition's undo substrate: one `NSUndoManager`-backed stack per board session
|
||||
/// (13-native-undo.md).
|
||||
/// The base edition's undo substrate: one stack per board session (13-native-undo.md).
|
||||
///
|
||||
/// ### Why `NSUndoManager` at all, when the steps are ours
|
||||
/// ### Two arrays, and why not `NSUndoManager`
|
||||
///
|
||||
/// 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
|
||||
/// This provider was an `NSUndoManager` for exactly one milestone, on the argument that 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.
|
||||
/// focused window hands back. All of that is still true, and none of it lives here: the retitling is
|
||||
/// `BoardUndoManager.undoMenuItemTitle` composing `undoMenuTitle(forUndoActionName:)` over the bare
|
||||
/// phrase this seam vends as a `String?`. The adapter is the `UndoManager`; the substrate never
|
||||
/// needed to be one.
|
||||
///
|
||||
/// 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).
|
||||
/// What forced the change is the staleness milestone's third outcome. `HistoryStepOutcome.failed`
|
||||
/// means **the step stays put** — a disk error is retryable, so ⌘Z must still be able to reach the
|
||||
/// step it just could not write. `NSUndoManager` pops a group before running it and offers no way to
|
||||
/// put it back: a registration made while undoing lands on the *redo* stack by its own documented
|
||||
/// rule, and one made after the crossing returns clears the redo stack outright. Either way a failed
|
||||
/// undo would have quietly destroyed something. Two arrays express all three outcomes exactly, and
|
||||
/// the grammar they have to implement is four lines long.
|
||||
///
|
||||
/// ### 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.
|
||||
/// Nothing here groups, coalesces, or waits for the end of a run-loop turn — 13's "one gesture, one
|
||||
/// undo step" is a property of the Writer call sites (a multi-card move registers *one* step with a
|
||||
/// plural title), and the substrate's job is to not have opinions about it. This is what
|
||||
/// `NSUndoManager`'s `groupsByEvent = false` was buying, as an absence rather than a setting.
|
||||
///
|
||||
/// ### Undo flips to redo by re-registering
|
||||
/// ### Undo flips to redo by reversing
|
||||
///
|
||||
/// 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").
|
||||
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
||||
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
||||
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
||||
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
||||
/// "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()
|
||||
/// The two stacks, top last. Both hold steps oriented for crossing — see the type's note.
|
||||
private var undoSteps: [HistoryStep] = []
|
||||
private var redoSteps: [HistoryStep] = []
|
||||
|
||||
/// 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
|
||||
}
|
||||
public init() {}
|
||||
|
||||
// MARK: - HistoryProviding
|
||||
|
||||
public var canUndo: Bool { manager.canUndo }
|
||||
public var canUndo: Bool { !undoSteps.isEmpty }
|
||||
|
||||
public var canRedo: Bool { manager.canRedo }
|
||||
public var canRedo: Bool { !redoSteps.isEmpty }
|
||||
|
||||
/// `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
|
||||
}
|
||||
/// The phrase the menu title is composed from, or `nil` when there is nothing to cross — and
|
||||
/// also `nil` for a step registered without a name, which is the emptiness the adapter's `""`
|
||||
/// contract is written against.
|
||||
public var undoActionName: String? { name(of: undoSteps.last) }
|
||||
|
||||
public var redoActionName: String? {
|
||||
guard canRedo, !manager.redoActionName.isEmpty else { return nil }
|
||||
return manager.redoActionName
|
||||
}
|
||||
public var redoActionName: String? { name(of: redoSteps.last) }
|
||||
|
||||
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
||||
/// substrate shares.
|
||||
public func register(_ step: HistoryStep) {
|
||||
push(step)
|
||||
undoSteps.append(step)
|
||||
redoSteps.removeAll()
|
||||
}
|
||||
|
||||
public func undo() {
|
||||
cross(manager.undo, while: { self.manager.canUndo })
|
||||
}
|
||||
public func undo() { cross(.undo) }
|
||||
|
||||
public func redo() {
|
||||
cross(manager.redo, while: { self.manager.canRedo })
|
||||
}
|
||||
public func redo() { cross(.redo) }
|
||||
|
||||
public func clear() {
|
||||
manager.removeAllActions()
|
||||
undoSteps.removeAll()
|
||||
redoSteps.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - The stack
|
||||
// MARK: - The crossing
|
||||
|
||||
/// 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
|
||||
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 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
|
||||
/// The loop's own exit is an empty stack, so a stack of nothing but stale steps empties itself
|
||||
/// and stops rather than spinning. The other two outcomes each end the crossing after one step:
|
||||
/// an applied step is the ⌘Z the user asked for, and a failed one leaves the stack exactly as it
|
||||
/// found it (`HistoryStepOutcome.failed`).
|
||||
private func cross(_ direction: HistoryDirection) {
|
||||
while let step = pop(direction) {
|
||||
switch step.undo(direction) {
|
||||
case .applied:
|
||||
push(step.reversed, onto: direction.opposite)
|
||||
return
|
||||
case .skipped:
|
||||
continue
|
||||
case .failed:
|
||||
push(step, onto: direction)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pop(_ direction: HistoryDirection) -> HistoryStep? {
|
||||
direction == .undo ? undoSteps.popLast() : redoSteps.popLast()
|
||||
}
|
||||
|
||||
private func push(_ step: HistoryStep, onto direction: HistoryDirection) {
|
||||
if direction == .undo {
|
||||
undoSteps.append(step)
|
||||
} else {
|
||||
redoSteps.append(step)
|
||||
}
|
||||
}
|
||||
|
||||
private func name(of step: HistoryStep?) -> String? {
|
||||
guard let name = step?.name, !name.isEmpty else { return nil }
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user