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:
@@ -32,25 +32,54 @@ import 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).
|
||||
///
|
||||
/// ### The read-only lock disables Undo and Redo here
|
||||
///
|
||||
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
|
||||
/// disables Undo/Redo with the other mutating commands; **the stack itself survives the lock and
|
||||
/// resumes when it clears**" (13 ▸ Rules). This is the right place for it and the only one: every
|
||||
/// surface that offers ⌘Z — the Edit menu's nil-target row, the toolbar pair, a card window's
|
||||
/// responder chain — validates through this object, so answering `false` here disables all of them
|
||||
/// at once, exactly as the lock's other victims disable through menu validation (02-architecture.md
|
||||
/// § "The lock's scope"). Putting it in the *provider* would have been the same answer in the wrong
|
||||
/// place: the stack is not the thing that is locked, the board is, and a Pro session binding the git
|
||||
/// provider must inherit the rule without reimplementing it.
|
||||
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) {
|
||||
/// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather
|
||||
/// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*,
|
||||
/// and Pro binds a different substrate behind the same one), and a closure is what lets the
|
||||
/// composition root wire the board's own truth in without this file learning what a `BoardStore`
|
||||
/// is. The default answers "writable", which is what a manager built without a board — a test of
|
||||
/// the adapter's own grammar — should have.
|
||||
private let isReadOnly: @MainActor () -> Bool
|
||||
|
||||
public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) {
|
||||
self.history = history
|
||||
self.isReadOnly = isReadOnly
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Enablement
|
||||
|
||||
public override var canUndo: Bool { history.canUndo }
|
||||
/// **False under the lock, whatever the stack holds.** The steps are still there — this is an
|
||||
/// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it
|
||||
/// would have crossed before it landed.
|
||||
public override var canUndo: Bool { !isReadOnly() && history.canUndo }
|
||||
|
||||
public override var canRedo: Bool { history.canRedo }
|
||||
public override var canRedo: Bool { !isReadOnly() && history.canRedo }
|
||||
|
||||
// MARK: Crossing
|
||||
|
||||
/// Not gated on the lock, deliberately: `undo:` reaches a manager only through a menu item or
|
||||
/// toolbar button that has already validated against `canUndo`, and a crossing that somehow
|
||||
/// started anyway is refused one layer down by `performWrite` — which leaves the step on the
|
||||
/// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second
|
||||
/// guard here would be a second answer to one question.
|
||||
public override func undo() { history.undo() }
|
||||
|
||||
public override func redo() { history.redo() }
|
||||
|
||||
Reference in New Issue
Block a user