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:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user