Files
lanework/Kanban/History/CardWindowUndo.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

240 lines
13 KiB
Swift

import Foundation
// MARK: - CardWindowUndo
/// **One card window's undo session** — the second of 13-native-undo.md's two levels (re-ruled
/// 2026-07-31, the session-coarsening model).
///
/// > "the **board stack** is owned by the board session and shared by board surfaces; a **card window
/// > owns its own stack** for the session it represents — every gesture issued in that window
/// > (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where
/// > undoable) registers there at fine grain, and `window.undoManager` answers with it (standard
/// > per-window AppKit scoping)."
///
/// ### Two jobs, and the second is why this is a type rather than a stored provider
///
/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window
/// registers are values-based inverses at the Writer boundary — the same shape whatever substrate the
/// board's own history has — so a Pro git board's card window still walks its own gestures with the
/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one
/// commit" — 06-history-undo.md ▸ Undo routing).
///
/// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to
/// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly
/// the steps still on this stack, composed — see `netEffect()`.
///
/// ### Why the session-start capture is distributed rather than a subtree snapshot
///
/// A single snapshot of the card folder taken at window open, diffed against disk at close, was the
/// obvious shape and is the wrong one: it would sweep in **every** change to the card during the
/// window's life, an agent's included, and 13 ▸ Rules is explicit that "foreign writes never join the
/// stack". A disk diff cannot tell the user's gesture from somebody else's write; a stack of the
/// user's gestures never has to.
///
/// So the capture lives where it already lived — each fine step carries the value its write
/// overwrote (`CardBodyEditSession`'s session-origin bytes, `CommentEditSession.sessionStart`, the
/// prior style fields) — and the coarse step is their composition. That also makes the coarse step's
/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and
/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted.
///
/// ### The values are anchored to the card, not to its folder
///
/// "Session steps anchor by card identity, never by path" (13 ▸ Rules, ruled 2026-07-31): every write
/// folded here names the card's UUID and the thread position inside it (`HistoryAnchor`), never the
/// lane the card happened to be in when the gesture ran. The fold is therefore over identities the
/// whole way down, and one board-side lane move — which used to stale every component of the coarse
/// step at once — changes nothing about it.
@MainActor
public final class CardWindowUndo {
// MARK: The fine stack
/// This window's own steps. `NativeHistoryProvider` verbatim: the grammar a window needs — one
/// register is one step, a stale step is skipped and falls through, a failed one stays — is the
/// grammar that type already implements and this milestone had no reason to fork.
public let stack = NativeHistoryProvider()
/// Whether the board is refusing writes, wired by the host once the window has joined its board
/// (`BoardUndoManager.isReadOnly`'s closure, one level down): the lock disables Undo and Redo in
/// a card window exactly as it does on the board (13 ▸ Rules ▸ locks).
public var isReadOnly: @MainActor () -> Bool = { false }
/// **What this window hands back from `windowWillReturnUndoManager`** — the AppKit face over the
/// stack above, so the Edit menu's rows light up, disable and retitle from *this window's*
/// gestures.
///
/// There is deliberately **no fall-through**: this manager never consults the board's stack, so
/// "exhausting the window stack beeps; it never reaches board history" (06 ▸ Undo routing) is a
/// property of what the window answers with rather than a rule someone has to remember to apply.
public private(set) lazy var manager: BoardUndoManager = BoardUndoManager(
history: stack,
isReadOnly: { [weak self] in self?.isReadOnly() ?? false }
)
// MARK: The writes behind the steps
/// One gesture's write, in the raw terms a fold needs: the two halves and what each of them
/// leaves behind.
///
/// It is the *unwrapped* pair — `BoardStore.registerStep`'s arguments before that method wraps
/// them in validation and a `performWrite` bracket. The coarse step has to compose the writes
/// themselves, because a composition of wrapped steps would validate and bracket each component
/// separately, which is precisely the partial session revert 13 forbids ("any stale component
/// skips the whole step — never a partial session revert").
struct Write {
let undoExpects: [HistoryExpectation]
let redoExpects: [HistoryExpectation]
let undo: @MainActor (BoardStore) throws -> Void
let redo: @MainActor (BoardStore) throws -> Void
}
/// The raw writes, keyed by the step they belong to. Keyed rather than appended so that a gesture
/// undone inside the window drops out of the fold for free: membership is `stack.pendingSteps`',
/// and this is only the lookup.
private var writes: [UUID: Write] = [:]
public init() {}
/// Records the raw write behind a step this window is about to register. Called by
/// `BoardStore.registerStep`, which is the one place both halves are in hand.
func record(_ id: UUID, _ write: Write) {
writes[id] = write
}
// MARK: - The fold
/// **The session's net effect, or `nil` when there is none** — what the window's close registers
/// on the board stack as one coarse step, and what "a session with no net change registers
/// nothing" means in code.
///
/// ### The composition
///
/// - **undo** — every live step's undo, newest first. Replaying the session backwards lands on the
/// state it started from, deleted comments included: their backing is still in
/// `comments/.trash/` because this step's own existence is what defers the purge.
/// - **redo** — every live step's redo, oldest first. The session, replayed.
/// - **the undo's expectations** — the state the session's writes left, folded **last-write-wins**
/// per field: what must still be true for the whole step to be safe to cross.
/// - **the redo's expectations** — the state the coarse undo leaves, folded **first-write-wins**:
/// the mirror, for the same reason.
///
/// One `HistoryStep` carrying every component's expectations is what makes validation
/// transactional without a word of new machinery: `BoardStore.cross` already checks the whole list
/// before writing anything, so any stale component skips the whole step with the ordinary
/// info-tone banner.
///
/// ### "No net change" is an equality, not a step count
///
/// A body typed away and typed back across two Edit sessions is two steps whose folded before and
/// after say the same thing, and 13 says that session registers nothing. Comparing the two folds
/// is the whole test — and it is exact, because both sides are the values the app itself wrote.
func netEffect() -> Write? {
// Every step on this stack was recorded here as it was registered, and nothing is ever
// removed — a step undone inside the window may still be redone, so its write has to survive
// being off the undo stack. The table is therefore complete by construction and dies with the
// window; pruning it against one stack would silently empty a redone step's half of the fold.
let live = stack.pendingSteps.compactMap { writes[$0.id] }
guard !live.isEmpty else { return nil }
let after = Self.fold(live.map(\.undoExpects))
let before = Self.fold(live.reversed().map(\.redoExpects))
guard after != before else { return nil }
return Write(
undoExpects: after.expectations,
redoExpects: before.expectations,
undo: { store in
for write in live.reversed() { try write.undo(store) }
},
redo: { store in
for write in live { try write.redo(store) }
}
)
}
// MARK: Folding
/// The merged expectations of a sequence of writes, **later entries winning** — so folding a list
/// in registration order yields the state the last write left, and folding it reversed yields the
/// state the first write found.
///
/// Merging is per target *and* per field: two style gestures that set different dimensions of one
/// card fold to one target carrying both, while two that set the same dimension fold to one value.
/// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of
/// one comment fold to "in the trash" rather than to two contradictory claims.
///
/// **A target is an anchor, not a path** (13 ▸ Rules, ruled 2026-07-31): every write folded here
/// is a card window's, so every anchor is a card identity, and the fold is over identities the
/// whole way down — which is what carries "session steps anchor by card identity" into the coarse
/// step without the fold knowing anything about it.
static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
var fold = Fold()
for list in lists {
for expectation in list { fold.merge(expectation) }
}
return fold
}
/// A set of expectations being merged, in first-seen target order.
struct Fold: Equatable {
private struct Target: Equatable {
var presence: HistoryExpectation.Presence
var fields: [ExpectedField.Kind: ExpectedField]
}
private var targets: [HistoryAnchor: Target] = [:]
/// First-seen order, so the folded list a step carries is stable rather than hash-ordered —
/// a skip banner and a test both read better when the card comes before its comments.
///
/// **Deliberately outside equality** (see `==`): the two folds a net-effect test compares are
/// built by walking the same writes in opposite directions, so their orders differ by
/// construction while the question being asked — did anything actually change — is about the
/// values alone.
private var order: [HistoryAnchor] = []
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
/// **A later `.absent` clears what earlier writes said about the target**, which is how a move
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
/// live location, and carrying the edit's body expectation there would make the session's own
/// step stale the moment it was registered. Every move-shaped step declares both of its
/// anchors precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
mutating func merge(_ expectation: HistoryExpectation) {
let key = Self.key(expectation.anchor)
if targets[key] == nil {
order.append(key)
targets[key] = Target(presence: expectation.presence, fields: [:])
}
if expectation.presence == .absent {
targets[key] = Target(presence: .absent, fields: [:])
return
}
targets[key]?.presence = expectation.presence
for field in expectation.fields {
targets[key]?.fields[field.kind] = field
}
}
/// The fold, back in the currency `HistoryStep` speaks. Fields are ordered by the kind's own
/// declaration order so two equal folds always render identically.
var expectations: [HistoryExpectation] {
order.compactMap { key in
guard let target = targets[key] else { return nil }
let fields = Self.fieldOrder.compactMap { target.fields[$0] }
return HistoryExpectation(anchor: key, presence: target.presence, fields: fields)
}
}
/// Two anchors are one target when they name the same thing. Identity anchors already do that
/// by construction — a UUID is its own normal form — so the only normalization left is the
/// path anchor's, which a board gesture may spell with a trailing slash or a `.` component.
private static func key(_ anchor: HistoryAnchor) -> HistoryAnchor {
guard case let .path(url) = anchor else { return anchor }
return .path(url.standardizedFileURL)
}
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
}
}