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
+95 -21
View File
@@ -37,21 +37,26 @@ public enum CommentTarget: Sendable, Equatable {
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every
/// window over a board (board window, its card windows) shares the store and shares the stack"
/// (13 Rules). A comment posted in a card window is undone by Z in the board window, which is only
/// true if the step was registered here.
/// *board's* store one store per board, whatever the window (02-architecture.md § Components), so a
/// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// other write in the app.
///
/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 the
/// session-coarsening model, 13 Rules two levels): every gesture below takes the issuing window's
/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack
/// never carries a granular comment step" (13 Interaction with the trash). Board history sees the
/// window's session as one coarse step when the window closes (`registerCardSession`).
///
/// ### What registers a step, and what deliberately does not
///
/// Two of the five (13 Interaction with the trash, the comment clause; 13 Rules):
///
/// - **`postComment`** inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
/// bytes 05-card-window.md), which is a live buffer, not a stack entry.
/// - **`registerCommentEdit`** one *session*, at its commit point, holding the bytes it opened on
/// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
/// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
/// have (05-card-window.md The comments column).
/// - **`purgeCommentTrash` registers nothing** the permanent-delete posture. It is also what makes
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
@@ -122,8 +127,13 @@ extension BoardStore {
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
/// that re-minted would post a *different* comment, and any step registered above this one naming
/// the posted id would name nothing.
///
/// **The step lands on the issuing window's stack** (13 Rules two levels, re-ruled
/// 2026-07-31): the composer is a card window's surface, so the window passes its own
/// (`CardWindowHost.configureComments`). `nil` is the board's stack a caller with no window,
/// which in the app is nobody and in a test is the shortest way to drive the write.
@discardableResult
public func postComment(inCard id: ItemID) -> ItemID? {
public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> ItemID? {
guard let card = commentSubject(id) else { return nil }
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
@@ -138,6 +148,7 @@ extension BoardStore {
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in
@@ -155,8 +166,8 @@ extension BoardStore {
// MARK: Editing
/// An inline edit session's save one bracket, **no step** (13's no-capture rule; the session
/// owns Cancel).
/// An inline edit session's save one bracket, **no step**, exactly as a card body's ~700 ms
/// tick writes no step: a session is not a save (`registerCommentEdit`, below).
///
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
@discardableResult
@@ -169,6 +180,47 @@ extension BoardStore {
return wrote ?? false
}
/// **Registers one inline edit session as one step** `registerBodyEdit`'s shape one level down,
/// and its reasons verbatim.
///
/// The window stack is what made this possible and what made it necessary. 13-native-undo.md's
/// no-byte-capture rule is about the *board* stack, where a comment's granular history has no
/// business ("the board stack never carries a granular comment step"); the re-ruled two-level
/// model puts every gesture issued in a card window on that window's own stack at fine grain,
/// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's
/// Edit session registers, at the same kind of boundary Save, , or the close flush with the
/// bytes the session opened on (`CommentEditSession.sessionStart`).
///
/// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back,
/// so the session's net effect is nothing and there is nothing to undo.
///
/// Its predicate is the body's: "body steps compare bytes" (13 Rules), against the comment's own
/// folder so a foreign edit to that comment skips the step, and a foreign edit to the card or to
/// a sibling comment leaves it alone.
public func registerCommentEdit(
_ commentID: ItemID,
inCard id: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let title = card.title
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title)
}
}
// MARK: Delete and its inverse
/// **Deletes a comment a move into `comments/.trash/`, immediate, no confirm, undoable**
@@ -179,8 +231,21 @@ extension BoardStore {
/// and existence is the whole predicate. The container rides in the path exactly as it does at
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
/// move back would otherwise land on top of whatever now sits at it and because the close fold
/// reads these lists as the state the session left. A move that named only its destination would
/// leave the session's fold claiming a comment is still at a path it has left, and the coarse step
/// would be stale the moment it was registered (`CardWindowUndo.netEffect`).
///
/// **The step lands on the issuing window's stack** `postComment`'s rule, and the one 13's
/// comments paragraph states outright: "the step lives on the card window's own stack ... so the
/// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
/// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
/// keeping alive (`registerCardSession`).
@discardableResult
public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool {
public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let card = commentSubject(id) else { return false }
let folder = card.folder
let title = card.title
@@ -195,8 +260,9 @@ extension BoardStore {
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
undoExpects: [.present(trashed)],
redoExpects: [.present(live)]
on: window,
undoExpects: [.present(trashed), .absent(live)],
redoExpects: [.present(live), .absent(trashed)]
) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in
@@ -207,12 +273,20 @@ extension BoardStore {
// MARK: The purge, and the crash residue it leaves
/// **Empties one card's `comments/.trash/`** the card window's close flush (01-storage-format.md
/// § Enhanced schema: "purged when the card window closes (rides the close flush)").
/// **Empties one card's `comments/.trash/`** **when undo no longer needs it** (01-storage-format.md
/// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md Interaction with the trash).
///
/// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must
/// not be: invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with
/// the ordinary info-tone banner the first time one is crossed.
/// The call site moved with that re-ruling and this method did not change: the window's close no
/// longer purges on its own, it hands this work to the coarse close step as that step's
/// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
/// stack undone-and-superseded, dropped, gone stale or when the board session ends. On a git
/// board the step is never kept, so the retirement fires at the close flush, which is where the
/// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next
/// card-window open is unchanged.
///
/// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be:
/// invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with the
/// ordinary info-tone banner the first time one is crossed.
public func purgeCommentTrash(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
try? performWrite { () throws(BoardWriteError) -> Void in