Give card windows their own undo stacks and coarsen the close

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

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 19:39:50 -04:00
parent c0c741fe62
commit 71664dab02
23 changed files with 668 additions and 124 deletions
+70 -2
View File
@@ -74,22 +74,90 @@ extension BoardStore {
func registerStep(
_ name: String,
subject: String? = nil,
on window: CardWindowUndo? = nil,
retiring: (@MainActor () -> Void)? = nil,
undoExpects: [HistoryExpectation],
redoExpects: [HistoryExpectation],
undo: @escaping @MainActor (BoardStore) throws -> Void,
redo: @escaping @MainActor (BoardStore) throws -> Void
) {
guard let history else { return }
let retirement = retiring.map(HistoryStep.Retirement.init)
// **The routing decision, and the whole of it** (13 Rules two levels): a gesture issued
// in a card window lands on that window's stack, and everything else on the board's. It is a
// parameter rather than ambient state on purpose the issuing surface is knowledge only the
// call site has, and a store-wide "current window" would be a second answer able to be wrong
// for exactly one gesture (the board styling a card whose window happens to be open).
guard let sink: any HistoryProviding = window?.stack ?? history else {
// No substrate at all a repo-nested board (06 Rules), or a store with no session.
// Nothing records the step, so nothing can ever retire it: the work is owed now.
retirement?.run()
return
}
let named = subject ?? name
history.register(HistoryStep(
let step = HistoryStep(
name: name,
retirement: retirement,
undo: { [weak self] direction in
BoardStore.cross(self, direction, named, undoExpects, undo)
},
redo: { [weak self] direction in
BoardStore.cross(self, direction, named, redoExpects, redo)
}
)
// The raw halves, kept beside the window's stack for the close fold before the register, so
// a fold taken from inside a registration's own side effects can never see a step it has no
// write for (`CardWindowUndo.netEffect`).
window?.record(step.id, CardWindowUndo.Write(
undoExpects: undoExpects,
redoExpects: redoExpects,
undo: undo,
redo: redo
))
sink.register(step)
}
// MARK: The window close's coarse step
/// **Registers one card-window session as one board step** 13-native-undo.md Rules' window
/// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card
/// title', values-based, whose undo restores the card subtree to its session-start state
/// deleted comments included and whose redo reapplies the net effect").
///
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
/// whether there is a board to register it on is this method's:
///
/// - **A vanished card registers nothing.** 05-card-window.md Deletion & lifecycle dismisses the
/// window when its card leaves the board into the trash, with its lane, to another board and
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
/// naming folders that have moved could only be a step that skips, so the honest answer is not
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
/// it. (A trashed card keeps its `comments/.trash/` too "a trashed card carries its
/// `comments/`", 01-storage-format.md and the residue sweeps at the next open of that card.)
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
///
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 Interaction with the trash).
/// - Returns: whether the purge now has an owner a live step holding it until the step leaves
/// the board stack, or a substrate that declined to keep the step and therefore ran it already
/// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still
/// owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
inCard cardID: ItemID,
retiring: @escaping @MainActor () -> Void
) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
guard let net = window.netEffect() else { return false }
registerStep(
HistoryPhrase.cardSession,
subject: item.title,
retiring: retiring,
undoExpects: net.undoExpects,
redoExpects: net.redoExpects,
undo: net.undo,
redo: net.redo
)
return true
}
/// Validates one side of a step against disk, then runs it as an ordinary bracketed write.
+10
View File
@@ -21,6 +21,11 @@ import AppKit
/// Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
/// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
///
/// **And one per open card window**, over that window's own stack (13 Rules two levels, re-ruled
/// 2026-07-31 `CardWindowUndo.manager`): the second level needs precisely the same translation, so
/// it gets the same object rather than a second one shaped like it. The name is now half-right and
/// kept anyway: renaming it would say the two levels are two mechanisms, and they are not.
///
/// ### It deliberately keeps its inherited stack empty
///
/// Every question is overridden, so anything that *did* register into this manager by accident (a
@@ -33,6 +38,11 @@ import AppKit
/// not empty the board's stack (13-native-undo.md Rules the stack belongs to the *session*, and
/// its one clearing point is that session's teardown).
///
/// That non-forwarding is what the **close fold** rests on, now that a card window has a stack of its
/// own: AppKit empties a closing window's undo manager, and the session's net effect is read off that
/// window's steps a moment later, from `onDisappear` (`CardWindowSession.endSession`). A forwarded
/// clear would silently make every close a no-net-change close.
///
/// ### The read-only lock disables Undo and Redo here
///
/// "Every read-only lock (vanished root, failed reload after wholesale ops, unwritable location)
+19 -3
View File
@@ -64,9 +64,12 @@ public enum HistoryPhrase {
case card
case lane
case board
/// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and
/// the draft save, the inline edit and the trash purge register no step at all
/// (13-native-undo.md no byte capture in any tier, and the permanent-delete posture).
/// One comment. `.delete` and `.edit` reach it the inline edit session joined the
/// vocabulary with the window stack (re-ruled 2026-07-31: "every gesture issued in that
/// window comment post/delete/**edit**, body Edit sessions ... registers there at fine
/// grain"), registered at its own commit point exactly as the body's session is. Posting has
/// its own phrase (`comment`, below); the draft save and the trash purge still register no
/// step at all (13-native-undo.md the permanent-delete posture).
case comment
var singular: String {
@@ -99,6 +102,19 @@ public enum HistoryPhrase {
/// login'") is dropped exactly as every other phrase drops it a menu row has to stay short.
public static let comment = "Comment"
// MARK: The card-window session
/// **The coarse close step's phrase** one card window's whole session, as the board's stack sees
/// it: 13-native-undo.md Rules names it "Edit card 'Fix login'", so the menu title is the same
/// verb and noun every other card edit uses, dropping the item clause a menu row has no space for.
///
/// Deliberately *not* a new verb. The session is an edit to a card the fine-grained things
/// inside it (a comment posted, a colour chosen, a paragraph rewritten) are the window stack's
/// story, and a board-level row that tried to enumerate them would be the "Mixed update" problem
/// in a menu (06-history-undo.md Commit messages). It reads identically to a body-edit step
/// because on the board's stack it *is* the card's edit.
public static let cardSession = name(.edit, kind: .card)
// MARK: Composition
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`.
+73 -5
View File
@@ -105,9 +105,55 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// and the skip sentence has to name the keystroke rather than the half (see `HistoryDirection`).
public struct HistoryStep {
/// **What a step owes the world once it has left history for good** run exactly once, by
/// whichever provider drops it.
///
/// It exists for one consumer, and the design names it precisely: the window-close coarse step
/// defers a card's `comments/.trash/` purge, because "the coarse close step's undo restores
/// deleted comments, so their backing lives as long as the step does the purge runs when the
/// coarse step leaves the board stack (undone-and-superseded, dropped off the end, or gone
/// stale) or the board session ends" (13-native-undo.md Interaction with the trash, re-ruled
/// 2026-07-31). A step is the only object that knows all three of those moments, and it knows
/// none of them itself so the *provider* reports them, through this.
///
/// **A reference type inside a value type, deliberately.** `reversed` copies the step every time
/// it crosses, and the two copies must not each run the work: sharing one latch is what makes
/// "exactly once" a property of the object rather than of the bookkeeping around it.
///
/// A step with no retirement every board gesture carries `nil` and costs nothing.
@MainActor
public final class Retirement {
private var work: (@MainActor () -> Void)?
public init(_ work: @escaping @MainActor () -> Void) {
self.work = work
}
/// Runs the work, once. Later calls do nothing, which is what lets every provider report a
/// drop without first checking whether another one already has.
public func run() {
let work = self.work
self.work = nil
work?()
}
/// Whether the work is still owed the assertion a test makes instead of watching disk.
public var isOwed: Bool { work != nil }
}
/// This step's identity, stable across `reversed` which is what makes it usable as a key into
/// state held *beside* a stack (`CardWindowUndo`, whose fold has to find the raw write behind a
/// step that may have crossed any number of times).
public let id: UUID
/// The menu phrase, unprefixed see the type's note.
public let name: String
/// What this step owes when it leaves history see `Retirement`. `nil` for every step that owes
/// nothing, which is all but the card-window close step.
public let retirement: Retirement?
/// Walks the board back across this step. Registered at the Writer boundary as the *inverse* of
/// the write that just landed.
public let undo: @MainActor (HistoryDirection) -> HistoryStepOutcome
@@ -117,11 +163,15 @@ public struct HistoryStep {
public let redo: @MainActor (HistoryDirection) -> HistoryStepOutcome
public init(
id: UUID = UUID(),
name: String,
retirement: Retirement? = nil,
undo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome,
redo: @escaping @MainActor (HistoryDirection) -> HistoryStepOutcome
) {
self.id = id
self.name = name
self.retirement = retirement
self.undo = undo
self.redo = redo
}
@@ -129,8 +179,12 @@ public struct HistoryStep {
/// The same step read backwards what a provider puts on the opposite stack once this one has
/// applied. The name does not change, which is the whole of "Undo Move Card" becoming "Redo Move
/// Card": the phrase names the *gesture*, not the direction.
///
/// **The identity and the retirement travel with it**, both for the same reason: a step that has
/// crossed is the same step, so the fold that keyed state on it must still find that state, and
/// the purge it defers must still be owed exactly once.
public var reversed: HistoryStep {
HistoryStep(name: name, undo: redo, redo: undo)
HistoryStep(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
}
}
@@ -138,11 +192,16 @@ public struct HistoryStep {
/// The undo/redo substrate, behind one protocol boundary (12-editions.md The provider seam).
///
/// ### One per board session
/// ### One per board session and one per open card window
///
/// "One stack per board, owned by the board session. Not per-window: every window over a board
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
/// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
/// "Two levels: one stack per board, one per open card window" (13-native-undo.md Rules, re-ruled
/// 2026-07-31). The **board** stack is the session's, shared by board surfaces, and its
/// implementation is what this protocol is a seam for. A **card window** owns a second stack for its
/// own gestures always a `NativeHistoryProvider`, in either tier, because a window's fine-grained
/// inverses are values-based whatever the board's substrate is (`CardWindowUndo`); what reaches this
/// seam from a window is the one coarse step its close registers.
///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31):
/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
@@ -160,6 +219,15 @@ public struct HistoryStep {
/// relaunch because git does (06). Both are honest implementations of these seven members.
/// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's
/// (06 Undo routing, tier-independent) `BoardUndoRouting`.
///
/// ### One obligation every implementation shares: retire what you drop
///
/// A step may owe work for as long as it is crossable and no longer (`HistoryStep.Retirement` the
/// deferred `comments/.trash/` purge). Only the substrate knows when a step stops being crossable, so
/// **every implementation must call `retirement?.run()` on every step it lets go**: the redo stack it
/// clears on a `register`, a step it drops as stale, everything in `clear()`, and for a substrate
/// that keeps no steps at all the step handed to `register` itself. Nothing else in the app can
/// observe that moment, and a step dropped in silence would defer its work forever.
@MainActor
public protocol HistoryProviding: AnyObject {
+25
View File
@@ -36,6 +36,31 @@ public enum ExpectedField: Sendable, Equatable {
/// The body span, **byte for byte** the Edit session's step, and the one inverse in the app
/// whose fidelity is not field-level (13: "body steps compare bytes").
case body(String)
/// Which field this is, ignoring the value it carries the key a fold merges on
/// (`CardWindowUndo`): two writes to `background` inside one card-window session are one field
/// with a first and a last value, while a write to `background` and one to `icon` are two.
var kind: Kind {
switch self {
case .title: .title
case .order: .order
case .width: .width
case .background: .background
case .icon: .icon
case .body: .body
}
}
/// The field names, as a comparable value deliberately not the `String` keys, which are
/// `FrontmatterKeys`' business and would tie a fold to the file format.
enum Kind: Hashable, Sendable {
case title
case order
case width
case background
case icon
case body
}
}
// MARK: - HistoryExpectation
+32 -1
View File
@@ -2,7 +2,11 @@ import Foundation
// MARK: - NativeHistoryProvider
/// The free tier's undo substrate: one stack per board session (13-native-undo.md).
/// The free tier's undo substrate: one stack per board session (13-native-undo.md) **and the
/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
/// the two it is; both need the same four-line grammar.
///
/// ### Two arrays, and why not `NSUndoManager`
///
@@ -59,10 +63,25 @@ public final class NativeHistoryProvider: HistoryProviding {
public var redoActionName: String? { name(of: redoSteps.last) }
/// **The steps currently crossable by Z, oldest first** read-only, and read by exactly one
/// caller: the card window's close, which folds the session's own stack into the one coarse board
/// step (13-native-undo.md Rules "Window close coarsens"; `CardWindowUndo`).
///
/// The undo stack is the honest input to that fold and the redo stack is not: a gesture the user
/// undid inside the window is a gesture whose effect is no longer on disk, so it must contribute
/// nothing to the session's net effect. Nothing here decides what a fold *means* this is the
/// membership question, and the two arrays already answer it.
public var pendingSteps: [HistoryStep] { undoSteps }
/// Records one undoable step and clears the redo stack the classic rule, and the one every
/// substrate shares.
///
/// The cleared steps are **retired** on the way out (`HistoryStep.Retirement`): this is the
/// "undone-and-superseded" half of the deferred purge's release condition, and it is the only
/// moment the app can see it.
public func register(_ step: HistoryStep) {
undoSteps.append(step)
retire(redoSteps)
redoSteps.removeAll()
}
@@ -70,11 +89,20 @@ public final class NativeHistoryProvider: HistoryProviding {
public func redo() { cross(.redo) }
/// Session teardown, the add-git substrate swap, a branch reseed every step goes, so every step
/// retires: "the purge runs when the coarse step leaves the board stack ... or the board session
/// ends" (13 Interaction with the trash).
public func clear() {
retire(undoSteps)
retire(redoSteps)
undoSteps.removeAll()
redoSteps.removeAll()
}
private func retire(_ steps: [HistoryStep]) {
for step in steps { step.retirement?.run() }
}
// MARK: - The crossing
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** 13's
@@ -91,6 +119,9 @@ public final class NativeHistoryProvider: HistoryProviding {
push(step.reversed, onto: direction.opposite)
return
case .skipped:
// Dropped for good the third of the deferred purge's release conditions ("gone
// stale"), and the reason a skip is reported here rather than merely counted.
step.retirement?.run()
continue
case .failed:
push(step, onto: direction)