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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user