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:
@@ -320,6 +320,25 @@ public final class BannerCenter {
|
||||
signposts.insert(InfoSignpost(message: message), at: 0)
|
||||
}
|
||||
|
||||
/// **The skipped undo step** (13-native-undo.md ▸ Rules ▸ staleness validation): an inverse found
|
||||
/// its target holding somebody else's newer value, so it was popped rather than applied and ⌘Z
|
||||
/// fell through to the next step. This is the row that says so.
|
||||
///
|
||||
/// **A signpost, and no new class** — the vocabulary's answer rather than a compromise. 13 asks
|
||||
/// for an "info-tone banner", and 02-architecture.md § The banner surface gives the info tone
|
||||
/// exactly two halves: the pinned in-progress row with its spinner, and the passive signpost.
|
||||
/// Nothing is in flight here, so the passive half is the whole of the choice. It also reads
|
||||
/// right: unlike a `loss` row (warning tone, "content that didn't arrive though nothing failed"),
|
||||
/// **nothing was lost and nothing failed** — the file holds exactly what its most recent writer
|
||||
/// meant it to, the stack moved on to a step that did apply, and the user's ⌘Z did something. A
|
||||
/// row that ranks last and may collapse behind "+N more" is the honest weight for that: calm by
|
||||
/// design, nothing gated on seeing it instantly. And it is emphatically not a `oneShot`, which
|
||||
/// carries a `BoardWriteError` — a *failed* inverse posts one of those instead, and the two rows
|
||||
/// must stay distinguishable (`HistoryStepOutcome`).
|
||||
public func postSkippedStep(_ direction: HistoryDirection, subject: String) {
|
||||
postSignpost(Self.skippedStepMessage(direction, subject: subject))
|
||||
}
|
||||
|
||||
/// One item a degraded paste could not bring its attachments with — what
|
||||
/// `degradedPasteMessage(for:)` names.
|
||||
///
|
||||
@@ -796,6 +815,30 @@ public final class BannerCenter {
|
||||
return "Moved '\(name)' into attachments — \(subject)"
|
||||
}
|
||||
|
||||
/// The skipped-step line — 13-native-undo.md ▸ Rules' own example sentence, "Undo skipped — 'Fix
|
||||
/// login' changed outside Lanework", with ⇧⌘Z's mirror ("Redo skipped — …").
|
||||
///
|
||||
/// **The verb is the command the user pressed**, not the half of the step that declined: a step
|
||||
/// already undone sits on the redo stack reversed, so the closure ⇧⌘Z crosses is the one
|
||||
/// registered as `redo`, and a sentence naming the half would tell the user they pressed the
|
||||
/// other key (`HistoryDirection`).
|
||||
///
|
||||
/// **`subject` is quoted whatever it names** — the item's title for a step with one target
|
||||
/// ("'Fix login'"), the step's own 06 phrase for a batch or an untitled item ("'Move 3 Cards'").
|
||||
/// One sentence shape for both readings, chosen where the step is registered
|
||||
/// (`BoardStore.registerStep`) because that is the only place that knows how many items it named.
|
||||
///
|
||||
/// **"changed outside Lanework"** is the design's own wording and stays literal: it is the whole
|
||||
/// explanation the row owes — the app did not decline out of caution, somebody else wrote to that
|
||||
/// item, and the reason the step is gone is that applying it would have thrown their edit away.
|
||||
public nonisolated static func skippedStepMessage(_ direction: HistoryDirection, subject: String) -> String {
|
||||
let verb = switch direction {
|
||||
case .undo: "Undo"
|
||||
case .redo: "Redo"
|
||||
}
|
||||
return "\(verb) skipped — '\(subject)' changed outside Lanework"
|
||||
}
|
||||
|
||||
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
||||
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
||||
/// the diagnosis as its tail.
|
||||
|
||||
Reference in New Issue
Block a user