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
+93 -24
View File
@@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it.
let body: CardBodyEditSession
/// **This window's own undo stack** 13-native-undo.md Rules' second level (re-ruled
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
/// `window.undoManager` answers with it".
///
/// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
/// folded from this stack, and a stack held only by the view would be gone by the time the fold
/// ran. Every window gesture registers into it through the store's own methods, which take it as
/// a parameter (`CardWindowUndo`).
let undo = CardWindowUndo()
/// The window's comments pane the thread, the composer's draft buffer, and the one open inline
/// edit session (05-card-window.md The comments column).
///
@@ -85,6 +95,16 @@ final class CardWindowSession: CardSessionFlushing {
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// **Where the close registers this session as one board step**
/// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
/// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
/// having no idea what a board is.
///
/// It answers whether the deferred `comments/.trash/` purge now has an owner see `endSession()`.
/// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
/// object's to run, which is also true.
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
@@ -164,14 +184,25 @@ final class CardWindowSession: CardSessionFlushing {
// session, "never per save tick" (06-history-undo.md Rules Auto-commit). The debounced
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
// the committer's own debounce outlives them and this call is where the session is known to
// be over.
// be over. It is also where the body's *last* fine step joins this window's stack, which is
// why it has to precede the fold below.
body.endEditSession()
// **Saves first, purge last** the inline comment session's flush and the draft's save land
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
// body's for the same reason it runs at all: this is the one place the window's whole close
// work has a fixed order.
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// land before the fold.
comments.endSession()
// **The coarse close step, and the purge it defers** (13-native-undo.md Rules "Window
// close coarsens"; Interaction with the trash).
//
// This is the one place that knows both halves: the window's stack, which is the session's
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
// still restores comments out of it. Registering answers whether the step took the purge on
// and a board whose substrate keeps no steps has already run it by the time that answer comes
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
if registerSessionStep?(undo, purge) != true {
purge()
}
}
}
@@ -415,6 +446,7 @@ struct CardWindowHost: View {
attachments: attachments,
comments: session.comments,
thumbnails: thumbnails,
undo: session.undo,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
@@ -595,12 +627,7 @@ struct CardWindowHost: View {
guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text)
}
// The session's one undo step, at the EditPreview flip (13-native-undo.md Rules). Weakly,
// `save`'s rule: a session ending after the board window has gone registers nothing rather
// than resurrecting a released store and the board's stack died with it anyway.
session.body.registerUndo = { [weak store] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
}
Self.configureUndo(session, store: store, cardID: cardID)
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
}
@@ -632,7 +659,32 @@ struct CardWindowHost: View {
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
}
/// Points this window's session at **its own undo stack** the three seams the two-level model
/// is made of (13-native-undo.md Rules, re-ruled 2026-07-31).
///
/// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
/// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
/// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
/// `comments/.trash/` purge.
///
/// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
/// window has gone registers nothing rather than resurrecting a released store and a window with
/// no board keeps the purge itself, which is what the `false` says.
///
/// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
/// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
}
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
session.registerSessionStep = { [weak store] undo, purge in
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
}
}
/// Points the comments pane at its card **the one place every comment gesture learns which card
@@ -651,7 +703,17 @@ struct CardWindowHost: View {
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
/// target resolution is invisible in a running window until it is wrong, and this shape is what
/// lets a test drive the real wiring rather than a re-typed copy of it.
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
///
/// - Parameter undo: **this window's stack** where every comment gesture's fine step lands
/// (13-native-undo.md Rules two levels). Not optional and not defaulted: a comments pane
/// only ever exists inside a card window, so a call with no window would be a call with no
/// answer to which stack it meant.
static func configureComments(
_ comments: CardComments,
store: BoardStore,
cardID: ItemID,
on undo: CardWindowUndo
) {
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
@@ -665,11 +727,14 @@ struct CardWindowHost: View {
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
}
comments.deleteComment = { [weak store] id in
store?.deleteComment(id, inCard: cardID) ?? false
store?.deleteComment(id, inCard: cardID, on: undo) ?? false
}
comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false
}
comments.registerCommentEdit = { [weak store] id, prior, new in
store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
}
comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target)
}
@@ -680,7 +745,7 @@ struct CardWindowHost: View {
store?.saveCommentDraft(inCard: cardID, body: text)
}
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID)
store?.postComment(inCard: cardID, on: undo)
}
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
// reload (10-accessibility.md "app-mediated echoes never announce", per comment). A store
@@ -788,14 +853,18 @@ struct CardWindowHost: View {
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
)
// **The board's stack, not one of this window's own** (13-native-undo.md Rules: "not
// per-window: every window over a board (board window, its card windows) shares the store
// and shares the stack"). Same closure shape as the board window's, and deliberately the
// same object: Z with a card window in front crosses the board step the user last made,
// wherever they made it. The card's *text* surfaces are untouched by this the body editor
// and the raw-source editor each vend their own manager to the responder chain, which is
// what keeps typing undo out of the board's stack (06-history-undo.md Undo routing).
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
// **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
// scoping)". Z with this window in front walks the gestures made *here*, newest first, and
// when they run out it beeps "no fall-through: exhausting the window's stack ... never
// reaches board history" (06-history-undo.md Undo routing). What board history gets is the
// one coarse step this session registers when the window closes.
//
// The card's *text* surfaces are untouched by this the body editor and the raw-source
// editor each vend their own manager to the responder chain, which is what keeps typing undo
// above either stack (06 Undo routing, unchanged).
windowController.windowUndoManager = { [session] in session.undo.manager }
windowController.onAttach = { window in
if let recordID,