Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.
2432 tests in 418 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -339,9 +339,12 @@ public final class AppModel {
|
||||
/// open-now flag without matching by identity a second time.
|
||||
public let recordID: UUID
|
||||
|
||||
/// This board's undo/redo substrate — **one stack per board session, never per window**
|
||||
/// (13-native-undo.md ▸ Rules). It lives here for the store's reason exactly: the session is
|
||||
/// what every window over this board shares, and "undo is board-local".
|
||||
/// This board's undo/redo substrate — **the board half of 13-native-undo.md ▸ Rules' two
|
||||
/// levels** (re-ruled 2026-07-31): one stack per board session, carrying board-surface
|
||||
/// gestures and the one coarse step each card window's close registers. A card window's own
|
||||
/// fine-grained stack is not here and never was the session's (`CardWindowUndo`, held by the
|
||||
/// window). It lives here for the store's reason exactly: the session is what every window
|
||||
/// over this board shares, and "undo is board-local".
|
||||
///
|
||||
/// Which implementation it is, is the tier's answer and nobody else's
|
||||
/// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`.
|
||||
|
||||
@@ -276,7 +276,7 @@ struct BoardWindowHost: View {
|
||||
// nothing rather than a stack with no board behind it. The Edit menu's Undo/Redo rows and
|
||||
// the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
|
||||
// up: `NSWindow` validates and crosses them against exactly this manager.
|
||||
windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager }
|
||||
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
|
||||
|
||||
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
|
||||
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
|
||||
|
||||
@@ -55,6 +55,16 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// rather than pretending to have written it.
|
||||
let body: CardBodyEditSession
|
||||
|
||||
/// **This window's own undo stack** — 13-native-undo.md ▸ Rules' second level (re-ruled
|
||||
/// 2026-07-31): "a card window owns its own stack for the session it represents ... and
|
||||
/// `window.undoManager` answers with it".
|
||||
///
|
||||
/// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
|
||||
/// folded from this stack, and a stack held only by the view would be gone by the time the fold
|
||||
/// ran. Every window gesture registers into it through the store's own methods, which take it as
|
||||
/// a parameter (`CardWindowUndo`).
|
||||
let undo = CardWindowUndo()
|
||||
|
||||
/// The window's comments pane — the thread, the composer's draft buffer, and the one open inline
|
||||
/// edit session (05-card-window.md ▸ The comments column).
|
||||
///
|
||||
@@ -85,6 +95,16 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
var rawSourceCancel: (@MainActor () -> Void)?
|
||||
var rawSourceIsActive: (@MainActor () -> Bool)?
|
||||
|
||||
/// **Where the close registers this session as one board step** —
|
||||
/// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
|
||||
/// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
|
||||
/// having no idea what a board is.
|
||||
///
|
||||
/// It answers whether the deferred `comments/.trash/` purge now has an owner — see `endSession()`.
|
||||
/// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
|
||||
/// object's to run, which is also true.
|
||||
var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?
|
||||
|
||||
/// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
|
||||
/// holding keystrokes its file has not got — see `CardSessionFlushing`.
|
||||
///
|
||||
@@ -164,14 +184,25 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
// session, "never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). The debounced
|
||||
// saves inside the session are ordinary bracketed writes; what makes them one commit is that
|
||||
// the committer's own debounce outlives them and this call is where the session is known to
|
||||
// be over.
|
||||
// be over. It is also where the body's *last* fine step joins this window's stack, which is
|
||||
// why it has to precede the fold below.
|
||||
body.endEditSession()
|
||||
// **Saves first, purge last** — the inline comment session's flush and the draft's save land
|
||||
// before `comments/.trash/` is emptied, which is the order that keeps the purge from removing
|
||||
// a folder a save was about to write into (`CardComments.endSession`). It runs after the
|
||||
// body's for the same reason it runs at all: this is the one place the window's whole close
|
||||
// work has a fixed order.
|
||||
// **The saves, in the order the comments build fixed**: the inline session's flush, then the
|
||||
// draft's (`CardComments.endSession`). Both may register their own last fine step, so both
|
||||
// land before the fold.
|
||||
comments.endSession()
|
||||
// **The coarse close step, and the purge it defers** (13-native-undo.md ▸ Rules ▸ "Window
|
||||
// close coarsens"; ▸ Interaction with the trash).
|
||||
//
|
||||
// This is the one place that knows both halves: the window's stack, which is the session's
|
||||
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
|
||||
// still restores comments out of it. Registering answers whether the step took the purge on —
|
||||
// and a board whose substrate keeps no steps has already run it by the time that answer comes
|
||||
// back, which is how Pro keeps purging at the close flush without a word about tiers here.
|
||||
let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
|
||||
if registerSessionStep?(undo, purge) != true {
|
||||
purge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +446,7 @@ struct CardWindowHost: View {
|
||||
attachments: attachments,
|
||||
comments: session.comments,
|
||||
thumbnails: thumbnails,
|
||||
undo: session.undo,
|
||||
history: cardHistory,
|
||||
fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
|
||||
onToggleTask: { offset, checked in
|
||||
@@ -595,12 +627,7 @@ struct CardWindowHost: View {
|
||||
guard let store else { return .vanished }
|
||||
return store.writeCardBody(inCard: cardID, body: text)
|
||||
}
|
||||
// The session's one undo step, at the Edit→Preview flip (13-native-undo.md ▸ Rules). Weakly,
|
||||
// `save`'s rule: a session ending after the board window has gone registers nothing rather
|
||||
// than resurrecting a released store — and the board's stack died with it anyway.
|
||||
session.body.registerUndo = { [weak store] priorBody, newBody in
|
||||
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody)
|
||||
}
|
||||
Self.configureUndo(session, store: store, cardID: cardID)
|
||||
bodyPresentation.flushEdits = { [session] in
|
||||
session.body.endEditSession()
|
||||
}
|
||||
@@ -632,7 +659,32 @@ struct CardWindowHost: View {
|
||||
session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
|
||||
session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
|
||||
Self.configureAttachments(attachments, store: store, cardID: cardID)
|
||||
Self.configureComments(session.comments, store: store, cardID: cardID)
|
||||
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
}
|
||||
|
||||
/// Points this window's session at **its own undo stack** — the three seams the two-level model
|
||||
/// is made of (13-native-undo.md ▸ Rules, re-ruled 2026-07-31).
|
||||
///
|
||||
/// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
|
||||
/// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
|
||||
/// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
|
||||
/// `comments/.trash/` purge.
|
||||
///
|
||||
/// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
|
||||
/// window has gone registers nothing rather than resurrecting a released store — and a window with
|
||||
/// no board keeps the purge itself, which is what the `false` says.
|
||||
///
|
||||
/// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
|
||||
/// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is
|
||||
/// what lets a test drive the real wiring rather than a re-typed copy of it.
|
||||
static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
|
||||
session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
|
||||
store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
|
||||
}
|
||||
session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
|
||||
session.registerSessionStep = { [weak store] undo, purge in
|
||||
store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Points the comments pane at its card — **the one place every comment gesture learns which card
|
||||
@@ -651,7 +703,17 @@ struct CardWindowHost: View {
|
||||
/// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
|
||||
/// target resolution is invisible in a running window until it is wrong, and this shape is what
|
||||
/// lets a test drive the real wiring rather than a re-typed copy of it.
|
||||
static func configureComments(_ comments: CardComments, store: BoardStore, cardID: ItemID) {
|
||||
///
|
||||
/// - Parameter undo: **this window's stack** — where every comment gesture's fine step lands
|
||||
/// (13-native-undo.md ▸ Rules ▸ two levels). Not optional and not defaulted: a comments pane
|
||||
/// only ever exists inside a card window, so a call with no window would be a call with no
|
||||
/// answer to which stack it meant.
|
||||
static func configureComments(
|
||||
_ comments: CardComments,
|
||||
store: BoardStore,
|
||||
cardID: ItemID,
|
||||
on undo: CardWindowUndo
|
||||
) {
|
||||
comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
|
||||
comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
|
||||
comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
|
||||
@@ -665,11 +727,14 @@ struct CardWindowHost: View {
|
||||
store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
|
||||
}
|
||||
comments.deleteComment = { [weak store] id in
|
||||
store?.deleteComment(id, inCard: cardID) ?? false
|
||||
store?.deleteComment(id, inCard: cardID, on: undo) ?? false
|
||||
}
|
||||
comments.editComment = { [weak store] id, body in
|
||||
store?.editComment(id, inCard: cardID, body: body) ?? false
|
||||
}
|
||||
comments.registerCommentEdit = { [weak store] id, prior, new in
|
||||
store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
|
||||
}
|
||||
comments.importAttachments = { [weak store] urls, target in
|
||||
store?.importCommentAttachments(urls, inCard: cardID, target: target)
|
||||
}
|
||||
@@ -680,7 +745,7 @@ struct CardWindowHost: View {
|
||||
store?.saveCommentDraft(inCard: cardID, body: text)
|
||||
}
|
||||
comments.composer.post = { [weak store] in
|
||||
store?.postComment(inCard: cardID)
|
||||
store?.postComment(inCard: cardID, on: undo)
|
||||
}
|
||||
// The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
|
||||
// reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
|
||||
@@ -788,14 +853,18 @@ struct CardWindowHost: View {
|
||||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||||
)
|
||||
|
||||
// **The board's stack, not one of this window's own** (13-native-undo.md ▸ Rules: "not
|
||||
// per-window: every window over a board (board window, its card windows) shares the store
|
||||
// and shares the stack"). Same closure shape as the board window's, and deliberately the
|
||||
// same object: ⌘Z with a card window in front crosses the board step the user last made,
|
||||
// wherever they made it. The card's *text* surfaces are untouched by this — the body editor
|
||||
// and the raw-source editor each vend their own manager to the responder chain, which is
|
||||
// what keeps typing undo out of the board's stack (06-history-undo.md ▸ Undo routing).
|
||||
windowController.boardUndoManager = { appModel.session(for: ref.board)?.undoManager }
|
||||
// **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 —
|
||||
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
|
||||
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
|
||||
// scoping)". ⌘Z with this window in front walks the gestures made *here*, newest first, and
|
||||
// when they run out it beeps — "no fall-through: exhausting the window's stack ... never
|
||||
// reaches board history" (06-history-undo.md ▸ Undo routing). What board history gets is the
|
||||
// one coarse step this session registers when the window closes.
|
||||
//
|
||||
// The card's *text* surfaces are untouched by this — the body editor and the raw-source
|
||||
// editor each vend their own manager to the responder chain, which is what keeps typing undo
|
||||
// above either stack (06 ▸ Undo routing, unchanged).
|
||||
windowController.windowUndoManager = { [session] in session.undo.manager }
|
||||
|
||||
windowController.onAttach = { window in
|
||||
if let recordID,
|
||||
|
||||
@@ -56,19 +56,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
/// window that has nothing to flush.
|
||||
var onCloseRequested: (() -> Void)?
|
||||
|
||||
/// This window's board undo stack, asked for afresh every time AppKit wants it — the board
|
||||
/// window's and its card windows' shared answer (13-native-undo.md ▸ Rules: "one stack per
|
||||
/// board, owned by the board session ... `window.undoManager` for board surfaces returns the
|
||||
/// session's manager").
|
||||
/// **The stack this window's ⌘Z crosses**, asked for afresh every time AppKit wants it —
|
||||
/// 13-native-undo.md ▸ Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
|
||||
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
|
||||
///
|
||||
/// A closure rather than a stored manager for two reasons: the session does not exist yet when
|
||||
/// the window attaches, and it stops existing at teardown while the window is still closing —
|
||||
/// answering `nil` then is what keeps a torn-down board's stack from being reachable through a
|
||||
/// window that outlived it by a run-loop turn.
|
||||
/// A closure rather than a stored manager for two reasons: a board window's session does not
|
||||
/// exist yet when the window attaches, and it stops existing at teardown while the window is
|
||||
/// still closing — answering `nil` then is what keeps a torn-down board's stack from being
|
||||
/// reachable through a window that outlived it by a run-loop turn.
|
||||
///
|
||||
/// `nil` on every window that is not showing a board (welcome, the bootstrap, the template
|
||||
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
|
||||
/// chooser), which `BoardUndoRouting` reads as "the platform default".
|
||||
var boardUndoManager: (() -> UndoManager?)?
|
||||
var windowUndoManager: (() -> UndoManager?)?
|
||||
|
||||
/// The text manager this window hands back while a field editor holds the keyboard, and the one
|
||||
/// it hands back when there is no board — 06-history-undo.md ▸ Undo routing, via
|
||||
@@ -215,16 +214,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
|
||||
/// The window-level half of 06-history-undo.md ▸ Undo routing (see `BoardUndoRouting`, which
|
||||
/// owns the rule and the reasoning): the board's stack when the keyboard is on the board, a
|
||||
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
|
||||
/// text manager of this window's own while a field editor has it.
|
||||
///
|
||||
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
|
||||
/// condition that this window has a board: `responds(to:)` reports this method whatever the
|
||||
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
|
||||
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
|
||||
/// rather than the one AppKit creates for a delegate that stays silent. A window with no board
|
||||
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
|
||||
/// still defers to SwiftUI's delegate if it has an opinion.
|
||||
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
||||
let board = boardUndoManager?()
|
||||
let board = windowUndoManager?()
|
||||
if board == nil, let previousDelegate,
|
||||
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
|
||||
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user