Files
lanework/Kanban/History/HistoryProviding.swift
T
rzen d076427ee0 Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the
no-undo case is gone, makeHistoryProvider answers git or native, and
the native path provably never touches the enclosing repository's
.git. Session undo steps anchor by card identity, never by path
(9119aa1): HistoryAnchor carries the card UUID (plus comment/draft
vocabulary) and apply-time validation resolves the current folder via
the same both-container walk writeCardBody uses — a board-side lane or
trash move no longer stales the coarse close step, while a genuine
field collision still skips it whole.

2448 tests in 423 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 21:19:42 -04:00

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 board without app-managed git — repo-nested included (re-ruled 2026-07-31) — 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), 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()
}