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:
@@ -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 {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user