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