import Foundation import os // MARK: - Registration at the Writer boundary /// The inverse-registration layer: how a `BoardStore` write turns into an undo step /// (13-native-undo.md ▸ Rules). /// /// ### Why it is an extension rather than lines inside each method /// /// Every registration in `BoardStore` is the same four moves — name the gesture, hold the *values* /// the write is about to overwrite, hold the values it is about to set, and hand the pair to the /// session's stack — and only the middle two differ per operation. Spelling the frame once here /// leaves each call site with the part that is actually about *that* operation: which fields it /// touched and what they said before. It also keeps the one rule every step must obey in a single /// place — **an inverse is performed as an ordinary app-mediated write**, through `performWrite`, /// so an undo brackets the watcher, echoes back through the reload like any other change, refreshes /// every window on the board, and (on git boards, pro-m1) commits. Undone changes are real writes, /// never in-memory reverts. /// /// ### Values, never live references /// /// Nothing a step closes over is read from the snapshot at crossing time: every closure below /// captures ids, folder URLs, orders, titles, style values and body bytes as **values**, computed /// from the pre-write snapshot the store was holding when the gesture ran. That is what makes a step /// meaningful minutes later, after any number of reloads, and it is what the **field-level staleness /// predicate** compares against — each step carries both sides of its write (13 ▸ Rules ▸ staleness /// validation). /// /// ### Every step declares what its write left behind /// /// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]` /// lists: what the board must look like for the undo to be safe (the state the *forward* write left), /// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per /// item the gesture touched, naming that item's folder, whether it should be there and live, and the /// fields the write actually set — "extend the registration to carry whatever the predicate needs, no /// more". A field this gesture never wrote is never listed, which is what makes a foreign edit /// elsewhere — another card, another field of the same card — leave the step alone. @MainActor extension BoardStore { // MARK: The funnel /// Registers one gesture's step on the board's stack — the one call every write site below makes, /// and the only place `HistoryStep` is built. /// /// `undo` walks the board back across the write; `redo` replays the write itself. Both are handed /// the store rather than capturing it, so the only reference a step holds to the board is the /// weak one this method installs: a stack outliving its session must not be the reason a board /// stays in memory. /// /// `subject` is what the **skip banner** quotes: the item's title where the step names exactly /// one titled item, and otherwise `nil`, which falls back to the step's own 06 phrase. That is /// two readings of one sentence rather than two sentences — 13's own example is an item ("Undo /// skipped — 'Fix login' changed outside Lanework") and a batch has no single item to name, so it /// says what it is instead ("Undo skipped — 'Move 3 Cards' changed outside Lanework"). An /// untitled item takes the phrase too: "Untitled" is a rendering, never a value (03-board-ui.md /// § Card face). /// /// **A failed inverse answers `.failed`, a stale one `.skipped`** — the distinction the stack /// depends on (`HistoryStepOutcome`): a stale step is dropped and ⌘Z falls through, a failed one /// stays put with `performWrite`'s error banner already standing. The read-only lock reaches this /// path only from a direct call — the adapter disables Undo and Redo under it /// (`BoardUndoManager`, 13 ▸ Rules ▸ locks) — and is a refusal rather than a staleness, so it too /// leaves the stack where it is: the lock clears, the stack resumes. /// /// The seam is spelled with an **untyped** `throws`, deliberately and at a cost worth recording: /// every inverse below is made of `BoardWriter` calls and throws nothing but `BoardWriteError`, /// but a multi-statement closure literal cannot inherit a typed `throws` from its context — /// Swift's inference stops at the brace — so a typed seam would make all twelve call sites spell /// `{ (_: BoardStore) throws(BoardWriteError) -> Void in }` (the same wart `performWrite`'s doc /// comment records, arriving from a third direction). `cross` narrows it back, and reports rather /// than swallows the case that cannot happen. func registerStep( _ name: String, subject: String? = nil, on window: CardWindowUndo? = nil, retiring: (@MainActor () -> Void)? = nil, undoExpects: [HistoryExpectation], redoExpects: [HistoryExpectation], undo: @escaping @MainActor (BoardStore) throws -> Void, redo: @escaping @MainActor (BoardStore) throws -> Void ) { let retirement = retiring.map(HistoryStep.Retirement.init) // **The routing decision, and the whole of it** (13 ▸ Rules ▸ two levels): a gesture issued // in a card window lands on that window's stack, and everything else on the board's. It is a // parameter rather than ambient state on purpose — the issuing surface is knowledge only the // call site has, and a store-wide "current window" would be a second answer able to be wrong // for exactly one gesture (the board styling a card whose window happens to be open). guard let sink: any HistoryProviding = window?.stack ?? history else { // No substrate at all — a repo-nested board (06 ▸ Rules), or a store with no session. // Nothing records the step, so nothing can ever retire it: the work is owed now. retirement?.run() return } let named = subject ?? name let step = HistoryStep( name: name, retirement: retirement, undo: { [weak self] direction in BoardStore.cross(self, direction, named, undoExpects, undo) }, redo: { [weak self] direction in BoardStore.cross(self, direction, named, redoExpects, redo) } ) // The raw halves, kept beside the window's stack for the close fold — before the register, so // a fold taken from inside a registration's own side effects can never see a step it has no // write for (`CardWindowUndo.netEffect`). window?.record(step.id, CardWindowUndo.Write( undoExpects: undoExpects, redoExpects: redoExpects, undo: undo, redo: redo )) sink.register(step) } // MARK: The window close's coarse step /// **Registers one card-window session as one board step** — 13-native-undo.md ▸ Rules' window /// close ("the session's net effect registers on the board stack as one coarse step, 'Edit card /// ⟨title⟩', values-based, whose undo restores the card subtree to its session-start state — /// deleted comments included — and whose redo reapplies the net effect"). /// /// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about /// whether there is a board to register it on is this method's: /// /// - **A vanished card registers nothing.** 05-card-window.md ▸ Deletion & lifecycle dismisses the /// window when its card leaves the board — into the trash, with its lane, to another board — and /// the card's own departure is already a board step of its own (`deleteCard`). A session step /// naming folders that have moved could only be a step that skips, so the honest answer is not /// to register one: the window's fine stack dies with the window, as 13's session-only rule has /// it. (A trashed card keeps its `comments/.trash/` too — "a trashed card carries its /// `comments/`", 01-storage-format.md — and the residue sweeps at the next open of that card.) /// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`. /// /// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash). /// - Returns: whether the purge now has an owner — a live step holding it until the step leaves /// the board stack, or a substrate that declined to keep the step and therefore ran it already /// (`GitHistoryProvider.register`). `false` means nothing was registered and the caller still /// owes the purge. @discardableResult func registerCardSession( _ window: CardWindowUndo, inCard cardID: ItemID, retiring: @escaping @MainActor () -> Void ) -> Bool { guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false } guard let net = window.netEffect() else { return false } registerStep( HistoryPhrase.cardSession, subject: item.title, retiring: retiring, undoExpects: net.undoExpects, redoExpects: net.redoExpects, undo: net.undo, redo: net.redo ) return true } /// Validates one side of a step against disk, then runs it as an ordinary bracketed write. /// /// The order is the whole rule: **nothing is written until every target still holds what this /// step left there** (13 ▸ Rules ▸ staleness validation, "Never apply a stale inverse on top of /// someone else's newer write"). A stale target posts the info-tone row that explains the skip and /// answers `.skipped`, which pops the step and falls the crossing through to the next one down. private static func cross( _ store: BoardStore?, _ direction: HistoryDirection, _ subject: String, _ expectations: [HistoryExpectation], _ write: @MainActor (BoardStore) throws -> Void ) -> HistoryStepOutcome { // The board went away under its own stack — a session torn down between the registration and // the ⌘Z. There is nothing to write to and nothing to say it on, and a step whose board is // gone is not one to pop: the stack is about to be cleared with the session anyway. guard let store else { return .failed } guard HistoryStaleness.isCurrent(expectations) else { store.banners.postSkippedStep(direction, subject: subject) return .skipped } var foreign: (any Error)? let landed: Void? = try? store.performWrite { () throws(BoardWriteError) -> Void in do { try write(store) } catch let error as BoardWriteError { // The Writer's own failure: `performWrite` posts it to the banner and rethrows, which // is the whole of how an inverse that could not land explains itself. throw error } catch { foreign = error } } if let foreign { historyLogger.error("an inverse threw something that is not a write error: \(String(describing: foreign), privacy: .public)") return .failed } // `nil` is either the Writer's `BoardWriteError` (bannered on the way past) or the read-only // lock's refusal (the lock row is already standing). Both are failures, never staleness. return landed == nil ? .failed : .applied } private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history") // MARK: Creates /// One item a gesture brought into being: where it landed, the bytes it landed with, and any /// files the same gesture imported into it. /// /// The bytes are captured at create time because the *inverse of a create is a removal* — "create /// → remove the created folder" (13 ▸ Rules) — and a removal leaves nothing for a redo to read. /// Holding them is what lets ⇧⌘Z put the item back at its own path under its own UUID, which /// every step registered above this one on the stack depends on. struct CreatedItem { let folder: URL let indexText: String /// The Finder files this create imported (`createCards(fromFiles:)`), replayed on redo from /// the same source URLs the gesture used. Empty for every other create. var attachments: [URL] = [] } /// Registers a create's step: undo removes the folders, redo puts them back byte-for-byte. /// /// **Removal, not a move into the trash**, exactly as 13 words it: an undone create leaves *no /// trace*, because the item was born of the gesture being undone — filing it in the trash would /// leave a card the user never really made for them to find. `purgeIsUnrecoverable` is untouched /// by this: that flag is about the trash's own permanent delete, whose loss is the user's own /// final gesture, while this loss is one ⇧⌘Z away. /// /// The honest edge, recorded rather than papered over: anything that happened *inside* the /// created folder through an operation that registers no step of its own — an attachment added by /// hand, a foreign edit — goes with the folder and does not come back on redo. That follows from /// 13's own two rulings (remove the folder; attachment operations register nothing in v1) rather /// than from anything decided here. /// /// **Its staleness predicate is existence, and deliberately nothing else** (13: "existence … /// for create/delete/restore steps"). A create's after-value *is* the item's being there, so the /// undo validates that the folders are still at their paths and the redo that they are still /// gone. The same honest edge follows: a foreign *edit* inside a created card does not stop ⌘Z /// from removing it, because the create never wrote that field — while a foreign *delete* does, /// since the card the user is looking at in the trash is at a different path now. func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind, subject: String? = nil) { guard !items.isEmpty else { return } let operation: WriteOperation = kind == .lane ? .createLane : .createCard registerStep( HistoryPhrase.name(.add, kind: kind, count: items.count), subject: subject, undoExpects: items.map { .present($0.folder) }, redoExpects: items.map { .absent($0.folder) } ) { _ in // Reversed, so a lane and a card created by one gesture unwind child-first — the same // order they were made in, read backwards. for item in items.reversed() { try BoardWriter.purgeItem(at: item.folder) } } redo: { _ in for item in items { try BoardWriter.recreateItem(at: item.folder, indexText: item.indexText, operation: operation) guard !item.attachments.isEmpty else { continue } _ = try BoardWriter.importAttachments(item.attachments, intoCard: item.folder) } } } /// A just-created item, ready to register — or `nil` when its bytes could not be read back, in /// which case the gesture simply registers nothing rather than arming an undo whose redo could /// not restore anything. func createdItem(at folder: URL, kind: HistoryPhrase.Kind, attachments: [URL] = []) -> CreatedItem? { let operation: WriteOperation = kind == .lane ? .createLane : .createCard guard let text = try? BoardWriter.readIndexText(ofItem: folder, operation: operation) else { return nil } return CreatedItem(folder: folder, indexText: text, attachments: attachments) } // MARK: Restoring a field to what it said before /// Puts a lenient string field (`title`, `background`, `icon`) back to the value it held before /// the write — or removes the key, which is what "before" means for a field that was not there. /// /// A **malformed** prior reads as a removal, and that is the one place an inverse is not /// byte-exact: the app cannot re-emit `background: [a, b]` through a document edit that only /// knows how to set scalars. It is also the case the forward write was designed to clear /// ("choosing any well replaces it" — 03-board-ui.md § Styling ▸ Controls), so the undo lands the /// item on the app's own reading of that field rather than resurrecting a value nothing could /// read. static func restore(_ prior: FieldValue, to key: String, in document: inout FrontmatterDocument) { if let value = prior.value { document.set(key, to: .string(value)) } else { document.remove(key) } } /// The style fields a gesture actually set — **one entry per dimension it did not `.keep`**, so a /// step that only chose a colour never validates the symbol beside it. /// /// `.remove` is spelled as `nil`, which is the after-value the None well leaves: the key is gone, /// and the item renders the level's default (03-board-ui.md § Styling ▸ Controls). static func styledFields(background: StyleChange, icon: StyleChange) -> [ExpectedField] { field(background, as: ExpectedField.background) + field(icon, as: ExpectedField.icon) } /// The same dimensions, holding the values the *inverse* restores — what the redo half validates /// against once ⌘Z has run. /// /// The prior is read as `FieldValue.value`, which is `nil` for a missing key **and** for a /// malformed one, exactly matching `restore(_:to:in:)`: a malformed prior reads as a removal, so /// the state the undo leaves is an absent key either way. static func restoredStyleFields( background: StyleChange, priorBackground: FieldValue, icon: StyleChange, priorIcon: FieldValue ) -> [ExpectedField] { var fields: [ExpectedField] = [] if background != .keep { fields.append(.background(priorBackground.value)) } if icon != .keep { fields.append(.icon(priorIcon.value)) } return fields } private static func field( _ change: StyleChange, as make: (String?) -> ExpectedField ) -> [ExpectedField] { switch change { case .keep: [] case let .set(value): [make(value)] case .remove: [make(nil)] } } /// Puts a lane's `width` back — the integer it held, or no key at all. /// /// Note the asymmetry with the forward write, which removes the key at one unit (the /// remove-at-default family): an inverse restores the *value*, so a hand-written `width: 1` comes /// back as `width: 1` rather than as an absence that renders the same. Restoring what was there /// outranks re-deriving what the app would have written. static func restoreWidth(_ prior: FieldValue, in document: inout FrontmatterDocument) { if let value = prior.value { document.set(FrontmatterKeys.width, to: .int(value)) } else { document.remove(FrontmatterKeys.width) } } }