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
+34 -2
View File
@@ -423,6 +423,11 @@ public final class BoardStore: HealHost {
/// made its own would be a second answer to which stack a board has.
/// `AppModel.beginSession` wires it the moment the session's provider exists.
///
/// **It is the board's stack, and not every step's destination** (13 Rules two levels,
/// re-ruled 2026-07-31): a gesture issued in a card window registers on *that window's* stack
/// instead, which the write methods below take as a parameter (`CardWindowUndo`). This one carries
/// board-surface gestures and the coarse step a window's close folds its session into.
///
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
/// board that could only be freed by remembering to empty its undo stack first. `nil` no session
@@ -1490,7 +1495,19 @@ public final class BoardStore: HealHost {
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
/// targets written before it written the Writer is "atomic per filesystem operation, not per
/// gesture" and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
///
/// **The step's stack is the anchor's** (13-native-undo.md Rules two levels): the card
/// window's sidebar editor passes that window's own (`CardStyleSection`), so a colour chosen there
/// is one of the window's fine-grained gestures and joins board history only inside the coarse
/// close step. The board popover, the Style popover and the quick-style rows pass nothing, which
/// is the board's stack where a board-issued gesture belongs even when it names a card whose
/// window is open.
public func applyStyle(
to target: StyleTarget,
background: StyleChange = .keep,
icon: StyleChange = .keep,
on window: CardWindowUndo? = nil
) {
let edits: [(
id: ItemID?,
folder: URL,
@@ -1550,6 +1567,7 @@ public final class BoardStore: HealHost {
registerStep(
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
subject: subject,
on: window,
undoExpects: edits.map {
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
},
@@ -1961,7 +1979,20 @@ public final class BoardStore: HealHost {
/// session that ended because its card was moved to the trash registers against the trash folder
/// it actually flushed into, and a later restore moves the card out from under the step, which
/// the ordinary existence check then reads as the collision it is.
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) {
///
/// ### Which stack it lands on is the caller's to say
///
/// An Edit session belongs to a *window*, so the card window passes its own
/// (`CardWindowHost.configureSession` `CardWindowUndo`) and the step never reaches board
/// history until the window closes and folds it into the coarse session step (13 Rules two
/// levels). `nil` the default, and what a test or any non-window caller passes is the board's
/// stack, exactly as before.
public func registerBodyEdit(
inCard cardID: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot)
@@ -1969,6 +2000,7 @@ public final class BoardStore: HealHost {
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in