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