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:
2026-07-28 14:54:42 -04:00
parent 2148ebb379
commit 50669489cb
10 changed files with 1333 additions and 149 deletions
+103 -3
View File
@@ -20,25 +20,35 @@ private final class StepLog {
private(set) var crossings: [String] = []
/// The direction each crossing was told it was the argument the skip banner's verb comes from.
private(set) var directions: [HistoryDirection] = []
/// A step that applies in both directions the ordinary case.
func step(_ name: String) -> HistoryStep {
step(name, undo: .applied, redo: .applied)
}
/// A step whose inverse declines 13's staleness skip, without needing a foreign writer.
/// A step whose inverse declines as stale 13's skip, without needing a foreign writer.
func staleStep(_ name: String) -> HistoryStep {
step(name, undo: .skipped, redo: .applied)
}
/// A step whose inverse could not be written the disk-error fate, which is not staleness.
func failingStep(_ name: String) -> HistoryStep {
step(name, undo: .failed, redo: .applied)
}
func step(_ name: String, undo: HistoryStepOutcome, redo: HistoryStepOutcome) -> HistoryStep {
HistoryStep(
name: name,
undo: { [weak self] in
undo: { [weak self] direction in
self?.crossings.append("undo \(name)")
self?.directions.append(direction)
return undo
},
redo: { [weak self] in
redo: { [weak self] direction in
self?.crossings.append("redo \(name)")
self?.directions.append(direction)
return redo
}
)
@@ -172,6 +182,55 @@ struct NativeHistoryProviderTests {
#expect(provider.redoActionName == "Move Card")
}
@Test("A step whose write failed stays on the stack, and the crossing stops there")
func aFailedStepStaysAndStopsTheCrossing() {
let log = StepLog()
let provider = NativeHistoryProvider()
provider.register(log.step("Move Card"))
provider.register(log.failingStep("Rename Card"))
provider.undo()
// It was reached and it declined and unlike a stale step it is still there to retry, with
// the step below it untouched underneath.
#expect(log.crossings == ["undo Rename Card"], "no fall-through: a refused disk is not a reason to try more")
#expect(provider.canUndo)
#expect(provider.undoActionName == "Rename Card")
#expect(provider.canRedo == false, "nothing landed, so nothing is redoable")
provider.undo()
#expect(log.crossings == ["undo Rename Card", "undo Rename Card"], "⌘Z can retry it")
}
@Test("A failed redo leaves the redo stack alone too")
func aFailedRedoStays() {
let log = StepLog()
let provider = NativeHistoryProvider()
provider.register(log.step("Move Card", undo: .applied, redo: .failed))
provider.undo()
provider.redo()
#expect(provider.canRedo, "still there to retry")
#expect(provider.redoActionName == "Move Card")
#expect(provider.canUndo == false)
}
@Test("A step is told which command it is being crossed by, not which half is running")
func stepsAreToldTheDirection() {
let log = StepLog()
let provider = NativeHistoryProvider()
provider.register(log.step("Rename Card"))
provider.undo()
provider.redo()
provider.undo()
// The third crossing runs the *undo* half again, and the second runs the half registered as
// `redo` what each is told is Z, Z, Z, which is what the skip banner has to say.
#expect(log.directions == [.undo, .redo, .undo])
}
@Test("A stack of nothing but stale steps empties itself and stops")
func anEntirelyStaleStackEmptiesItself() {
let log = StepLog()
@@ -283,6 +342,26 @@ struct BoardUndoManagerTests {
#expect(manager.redoMenuItemTitle == "Redo Rename Lane")
}
@Test("A read-only board disables both directions, whatever the stack holds")
func theLockDisablesEnablement() {
final class Lock { var isOn = false }
let lock = Lock()
let provider = FakeHistoryProvider()
let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn })
provider.canUndo = true
provider.canRedo = true
#expect(manager.canUndo)
lock.isOn = true
#expect(manager.canUndo == false, "disabled with every other mutating command")
#expect(manager.canRedo == false)
#expect(provider.canUndo, "an enablement answer, not a clearing — the stack survives")
lock.isOn = false
#expect(manager.canUndo, "and resumes when the lock clears")
}
@Test("Crossing forwards to the provider — what ⌘Z and the toolbar item actually reach")
func crossingForwards() {
let provider = FakeHistoryProvider()
@@ -476,6 +555,27 @@ struct BoardSessionHistoryTests {
#expect(log.crossings.isEmpty)
}
@Test("The session's manager answers this board's own read-only lock")
func theSessionWiresTheLock() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
session.history.register(StepLog().step("Move Card"))
#expect(session.undoManager.canUndo)
session.store.enterVanishedRootLock()
#expect(session.undoManager.canUndo == false)
#expect(session.history.canUndo, "the stack itself survives the lock")
session.store.handleWatcherEvent(.treeChanged(.appMediated))
await session.store.awaitQuiescence()
#expect(session.undoManager.canUndo, "and resumes when it clears")
}
@Test("The composition root decides which provider a session gets")
func theProviderIsBoundAtComposition() throws {
let fixture = try makeBoard()