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
+70 -2
View File
@@ -74,22 +74,90 @@ extension BoardStore {
func registerStep(
_ name: String,
subject: String? = nil,
on window: CardWindowUndo? = nil,
retiring: (@MainActor () -> Void)? = nil,
undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation],
undo: @escaping @MainActor (BoardStore) throws -> Void,
redo: @escaping @MainActor (BoardStore) throws -> Void
) {
guard let history else { return }
let retirement = retiring.map(HistoryStep.Retirement.init)
// **The routing decision, and the whole of it** (13 Rules two levels): a gesture issued
// in a card window lands on that window's stack, and everything else on the board's. It is a
// parameter rather than ambient state on purpose the issuing surface is knowledge only the
// call site has, and a store-wide "current window" would be a second answer able to be wrong
// for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else {
// No substrate at all a repo-nested board (06 Rules), or a store with no session.
// Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run()
return
}
let named = subject ?? name
history.register(HistoryStep(
let step = HistoryStep(
name: name,
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
},
redo: { [weak self] direction in
BoardStore.cross(self, direction, named, redoExpects, redo)
}
)
// The raw halves, kept beside the window's stack for the close fold before the register, so
// a fold taken from inside a registration's own side effects can never see a step it has no
// write for (`CardWindowUndo.netEffect`).
window?.record(step.id, CardWindowUndo.Write(
undoExpects: undoExpects,
redoExpects: redoExpects,
undo: undo,
redo: redo
))
sink.register(step)
}
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** 13-native-undo.md Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
/// title', values-based, whose undo restores the card subtree to its session-start state
/// deleted comments included and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
///
/// - **A vanished card registers nothing.** 05-card-window.md Deletion & lifecycle dismisses the
/// window when its card leaves the board into the trash, with its lane, to another board and
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
/// naming folders that have moved could only be a step that skips, so the honest answer is not
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
/// it. (A trashed card keeps its `comments/.trash/` too "a trashed card carries its
/// `comments/`", 01-storage-format.md and the residue sweeps at the next open of that card.)
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
/// - Returns: whether the purge now has an owner a live step holding it until the step leaves
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
/// owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void
) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
guard let net = window.netEffect() else { return false }
registerStep(
HistoryPhrase.cardSession,
subject: item.title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
undo: net.undo,
redo: net.redo
)
return true
}
/// Validates one side of a step against disk, then runs it as an ordinary bracketed write.