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