Give card windows their own undo stacks and coarsen the close

Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+6 -3
View File
@@ -339,9 +339,12 @@ public final class AppModel {
/// open-now flag without matching by identity a second time.
public let recordID: UUID
/// This board's undo/redo substrate **one stack per board session, never per window**
/// (13-native-undo.md Rules). It lives here for the store's reason exactly: the session is
/// what every window over this board shares, and "undo is board-local".
/// This board's undo/redo substrate **the board half of 13-native-undo.md Rules' two
/// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
/// gestures and the one coarse step each card window's close registers. A card window's own
/// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
/// window). It lives here for the store's reason exactly: the session is what every window
/// over this board shares, and "undo is board-local".
///
/// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
+1 -1
View File
@@ -276,7 +276,7 @@ struct BoardWindowHost: View {
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
// up: `NSWindow` validates and crosses them against exactly this manager.
windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager }
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
// The window-title widget (03-board-ui.md § Board popover) **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
+93 -24
View File
@@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing {
/// rather than pretending to have written it.
let body: CardBodyEditSession
/// **This window's own undo stack** 13-native-undo.md Rules' second level (re-ruled
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
/// `window.undoManager` answers with it".
///
/// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
/// folded from this stack, and a stack held only by the view would be gone by the time the fold
/// ran. Every window gesture registers into it through the store's own methods, which take it as
/// a parameter (`CardWindowUndo`).
let undo = CardWindowUndo()
/// The window's comments pane the thread, the composer's draft buffer, and the one open inline
/// edit session (05-card-window.md The comments column).
///
@@ -85,6 +95,16 @@ final class CardWindowSession: CardSessionFlushing {
var rawSourceCancel: (@MainActor () -> Void)?
var rawSourceIsActive: (@MainActor () -> Bool)?
/// **Where the close registers this session as one board step**
/// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
/// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
/// having no idea what a board is.
///
/// It answers whether the deferred `comments/.trash/` purge now has an owner see `endSession()`.
/// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
/// object's to run, which is also true.
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
/// holding keystrokes its file has not got see `CardSessionFlushing`.
///
@@ -164,14 +184,25 @@ final class CardWindowSession: CardSessionFlushing {
// session, "never per save tick" (06-history-undo.md Rules Auto-commit). The debounced
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
// the committer's own debounce outlives them and this call is where the session is known to
// be over.
// be over. It is also where the body's *last* fine step joins this window's stack, which is
// why it has to precede the fold below.
body.endEditSession()
// **Saves first, purge last** the inline comment session's flush and the draft's save land
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
// body's for the same reason it runs at all: this is the one place the window's whole close
// work has a fixed order.
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
// land before the fold.
comments.endSession()
// **The coarse close step, and the purge it defers** (13-native-undo.md Rules "Window
// close coarsens"; Interaction with the trash).
//
// This is the one place that knows both halves: the window's stack, which is the session's
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
// still restores comments out of it. Registering answers whether the step took the purge on
// and a board whose substrate keeps no steps has already run it by the time that answer comes
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
if registerSessionStep?(undo, purge) != true {
purge()
}
}
}
@@ -415,6 +446,7 @@ struct CardWindowHost: View {
attachments: attachments,
comments: session.comments,
thumbnails: thumbnails,
undo: session.undo,
history: cardHistory,
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
onToggleTask: { offset, checked in
@@ -595,12 +627,7 @@ struct CardWindowHost: View {
guard let store else { return .vanished }
return store.writeCardBody(inCard: cardID, body: text)
}
// The session's one undo step, at the EditPreview flip (13-native-undo.md Rules). Weakly,
// `save`'s rule: a session ending after the board window has gone registers nothing rather
// than resurrecting a released store and the board's stack died with it anyway.
session.body.registerUndo = { [weak store] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
}
Self.configureUndo(session, store: store, cardID: cardID)
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
}
@@ -632,7 +659,32 @@ struct CardWindowHost: View {
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
Self.configureAttachments(attachments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID)
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
}
/// Points this window's session at **its own undo stack** the three seams the two-level model
/// is made of (13-native-undo.md Rules, re-ruled 2026-07-31).
///
/// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
/// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
/// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
/// `comments/.trash/` purge.
///
/// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
/// window has gone registers nothing rather than resurrecting a released store and a window with
/// no board keeps the purge itself, which is what the `false` says.
///
/// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
/// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
}
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
session.registerSessionStep = { [weak store] undo, purge in
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
}
}
/// Points the comments pane at its card **the one place every comment gesture learns which card
@@ -651,7 +703,17 @@ struct CardWindowHost: View {
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
/// target resolution is invisible in a running window until it is wrong, and this shape is what
/// lets a test drive the real wiring rather than a re-typed copy of it.
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
///
/// - Parameter undo: **this window's stack** where every comment gesture's fine step lands
/// (13-native-undo.md Rules two levels). Not optional and not defaulted: a comments pane
/// only ever exists inside a card window, so a call with no window would be a call with no
/// answer to which stack it meant.
static func configureComments(
_ comments: CardComments,
store: BoardStore,
cardID: ItemID,
on undo: CardWindowUndo
) {
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
@@ -665,11 +727,14 @@ struct CardWindowHost: View {
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
}
comments.deleteComment = { [weak store] id in
store?.deleteComment(id, inCard: cardID) ?? false
store?.deleteComment(id, inCard: cardID, on: undo) ?? false
}
comments.editComment = { [weak store] id, body in
store?.editComment(id, inCard: cardID, body: body) ?? false
}
comments.registerCommentEdit = { [weak store] id, prior, new in
store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
}
comments.importAttachments = { [weak store] urls, target in
store?.importCommentAttachments(urls, inCard: cardID, target: target)
}
@@ -680,7 +745,7 @@ struct CardWindowHost: View {
store?.saveCommentDraft(inCard: cardID, body: text)
}
comments.composer.post = { [weak store] in
store?.postComment(inCard: cardID)
store?.postComment(inCard: cardID, on: undo)
}
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
// reload (10-accessibility.md "app-mediated echoes never announce", per comment). A store
@@ -788,14 +853,18 @@ struct CardWindowHost: View {
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
)
// **The board's stack, not one of this window's own** (13-native-undo.md Rules: "not
// per-window: every window over a board (board window, its card windows) shares the store
// and shares the stack"). Same closure shape as the board window's, and deliberately the
// same object: Z with a card window in front crosses the board step the user last made,
// wherever they made it. The card's *text* surfaces are untouched by this the body editor
// and the raw-source editor each vend their own manager to the responder chain, which is
// what keeps typing undo out of the board's stack (06-history-undo.md Undo routing).
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
// **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
// scoping)". Z with this window in front walks the gestures made *here*, newest first, and
// when they run out it beeps "no fall-through: exhausting the window's stack ... never
// reaches board history" (06-history-undo.md Undo routing). What board history gets is the
// one coarse step this session registers when the window closes.
//
// The card's *text* surfaces are untouched by this the body editor and the raw-source
// editor each vend their own manager to the responder chain, which is what keeps typing undo
// above either stack (06 Undo routing, unchanged).
windowController.windowUndoManager = { [session] in session.undo.manager }
windowController.onAttach = { window in
if let recordID,
+13 -14
View File
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// window that has nothing to flush.
var onCloseRequested: (() -> Void)?
/// This window's board undo stack, asked for afresh every time AppKit wants it the board
/// window's and its card windows' shared answer (13-native-undo.md Rules: "one stack per
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the
/// session's manager").
/// **The stack this window's Z crosses**, asked for afresh every time AppKit wants it
/// 13-native-undo.md Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
///
/// A closure rather than a stored manager for two reasons: the session does not exist yet when
/// the window attaches, and it stops existing at teardown while the window is still closing
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a
/// window that outlived it by a run-loop turn.
/// A closure rather than a stored manager for two reasons: a board window's session does not
/// exist yet when the window attaches, and it stops existing at teardown while the window is
/// still closing answering `nil` then is what keeps a torn-down board's stack from being
/// reachable through a window that outlived it by a run-loop turn.
///
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var boardUndoManager: (() -> UndoManager?)?
var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board 06-history-undo.md Undo routing, via
@@ -215,16 +214,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
}
/// The window-level half of 06-history-undo.md Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it.
///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a board: `responds(to:)` reports this method whatever the
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no board
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = boardUndoManager?()
let board = windowUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
+16 -6
View File
@@ -247,12 +247,22 @@ public final class GitHistoryProvider: HistoryProviding {
// MARK: - HistoryProviding
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers
/// against whatever provider the session bound, and this one has a repository to read instead
/// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is
/// the substrate" (14 C1) means in code.
public func register(_ step: HistoryStep) {}
/// **Deliberately nothing except the one thing a dropped step is owed.** On a git board an undo
/// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate
/// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and
/// this one has a repository to read instead so the registrations arrive and are dropped, which
/// is exactly what "the commit trail itself is the substrate" (14 C1) means in code.
///
/// 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 {
guard !isCrossing, isHeld?() != true else { return false }
+70 -2
View File
@@ -74,22 +74,90 @@ extension BoardStore {
func registerStep(
_ name: String,
subject: String? = nil,
on window: CardWindowUndo? = nil,
retiring: (@MainActor () -> Void)? = nil,
undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation],
undo: @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
history.register(HistoryStep(
let step = HistoryStep(
name: name,
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
},
redo: { [weak self] direction in
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.
+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
/// 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
///
/// 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
/// 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
///
/// "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 lane
case board
/// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and
/// the draft save, the inline edit and the trash purge register no step at all
/// (13-native-undo.md no byte capture in any tier, and the permanent-delete posture).
/// One comment. `.delete` and `.edit` reach it the inline edit session joined the
/// vocabulary with the window stack (re-ruled 2026-07-31: "every gesture issued in that
/// 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
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.
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
/// 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`).
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.
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
/// the write that just landed.
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
@@ -117,11 +163,15 @@ public struct HistoryStep {
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init(
id: UUID = UUID(),
name: String,
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.id = id
self.name = name
self.retirement = retirement
self.undo = undo
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
/// 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.
///
/// **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 {
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).
///
/// ### 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
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
/// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
/// "Two levels: one stack per board, one per open card window" (13-native-undo.md Rules, re-ruled
/// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its
/// 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):
/// 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
@@ -160,6 +219,15 @@ public struct HistoryStep {
/// 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
/// (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
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
/// whose fidelity is not field-level (13: "body steps compare bytes").
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
+32 -1
View File
@@ -2,7 +2,11 @@ import Foundation
// 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`
///
@@ -59,10 +63,25 @@ public final class NativeHistoryProvider: HistoryProviding {
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
/// 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) {
undoSteps.append(step)
retire(redoSteps)
redoSteps.removeAll()
}
@@ -70,11 +89,20 @@ public final class NativeHistoryProvider: HistoryProviding {
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() {
retire(undoSteps)
retire(redoSteps)
undoSteps.removeAll()
redoSteps.removeAll()
}
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
// MARK: - The crossing
/// 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)
return
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
case .failed:
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.
/// `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
/// 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
@@ -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
/// 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.
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: [(
id: ItemID?,
folder: URL,
@@ -1550,6 +1567,7 @@ public final class BoardStore: HealHost {
registerStep(
HistoryPhrase.name(.restyle, kind: kind, count: edits.count),
subject: subject,
on: window,
undoExpects: edits.map {
.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
/// 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.
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 }
let folder = target.folder(under: rootURL)
let title = Self.cardTitle(at: target, in: snapshot)
@@ -1969,6 +2000,7 @@ public final class BoardStore: HealHost {
registerStep(
HistoryPhrase.name(.edit, kind: .card),
subject: title,
on: window,
undoExpects: [.present(folder, .body(newBody))],
redoExpects: [.present(folder, .body(priorBody))]
) { _ 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
/// 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
/// window over a board (board window, its card windows) shares the store and shares the stack"
/// (13 Rules). A comment posted in a card window is undone by Z in the board window, which is only
/// true if the step was registered here.
/// *board's* store one store per board, whatever the window (02-architecture.md § Components), so a
/// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// other write in the app.
///
/// **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
///
/// 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.
/// - **`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
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
/// bytes 05-card-window.md), which is a live buffer, not a stack entry.
/// - **`registerCommentEdit`** one *session*, at its commit point, holding the bytes it opened on
/// (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
/// per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// - **`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
/// 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
@@ -122,8 +127,13 @@ extension BoardStore {
/// **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
/// 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
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 posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
@@ -138,6 +148,7 @@ extension BoardStore {
registerStep(
HistoryPhrase.comment,
subject: title,
on: window,
undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in
@@ -155,8 +166,8 @@ extension BoardStore {
// MARK: Editing
/// An inline edit session's save one bracket, **no step** (13's no-capture rule; the session
/// owns Cancel).
/// An inline edit session's save one bracket, **no step**, exactly as a card body's ~700 ms
/// 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.
@discardableResult
@@ -169,6 +180,47 @@ extension BoardStore {
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
/// **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
/// 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.
///
/// **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
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 }
let folder = card.folder
let title = card.title
@@ -195,8 +260,9 @@ extension BoardStore {
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
undoExpects: [.present(trashed)],
redoExpects: [.present(live)]
on: window,
undoExpects: [.present(trashed), .absent(live)],
redoExpects: [.present(live), .absent(trashed)]
) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in
@@ -207,12 +273,20 @@ extension BoardStore {
// 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
/// § Enhanced schema: "purged when the card window closes (rides the close flush)").
/// **Empties one card's `comments/.trash/`** **when undo no longer needs it** (01-storage-format.md
/// § 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
/// 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.
/// The call site moved with that re-ruling and this method did not change: the window's close no
/// longer purges on its own, it hands this work to the coarse close step as that step's
/// **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) {
guard let card = commentSubject(id) else { return }
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`
/// 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
/// discipline v7 already taught, now doing the ordering as well.
static let version = 8
/// discipline v7 already taught, now doing the ordering as well. **v9 teaches the one-line
/// 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
@@ -381,6 +389,16 @@ enum AgentGuide {
containing `: ` or starting with `#`, `[`, `{`, or a quote when in doubt,
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
custom metadata (`project:`, `tags:`, `claimed-by:` ) is safe to add and
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)
- Frontmatter must parse as YAML; `schema` (plus `order` on lanes and
cards) is required. Keep `schema: 1`. The classic violation is an
unquoted colon in a title (see Frontmatter above).
cards) is required. Keep `schema: 1`. The classic violations are an
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.
- Never create a card folder without an `index.md`.
- Never rename UUID folders.
+29 -8
View File
@@ -108,7 +108,8 @@ public final class CardComments {
@ObservationIgnored
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
public var purgeTrash: (() -> Void)?
@@ -127,6 +128,12 @@ public final class CardComments {
@ObservationIgnored
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/`
/// `BoardStore.importCommentAttachments(_:inCard:target:)`.
@ObservationIgnored
@@ -319,6 +326,9 @@ public final class CardComments {
session.save = { [weak self] text in
self?.editComment?(commentID, text) ?? false
}
session.registerUndo = { [weak self] prior, new in
self?.registerCommentEdit?(commentID, prior, new)
}
editing = session
}
@@ -409,20 +419,31 @@ public final class CardComments {
// 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
/// abandon), then the composer's draft lands, and only then is `comments/.trash/` emptied. The
/// purge going last is what makes it safe at all: it removes the folders a delete moved aside, and
/// running it before a session's save could remove a folder that save was about to write into.
/// abandon), and then the composer's draft lands. Ending twice does nothing the second time the
/// sessions latch which is what makes the two paths that call this (a window closed on its own,
/// 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
/// trash is a no-op which is what makes the two paths that call this (a window closed on its
/// own, and the board's close flush driving it) safe to both exist.
/// **The purge is no longer here** (re-ruled 2026-07-31 13-native-undo.md Interaction with the
/// trash): `comments/.trash/` is what the coarse close step's undo restores deleted comments from,
/// 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() {
editing?.endOnClose()
editing = nil
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?()
}
+7 -1
View File
@@ -32,6 +32,11 @@ struct CardStyleSection: View {
let store: BoardStore
let recents: StyleRecents
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,
/// so every section in this sidebar derives its geometry the same way.
@@ -58,7 +63,8 @@ struct CardStyleSection: View {
layout: .sidebar(
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize),
bodyPointSize: pointSize
)
),
undo: undo
)
}
.frame(maxWidth: .infinity, alignment: .leading)
+6 -1
View File
@@ -91,6 +91,11 @@ struct CardWindowView: View {
let comments: CardComments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
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
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule;
/// see `historySlot`.
@@ -314,7 +319,7 @@ struct CardWindowView: View {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
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
// 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
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`.
@ObservationIgnored
public private(set) var saveAttempts = 0
@@ -142,7 +152,9 @@ public final class CommentEditSession {
public func commit() -> Bool {
guard !hasEnded else { return false }
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
@@ -179,11 +191,25 @@ public final class CommentEditSession {
public func endOnClose() -> Bool {
guard !hasEnded else { return false }
hasEnded = true
return flush()
let wrote = flush()
registerStep()
return wrote
}
// 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() {
cancelPending()
let interval = debounceInterval
+15 -4
View File
@@ -30,14 +30,18 @@ import SwiftUI
/// colour to remember; only `.set` reaches `StyleRecents.record`.
@MainActor
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(
background: StyleChange = .keep,
icon: StyleChange = .keep,
to target: StyleTarget,
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 {
recents.record(value)
}
@@ -239,6 +243,13 @@ struct StyleEditorView: View {
/// size and a default argument cannot read one.
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
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
/// rule).
@@ -293,7 +304,7 @@ struct StyleEditorView: View {
columns: layout.backgroundColumns,
layout: layout,
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,
layout: layout,
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 {
+16
View File
@@ -705,6 +705,22 @@ struct AgentGuideContentTests {
#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
/// (01-storage-format.md Changes from the pathfinder schema; Deletion). The one legitimate
/// 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")
struct CardCommentsCloseTests {
@Test("Saves first, purge last")
func savesPrecedeThePurge() {
// The purge removes the folders a delete moved aside; running it before a session's save
// could remove a folder that save was about to write into.
@Test("The saves land, and the purge is not the pane's to run")
func savesLandAndThePurgeDefers() {
// Realigned 2026-07-31 with the session-coarsening model (13-native-undo.md Interaction
// 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 comments = makeComments(spy)
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 draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
let purge = try? #require(spy.events.firstIndex(of: .purge))
#expect((edit ?? 0) < (purge ?? 0))
#expect((draft ?? 0) < (purge ?? 0))
#expect((edit ?? 0) < (draft ?? 1))
#expect(!spy.events.contains(.purge), "the trash outlives the pane's own close")
// 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")
@@ -472,13 +478,15 @@ struct CardCommentsCloseTests {
#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")
func aCleanPaneStillPurges() {
@Test("A clean pane's close writes nothing and purges nothing")
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 comments = makeComments(spy)
comments.endSession()
#expect(spy.events == [.purge])
#expect(spy.events.isEmpty)
}
}
@@ -580,7 +588,7 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
comments.cardFolder = fixture.url(card)
CardWindowHost.configureComments(comments, store: store, cardID: cardID)
CardWindowHost.configureComments(comments, store: store, cardID: cardID, on: CardWindowUndo())
comments.open()
comments.composer.edited("A remark.\n")
@@ -599,8 +607,12 @@ struct CardCommentsWiringTests {
#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 {
// 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()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
@@ -610,7 +622,9 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
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()
#expect(comments.thread.comments.count == 1)
@@ -619,6 +633,10 @@ struct CardCommentsWiringTests {
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
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)
}
@@ -633,7 +651,9 @@ struct CardCommentsWiringTests {
let comments = CardComments()
comments.isEditable = true
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.beginEdit(ItemID(rawValue: CommentIdent.one))
+3 -1
View File
@@ -276,7 +276,9 @@ struct CommentPaneAnnouncementTests {
comments.isEditable = true
comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
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)
comments.announce = { spoken.record($0) }
return (comments, spoken)
+20 -7
View File
@@ -703,7 +703,7 @@ struct UndoCommandSurfaceTests {
defer: true
)
let controller = HostedWindowController()
controller.boardUndoManager = { manager }
controller.windowUndoManager = { manager }
controller.attach(to: window)
return (window, controller)
}
@@ -792,8 +792,11 @@ struct UndoCommandSurfaceTests {
#expect(undoRow.title == "Undo")
}
@Test("Every window over one board answers with that board's one stack")
func cardWindowsShareTheBoardsStack() throws {
@Test("A board window answers with the board's stack; a card window answers with its own")
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()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
@@ -801,10 +804,12 @@ struct UndoCommandSurfaceTests {
let ref = try openBoard(model, at: fixture.root)
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)
defer { boardController.detach() }
let (cardWindow, cardController) = hostedWindow(session.undoManager)
let (cardWindow, cardController) = hostedWindow(card.manager)
defer { cardController.detach() }
let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:")
@@ -812,9 +817,17 @@ struct UndoCommandSurfaceTests {
session.history?.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow))
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
#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