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] } }