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
219 lines
12 KiB
Swift
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]
|
|
}
|
|
}
|