The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
277 lines
16 KiB
Swift
277 lines
16 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
|
|
}
|
|
|
|
/// **Empties this window's fine stack, registering nothing** — the branch switch's settle step
|
|
/// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31).
|
|
///
|
|
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
|
|
/// > the branch being left — Save All and Discard alike end with every window's stack empty, the
|
|
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
|
|
/// > their cards onto the new branch with fresh stacks."
|
|
///
|
|
/// ### Closing the stack is not closing the window
|
|
///
|
|
/// This is the whole reason it is a method rather than a call to `stack.clear()` at the call site.
|
|
/// A window *close* owes the board one coarse step folded from this stack
|
|
/// (`BoardStore.registerCardSession`, `netEffect()`); a settle clear owes it nothing, because the
|
|
/// window is not closing — it stays open on the other side of the checkout and its next gesture
|
|
/// starts a fresh stack. So nothing here registers, nothing folds, and the fold that runs at the
|
|
/// window's eventual close sees only what the *new* branch's session did: `netEffect()` reads
|
|
/// `stack.pendingSteps`, which this empties, and `registerCardSession` answers `false` over an
|
|
/// empty fold exactly as it does for a session with no net change.
|
|
///
|
|
/// The raw writes go with the steps. They are keyed by step id and nothing on either stack names
|
|
/// them any more, so keeping them would be a table that only grows — and a redone step's half of
|
|
/// the fold, which is the one reason the table outlives the undo stack, has nothing left to be
|
|
/// half of once both stacks are empty.
|
|
///
|
|
/// **Nothing retires here that would not have retired anyway.** `NativeHistoryProvider.clear()`
|
|
/// runs each dropped step's retirement, and a window's fine steps carry none: the deferred
|
|
/// `comments/.trash/` purge is the *coarse* step's (13-native-undo.md ▸ Interaction with the
|
|
/// trash), registered on the board's stack at close and never on this one.
|
|
func discardSteps() {
|
|
stack.clear()
|
|
writes.removeAll()
|
|
}
|
|
|
|
// 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 — and, since
|
|
/// the sweep gate (13 ▸ Interaction with the trash, ruled 2026-07-31), what keeps the *next*
|
|
/// window open from sweeping it as residue. That claim needs nothing of its own here: the fold
|
|
/// below carries each delete's `.present(.trashedComment(…))` into the coarse step's
|
|
/// expectations, and `HistoryStep.backing(declaredBy:)` reads it straight off them.
|
|
/// - **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]
|
|
}
|
|
}
|