Files
lanework/Kanban/History/CardWindowUndo.swift
T
rzen a381fac742 Make the card window the commit unit on Pro boards
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.

Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.

2444 tests in 422 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 20:23:45 -04:00

219 lines
12 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.
@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.
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: [URL: 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: [URL] = []
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
/// **A later `.absent` clears what earlier writes said about the path**, which is how a move
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
/// live path, 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 paths
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
mutating func merge(_ expectation: HistoryExpectation) {
let key = expectation.folder.standardizedFileURL
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(folder: key, presence: target.presence, fields: fields)
}
}
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
}
}