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
+6 -3
View File
@@ -339,9 +339,12 @@ public final class AppModel {
/// open-now flag without matching by identity a second time.
public let recordID: UUID
/// This board's undo/redo substrate **one stack per board session, never per window**
/// (13-native-undo.md Rules). It lives here for the store's reason exactly: the session is
/// what every window over this board shares, and "undo is board-local".
/// This board's undo/redo substrate **the board half of 13-native-undo.md Rules' two
/// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
/// gestures and the one coarse step each card window's close registers. A card window's own
/// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
/// window). It lives here for the store's reason exactly: the session is what every window
/// over this board shares, and "undo is board-local".
///
/// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
+1 -1
View File
@@ -276,7 +276,7 @@ struct BoardWindowHost: View {
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager.
windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager }
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
+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,
+13 -14
View File
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window that has nothing to flush.
var onCloseRequested: (() -> Void)?
/// This window's board undo stack, asked for afresh every time AppKit wants it the board
/// window's and its card windows' shared answer (13-native-undo.md Rules: "one stack per
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the
/// session's manager").
/// **The stack this window's Z crosses**, asked for afresh every time AppKit wants it
/// 13-native-undo.md Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
///
/// A closure rather than a stored manager for two reasons: the session does not exist yet when
/// the window attaches, and it stops existing at teardown while the window is still closing
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a
/// window that outlived it by a run-loop turn.
/// A closure rather than a stored manager for two reasons: a board window's session does not
/// exist yet when the window attaches, and it stops existing at teardown while the window is
/// still closing answering `nil` then is what keeps a torn-down board's stack from being
/// reachable through a window that outlived it by a run-loop turn.
///
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var boardUndoManager: (() -> UndoManager?)?
var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board 06-history-undo.md Undo routing, via
@@ -215,16 +214,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
}
/// The window-level half of 06-history-undo.md Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it.
///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a board: `responds(to:)` reports this method whatever the
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no board
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?()
let board = windowUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {