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
266 lines
14 KiB
Swift
266 lines
14 KiB
Swift
import Foundation
|
|
|
|
// MARK: - HistoryDirection
|
|
|
|
/// Which command the user pressed — ⌘Z or ⇧⌘Z.
|
|
///
|
|
/// **Not which half of a step is running.** A step that has already been undone goes onto the redo
|
|
/// stack *reversed* (`HistoryStep.reversed`), so the closure ⇧⌘Z crosses is the one registered as
|
|
/// `redo` and the closure a second ⌘Z crosses is the one registered as `undo` — the halves swap, and
|
|
/// a step cannot tell which it is by looking at itself. What it has to be told is the **command**,
|
|
/// because the one sentence a step ever says out loud names it: "Undo skipped — 'Fix login' changed
|
|
/// outside Lanework" (13-native-undo.md ▸ Rules), with "Redo skipped" as its mirror.
|
|
public enum HistoryDirection: Sendable, Equatable {
|
|
case undo
|
|
case redo
|
|
|
|
/// The stack a step lands on once it has been crossed this way.
|
|
public var opposite: HistoryDirection {
|
|
self == .undo ? .redo : .undo
|
|
}
|
|
}
|
|
|
|
// MARK: - HistoryStepOutcome
|
|
|
|
/// What happened when a step was asked to walk its write back (or forward again).
|
|
///
|
|
/// The three cases are three different fates for the *stack*, which is the only thing the provider
|
|
/// asks about:
|
|
///
|
|
/// - `.applied` — the write landed; the step's mirror image joins the opposite stack.
|
|
/// - `.skipped` — **stale** (13-native-undo.md ▸ Rules ▸ staleness validation): "target folder gone,
|
|
/// or the field no longer holding the step's after-value → the step is **skipped, not applied**:
|
|
/// popped from the stack ... and ⌘Z falls through to the next step". The step is dropped and the
|
|
/// crossing continues.
|
|
/// - `.failed` — the inverse was attempted and could not be written. The step **stays**, and the
|
|
/// crossing stops (see the case's own note).
|
|
///
|
|
/// The provider reads this answer and nothing else — the *predicate* (field-level, settled) and the
|
|
/// info-tone banner that explains a skip both belong to the step, which is the only side that knows
|
|
/// what it wrote and which board to say it on.
|
|
public enum HistoryStepOutcome: Equatable, Sendable {
|
|
|
|
/// The step ran. Its mirror image joins the opposite stack.
|
|
case applied
|
|
|
|
/// The step declined: the board no longer holds the value this step's write left, so applying
|
|
/// its inverse would clobber somebody else's newer edit. Nothing ran, the step is dropped, and
|
|
/// the crossing continues with the next one down.
|
|
case skipped
|
|
|
|
/// The step tried and could not: the Writer refused the inverse (a disk error, a permission
|
|
/// problem, a board that has gone read-only under the stack). Nothing landed.
|
|
///
|
|
/// **The step stays put and the crossing stops** — the one thing that distinguishes this from
|
|
/// `.skipped`, and the reason the case exists. 13 is silent here, so the posture is the honest
|
|
/// reading of its two rules: a *stale* step is one the board has moved past, so dropping it
|
|
/// loses nothing; a *failed* one is a step the user still means to cross, refused by a condition
|
|
/// that is usually momentary (a full disk, an unplugged volume), so popping it would spend their
|
|
/// only route back on a transient error. The failure has already banners itself as an ordinary
|
|
/// write failure (`BoardStore.performWrite` posts before it rethrows), which is exactly the
|
|
/// vocabulary 02-architecture.md § Write-failure surfacing gives it — and which is why a failure
|
|
/// must *not* also raise the info-tone skip row: two rows for one event would say the step is
|
|
/// both gone and retryable.
|
|
///
|
|
/// Stopping rather than falling through follows from the same reading: fall-through exists to
|
|
/// walk past steps the board no longer has a use for, and a disk that just refused one write is
|
|
/// not a reason to attempt N more.
|
|
case failed
|
|
}
|
|
|
|
// MARK: - HistoryStep
|
|
|
|
/// One undoable step: a name, and the pair of actions that walk the board backwards and forwards
|
|
/// across it.
|
|
///
|
|
/// ### An operation pair, not an `NSUndoManager` registration
|
|
///
|
|
/// 13-native-undo.md ▸ Rules puts every undoable change at the Writer boundary — "each Writer call
|
|
/// site registers the inverse operation, computed from the pre-write snapshot the store already
|
|
/// holds: move → move back ...; rename → restore title" — and both halves of that write are already
|
|
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
|
|
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
|
|
/// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's
|
|
/// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md ▸ The provider
|
|
/// seam), and neither substrate appears here.
|
|
///
|
|
/// ### `name` is the 06 vocabulary, unprefixed
|
|
///
|
|
/// "The 06 vocabulary supplies menu titles ('Undo Move 3 Cards'), via NSUndoManager's dynamic
|
|
/// retitling — the same naming machinery both tiers use" (13). What the step carries is the bare
|
|
/// phrase — `"Move Card"`, `"Move 3 Cards"`, `"Rename Lane"` — in the vocabulary of
|
|
/// 06-history-undo.md ▸ Commit messages, plural-folded by the same rule ("one gesture, one undo
|
|
/// step — a multi-card move is one step with a plural title"). The **"Undo "/"Redo " prefix is
|
|
/// never part of it**: the platform composes and localizes that (`BoardUndoManager`), and a step
|
|
/// that spelled it would read "Undo Undo Move Card" in the Edit menu.
|
|
///
|
|
/// ### Both closures are `@MainActor`, and both are handed the direction
|
|
///
|
|
/// Everything they touch — the store, the snapshot, the banners — is `@MainActor`, and a step exists
|
|
/// to be run from a menu command. Marking them says so at the seam instead of leaving each provider
|
|
/// to rediscover it.
|
|
///
|
|
/// The `HistoryDirection` argument is **which command the user pressed**, not which half is running:
|
|
/// `reversed` swaps the two closures, so the half a ⌘Z crosses is the `redo` one as often as not,
|
|
/// 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
|
|
|
|
/// Walks it forward again — the original write, replayed. Reached only after `undo` applied,
|
|
/// because that is the only way a step reaches the redo stack.
|
|
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
|
|
}
|
|
|
|
/// 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(id: id, name: name, retirement: retirement, undo: redo, redo: undo)
|
|
}
|
|
}
|
|
|
|
// MARK: - HistoryProviding
|
|
|
|
/// The undo/redo substrate, behind one protocol boundary (12-editions.md ▸ The provider seam).
|
|
///
|
|
/// ### One per board session — and one per open card window
|
|
///
|
|
/// "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
|
|
/// restore commits over HEAD's first-parent ancestry — 06-history-undo.md), a repo-nested board
|
|
/// binds none at all, and Teams inherits Pro's.
|
|
///
|
|
/// ### What this protocol deliberately does not say
|
|
///
|
|
/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation
|
|
/// detail, and a seam that vended one would be a seam only one provider could ever satisfy — the
|
|
/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof
|
|
/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that
|
|
/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it.
|
|
/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives
|
|
/// 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 {
|
|
|
|
/// Records one undoable step, on top of the undo stack, clearing the redo stack — the classic
|
|
/// rule, and the one every substrate shares.
|
|
///
|
|
/// Called once per *gesture*, never once per write: "coalescing follows commit granularity ...
|
|
/// a multi-card move is one step with a plural title; an Edit session is one step, registered at
|
|
/// the Edit→Preview flip" (13 ▸ Rules).
|
|
func register(_ step: HistoryStep)
|
|
|
|
/// Whether there is a step to cross. What the Edit menu's Undo row and the toolbar's Undo item
|
|
/// enable on, through the same responder-chain answer.
|
|
var canUndo: Bool { get }
|
|
|
|
var canRedo: Bool { get }
|
|
|
|
/// The name of the step ⌘Z would cross, or `nil` when there is none — the phrase the menu title
|
|
/// is composed from ("Move 3 Cards" → "Undo Move 3 Cards").
|
|
var undoActionName: String? { get }
|
|
|
|
var redoActionName: String? { get }
|
|
|
|
/// Crosses one step backwards. A stale step is skipped rather than applied, and the crossing
|
|
/// falls through to the next one (13 ▸ Rules ▸ staleness validation); a step whose write *failed*
|
|
/// stays where it is and stops the crossing (`HistoryStepOutcome.failed`); an empty stack does
|
|
/// nothing.
|
|
func undo()
|
|
|
|
func redo()
|
|
|
|
/// Drops every step in both directions — session-only persistence (13 ▸ Rules), run at the board
|
|
/// session's teardown. Also what a substrate that must re-seed (a branch switch, 06) calls first.
|
|
func clear()
|
|
}
|