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