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. /// open-now flag without matching by identity a second time.
public let recordID: UUID public let recordID: UUID
/// This board's undo/redo substrate **one stack per board session, never per window** /// This board's undo/redo substrate **the board half of 13-native-undo.md Rules' two
/// (13-native-undo.md Rules). It lives here for the store's reason exactly: the session is /// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
/// what every window over this board shares, and "undo is board-local". /// 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 /// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`. /// (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 // 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 // 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. // 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 // 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 // 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. /// rather than pretending to have written it.
let body: CardBodyEditSession 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 /// 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). /// edit session (05-card-window.md The comments column).
/// ///
@@ -85,6 +95,16 @@ final class CardWindowSession: CardSessionFlushing {
var rawSourceCancel: (@MainActor () -> Void)? var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)? 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 /// 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`. /// 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 // 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 // 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 // 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() body.endEditSession()
// **Saves first, purge last** the inline comment session's flush and the draft's save land // **The saves, in the order the comments build fixed**: the inline session's flush, then the
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing // draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// a folder a save was about to write into (`CardComments.endSession`). It runs after the // land before the fold.
// 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.
comments.endSession() 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, attachments: attachments,
comments: session.comments, comments: session.comments,
thumbnails: thumbnails, thumbnails: thumbnails,
undo: session.undo,
history: cardHistory, history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in onToggleTask: { offset, checked in
@@ -595,12 +627,7 @@ struct CardWindowHost: View {
guard let store else { return .vanished } guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text) return store.writeCardBody(inCard: cardID, body: text)
} }
// The session's one undo step, at the EditPreview flip (13-native-undo.md Rules). Weakly, Self.configureUndo(session, store: store, cardID: cardID)
// `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)
}
bodyPresentation.flushEdits = { [session] in bodyPresentation.flushEdits = { [session] in
session.body.endEditSession() session.body.endEditSession()
} }
@@ -632,7 +659,32 @@ struct CardWindowHost: View {
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() } session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() } session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID) 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 /// 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 /// `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 /// 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. /// 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.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) } comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) } comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
@@ -665,11 +727,14 @@ struct CardWindowHost: View {
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters)) store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
} }
comments.deleteComment = { [weak store] id in 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 comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false 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 comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target) store?.importCommentAttachments(urls, inCard: cardID, target: target)
} }
@@ -680,7 +745,7 @@ struct CardWindowHost: View {
store?.saveCommentDraft(inCard: cardID, body: text) store?.saveCommentDraft(inCard: cardID, body: text)
} }
comments.composer.post = { [weak store] in 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 // 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 // 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) 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 // **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
// per-window: every window over a board (board window, its card windows) shares the store // superseding the shared-stack wiring): "a card window owns its own stack for the session it
// and shares the stack"). Same closure shape as the board window's, and deliberately the // represents ... and `window.undoManager` answers with it (standard per-window AppKit
// same object: Z with a card window in front crosses the board step the user last made, // scoping)". Z with this window in front walks the gestures made *here*, newest first, and
// wherever they made it. The card's *text* surfaces are untouched by this the body editor // when they run out it beeps "no fall-through: exhausting the window's stack ... never
// and the raw-source editor each vend their own manager to the responder chain, which is // reaches board history" (06-history-undo.md Undo routing). What board history gets is the
// what keeps typing undo out of the board's stack (06-history-undo.md Undo routing). // one coarse step this session registers when the window closes.
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager } //
// 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 windowController.onAttach = { window in
if let recordID, if let recordID,
+13 -14
View File
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window that has nothing to flush. /// window that has nothing to flush.
var onCloseRequested: (() -> Void)? var onCloseRequested: (() -> Void)?
/// This window's board undo stack, asked for afresh every time AppKit wants it the board /// **The stack this window's Z crosses**, asked for afresh every time AppKit wants it
/// window's and its card windows' shared answer (13-native-undo.md Rules: "one stack per /// 13-native-undo.md Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the /// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
/// session's manager").
/// ///
/// A closure rather than a stored manager for two reasons: the session does not exist yet when /// A closure rather than a stored manager for two reasons: a board window's session does not
/// the window attaches, and it stops existing at teardown while the window is still closing /// exist yet when the window attaches, and it stops existing at teardown while the window is
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a /// still closing answering `nil` then is what keeps a torn-down board's stack from being
/// window that outlived it by a run-loop turn. /// 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". /// 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 /// 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 /// 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 /// 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. /// 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 /// **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 /// 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. /// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?() let board = windowUndoManager?()
if board == nil, let previousDelegate, if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))), previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) { let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
+16 -6
View File
@@ -247,12 +247,22 @@ public final class GitHistoryProvider: HistoryProviding {
// MARK: - HistoryProviding // MARK: - HistoryProviding
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's /// **Deliberately nothing except the one thing a dropped step is owed.** On a git board an undo
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers /// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate
/// against whatever provider the session bound, and this one has a repository to read instead /// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and
/// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is /// this one has a repository to read instead so the registrations arrive and are dropped, which
/// the substrate" (14 C1) means in code. /// is exactly what "the commit trail itself is the substrate" (14 C1) means in code.
public func register(_ step: HistoryStep) {} ///
/// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
/// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is
/// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
/// rides the close flush there as before" (13 Interaction with the trash). A card window's close
/// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
/// immediately at the close flush, exactly where it ran before this milestone with no call
/// site anywhere asking which substrate it is talking to.
public func register(_ step: HistoryStep) {
step.retirement?.run()
}
public var canUndo: Bool { public var canUndo: Bool {
guard !isCrossing, isHeld?() != true else { return false } guard !isCrossing, isHeld?() != true else { return false }
+70 -2
View File
@@ -74,22 +74,90 @@ extension BoardStore {
func registerStep( func registerStep(
_ name: String, _ name: String,
subject: String? = nil, subject: String? = nil,
on window: CardWindowUndo? = nil,
retiring: (@MainActor () -> Void)? = nil,
undoExpects: [HistoryExpectation], undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation], redoExpects: [HistoryExpectation],
undo: @escaping @MainActor (BoardStore) throws -> Void, undo: @escaping @MainActor (BoardStore) throws -> Void,
redo: @escaping @MainActor (BoardStore) throws -> Void redo: @escaping @MainActor (BoardStore) throws -> Void
) { ) {
guard let history else { return } let retirement = retiring.map(HistoryStep.Retirement.init)
// **The routing decision, and the whole of it** (13 Rules two levels): a gesture issued
// in a card window lands on that window's stack, and everything else on the board's. It is a
// parameter rather than ambient state on purpose the issuing surface is knowledge only the
// call site has, and a store-wide "current window" would be a second answer able to be wrong
// for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else {
// No substrate at all a repo-nested board (06 Rules), or a store with no session.
// Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run()
return
}
let named = subject ?? name let named = subject ?? name
history.register(HistoryStep( let step = HistoryStep(
name: name, name: name,
retirement: retirement,
undo: { [weak self] direction in undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo) BoardStore.cross(self, direction, named, undoExpects, undo)
}, },
redo: { [weak self] direction in redo: { [weak self] direction in
BoardStore.cross(self, direction, named, redoExpects, redo) BoardStore.cross(self, direction, named, redoExpects, redo)
} }
)
// The raw halves, kept beside the window's stack for the close fold before the register, so
// a fold taken from inside a registration's own side effects can never see a step it has no
// write for (`CardWindowUndo.netEffect`).
window?.record(step.id, CardWindowUndo.Write(
undoExpects: undoExpects,
redoExpects: redoExpects,
undo: undo,
redo: redo
)) ))
sink.register(step)
}
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** 13-native-undo.md Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
/// title', values-based, whose undo restores the card subtree to its session-start state
/// deleted comments included and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
///
/// - **A vanished card registers nothing.** 05-card-window.md Deletion & lifecycle dismisses the
/// window when its card leaves the board into the trash, with its lane, to another board and
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
/// naming folders that have moved could only be a step that skips, so the honest answer is not
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
/// it. (A trashed card keeps its `comments/.trash/` too "a trashed card carries its
/// `comments/`", 01-storage-format.md and the residue sweeps at the next open of that card.)
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
/// - Returns: whether the purge now has an owner a live step holding it until the step leaves
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
/// owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void
) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
guard let net = window.netEffect() else { return false }
registerStep(
HistoryPhrase.cardSession,
subject: item.title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
undo: net.undo,
redo: net.redo
)
return true
} }
/// Validates one side of a step against disk, then runs it as an ordinary bracketed write. /// Validates one side of a step against disk, then runs it as an ordinary bracketed write.
+10
View File
@@ -21,6 +21,11 @@ import AppKit
/// Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user /// Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code. /// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
/// ///
/// **And one per open card window**, over that window's own stack (13 Rules two levels, re-ruled
/// 2026-07-31 `CardWindowUndo.manager`): the second level needs precisely the same translation, so
/// it gets the same object rather than a second one shaped like it. The name is now half-right and
/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not.
///
/// ### It deliberately keeps its inherited stack empty /// ### It deliberately keeps its inherited stack empty
/// ///
/// Every question is overridden, so anything that *did* register into this manager by accident (a /// Every question is overridden, so anything that *did* register into this manager by accident (a
@@ -33,6 +38,11 @@ import AppKit
/// not empty the board's stack (13-native-undo.md Rules the stack belongs to the *session*, and /// not empty the board's stack (13-native-undo.md Rules the stack belongs to the *session*, and
/// its one clearing point is that session's teardown). /// its one clearing point is that session's teardown).
/// ///
/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its
/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that
/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded
/// clear would silently make every close a no-net-change close.
///
/// ### The read-only lock disables Undo and Redo here /// ### The read-only lock disables Undo and Redo here
/// ///
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location) /// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
+19 -3
View File
@@ -64,9 +64,12 @@ public enum HistoryPhrase {
case card case card
case lane case lane
case board case board
/// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and /// One comment. `.delete` and `.edit` reach it the inline edit session joined the
/// the draft save, the inline edit and the trash purge register no step at all /// vocabulary with the window stack (re-ruled 2026-07-31: "every gesture issued in that
/// (13-native-undo.md no byte capture in any tier, and the permanent-delete posture). /// window comment post/delete/**edit**, body Edit sessions ... registers there at fine
/// grain"), registered at its own commit point exactly as the body's session is. Posting has
/// its own phrase (`comment`, below); the draft save and the trash purge still register no
/// step at all (13-native-undo.md the permanent-delete posture).
case comment case comment
var singular: String { var singular: String {
@@ -99,6 +102,19 @@ public enum HistoryPhrase {
/// login'") is dropped exactly as every other phrase drops it a menu row has to stay short. /// login'") is dropped exactly as every other phrase drops it a menu row has to stay short.
public static let comment = "Comment" public static let comment = "Comment"
// MARK: The card-window session
/// **The coarse close step's phrase** one card window's whole session, as the board's stack sees
/// it: 13-native-undo.md Rules names it "Edit card 'Fix login'", so the menu title is the same
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
///
/// Deliberately *not* a new verb. The session is an edit to a card the fine-grained things
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
/// in a menu (06-history-undo.md Commit messages). It reads identically to a body-edit step
/// because on the board's stack it *is* the card's edit.
public static let cardSession = name(.edit, kind: .card)
// MARK: Composition // MARK: Composition
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`. /// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`.
+73 -5
View File
@@ -105,9 +105,55 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`). /// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`).
public struct HistoryStep { public struct HistoryStep {
/// **What a step owes the world once it has left history for good** run exactly once, by
/// whichever provider drops it.
///
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
/// deleted comments, so their backing lives as long as the step does the purge runs when the
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
/// stale) or the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
/// none of them itself so the *provider* reports them, through this.
///
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
/// "exactly once" a property of the object rather than of the bookkeeping around it.
///
/// A step with no retirement every board gesture carries `nil` and costs nothing.
@MainActor
public final class Retirement {
private var work: (@MainActor () -> Void)?
public init(_ work: @escaping @MainActor () -> Void) {
self.work = work
}
/// Runs the work, once. Later calls do nothing, which is what lets every provider report a
/// drop without first checking whether another one already has.
public func run() {
let work = self.work
self.work = nil
work?()
}
/// Whether the work is still owed the assertion a test makes instead of watching disk.
public var isOwed: Bool { work != nil }
}
/// This step's identity, stable across `reversed` which is what makes it usable as a key into
/// state held *beside* a stack (`CardWindowUndo`, whose fold has to find the raw write behind a
/// step that may have crossed any number of times).
public let id: UUID
/// The menu phrase, unprefixed see the type's note. /// The menu phrase, unprefixed see the type's note.
public let name: String public let name: String
/// What this step owes when it leaves history see `Retirement`. `nil` for every step that owes
/// nothing, which is all but the card-window close step.
public let retirement: Retirement?
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of /// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
/// the write that just landed. /// the write that just landed.
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
@@ -117,11 +163,15 @@ public struct HistoryStep {
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init( public init(
id: UUID = UUID(),
name: String, name: String,
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome, undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) { ) {
self.id = id
self.name = name self.name = name
self.retirement = retirement
self.undo = undo self.undo = undo
self.redo = redo self.redo = redo
} }
@@ -129,8 +179,12 @@ public struct HistoryStep {
/// The same step read backwards what a provider puts on the opposite stack once this one has /// The same step read backwards what a provider puts on the opposite stack once this one has
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move /// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
/// Card": the phrase names the *gesture*, not the direction. /// Card": the phrase names the *gesture*, not the direction.
///
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
/// the purge it defers must still be owed exactly once.
public var reversed: HistoryStep { public var reversed: HistoryStep {
HistoryStep(name: name, undo: redo, redo: undo) HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
} }
} }
@@ -138,11 +192,16 @@ public struct HistoryStep {
/// The undo/redo substrate, behind one protocol boundary (12-editions.md The provider seam). /// The undo/redo substrate, behind one protocol boundary (12-editions.md The provider seam).
/// ///
/// ### One per board session /// ### One per board session and one per open card window
/// ///
/// "One stack per board, owned by the board session. Not per-window: every window over a board /// "Two levels: one stack per board, one per open card window" (13-native-undo.md Rules, re-ruled
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md /// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its
/// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds /// implementation is what this protocol is a seam for. A **card window** owns a second stack for its
/// own gestures always a `NativeHistoryProvider`, in either tier, because a window's fine-grained
/// inverses are values-based whatever the board's substrate is (`CardWindowUndo`); what reaches this
/// seam from a window is the one coarse step its close registers.
///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31): /// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31):
/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at /// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward /// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
@@ -160,6 +219,15 @@ public struct HistoryStep {
/// relaunch because git does (06). Both are honest implementations of these seven members. /// relaunch because git does (06). Both are honest implementations of these seven members.
/// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's /// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's
/// (06 Undo routing, tier-independent) `BoardUndoRouting`. /// (06 Undo routing, tier-independent) `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you drop
///
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` the
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and for a substrate
/// that keeps no steps at all the step handed to `register` itself. Nothing else in the app can
/// observe that moment, and a step dropped in silence would defer its work forever.
@MainActor @MainActor
public protocol HistoryProviding: AnyObject { public protocol HistoryProviding: AnyObject {
+25
View File
@@ -36,6 +36,31 @@ public enum ExpectedField: Sendable, Equatable {
/// The body span, **byte for byte** the Edit session's step, and the one inverse in the app /// The body span, **byte for byte** the Edit session's step, and the one inverse in the app
/// whose fidelity is not field-level (13: "body steps compare bytes"). /// whose fidelity is not field-level (13: "body steps compare bytes").
case body(String) case body(String)
/// Which field this is, ignoring the value it carries the key a fold merges on
/// (`CardWindowUndo`): two writes to `background` inside one card-window session are one field
/// with a first and a last value, while a write to `background` and one to `icon` are two.
var kind: Kind {
switch self {
case .title: .title
case .order: .order
case .width: .width
case .background: .background
case .icon: .icon
case .body: .body
}
}
/// The field names, as a comparable value deliberately not the `String` keys, which are
/// `FrontmatterKeys`' business and would tie a fold to the file format.
enum Kind: Hashable, Sendable {
case title
case order
case width
case background
case icon
case body
}
} }
// MARK: - HistoryExpectation // MARK: - HistoryExpectation
+32 -1
View File
@@ -2,7 +2,11 @@ import Foundation
// MARK: - NativeHistoryProvider // MARK: - NativeHistoryProvider
/// The free tier's undo substrate: one stack per board session (13-native-undo.md). /// The free tier's undo substrate: one stack per board session (13-native-undo.md) **and the
/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
/// the two it is; both need the same four-line grammar.
/// ///
/// ### Two arrays, and why not `NSUndoManager` /// ### Two arrays, and why not `NSUndoManager`
/// ///
@@ -59,10 +63,25 @@ public final class NativeHistoryProvider: HistoryProviding {
public var redoActionName: String? { name(of: redoSteps.last) } public var redoActionName: String? { name(of: redoSteps.last) }
/// **The steps currently crossable by Z, oldest first** read-only, and read by exactly one
/// caller: the card window's close, which folds the session's own stack into the one coarse board
/// step (13-native-undo.md Rules "Window close coarsens"; `CardWindowUndo`).
///
/// The undo stack is the honest input to that fold and the redo stack is not: a gesture the user
/// undid inside the window is a gesture whose effect is no longer on disk, so it must contribute
/// nothing to the session's net effect. Nothing here decides what a fold *means* this is the
/// membership question, and the two arrays already answer it.
public var pendingSteps: [HistoryStep] { undoSteps }
/// Records one undoable step and clears the redo stack the classic rule, and the one every /// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares. /// substrate shares.
///
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
/// moment the app can see it.
public func register(_ step: HistoryStep) { public func register(_ step: HistoryStep) {
undoSteps.append(step) undoSteps.append(step)
retire(redoSteps)
redoSteps.removeAll() redoSteps.removeAll()
} }
@@ -70,11 +89,20 @@ public final class NativeHistoryProvider: HistoryProviding {
public func redo() { cross(.redo) } public func redo() { cross(.redo) }
/// Session teardown, the add-git substrate swap, a branch reseed every step goes, so every step
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
/// ends" (13 Interaction with the trash).
public func clear() { public func clear() {
retire(undoSteps)
retire(redoSteps)
undoSteps.removeAll() undoSteps.removeAll()
redoSteps.removeAll() redoSteps.removeAll()
} }
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
// MARK: - The crossing // MARK: - The crossing
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's /// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
@@ -91,6 +119,9 @@ public final class NativeHistoryProvider: HistoryProviding {
push(step.reversed, onto: direction.opposite) push(step.reversed, onto: direction.opposite)
return return
case .skipped: case .skipped:
// Dropped for good the third of the deferred purge's release conditions ("gone
// stale"), and the reason a skip is reported here rather than merely counted.
step.retirement?.run()
continue continue
case .failed: case .failed:
push(step, onto: direction) push(step, onto: direction)
+34 -2
View File
@@ -423,6 +423,11 @@ public final class BoardStore: HealHost {
/// made its own would be a second answer to which stack a board has. /// made its own would be a second answer to which stack a board has.
/// `AppModel.beginSession` wires it the moment the session's provider exists. /// `AppModel.beginSession` wires it the moment the session's provider exists.
/// ///
/// **It is the board's stack, and not every step's destination** (13 Rules two levels,
/// re-ruled 2026-07-31): a gesture issued in a card window registers on *that window's* stack
/// instead, which the write methods below take as a parameter (`CardWindowUndo`). This one carries
/// board-surface gestures and the coarse step a window's close folds its session into.
///
/// **Weak, deliberately.** The session owns both the store and the provider, and the provider's /// **Weak, deliberately.** The session owns both the store and the provider, and the provider's
/// steps hold closures over *this* store: a strong reference here would close that loop, leaving a /// steps hold closures over *this* store: a strong reference here would close that loop, leaving a
/// board that could only be freed by remembering to empty its undo stack first. `nil` no session /// board that could only be freed by remembering to empty its undo stack first. `nil` no session
@@ -1490,7 +1495,19 @@ public final class BoardStore: HealHost {
/// like every other gesture with no second thing to do. A batch that fails partway leaves the /// like every other gesture with no second thing to do. A batch that fails partway leaves the
/// targets written before it written the Writer is "atomic per filesystem operation, not per /// targets written before it written the Writer is "atomic per filesystem operation, not per
/// gesture" and the reload shows the true state, which is the honest one. /// gesture" and the reload shows the true state, which is the honest one.
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) { ///
/// **The step's stack is the anchor's** (13-native-undo.md Rules two levels): the card
/// window's sidebar editor passes that window's own (`CardStyleSection`), so a colour chosen there
/// is one of the window's fine-grained gestures and joins board history only inside the coarse
/// close step. The board popover, the Style popover and the quick-style rows pass nothing, which
/// is the board's stack where a board-issued gesture belongs even when it names a card whose
/// window is open.
public func applyStyle(
to target: StyleTarget,
background: StyleChange = .keep,
icon: StyleChange = .keep,
on window: CardWindowUndo? = nil
) {
let edits: [( let edits: [(
id: ItemID?, id: ItemID?,
folder: URL, folder: URL,
@@ -1550,6 +1567,7 @@ public final class BoardStore: HealHost {
registerStep( registerStep(
HistoryPhrase.name(.restyle, kind: kind, count: edits.count), HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
subject: subject, subject: subject,
on: window,
undoExpects: edits.map { undoExpects: edits.map {
.present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon)) .present($0.folder, fields: Self.styledFields(background: $0.background, icon: $0.icon))
}, },
@@ -1961,7 +1979,20 @@ public final class BoardStore: HealHost {
/// session that ended because its card was moved to the trash registers against the trash folder /// session that ended because its card was moved to the trash registers against the trash folder
/// it actually flushed into, and a later restore moves the card out from under the step, which /// it actually flushed into, and a later restore moves the card out from under the step, which
/// the ordinary existence check then reads as the collision it is. /// the ordinary existence check then reads as the collision it is.
public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) { ///
/// ### Which stack it lands on is the caller's to say
///
/// An Edit session belongs to a *window*, so the card window passes its own
/// (`CardWindowHost.configureSession` `CardWindowUndo`) and the step never reaches board
/// history until the window closes and folds it into the coarse session step (13 Rules two
/// levels). `nil` the default, and what a test or any non-window caller passes is the board's
/// stack, exactly as before.
public func registerBodyEdit(
inCard cardID: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return } guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return }
let folder = target.folder(under: rootURL) let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot) let title = Self.cardTitle(at: target, in: snapshot)
@@ -1969,6 +2000,7 @@ public final class BoardStore: HealHost {
registerStep( registerStep(
HistoryPhrase.name(.edit, kind: .card), HistoryPhrase.name(.edit, kind: .card),
subject: title, subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))], undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))] redoExpects: [.present(folder, .body(priorBody))]
) { _ in ) { _ in
+95 -21
View File
@@ -37,21 +37,26 @@ public enum CommentTarget: Sendable, Equatable {
/// ///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for /// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the /// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every /// *board's* store one store per board, whatever the window (02-architecture.md § Components), so a
/// window over a board (board window, its card windows) shares the store and shares the stack" /// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// (13 Rules). A comment posted in a card window is undone by Z in the board window, which is only /// other write in the app.
/// true if the step was registered here. ///
/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 the
/// session-coarsening model, 13 Rules two levels): every gesture below takes the issuing window's
/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack
/// never carries a granular comment step" (13 Interaction with the trash). Board history sees the
/// window's session as one coarse step when the window closes (`registerCardSession`).
/// ///
/// ### What registers a step, and what deliberately does not /// ### What registers a step, and what deliberately does not
/// ///
/// Two of the five (13 Interaction with the trash, the comment clause; 13 Rules):
///
/// - **`postComment`** inverse: the rename back to `.draft`. Move-based, no capture. /// - **`postComment`** inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** inverse: the move back out of `comments/.trash/`. Move-based, no capture. /// - **`deleteComment`** inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule /// - **`registerCommentEdit`** one *session*, at its commit point, holding the bytes it opened on
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in /// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start /// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// bytes 05-card-window.md), which is a live buffer, not a stack entry. /// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
/// no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
/// have (05-card-window.md The comments column).
/// - **`purgeCommentTrash` registers nothing** the permanent-delete posture. It is also what makes /// - **`purgeCommentTrash` registers nothing** the permanent-delete posture. It is also what makes
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge /// the interaction with the stack correct for free: leftover comment steps go stale after a purge
/// and skip with the ordinary info-tone banner, because the folders their expectations name are /// and skip with the ordinary info-tone banner, because the folders their expectations name are
@@ -122,8 +127,13 @@ extension BoardStore {
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo /// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
/// that re-minted would post a *different* comment, and any step registered above this one naming /// that re-minted would post a *different* comment, and any step registered above this one naming
/// the posted id would name nothing. /// the posted id would name nothing.
///
/// **The step lands on the issuing window's stack** (13 Rules two levels, re-ruled
/// 2026-07-31): the composer is a card window's surface, so the window passes its own
/// (`CardWindowHost.configureComments`). `nil` is the board's stack a caller with no window,
/// which in the app is nobody and in a test is the shortest way to drive the write.
@discardableResult @discardableResult
public func postComment(inCard id: ItemID) -> ItemID? { public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> ItemID? {
guard let card = commentSubject(id) else { return nil } guard let card = commentSubject(id) else { return nil }
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title) try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
@@ -138,6 +148,7 @@ extension BoardStore {
registerStep( registerStep(
HistoryPhrase.comment, HistoryPhrase.comment,
subject: title, subject: title,
on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)], undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)] redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in ) { _ in
@@ -155,8 +166,8 @@ extension BoardStore {
// MARK: Editing // MARK: Editing
/// An inline edit session's save one bracket, **no step** (13's no-capture rule; the session /// An inline edit session's save one bracket, **no step**, exactly as a card body's ~700 ms
/// owns Cancel). /// tick writes no step: a session is not a save (`registerCommentEdit`, below).
/// ///
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone. /// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
@discardableResult @discardableResult
@@ -169,6 +180,47 @@ extension BoardStore {
return wrote ?? false return wrote ?? false
} }
/// **Registers one inline edit session as one step** `registerBodyEdit`'s shape one level down,
/// and its reasons verbatim.
///
/// The window stack is what made this possible and what made it necessary. 13-native-undo.md's
/// no-byte-capture rule is about the *board* stack, where a comment's granular history has no
/// business ("the board stack never carries a granular comment step"); the re-ruled two-level
/// model puts every gesture issued in a card window on that window's own stack at fine grain,
/// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's
/// Edit session registers, at the same kind of boundary Save, , or the close flush with the
/// bytes the session opened on (`CommentEditSession.sessionStart`).
///
/// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back,
/// so the session's net effect is nothing and there is nothing to undo.
///
/// Its predicate is the body's: "body steps compare bytes" (13 Rules), against the comment's own
/// folder so a foreign edit to that comment skips the step, and a foreign edit to the card or to
/// a sibling comment leaves it alone.
public func registerCommentEdit(
_ commentID: ItemID,
inCard id: ItemID,
priorBody: String,
newBody: String,
on window: CardWindowUndo? = nil
) {
guard priorBody != newBody, let card = commentSubject(id) else { return }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let title = card.title
registerStep(
HistoryPhrase.name(.edit, kind: .comment),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ in
_ = try BoardWriter.editComment(at: folder, body: priorBody, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.editComment(at: folder, body: newBody, cardTitle: title)
}
}
// MARK: Delete and its inverse // MARK: Delete and its inverse
/// **Deletes a comment a move into `comments/.trash/`, immediate, no confirm, undoable** /// **Deletes a comment a move into `comments/.trash/`, immediate, no confirm, undoable**
@@ -179,8 +231,21 @@ extension BoardStore {
/// and existence is the whole predicate. The container rides in the path exactly as it does at /// and existence is the whole predicate. The container rides in the path exactly as it does at
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in /// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself. /// the thread so a foreign restore or a foreign re-delete skips the right half by itself.
///
/// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
/// window stack): the undo needs the trashed folder there *and the live path free*, because the
/// move back would otherwise land on top of whatever now sits at it and because the close fold
/// reads these lists as the state the session left. A move that named only its destination would
/// leave the session's fold claiming a comment is still at a path it has left, and the coarse step
/// would be stale the moment it was registered (`CardWindowUndo.netEffect`).
///
/// **The step lands on the issuing window's stack** `postComment`'s rule, and the one 13's
/// comments paragraph states outright: "the step lives on the card window's own stack ... so the
/// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
/// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
/// keeping alive (`registerCardSession`).
@discardableResult @discardableResult
public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool { public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let card = commentSubject(id) else { return false } guard let card = commentSubject(id) else { return false }
let folder = card.folder let folder = card.folder
let title = card.title let title = card.title
@@ -195,8 +260,9 @@ extension BoardStore {
registerStep( registerStep(
HistoryPhrase.name(.delete, kind: .comment), HistoryPhrase.name(.delete, kind: .comment),
subject: title, subject: title,
undoExpects: [.present(trashed)], on: window,
redoExpects: [.present(live)] undoExpects: [.present(trashed), .absent(live)],
redoExpects: [.present(live), .absent(trashed)]
) { _ in ) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title) try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in } redo: { _ in
@@ -207,12 +273,20 @@ extension BoardStore {
// MARK: The purge, and the crash residue it leaves // MARK: The purge, and the crash residue it leaves
/// **Empties one card's `comments/.trash/`** the card window's close flush (01-storage-format.md /// **Empties one card's `comments/.trash/`** **when undo no longer needs it** (01-storage-format.md
/// § Enhanced schema: "purged when the card window closes (rides the close flush)"). /// § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md Interaction with the trash).
/// ///
/// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must /// The call site moved with that re-ruling and this method did not change: the window's close no
/// not be: invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with /// longer purges on its own, it hands this work to the coarse close step as that step's
/// the ordinary info-tone banner the first time one is crossed. /// **retirement** (`HistoryStep.Retirement`), and the purge runs when the step leaves the board
/// stack undone-and-superseded, dropped, gone stale or when the board session ends. On a git
/// board the step is never kept, so the retirement fires at the close flush, which is where the
/// purge always ran there ("purge rides the close flush"). The crash-residue sweep at the next
/// card-window open is unchanged.
///
/// One bracket, no step. Leftover comment steps on a stack are not pruned here and must not be:
/// invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with the
/// ordinary info-tone banner the first time one is crossed.
public func purgeCommentTrash(inCard id: ItemID) { public func purgeCommentTrash(inCard id: ItemID) {
guard let card = commentSubject(id) else { return } guard let card = commentSubject(id) else { return }
try? performWrite { () throws(BoardWriteError) -> Void in try? performWrite { () throws(BoardWriteError) -> Void in
+23 -4
View File
@@ -68,8 +68,16 @@ enum AgentGuide {
/// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified` /// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
/// descending, so there is no rank to mint on the way in the guide's smallest-`order`-minus-1024 /// descending, so there is no rank to mint on the way in the guide's smallest-`order`-minus-1024
/// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp /// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
/// discipline v7 already taught, now doing the ordering as well. /// discipline v7 already taught, now doing the ordering as well. **v9 teaches the one-line
static let version = 8 /// value**, from a real agent incident (2026-07-31): a card's `title` was a double-quoted
/// scalar wrapped across two lines, and a hand copy of that card onto another board took the
/// first line without its continuation. An unclosed quote does not stop at the key it began
/// on it runs to the end of the block so a board failed to load over a file whose only
/// defect was a missing second line, and the parser's complaint pointed at the *last* key it
/// swallowed rather than the title. The guide now teaches long values as one long line, says
/// why the wrapped shape is the dangerous one to copy, and names the unterminated quote beside
/// the unquoted colon in Hard rules.
static let version = 9
// MARK: - The version marker // MARK: - The version marker
@@ -381,6 +389,16 @@ enum AgentGuide {
containing `: ` or starting with `#`, `[`, `{`, or a quote when in doubt, containing `: ` or starting with `#`, `[`, `{`, or a quote when in doubt,
double-quote. double-quote.
**Keep every value on one line.** A double-quoted scalar may legally
continue on the following line (`title: "Long title` then ` the rest"`),
and some tools emit that shape for long titles but it is the most common
way frontmatter gets broken by hand: the continuation reads as a line of
its own, so an edit or a copy that takes only the first one leaves the
quote unclosed, and an unclosed quote runs on to swallow every key below
it. The whole file then fails to parse, not just the title. Titles have no
length limit write a long one as one long line, and when you copy a card
between boards, copy its frontmatter block whole.
Unknown keys are preserved verbatim by the app and invisible in its UI Unknown keys are preserved verbatim by the app and invisible in its UI
custom metadata (`project:`, `tags:`, `claimed-by:` ) is safe to add and custom metadata (`project:`, `tags:`, `claimed-by:` ) is safe to add and
survives every app rewrite. Reserved for Lanework's upcoming tracker sync survives every app rewrite. Reserved for Lanework's upcoming tracker sync
@@ -491,8 +509,9 @@ enum AgentGuide {
## Hard rules (the app fails loudly on violations) ## Hard rules (the app fails loudly on violations)
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and - Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
cards) is required. Keep `schema: 1`. The classic violation is an cards) is required. Keep `schema: 1`. The classic violations are an
unquoted colon in a title (see Frontmatter above). unquoted colon in a title and a quoted value left unclosed across a
line break (see Frontmatter above).
- Files must be UTF-8 without BOM. - Files must be UTF-8 without BOM.
- Never create a card folder without an `index.md`. - Never create a card folder without an `index.md`.
- Never rename UUID folders. - Never rename UUID folders.
+29 -8
View File
@@ -108,7 +108,8 @@ public final class CardComments {
@ObservationIgnored @ObservationIgnored
public var sweepTrashResidue: (() -> Void)? 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 @ObservationIgnored
public var purgeTrash: (() -> Void)? public var purgeTrash: (() -> Void)?
@@ -127,6 +128,12 @@ public final class CardComments {
@ObservationIgnored @ObservationIgnored
public var editComment: ((ItemID, String) -> Bool)? 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/` /// Imports files into an authoring surface's `attachments/`
/// `BoardStore.importCommentAttachments(_:inCard:target:)`. /// `BoardStore.importCommentAttachments(_:inCard:target:)`.
@ObservationIgnored @ObservationIgnored
@@ -319,6 +326,9 @@ public final class CardComments {
session.save = { [weak self] text in session.save = { [weak self] text in
self?.editComment?(commentID, text) ?? false self?.editComment?(commentID, text) ?? false
} }
session.registerUndo = { [weak self] prior, new in
self?.registerCommentEdit?(commentID, prior, new)
}
editing = session editing = session
} }
@@ -409,20 +419,31 @@ public final class CardComments {
// MARK: - The close flush // 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 /// 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 /// abandon), and then the composer's draft lands. Ending twice does nothing the second time the
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and /// sessions latch which is what makes the two paths that call this (a window closed on its own,
/// running it before a session's save could remove a folder that save was about to write into. /// 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 /// **The purge is no longer here** (re-ruled 2026-07-31 13-native-undo.md Interaction with the
/// trash is a no-op which is what makes the two paths that call this (a window closed on its /// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from,
/// own, and the board's close flush driving it) safe to both exist. /// 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() { public func endSession() {
editing?.endOnClose() editing?.endOnClose()
editing = nil editing = nil
composer.flush() 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?() purgeTrash?()
} }
+7 -1
View File
@@ -32,6 +32,11 @@ struct CardStyleSection: View {
let store: BoardStore let store: BoardStore
let recents: StyleRecents let recents: StyleRecents
let cardID: ItemID 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, /// 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. /// so every section in this sidebar derives its geometry the same way.
@@ -58,7 +63,8 @@ struct CardStyleSection: View {
layout: .sidebar( layout: .sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize), contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize bodyPointSize: pointSize
) ),
undo: undo
) )
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
+6 -1
View File
@@ -91,6 +91,11 @@ struct CardWindowView: View {
let comments: CardComments let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot. /// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache 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 /// **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; /// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
/// see `historySlot`. /// see `historySlot`.
@@ -314,7 +319,7 @@ struct CardWindowView: View {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) 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 // 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`). // 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 @ObservationIgnored
public var save: ((String) -> Bool)? 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`. /// How many saves have actually been attempted through `save`.
@ObservationIgnored @ObservationIgnored
public private(set) var saveAttempts = 0 public private(set) var saveAttempts = 0
@@ -142,7 +152,9 @@ public final class CommentEditSession {
public func commit() -> Bool { public func commit() -> Bool {
guard !hasEnded else { return false } guard !hasEnded else { return false }
hasEnded = true 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 /// **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 { public func endOnClose() -> Bool {
guard !hasEnded else { return false } guard !hasEnded else { return false }
hasEnded = true hasEnded = true
return flush() let wrote = flush()
registerStep()
return wrote
} }
// MARK: - Private // 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() { private func scheduleSave() {
cancelPending() cancelPending()
let interval = debounceInterval let interval = debounceInterval
+15 -4
View File
@@ -30,14 +30,18 @@ import SwiftUI
/// colour to remember; only `.set` reaches `StyleRecents.record`. /// colour to remember; only `.set` reaches `StyleRecents.record`.
@MainActor @MainActor
enum StyleCommand { enum StyleCommand {
/// - Parameter undo: the issuing window's own stack, for the one anchor that has one the card
/// window's sidebar (13-native-undo.md Rules two levels). `nil`, which every board-side
/// anchor passes, is the board's stack.
static func apply( static func apply(
background: StyleChange = .keep, background: StyleChange = .keep,
icon: StyleChange = .keep, icon: StyleChange = .keep,
to target: StyleTarget, to target: StyleTarget,
in store: BoardStore, in store: BoardStore,
recents: StyleRecents recents: StyleRecents,
on undo: CardWindowUndo? = nil
) { ) {
store.applyStyle(to: target, background: background, icon: icon) store.applyStyle(to: target, background: background, icon: icon, on: undo)
if case let .set(value) = background { if case let .set(value) = background {
recents.record(value) recents.record(value)
} }
@@ -239,6 +243,13 @@ struct StyleEditorView: View {
/// size and a default argument cannot read one. /// size and a default argument cannot read one.
var layout: StyleEditorLayout? var layout: StyleEditorLayout?
/// **Which stack this anchor's writes register on** the card window's own when the editor is
/// mounted in one (`CardStyleSection`), and `nil`, the board's, everywhere else
/// (13-native-undo.md Rules two levels). It sits beside `layout` and arrives the same way
/// the anchor telling the shared component about itself but unlike `layout` it is not geometry:
/// a colour chosen in a card window is one of that window's session gestures.
var undo: CardWindowUndo?
/// The live body metric, read here rather than passed in `CardStyleSection`'s pattern, so /// The live body metric, read here rather than passed in `CardStyleSection`'s pattern, so
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling /// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
/// rule). /// rule).
@@ -293,7 +304,7 @@ struct StyleEditorView: View {
columns: layout.backgroundColumns, columns: layout.backgroundColumns,
layout: layout, layout: layout,
apply: { change in apply: { change in
StyleCommand.apply(background: change, to: target, in: store, recents: recents) StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
} }
) )
} }
@@ -346,7 +357,7 @@ struct StyleEditorView: View {
columns: layout.symbolColumns, columns: layout.symbolColumns,
layout: layout, layout: layout,
apply: { change in apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents) StyleCommand.apply(icon: change, to: target, in: store, recents: recents, on: undo)
} }
) )
if let maximumHeight = layout.symbolGridMaximumHeight { if let maximumHeight = layout.symbolGridMaximumHeight {
+16
View File
@@ -705,6 +705,22 @@ struct AgentGuideContentTests {
#expect(!content.contains("Arrivals go on top")) #expect(!content.contains("Arrivals go on top"))
} }
/// **v9: values stay on one line** (2026-07-31 incident): a wrapped double-quoted `title` lost
/// its continuation line in a hand copy between boards, leaving the quote unclosed and an
/// unclosed quote runs to the end of the block, so the whole file stopped parsing and the board
/// refused to load. The guide has to teach the shape, say why copying it is where it breaks, and
/// name the unterminated quote in Hard rules beside the colon it now shares billing with.
@Test("v9 teaches one-line frontmatter values and names the unterminated quote")
func v9OneLineValueVocabularyIsPresent() {
let content = AgentGuide.content
#expect(content.contains("**Keep every value on one line.**"))
#expect(content.contains("swallow every key below"))
#expect(content.contains("copy its frontmatter block whole"))
// Hard rules names both classic violations, not just the colon.
#expect(content.contains("The classic violations are an"))
#expect(content.contains("quoted value left unclosed across a"))
}
/// The pathfinder's guide taught `media/` and tombstone deletes; both are retired /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
/// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate /// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// mention of `deleted:` is the warning never to write it. /// mention of `deleted:` is the warning never to write it.
+34 -14
View File
@@ -425,10 +425,13 @@ struct CardCommentsAttachmentTests {
@Suite("Card comments ▸ the close flush") @Suite("Card comments ▸ the close flush")
struct CardCommentsCloseTests { struct CardCommentsCloseTests {
@Test("Saves first, purge last") @Test("The saves land, and the purge is not the pane's to run")
func savesPrecedeThePurge() { func savesLandAndThePurgeDefers() {
// The purge removes the folders a delete moved aside; running it before a session's save // Realigned 2026-07-31 with the session-coarsening model (13-native-undo.md Interaction
// could remove a folder that save was about to write into. // with the trash): the ordering this pinned every save before anything empties
// `comments/.trash/` still holds and is now the window session's to keep, because only
// that level knows whether a coarse close step took the purge on (`CardWindowSession`).
// What the pane owes the close is its two saves, in order.
let spy = CommentsSpy() let spy = CommentsSpy()
let comments = makeComments(spy) let comments = makeComments(spy)
spy.thread = thread([comment(CommentIdent.one, body: "first\n")]) spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
@@ -441,9 +444,12 @@ struct CardCommentsCloseTests {
let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n"))) let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n")))
let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n"))) let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
let purge = try? #require(spy.events.firstIndex(of: .purge)) #expect((edit ?? 0) < (draft ?? 1))
#expect((edit ?? 0) < (purge ?? 0)) #expect(!spy.events.contains(.purge), "the trash outlives the pane's own close")
#expect((draft ?? 0) < (purge ?? 0))
// And it is still one call away, for whoever ends up owing it.
comments.purgeTrashNow()
#expect(spy.events.last == .purge)
} }
@Test("The close flushes the session — it never reverts it") @Test("The close flushes the session — it never reverts it")
@@ -472,13 +478,15 @@ struct CardCommentsCloseTests {
#expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1) #expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1)
} }
@Test("A clean pane still purges — the trash is the app's leftovers, not the user's work") @Test("A clean pane's close writes nothing and purges nothing")
func aCleanPaneStillPurges() { func aCleanPaneClosesQuietly() {
// The trash is still the app's leftovers rather than the user's work but who empties it,
// and when, is the window session's question now (`CardSessionUndoTests`).
let spy = CommentsSpy() let spy = CommentsSpy()
let comments = makeComments(spy) let comments = makeComments(spy)
comments.endSession() comments.endSession()
#expect(spy.events == [.purge]) #expect(spy.events.isEmpty)
} }
} }
@@ -580,7 +588,7 @@ struct CardCommentsWiringTests {
let comments = CardComments() let comments = CardComments()
comments.isEditable = true comments.isEditable = true
comments.cardFolder = fixture.url(card) comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: cardID) CardWindowHost.configureComments(comments, store: store, cardID: cardID, on: CardWindowUndo())
comments.open() comments.open()
comments.composer.edited("A remark.\n") comments.composer.edited("A remark.\n")
@@ -599,8 +607,12 @@ struct CardCommentsWiringTests {
#expect(!fixture.exists("\(card)/comments/.draft")) #expect(!fixture.exists("\(card)/comments/.draft"))
} }
@Test("Delete moves into comments/.trash/, and the close purge empties it") @Test("Delete moves into comments/.trash/, and the deferred purge empties it")
func deleteThenPurge() throws { func deleteThenPurge() throws {
// Realigned 2026-07-31 (13-native-undo.md Interaction with the trash): the pane's own
// close no longer purges `comments/.trash/` is what the window's coarse close step
// restores from, so emptying it is the purge the window session owns and hands out
// (`CardWindowSession.endSession`; `CardSessionUndoTests` drives the deferral itself).
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture) let card = try makeCommentBoard(fixture)
@@ -610,7 +622,9 @@ struct CardCommentsWiringTests {
let comments = CardComments() let comments = CardComments()
comments.isEditable = true comments.isEditable = true
comments.cardFolder = fixture.url(card) comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
comments.open() comments.open()
#expect(comments.thread.comments.count == 1) #expect(comments.thread.comments.count == 1)
@@ -619,6 +633,10 @@ struct CardCommentsWiringTests {
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
comments.endSession() comments.endSession()
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
"the saves land; the trash is not the pane's to empty any more")
comments.purgeTrashNow()
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
} }
@@ -633,7 +651,9 @@ struct CardCommentsWiringTests {
let comments = CardComments() let comments = CardComments()
comments.isEditable = true comments.isEditable = true
comments.cardFolder = fixture.url(card) comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
comments.open() comments.open()
comments.beginEdit(ItemID(rawValue: CommentIdent.one)) comments.beginEdit(ItemID(rawValue: CommentIdent.one))
+3 -1
View File
@@ -276,7 +276,9 @@ struct CommentPaneAnnouncementTests {
comments.isEditable = true comments.isEditable = true
comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
comments.cardTitle = "Fix login" comments.cardTitle = "Fix login"
CardWindowHost.configureComments(comments, store: store, cardID: ItemID(rawValue: Ident.card1)) CardWindowHost.configureComments(
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
)
let spoken = Spoken(store: store) let spoken = Spoken(store: store)
comments.announce = { spoken.record($0) } comments.announce = { spoken.record($0) }
return (comments, spoken) return (comments, spoken)
+20 -7
View File
@@ -703,7 +703,7 @@ struct UndoCommandSurfaceTests {
defer: true defer: true
) )
let controller = HostedWindowController() let controller = HostedWindowController()
controller.boardUndoManager = { manager } controller.windowUndoManager = { manager }
controller.attach(to: window) controller.attach(to: window)
return (window, controller) return (window, controller)
} }
@@ -792,8 +792,11 @@ struct UndoCommandSurfaceTests {
#expect(undoRow.title == "Undo") #expect(undoRow.title == "Undo")
} }
@Test("Every window over one board answers with that board's one stack") @Test("A board window answers with the board's stack; a card window answers with its own")
func cardWindowsShareTheBoardsStack() throws { func eachWindowAnswersWithItsOwnStack() throws {
// Realigned 2026-07-31 with the two-level model (13-native-undo.md Rules): this pinned the
// shared-stack wiring, which is exactly what the session-coarsening re-ruling replaced
// "a card window owns its own stack ... and `window.undoManager` answers with it".
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -801,10 +804,12 @@ struct UndoCommandSurfaceTests {
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
// The board window and one of its card windows, wired the way their hosts wire them. // The board window and one of its card windows, wired the way their hosts wire them
// (`BoardWindowHost.configureWindow`, `CardWindowHost.configureWindow`).
let card = CardWindowUndo()
let (boardWindow, boardController) = hostedWindow(session.undoManager) let (boardWindow, boardController) = hostedWindow(session.undoManager)
defer { boardController.detach() } defer { boardController.detach() }
let (cardWindow, cardController) = hostedWindow(session.undoManager) let (cardWindow, cardController) = hostedWindow(card.manager)
defer { cardController.detach() } defer { cardController.detach() }
let boardRow = menuItem("undo:") let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:") let cardRow = menuItem("undo:")
@@ -812,9 +817,17 @@ struct UndoCommandSurfaceTests {
session.history?.register(StepLog().step("Move 3 Cards")) session.history?.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow)) #expect(boardWindow.validateMenuItem(boardRow))
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
#expect(boardRow.title == "Undo Move 3 Cards") #expect(boardRow.title == "Undo Move 3 Cards")
#expect(cardRow.title == boardRow.title)
// **No fall-through** (06-history-undo.md Undo routing): the card window's own stack is
// empty, so its row is disabled and Z beeps it never reaches the board's step.
#expect(cardWindow.validateMenuItem(cardRow) == false)
#expect(cardRow.title == "Undo")
card.stack.register(StepLog().step("Comment"))
#expect(cardWindow.validateMenuItem(cardRow))
#expect(cardRow.title == "Undo Comment")
#expect(boardRow.title == "Undo Move 3 Cards", "and the board's row is untouched by it")
} }
// MARK: The toolbar twins // MARK: The toolbar twins