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
+29 -8
View File
@@ -108,7 +108,8 @@ public final class CardComments {
@ObservationIgnored
public var sweepTrashResidue: (() -> Void)?
/// The close purge `BoardStore.purgeCommentTrash(inCard:)`.
/// The deferred purge `BoardStore.purgeCommentTrash(inCard:)`, run through `purgeTrashNow()`
/// by whoever owes it (see that method).
@ObservationIgnored
public var purgeTrash: (() -> Void)?
@@ -127,6 +128,12 @@ public final class CardComments {
@ObservationIgnored
public var editComment: ((ItemID, String) -> Bool)?
/// One inline edit **session**'s undo step `BoardStore.registerCommentEdit(...)`, handed to each
/// session beside its save and called once, at the session's commit point (13-native-undo.md
/// Rules coalescing; `CommentEditSession.registerUndo`).
@ObservationIgnored
public var registerCommentEdit: ((ItemID, String, String) -> Void)?
/// Imports files into an authoring surface's `attachments/`
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
@ObservationIgnored
@@ -319,6 +326,9 @@ public final class CardComments {
session.save = { [weak self] text in
self?.editComment?(commentID, text) ?? false
}
session.registerUndo = { [weak self] prior, new in
self?.registerCommentEdit?(commentID, prior, new)
}
editing = session
}
@@ -409,20 +419,31 @@ public final class CardComments {
// MARK: - The close flush
/// **The window's close, in the order the brief fixes: saves first, purge last.**
/// **The pane's half of the window's close: the saves.**
///
/// The inline session flushes as the body's does (a flush, never a revert a close is not an
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
/// running it before a session's save could remove a folder that save was about to write into.
/// abandon), and then the composer's draft lands. Ending twice does nothing the second time the
/// sessions latch which is what makes the two paths that call this (a window closed on its own,
/// and the board's close flush driving it) safe to both exist.
///
/// Ending twice does nothing the second time the sessions latch, and a purge over an empty
/// trash is a no-op which is what makes the two paths that call this (a window closed on its
/// own, and the board's close flush driving it) safe to both exist.
/// **The purge is no longer here** (re-ruled 2026-07-31 13-native-undo.md Interaction with the
/// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from,
/// so emptying it belongs *after* that step exists and only when that step does not. The order
/// this method fixed is unchanged and still load-bearing saves land before anything empties the
/// trash it is now spelled one level up, where the step is registered (`CardWindowSession`),
/// because that is the only level that knows whether the purge is owed now or owed later.
public func endSession() {
editing?.endOnClose()
editing = nil
composer.flush()
}
/// **Empties `comments/.trash/`** the deferred purge, run by whoever currently owes it: the
/// window's close when no coarse step took it, or that step's retirement when one did.
///
/// A purge over an empty trash is a no-op, which is what keeps the two owners from having to agree
/// about anything but who calls first.
public func purgeTrashNow() {
purgeTrash?()
}
+7 -1
View File
@@ -32,6 +32,11 @@ struct CardStyleSection: View {
let store: BoardStore
let recents: StyleRecents
let cardID: ItemID
/// **This window's undo stack** (13-native-undo.md Rules two levels): a colour or symbol
/// chosen here is a gesture *issued in this window*, so its step joins the window's session and
/// reaches board history only inside the coarse close step. The shared editor takes it as an
/// anchor's parameter, exactly as it takes the layout.
let undo: CardWindowUndo
/// The live body metric, read here rather than passed in `CardAttachmentsSection`'s pattern,
/// so every section in this sidebar derives its geometry the same way.
@@ -58,7 +63,8 @@ struct CardStyleSection: View {
layout: .sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize
)
),
undo: undo
)
}
.frame(maxWidth: .infinity, alignment: .leading)
+6 -1
View File
@@ -91,6 +91,11 @@ struct CardWindowView: View {
let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// **This window's undo stack** (13-native-undo.md Rules two levels). It arrives for exactly
/// one consumer the Style section, the one sidebar anchor that *writes* because a gesture
/// issued in this window registers on this window's stack. It lives on the window's session so
/// the close can fold it, which is why it arrives here rather than being made here.
let undo: CardWindowUndo
/// **This card's commit trail** (05 History), or `nil` on every board with no app-managed git
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
/// see `historySlot`.
@@ -314,7 +319,7 @@ struct CardWindowView: View {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
CardStyleSection(store: store, recents: recents, cardID: card.id)
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
// keys and their order included, and `Card` has carried it since (`BoardModel`).
+28 -2
View File
@@ -77,6 +77,16 @@ public final class CommentEditSession {
@ObservationIgnored
public var save: ((String) -> Bool)?
/// Where this session's **one undo step** goes, called at its commit point with the bytes the
/// session opened on and the bytes it leaves `CardBodyEditSession.registerUndo`'s seam one level
/// down, and for its reason exactly: a session is one step, never one per debounced tick
/// (13-native-undo.md Rules coalescing).
///
/// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's*
/// stack. `nil` a session with no window behind it registers nothing.
@ObservationIgnored
public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)?
/// How many saves have actually been attempted through `save`.
@ObservationIgnored
public private(set) var saveAttempts = 0
@@ -142,7 +152,9 @@ public final class CommentEditSession {
public func commit() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
return flush()
let wrote = flush()
registerStep()
return wrote
}
/// **Cancel, or Escape** reverts to session-start bytes and ends (05; 11-command-nexus.md's
@@ -179,11 +191,25 @@ public final class CommentEditSession {
public func endOnClose() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
return flush()
let wrote = flush()
registerStep()
return wrote
}
// MARK: - Private
/// The session's one step, at whichever of its two ends got here first and **only when the
/// session actually changed the file**: `disk` is what this session knows the file says, so
/// `disk == sessionStart` covers both the session that only read and the one that typed its way
/// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated).
///
/// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is
/// nothing and a step would only offer to undo an undo.
private func registerStep() {
guard disk != sessionStart else { return }
registerUndo?(sessionStart, disk)
}
private func scheduleSave() {
cancelPending()
let interval = debounceInterval