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 next milestone's /// field-level staleness predicate compares against — each step already carries both sides of its /// write (13 ▸ Rules ▸ staleness validation). @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. /// /// **A step that could not write answers `.skipped`.** `HistoryStepOutcome` has two cases and the /// honest reading of a failed inverse is the second one — nothing landed, so nothing goes on the /// opposite stack — and `performWrite` has already posted the banner that says why. The read-only /// lock, the other way a write is refused here, cannot reach this path in practice: a lock /// disables Undo and Redo with the rest of the mutating commands (13 ▸ Rules ▸ locks). /// /// 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, undo: @escaping @MainActor (BoardStore) throws -> Void, redo: @escaping @MainActor (BoardStore) throws -> Void ) { guard let history else { return } history.register(HistoryStep( name: name, undo: { [weak self] in BoardStore.cross(self, undo) }, redo: { [weak self] in BoardStore.cross(self, redo) } )) } /// Runs one side of a step as an ordinary bracketed write. private static func cross( _ store: BoardStore?, _ write: @MainActor (BoardStore) throws -> Void ) -> HistoryStepOutcome { // The board went away under its own stack — a session torn down between the registration and // the ⌘Z. Nothing to write to, so nothing ran. guard let store else { 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 .skipped } return landed == nil ? .skipped : .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 tombstone**, exactly as 13 words it: an undone create leaves *no trace*, /// because the item was born of the gesture being undone — a tombstone would leave a trash row /// for a card the user never really made. `purgeIsUnrecoverable` is untouched by this: that flag /// is about Delete Immediately, 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. func registerCreation(_ items: [CreatedItem], kind: HistoryPhrase.Kind) { guard !items.isEmpty else { return } let operation: WriteOperation = kind == .lane ? .createLane : .createCard registerStep(HistoryPhrase.name(.add, kind: kind, count: items.count)) { _ 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) } } /// 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) } } /// Puts an item's tombstone back — **with the timestamp it carried**, not with `now`. /// /// The inverse of Put Back is the item returning to the trash exactly where it was, and the trash /// sorts by `deleted` (03-board-ui.md § Trash ▸ Contents): re-stamping would file the row under /// today and quietly reorder a list the user was reading. A prior that was malformed (or, by /// construction impossibly, missing) falls back to `now` — the item has to be tombstoned, and an /// unreadable timestamp is not a value to preserve. static func restoreTombstone(_ prior: FieldValue, in document: inout FrontmatterDocument) { document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date())) } }