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
+28 -2
View File
@@ -77,6 +77,16 @@ public final class CommentEditSession {
@ObservationIgnored
public var save: ((String) -> Bool)?
/// Where this session's **one undo step** goes, called at its commit point with the bytes the
/// session opened on and the bytes it leaves `CardBodyEditSession.registerUndo`'s seam one level
/// down, and for its reason exactly: a session is one step, never one per debounced tick
/// (13-native-undo.md Rules coalescing).
///
/// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's*
/// stack. `nil` a session with no window behind it registers nothing.
@ObservationIgnored
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
/// How many saves have actually been attempted through `save`.
@ObservationIgnored
public private(set) var saveAttempts = 0
@@ -142,7 +152,9 @@ public final class CommentEditSession {
public func commit() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
return flush()
let wrote = flush()
registerStep()
return wrote
}
/// **Cancel, or Escape** reverts to session-start bytes and ends (05; 11-command-nexus.md's
@@ -179,11 +191,25 @@ public final class CommentEditSession {
public func endOnClose() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
return flush()
let wrote = flush()
registerStep()
return wrote
}
// MARK: - Private
/// The session's one step, at whichever of its two ends got here first and **only when the
/// session actually changed the file**: `disk` is what this session knows the file says, so
/// `disk == sessionStart` covers both the session that only read and the one that typed its way
/// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated).
///
/// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is
/// nothing and a step would only offer to undo an undo.
private func registerStep() {
guard disk != sessionStart else { return }
registerUndo?(sessionStart, disk)
}
private func scheduleSave() {
cancelPending()
let interval = debounceInterval