Give card windows their own undo stacks and coarsen the close

Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+32 -1
View File
@@ -2,7 +2,11 @@ import Foundation
// MARK: - NativeHistoryProvider
/// The free tier's undo substrate: one stack per board session (13-native-undo.md).
/// The free tier's undo substrate: one stack per board session (13-native-undo.md) **and the
/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
/// the two it is; both need the same four-line grammar.
///
/// ### Two arrays, and why not `NSUndoManager`
///
@@ -59,10 +63,25 @@ public final class NativeHistoryProvider: HistoryProviding {
public var redoActionName: String? { name(of: redoSteps.last) }
/// **The steps currently crossable by Z, oldest first** read-only, and read by exactly one
/// caller: the card window's close, which folds the session's own stack into the one coarse board
/// step (13-native-undo.md Rules "Window close coarsens"; `CardWindowUndo`).
///
/// The undo stack is the honest input to that fold and the redo stack is not: a gesture the user
/// undid inside the window is a gesture whose effect is no longer on disk, so it must contribute
/// nothing to the session's net effect. Nothing here decides what a fold *means* this is the
/// membership question, and the two arrays already answer it.
public var pendingSteps: [HistoryStep] { undoSteps }
/// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares.
///
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
/// moment the app can see it.
public func register(_ step: HistoryStep) {
undoSteps.append(step)
retire(redoSteps)
redoSteps.removeAll()
}
@@ -70,11 +89,20 @@ public final class NativeHistoryProvider: HistoryProviding {
public func redo() { cross(.redo) }
/// Session teardown, the add-git substrate swap, a branch reseed every step goes, so every step
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
/// ends" (13 Interaction with the trash).
public func clear() {
retire(undoSteps)
retire(redoSteps)
undoSteps.removeAll()
redoSteps.removeAll()
}
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
// MARK: - The crossing
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
@@ -91,6 +119,9 @@ public final class NativeHistoryProvider: HistoryProviding {
push(step.reversed, onto: direction.opposite)
return
case .skipped:
// Dropped for good the third of the deferred purge's release conditions ("gone
// stale"), and the reason a skip is reported here rather than merely counted.
step.retirement?.run()
continue
case .failed:
push(step, onto: direction)