From 2148ebb379a346988c8b482bee83dae04bc18b67 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 14:24:31 -0400 Subject: [PATCH] Register inverse operations at the Writer boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store is the Writer boundary, so it computes and registers inverses: a weak history sink bound at session composition, one HistoryStep per gesture at exactly the brackets that were already one performWrite each — multi-card moves, style batches, width pairs, and multi-row restores each undo as one plurally-titled step, and the Edit session registers once at the flip from the bytes disk held before its first landed write, debounce ticks registering nothing. Crossings run through performWrite, so an undo brackets the watcher, echoes through the reload, and reaches every window; every closure captures values, never snapshots. The inventory follows 13 exactly: moves return to origin lane and order, renames restore or remove the title key, restyles and resizes restore field values or absence, tombstones and restores swap with captured timestamps, and an undone create is a real removal — no trace — with redo re-materializing the same UUID from bytes captured at gesture time. Purge, attachments, repair, bookkeeping, checkbox flips, raw Apply, and the whole arrival family register nothing, each exclusion documented where it lives. Step names speak 06's verb vocabulary through the new HistoryPhrase. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/App/AppModel.swift | 7 + Kanban/App/CardWindowHost.swift | 6 + Kanban/History/BoardStoreHistory.swift | 197 ++++++ Kanban/History/HistoryPhrase.swift | 93 +++ Kanban/LiveStore/BoardStore.swift | 551 ++++++++++++++-- Kanban/Storage/BoardWriter.swift | 90 +++ Kanban/UI/Card/CardBodyEditSession.swift | 45 +- KanbanTests/UndoWriteTests.swift | 807 +++++++++++++++++++++++ 8 files changed, 1732 insertions(+), 64 deletions(-) create mode 100644 Kanban/History/BoardStoreHistory.swift create mode 100644 Kanban/History/HistoryPhrase.swift create mode 100644 KanbanTests/UndoWriteTests.swift diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 4c7d719..a112d05 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -546,6 +546,13 @@ public final class AppModel { // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with // the board session and dies at close/quit ... standard macOS behavior". let history = makeHistoryProvider(store) + // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the + // store is that boundary — every app-mediated mutation goes out through one of its write + // methods — so it is the store that computes each inverse and registers it. What it cannot + // know is *which* stack, because a stack belongs to a session and a store knows nothing about + // windows; this line is where the session tells it. Weak on the store's side, so the loop + // this closes (provider → step closures → store) is not a retain cycle. + store.history = history sessions[ref] = BoardSession( store: store, recordID: recordID, diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 8367625..9854853 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -413,6 +413,12 @@ struct CardWindowHost: View { guard let store else { return .vanished } return store.writeCardBody(inCard: cardID, body: text) } + // The session's one undo step, at the Edit→Preview flip (13-native-undo.md ▸ Rules). Weakly, + // `save`'s rule: a session ending after the board window has gone registers nothing rather + // than resurrecting a released store — and the board's stack died with it anyway. + session.body.registerUndo = { [weak store] priorBody, newBody in + store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody) + } bodyPresentation.flushEdits = { [session] in session.body.endEditSession() } diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift new file mode 100644 index 0000000..6657b6f --- /dev/null +++ b/Kanban/History/BoardStoreHistory.swift @@ -0,0 +1,197 @@ +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())) + } +} diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift new file mode 100644 index 0000000..7bcd1bc --- /dev/null +++ b/Kanban/History/HistoryPhrase.swift @@ -0,0 +1,93 @@ +import Foundation + +// MARK: - HistoryPhrase + +/// The menu phrase an undo step carries — "Move 3 Cards", "Rename Lane", "Restyle Board". +/// +/// ### Why the vocabulary is 06's and not a new one +/// +/// 13-native-undo.md ▸ Rules hands the naming straight over: "The 06 vocabulary supplies menu titles +/// ('Undo Move 3 Cards'), via NSUndoManager's dynamic retitling — the same naming machinery both +/// editions use." So the verbs here are exactly 06-history-undo.md ▸ Commit messages' list — *Add / +/// Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes, and the board*, plus +/// the trash pair's **Restore** — and the plural rule is that section's own plural folding ("Delete +/// 12 cards"), which is also 13's coalescing sentence read out loud: "a multi-card move is one step +/// with a plural title". +/// +/// The **"Undo "/"Redo " prefix is never here**: the platform composes and localizes it +/// (`BoardUndoManager.undoMenuItemTitle`), and a phrase that spelled it would read "Undo Undo Move +/// Card" in the Edit menu — see `HistoryStep.name`. +/// +/// ### Title case, unlike a commit subject +/// +/// A commit subject is a sentence ("Move 3 cards to Done"); a menu item is a title, and macOS titles +/// its Edit-menu rows. The words are 06's; the casing is the menu's. Nothing else differs — and the +/// destination clause a commit subject carries has no place in a title that has to stay short enough +/// for a menu row. +/// +/// Pure, and its own type rather than a `String` built at each call site, because a phrase composed +/// in eleven places is a vocabulary that drifts in eleven places. +public enum HistoryPhrase { + + // MARK: Verbs + + /// 06-history-undo.md ▸ Commit messages' verb list, restricted to the operations 13 makes + /// undoable. `Permanently delete`, `Attach`, `Remove` and `Repair` are deliberately absent — + /// those are exactly the operations that register no step at all (13 ▸ Rules ▸ what is not + /// undoable, ▸ Out of scope). + public enum Verb: String, Sendable, CaseIterable { + /// A create — File ▸ New Lane, the new-card placeholder's commit, a Finder file drop's cards. + case add = "Add" + /// A tombstone (⌫, drop-on-trash, the card window's Actions ▸ Delete). + case delete = "Delete" + /// Put Back and drag-to-restore. + case restore = "Restore" + /// A drop that changes an item's parent. + case move = "Move" + /// A drop, a lane drag or ⌥⌘↑/⌥⌘↓ that changes rank among unchanged siblings. + case reorder = "Reorder" + case rename = "Rename" + case restyle = "Restyle" + case resize = "Resize" + /// An Edit session's body save — one step at the Edit→Preview flip (13 ▸ Rules). + case edit = "Edit" + } + + // MARK: Nouns + + /// What the gesture acted on. `board` is deliberately count-less: there is one board, and + /// "Rename 1 Board" is not a phrase anyone writes. + public enum Kind: Sendable, Equatable { + case card + case lane + case board + + var singular: String { + switch self { + case .card: "Card" + case .lane: "Lane" + case .board: "Board" + } + } + + var plural: String { + switch self { + case .card: "Cards" + case .lane: "Lanes" + case .board: "Board" + } + } + } + + // MARK: Composition + + /// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`. + /// + /// A `count` of one or less folds to the singular — a batch that turned out to name a single item + /// is one item, and a zero never reaches here because a gesture that wrote nothing registers + /// nothing. + public static func name(_ verb: Verb, kind: Kind, count: Int = 1) -> String { + guard count > 1, kind != .board else { return "\(verb.rawValue) \(kind.singular)" } + return "\(verb.rawValue) \(count) \(kind.plural)" + } +} diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 5331f0e..6dd8ab8 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -355,6 +355,27 @@ public final class BoardStore { @ObservationIgnored public var displayStateDelegate: (@MainActor () -> Void)? + /// **Where this board's inverses go** — the undo/redo substrate every write below registers into + /// (13-native-undo.md ▸ Rules: "Registration at the Writer boundary … each Writer call site + /// registers the inverse operation, computed from the pre-write snapshot the store already + /// holds"). + /// + /// The store is the Writer boundary: every app-mediated mutation in the app goes through one of + /// the methods below and out through `performWrite`, which is precisely the set of call sites 13 + /// names. So the sink belongs here, injected like `watcherBrackets` and for the same reason — the + /// stack is **the session's**, "one stack per board, owned by the board session", and a store that + /// made its own would be a second answer to which stack a board has. + /// `AppModel.beginSession` wires it the moment the session's provider exists. + /// + /// **Weak, deliberately.** The session owns both the store and the provider, and the provider's + /// steps hold closures over *this* store: a strong reference here would close that loop, leaving a + /// board that could only be freed by remembering to empty its undo stack first. `nil` — no session + /// yet, a storeless test, a board whose stack has been cleared away — keeps every method below + /// behaving exactly as it did before this milestone, registering nothing, which is `watcherBrackets`' + /// `nil` rule restated for a second seam. + @ObservationIgnored + public weak var history: (any HistoryProviding)? + // MARK: Reload machinery /// Monotonic id of the most recently *started* reload — and therefore also the number of tree @@ -858,11 +879,11 @@ public final class BoardStore { /// hand-written `width: 1` is legal and preserved until the app itself next edits width — the /// unchanged-units guard below skips it, so only a real change reaches the remove. private func writeLaneWidths(_ changes: [(id: ItemID, units: Int)]) { - let writes: [(folder: URL, units: Int)] = changes.compactMap { change in + let writes: [(folder: URL, units: Int, prior: FieldValue)] = changes.compactMap { change in guard let lane = snapshot.lanes.first(where: { $0.id == change.id }), LaneLayoutMath.displayUnits(of: lane) != change.units else { return nil } - return (rootURL.appendingPathComponent(change.id.rawValue), change.units) + return (rootURL.appendingPathComponent(change.id.rawValue), change.units, lane.width) } guard !writes.isEmpty else { return } @@ -870,16 +891,37 @@ public final class BoardStore { // call site Swift stops inferring the typed `throws(BoardWriteError)` and widens it to `any // Error`, which `performWrite` will not take. Same wart as the value-returning call sites // `performWrite`'s doc comment records, arriving from the other direction. - try? performWrite { () throws(BoardWriteError) -> Void in + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + for write in writes { + try Self.setWidth(write.units, at: write.folder) + } + } + guard landed != nil else { return } + + // resize → prior width (13-native-undo.md ▸ Rules). One step whatever the batch's size — the + // menu items step every selected lane in one gesture, and one gesture is one step. + registerStep(HistoryPhrase.name(.resize, kind: .lane, count: writes.count)) { _ in for write in writes { try BoardWriter.updateIndex(inItemFolder: write.folder, operation: .resize(title: nil)) { document in - if write.units == 1 { - document.remove(FrontmatterKeys.width) - } else { - document.set(FrontmatterKeys.width, to: .int(write.units)) - } + Self.restoreWidth(write.prior, in: &document) } } + } redo: { _ in + for write in writes { + try Self.setWidth(write.units, at: write.folder) + } + } + } + + /// The width write itself, spelled once so the gesture and its redo cannot drift apart on the + /// remove-at-default rule. + private static func setWidth(_ units: Int, at folder: URL) throws(BoardWriteError) { + try BoardWriter.updateIndex(inItemFolder: folder, operation: .resize(title: nil)) { document in + if units == 1 { + document.remove(FrontmatterKeys.width) + } else { + document.set(FrontmatterKeys.width, to: .int(units)) + } } } @@ -986,16 +1028,28 @@ public final class BoardStore { /// targets written before it written — the Writer is "atomic per filesystem operation, not per /// gesture" — and the reload shows the true state, which is the honest one. public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) { - let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target) + let edits: [( + folder: URL, + background: StyleChange, + icon: StyleChange, + priorBackground: FieldValue, + priorIcon: FieldValue + )] = styleSubjects(of: target) .compactMap { subject in let background = Self.effective(background, against: subject.background) let icon = Self.effective(icon, against: subject.icon) guard background != .keep || icon != .keep else { return nil } - return (folder: subject.folder, background: background, icon: icon) + return ( + folder: subject.folder, + background: background, + icon: icon, + priorBackground: subject.background, + priorIcon: subject.icon + ) } guard !edits.isEmpty else { return } - try? performWrite { () throws(BoardWriteError) -> Void in + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in for edit in edits { // `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a // failure names the item by the title it still has (see `WriteOperation.style`). @@ -1005,6 +1059,31 @@ public final class BoardStore { } } } + guard landed != nil else { return } + + // restyle → prior style (13-native-undo.md ▸ Rules). **One step for the batch**, which is the + // same sentence as this method's one bracket: "choosing a well applies to the whole selection + // — one gesture, one commit", substrate swapped. + let kind: HistoryPhrase.Kind = switch styleLevel(of: target) { + case .board: .board + case .lane: .lane + case .card: .card + } + registerStep(HistoryPhrase.name(.restyle, kind: kind, count: edits.count)) { _ in + for edit in edits { + try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in + Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document) + Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document) + } + } + } redo: { _ in + for edit in edits { + try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in + Self.apply(edit.background, to: FrontmatterKeys.background, in: &document) + Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document) + } + } + } } /// `change` narrowed against what is already on disk: `.keep` when it would write what is @@ -1045,9 +1124,16 @@ public final class BoardStore { /// and a menu item has no second thing to do about a failure. public func createLane() { let root = rootURL - try? performWrite { () throws(BoardWriteError) -> Void in - _ = try BoardWriter.createLane(inBoard: root, title: nil) + let created = try? performWrite { () throws(BoardWriteError) -> ItemID in + try BoardWriter.createLane(inBoard: root, title: nil) } + guard let created else { return } + + // create → remove the created folder (13-native-undo.md ▸ Rules). The bytes are read back + // here, while the folder still exists, because the inverse destroys it — see `CreatedItem`. + let folder = root.appendingPathComponent(created.rawValue, isDirectory: true) + guard let item = createdItem(at: folder, kind: .lane) else { return } + registerCreation([item], kind: .lane) } // MARK: - The new-card placeholder's commit @@ -1125,6 +1211,13 @@ public final class BoardStore { return nil } transient.commitPlaceholder(expecting: created) + + // create → remove the created folder (13-native-undo.md ▸ Rules). The rank the pair above + // may have written is inside the captured bytes, so a redo puts the card back where the + // gesture put it, not merely at the bottom of the lane. + if let item = createdItem(at: laneFolder.appendingPathComponent(created.rawValue, isDirectory: true), kind: .card) { + registerCreation([item], kind: .card) + } return created } @@ -1180,16 +1273,34 @@ public final class BoardStore { folder.append(component: cardID.rawValue) } - try? performWrite { () throws(BoardWriteError) -> Void in + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in // `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so the // banner names the item by the title it still has rather than the one that failed to // land (see `WriteOperation.rename`). - try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in - if let newTitle { - document.set(FrontmatterKeys.title, to: .string(newTitle)) - } else { - document.remove(FrontmatterKeys.title) - } + try Self.setTitle(newTitle, at: folder) + } + guard landed != nil else { return } + + // rename → restore title (13-native-undo.md ▸ Rules). The prior title is the *typed* value, + // `nil` for an untitled item — so undoing a rename that gave an untitled card a name takes + // the `title` key away again rather than writing `title: ""`. + let priorTitle = target.title + registerStep(HistoryPhrase.name(.rename, kind: target.cardID == nil ? .lane : .card)) { _ in + try Self.setTitle(priorTitle, at: folder) + } redo: { _ in + try Self.setTitle(newTitle, at: folder) + } + } + + /// The title write every rename shares — the item-level one and the board's — spelled once so + /// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and + /// its own undo. + private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) { + try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in + if let title { + document.set(FrontmatterKeys.title, to: .string(title)) + } else { + document.remove(FrontmatterKeys.title) } } } @@ -1231,6 +1342,12 @@ public final class BoardStore { /// rather than write blind. Nothing here inspects the body: the store never re-parses to /// second-guess the click, because its own snapshot is exactly as stale as the render was. /// + /// **It registers no undo step.** 13-native-undo.md ▸ Rules' inventory names the body write it + /// makes undoable precisely — "Edit-session body save → restore prior body bytes" — and a Preview + /// checkbox is not one: it belongs to no session, has no flip to coalesce at, and 05 files it + /// under what is "undoable on git boards", which is the *other* substrate's answer. Registering it + /// here would be extending 13's inventory rather than implementing it. + /// /// **A checkbox in a card that has gone writes nothing** — the vanished-target guard every /// gesture in this file makes, ancestor-walked through `liveItem`: the card window would be /// dismissing itself in the same breath, and the reload that removed the card is the authority. @@ -1297,6 +1414,42 @@ public final class BoardStore { } } + /// Registers **one Edit session** as one undo step — 13-native-undo.md ▸ Rules' coalescing + /// sentence, stated where the session ends rather than where the bytes land. + /// + /// ### Why this is not registered in `writeCardBody` + /// + /// Because a session is not a save. "An Edit session is one step, registered at the Edit→Preview + /// flip (the effective Save — 05-card-window.md)", and a session contains any number of debounced + /// saves: registering per write would put a step on the stack every ~700 ms of typing, and ⌘Z + /// would walk backwards through the user's keystrokes in seven-hundred-millisecond slices rather + /// than undoing the edit they made. So `CardBodyEditSession` remembers the bytes disk held when + /// the session's first save landed, and calls this once at the flip with that pair — the same + /// boundary pro-m1's auto-committer coalesces on, for the same reason. + /// + /// ### The bytes are the whole state + /// + /// `BoardWriter.writeBody` replaces the body span and nothing else, so a step built from two body + /// strings restores the prior body **byte for byte** — unknown keys, comments and key order above + /// the delimiter were never this write's to change. That is the one inverse in the app whose + /// fidelity is byte-level rather than field-level. + /// + /// Liveness is `writeCardBody`'s deliberately blind walk (`cardBodyTarget`), so a session that + /// ended because its card was tombstoned still registers — the keystrokes survived into the + /// tombstoned folder, and their undo has to be able to reach the same place. + public func registerBodyEdit(inCard cardID: ItemID, priorBody: String, newBody: String) { + guard priorBody != newBody, let target = Self.cardBodyTarget(cardID, in: snapshot) else { return } + let folder = rootURL + .appendingPathComponent(target.laneID.rawValue, isDirectory: true) + .appendingPathComponent(target.cardID.rawValue, isDirectory: true) + + registerStep(HistoryPhrase.name(.edit, kind: .card)) { _ in + _ = try BoardWriter.writeBody(inItemFolder: folder, body: priorBody) + } redo: { _ in + _ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody) + } + } + /// Which folder a card's body write lands in — **the one card walk that ignores liveness**. /// /// Every other resolution in this file goes through `liveItem`, whose ancestor-walked liveness is @@ -1373,6 +1526,11 @@ public final class BoardStore { /// /// A pull landing mid-session is not consulted at all: "Apply stays last-writer-wins" (05, citing /// 07-sync-collab.md), the same posture the Edit buffer takes. + /// + /// **It registers no undo step**, `toggleTaskMarker`'s reason: 13-native-undo.md ▸ Rules makes the + /// *Edit session's* body save undoable, and Apply is not one — it is a whole-file replacement of + /// bytes the user typed themselves, with the raw buffer still on screen as its own record of what + /// they were. public func applyCardSource(inCard cardID: ItemID, text: String) -> RawSourceApplyOutcome { guard let target = Self.liveItem(cardID, in: snapshot), let card = target.cardID else { return .vanished } let folder = rootURL @@ -1433,16 +1591,19 @@ public final class BoardStore { guard newTitle != snapshot.title.value else { return } let folder = rootURL - try? performWrite { () throws(BoardWriteError) -> Void in + let priorTitle = snapshot.title.value + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in // `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a // refusal names the board by the title it still has (see `WriteOperation.rename`). - try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in - if let newTitle { - document.set(FrontmatterKeys.title, to: .string(newTitle)) - } else { - document.remove(FrontmatterKeys.title) - } - } + try Self.setTitle(newTitle, at: folder) + } + guard landed != nil else { return } + + // rename → restore title, at the one level with no item to aim at. + registerStep(HistoryPhrase.name(.rename, kind: .board)) { _ in + try Self.setTitle(priorTitle, at: folder) + } redo: { _ in + try Self.setTitle(newTitle, at: folder) } } @@ -1471,18 +1632,28 @@ public final class BoardStore { let root = rootURL let folder = root.appendingPathComponent(id.rawValue) - try? performWrite { () throws(BoardWriteError) -> Void in + // The rank the lane held before the write and the one it lands on — both read out of the + // bracket below, because a renumber that fires inside it moves the *prior* value too: the + // dragged lane is among the renumbered children, so its pre-gesture `order` would no longer + // place it where it was. What an inverse must restore is the rank the file held immediately + // before its own rewrite, which is exactly what this captures either way. + var priorOrder = lanes[from].order + var newOrder: Double? + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target) if rank == nil { // Compact and place again. Unlike the card case the dragged lane *is* among the // renumbered children — it is a real folder on disk — so its fresh rank is dropped // from the ladder before the neighbours are consulted. try BoardWriter.renumberVisibleChildren(of: root) - var compacted = Ranks.renumbered(count: lanes.count) + let renumbered = Ranks.renumbered(count: lanes.count) + priorOrder = renumbered[from] + var compacted = renumbered compacted.remove(at: from) rank = Ranks.insertionRank(amongVisible: compacted, at: target) } guard let rank else { return } + newOrder = rank _ = try BoardWriter.moveItem( at: folder, @@ -1492,6 +1663,25 @@ public final class BoardStore { order: rank ) } + guard landed != nil, let newOrder else { return } + + // reorder → restore original `order` (13-native-undo.md ▸ Rules). A lane drag never changes + // parent — the board root is the only one there is — so 06's vocabulary word for it is + // Reorder, not Move. + let restored = priorOrder + registerStep(HistoryPhrase.name(.reorder, kind: .lane)) { _ in + try Self.setOrder(restored, at: folder) + } redo: { _ in + try Self.setOrder(newOrder, at: folder) + } + } + + /// The bare rank rewrite an inverse reorder performs — `moveItem`'s same-parent degenerate path + /// with the URL arithmetic taken out, since an inverse always names the folder directly. + private static func setOrder(_ order: Double, at folder: URL) throws(BoardWriteError) { + try BoardWriter.updateIndex(inItemFolder: folder, operation: .reorder(title: nil)) { document in + document.set(FrontmatterKeys.order, to: .double(order)) + } } /// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display @@ -1519,23 +1709,29 @@ public final class BoardStore { else { return } let root = rootURL - try? performWrite { () throws(BoardWriteError) -> Void in + // `moveLane`'s capture, per member — see its note on why the prior rank is read out of the + // bracket rather than off the snapshot. + var priorOrders = members.map(\.order) + var rewrites: [(folder: URL, order: Double)] = [] + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count) if ranks == nil { // Compact and place again. The dragged lanes *are* among the renumbered children — // they are real folders on disk — so their fresh rungs are dropped from the ladder // before the neighbours are consulted, exactly as `moveLane` drops its one. try BoardWriter.renumberVisibleChildren(of: root) - let compacted = zip(lanes, Ranks.renumbered(count: lanes.count)) - .filter { !ids.contains($0.0.id) } - .map(\.1) + let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count))) + priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1) + let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1) ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count) } guard let ranks else { return } for (member, rank) in zip(members, ranks) { + let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true) + rewrites.append((folder: folder, order: rank)) _ = try BoardWriter.moveItem( - at: root.appendingPathComponent(member.id.rawValue, isDirectory: true), + at: folder, toParent: root, sourceBoardRoot: root, destinationBoardRoot: root, @@ -1543,6 +1739,21 @@ public final class BoardStore { ) } } + guard landed != nil, !rewrites.isEmpty else { return } + + // reorder → restore original `order`, one step for the whole run: "one `performWrite` bracket + // per gesture whatever the set's size" is the same sentence as one gesture, one undo step. + let inverse = Array(zip(rewrites.map(\.folder), priorOrders)) + let forward = rewrites + registerStep(HistoryPhrase.name(.reorder, kind: .lane, count: forward.count)) { _ in + for (folder, order) in inverse { + try Self.setOrder(order, at: folder) + } + } redo: { _ in + for write in forward { + try Self.setOrder(write.order, at: write.folder) + } + } } // MARK: - Drag & drop commits @@ -1576,6 +1787,7 @@ public final class BoardStore { private struct DraggedCard { let id: ItemID let laneID: ItemID + let order: Double } /// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane @@ -1586,15 +1798,15 @@ public final class BoardStore { /// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial /// vanishing drops the survivors" is the design's own wording. private func draggedCards(_ ids: Set) -> [DraggedCard] { - var lanes: [ItemID: ItemID] = [:] + var homes: [ItemID: (lane: ItemID, order: Double)] = [:] for lane in snapshot.lanes where !lane.isDeleted { for card in lane.cards where !card.isDeleted { - lanes[card.id] = lane.id + homes[card.id] = (lane.id, card.order) } } return SelectionGrammar.liveCards(in: snapshot) .filter { ids.contains($0) } - .compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } } + .compactMap { id in homes[id].map { DraggedCard(id: id, laneID: $0.lane, order: $0.order) } } } /// The within-board card drop: `ids` land contiguously at logical position `index` among @@ -1629,7 +1841,12 @@ public final class BoardStore { let root = rootURL let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) - try? performWrite { () throws(BoardWriteError) -> Void in + // The pre-write home of every member, per 13's "move → move back (original lane, original + // `order`)". A renumber inside the bracket rewrites the destination lane's own cards, so a + // member that was already there has its captured rank refreshed — `moveLane`'s note. + var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (lane: $0.laneID, order: $0.order)) }) + var arrivals: [(id: ItemID, order: Double)] = [] + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count) if ranks == nil { // Compact and place again. The renumber assigns in display order over the lane's @@ -1637,9 +1854,11 @@ public final class BoardStore { // members already in this lane are dropped from it before the neighbours are // consulted, exactly as `moveLane` drops the dragged lane's own rung. try BoardWriter.renumberVisibleChildren(of: laneFolder) - let compacted = zip(rendered, Ranks.renumbered(count: rendered.count)) - .filter { !ids.contains($0.0.id) } - .map(\.1) + let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count))) + for (card, rank) in renumbered where ids.contains(card.id) { + origins[card.id] = (lane: laneID, order: rank) + } + let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1) ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count) } guard let ranks else { return } @@ -1648,6 +1867,7 @@ public final class BoardStore { let folder = root .appendingPathComponent(member.laneID.rawValue, isDirectory: true) .appendingPathComponent(member.id.rawValue, isDirectory: true) + arrivals.append((id: member.id, order: rank)) _ = try BoardWriter.moveItem( at: folder, toParent: laneFolder, @@ -1657,6 +1877,49 @@ public final class BoardStore { ) } } + guard landed != nil, !arrivals.isEmpty else { return } + + // move → move back (original lane, original `order`); a drop that never left its lane is + // 06's Reorder rather than Move, which is the same distinction the commit vocabulary draws. + let inverse: [(from: URL, toParent: URL, order: Double)] = arrivals.compactMap { arrival in + guard let origin = origins[arrival.id] else { return nil } + return ( + from: laneFolder.appendingPathComponent(arrival.id.rawValue, isDirectory: true), + toParent: root.appendingPathComponent(origin.lane.rawValue, isDirectory: true), + order: origin.order + ) + } + let forward: [(from: URL, order: Double)] = arrivals.compactMap { arrival in + guard let origin = origins[arrival.id] else { return nil } + return ( + from: root + .appendingPathComponent(origin.lane.rawValue, isDirectory: true) + .appendingPathComponent(arrival.id.rawValue, isDirectory: true), + order: arrival.order + ) + } + let crossedLanes = members.contains { $0.laneID != laneID } + registerStep(HistoryPhrase.name(crossedLanes ? .move : .reorder, kind: .card, count: arrivals.count)) { _ in + for step in inverse { + _ = try BoardWriter.moveItem( + at: step.from, + toParent: step.toParent, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: step.order + ) + } + } redo: { _ in + for step in forward { + _ = try BoardWriter.moveItem( + at: step.from, + toParent: laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: step.order + ) + } + } } /// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among @@ -1716,6 +1979,15 @@ public final class BoardStore { // board's own bracket, which is correct and needs no coordination: the source store's watcher // sees a foreign change and reloads, which is exactly what a foreign change is. // + // **None of these register an undo step, and the reason is 13's own two sentences.** Its inverse + // inventory names nine operations and an arrival is not among them; and "undo is board-local" — + // one stack per board — while a cross-board move's inverse would have to write into the *source* + // board, whose stack knows nothing about it and whose window may not even be open. The clipboard's + // half is the same shape one remove further: a paste's inverse needs the staged tree to still be + // there, which is exactly the staging lifecycle 13 defers with the attachment operations. Within a + // board, `copyCards`' ⌥-drag is left out with them: its Writer operation is `.copy`, not a create, + // and the three arrival paths are one gesture family that should gain undo together or not at all. + // // `sources` are the items' folder URLs in the source board — both boards are open in this app, // so both roots are already security-scoped and the payload can carry plain URLs. The source // board root is read back off the path rather than passed alongside: 01-storage-format.md's @@ -2027,6 +2299,13 @@ public final class BoardStore { // MARK: - Finder file drops + // **The attachment half registers no undo step** (13-native-undo.md ▸ Out of scope, ratified + // 2026-07-27): "attachment add/remove registers **no undo step** in v1", because remove → + // re-add needs the removed file to survive somewhere and that staging area is a design pass of + // its own. Add → remove would be a clean inverse on its own, but half a pair is worse than none: + // ⌘Z would undo attaching and refuse to undo detaching, which is not a rule anyone could learn. + // The *card-creating* half below is an ordinary create and does register one. + // // The writes an external Finder file drag performs (04-interactions.md ▸ Drag and drop, "Files // from Finder"): onto a card the files join its `attachments/`, onto lane empty space they become // one card each. The gesture's half — which card, which slot — is `BoardDropContext`'s; these are @@ -2107,6 +2386,12 @@ public final class BoardStore { /// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28, /// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half). /// + /// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked + /// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is + /// no ⌘Z that should follow it, and putting one on the stack would let the next ⌘Z undo something + /// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event, + /// 06-history-undo.md ▸ Commit messages.) + /// /// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole /// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on /// git boards) a single commit — the style batch's rule, applied to a batch the app started @@ -2236,6 +2521,7 @@ public final class BoardStore { let root = rootURL let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + var created: [(folder: URL, source: URL)] = [] try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks( amongVisible: rendered.map(\.order), at: target, count: urls.count) @@ -2265,8 +2551,17 @@ public final class BoardStore { try? FileManager.default.removeItem(at: folder) throw error } + created.append((folder: folder, source: url)) } } + + // create → remove the created folder (13-native-undo.md ▸ Rules), one step for the drop + // whatever its file count. The redo re-imports from the same source URLs the gesture used — + // the one create in the app whose replay needs more than the card's own bytes. Collected + // from what actually landed rather than from `urls`, so a batch that failed halfway still + // hands ⌘Z exactly the cards it left behind. + let items = created.compactMap { createdItem(at: $0.folder, kind: .card, attachments: [$0.source]) } + registerCreation(items, kind: .card) } /// The title a dropped file's card takes: **the filename without its extension** @@ -2334,7 +2629,12 @@ public final class BoardStore { let orders = rendered.map(\.order) let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) }) - try? performWrite { () throws(BoardWriteError) -> Void in + // Every rank this gesture rewrites, with the value it replaced — the permutation's own + // inverse. It is read out of the bracket because the ladder may be the *renumbered* one: + // after a compaction the card at display position `origin` holds `ladder[origin]`, which is + // what its own rewrite overwrites and therefore what an undo has to put back. + var rewrites: [(folder: URL, from: Double, to: Double)] = [] + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var ladder = orders if !Self.isStrictlyAscending(orders) { try BoardWriter.renumberVisibleChildren(of: laneFolder) @@ -2345,8 +2645,10 @@ public final class BoardStore { for (destination, id) in plan.ordering.enumerated() { guard let origin = positions[id], origin != destination else { continue } let rank = ladder[destination] + let folder = laneFolder.appendingPathComponent(id.rawValue, isDirectory: true) + rewrites.append((folder: folder, from: ladder[origin], to: rank)) try BoardWriter.updateIndex( - inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true), + inItemFolder: folder, // `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so // a failure names the card by its own title. operation: .reorder(title: nil) @@ -2355,6 +2657,21 @@ public final class BoardStore { } } } + guard landed != nil, !rewrites.isEmpty else { return } + + // reorder → restore original `order` (13-native-undo.md ▸ Rules). The step is named for the + // *gesture's* subject — the cards the user was moving — not for every sibling the permutation + // displaced, which is the same rule 06 applies to a commit subject. + let steps = rewrites + registerStep(HistoryPhrase.name(.reorder, kind: .card, count: selection.ids.count)) { _ in + for step in steps { + try Self.setOrder(step.from, at: step.folder) + } + } redo: { _ in + for step in steps { + try Self.setOrder(step.to, at: step.folder) + } + } } /// Whether a lane's ranks separate its cards on their own — the condition under which they can @@ -2415,13 +2732,13 @@ public final class BoardStore { /// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed /// sides, or nothing survives on either. public func delete(_ ids: Set) { - let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) } - guard !folders.isEmpty else { return } + let paths = TrashModel.paths(of: ids, on: .live, in: snapshot) + guard !paths.isEmpty else { return } // The successor is drawn from what the lane is *showing*, so a delete under an active search // walks the filtered lane rather than selecting a card the query has hidden. let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter) - tombstone(folders) + tombstone(paths) if let successor { select([successor], liveness: .live, anchor: successor, head: successor) @@ -2456,10 +2773,9 @@ public final class BoardStore { /// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on /// that: the paths resolve on the live side exactly as `delete(_:)`'s do. public func deleteByDrag(cardIDs: [ItemID]) { - let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot) - .map { $0.folder(under: rootURL) } - guard !folders.isEmpty else { return } - tombstone(folders) + let paths = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot) + guard !paths.isEmpty else { return } + tombstone(paths) } /// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — tombstones the @@ -2485,9 +2801,9 @@ public final class BoardStore { /// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too — and its /// window is already dismissing. public func deleteCard(_ id: ItemID) { - let folders = TrashModel.paths(of: [id], on: .live, in: snapshot).map { $0.folder(under: rootURL) } - guard !folders.isEmpty else { return } - tombstone(folders) + let paths = TrashModel.paths(of: [id], on: .live, in: snapshot) + guard !paths.isEmpty else { return } + tombstone(paths) } /// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and @@ -2495,8 +2811,27 @@ public final class BoardStore { /// /// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs /// between them is about the *selection*, and lives in the callers. - private func tombstone(_ folders: [URL]) { - try? performWrite { () throws(BoardWriteError) -> Void in + /// It is also where the tombstone's **undo step** is registered, for the identical reason: 13's + /// "tombstone (⌫) → restore" has to mean the same thing whichever gesture asked, and a step + /// registered at each caller would be three chances to name it differently. + private func tombstone(_ paths: [TrashModel.ItemPath]) { + let folders = paths.map { $0.folder(under: rootURL) } + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.deleteItem(at: folder) + } + } + guard landed != nil else { return } + + // tombstone → restore. The undo is Put Back's own write, which is what makes 13's "the stack + // and the trash are two doors to the same tombstone state" true on disk rather than by + // agreement — undoing a delete is *identical* in effect to Put Back. + let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card + registerStep(HistoryPhrase.name(.delete, kind: kind, count: folders.count)) { _ in + for folder in folders { + try BoardWriter.restoreItem(at: folder) + } + } redo: { _ in for folder in folders { try BoardWriter.deleteItem(at: folder) } @@ -2524,19 +2859,53 @@ public final class BoardStore { /// resolve rule ejects them from a `.trashed` set as a vanish — the same silent shrink an /// external restore would produce. public func putBack(_ ids: Set) { - let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) } - guard !folders.isEmpty else { return } + let paths = TrashModel.paths(of: ids, on: .trashed, in: snapshot) + guard !paths.isEmpty else { return } + // Captured before the write: the timestamp each row is filed in the trash under, which is + // what an undo has to put back — see `restoreTombstone`. + let restored = paths.map { (folder: $0.folder(under: rootURL), deleted: deletedField(at: $0)) } - try? performWrite { () throws(BoardWriteError) -> Void in - for folder in folders { - try BoardWriter.restoreItem(at: folder) + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + for item in restored { + try BoardWriter.restoreItem(at: item.folder) + } + } + guard landed != nil else { return } + + // restore (Put Back) → tombstone (13-native-undo.md ▸ Rules) — the trash pair read the other + // way round from `tombstone(_:)`'s step. + let kind: HistoryPhrase.Kind = paths.contains(where: \.isLane) ? .lane : .card + registerStep(HistoryPhrase.name(.restore, kind: kind, count: restored.count)) { _ in + for item in restored { + try BoardWriter.updateIndex(inItemFolder: item.folder, operation: .delete(title: nil)) { document in + Self.restoreTombstone(item.deleted, in: &document) + } + } + } redo: { _ in + for item in restored { + try BoardWriter.restoreItem(at: item.folder) } } } + /// The `deleted` value a trash row currently carries — the one field a Put Back's inverse has to + /// carry forward, and one the `ItemPath` vocabulary deliberately does not (a path is a location, + /// not a reading of the file there). + private func deletedField(at path: TrashModel.ItemPath) -> FieldValue { + guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return .missing } + guard let cardID = path.cardID else { return lane.deleted } + return lane.cards.first(where: { $0.id == cardID })?.deleted ?? .missing + } + /// Delete Immediately ⌥⌘⌫: physically removes every tombstoned item in `ids` (03-board-ui.md § /// Trash), in one bracket. /// + /// **It registers no undo step, and `purgeIsUnrecoverable` stays `true`** — 13-native-undo.md + /// ▸ Rules settles this by name: "Permanently delete (Delete Immediately, Empty Trash) — + /// `purgeIsUnrecoverable` stays true in base, and the existing confirmation rule already fires on + /// all base boards … the confirm *is* the safety". A stack entry here would be a promise the + /// filesystem cannot keep. + /// /// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s /// question and the alert is the window's; a store method that put up its own dialog could not /// be driven from a test, and the same purge is reached by two surfaces (the menu item and the @@ -2561,6 +2930,9 @@ public final class BoardStore { /// Empty Trash… ⇧⌘⌫: purges **every** tombstone on the board, in one bracket. /// + /// Not undoable, `deleteImmediately`'s ruling and its wording — this is the other half of 13's + /// "Permanently delete". + /// /// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come /// from the snapshot, never from the filtered view — "a bulk command about the trash itself never /// silently narrows to the visible subset". The filter does not reach this method at all, which @@ -2641,7 +3013,12 @@ public final class BoardStore { let rendered = destination.cards.filter { !$0.isDeleted } let target = min(max(0, index), rendered.count) - try? performWrite { () throws(BoardWriteError) -> Void in + // What each row was before the gesture — its lane, its recorded rank, and the timestamp it is + // filed in the trash under — against where it lands. A tombstoned card is not among the + // destination's rendered cards, so a renumber inside the bracket cannot touch its own rank; + // only the neighbours' move, and their sequence is preserved. + var moves: [(cardID: ItemID, laneID: ItemID, priorOrder: Double, deleted: FieldValue, order: Double)] = [] + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count) if ranks == nil { try BoardWriter.renumberVisibleChildren(of: laneFolder) @@ -2655,6 +3032,13 @@ public final class BoardStore { for (row, rank) in zip(rows, ranks) { let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root) + moves.append(( + cardID: row.card.id, + laneID: row.laneID, + priorOrder: row.card.order, + deleted: row.card.deleted, + order: rank + )) guard row.laneID != laneID else { try BoardWriter.updateIndex( @@ -2680,6 +3064,49 @@ public final class BoardStore { ) } } + guard landed != nil, !moves.isEmpty else { return } + + // restore → tombstone (13-native-undo.md ▸ Rules), with the position half of the gesture + // walked back too: the row returns to the lane it was trashed in, at the rank it was trashed + // holding, under the timestamp it was trashed at — which is exactly where its trash row was. + let steps = moves + registerStep(HistoryPhrase.name(.restore, kind: .card, count: steps.count)) { _ in + for step in steps { + let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root) + if step.laneID != laneID { + _ = try BoardWriter.moveItem( + at: laneFolder.appendingPathComponent(step.cardID.rawValue, isDirectory: true), + toParent: root.appendingPathComponent(step.laneID.rawValue, isDirectory: true), + sourceBoardRoot: root, + destinationBoardRoot: root, + order: step.priorOrder + ) + } + try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .delete(title: nil)) { document in + Self.restoreTombstone(step.deleted, in: &document) + document.set(FrontmatterKeys.order, to: .double(step.priorOrder)) + } + } + } redo: { _ in + for step in steps { + let priorFolder = TrashModel.ItemPath(laneID: step.laneID, cardID: step.cardID).folder(under: root) + guard step.laneID != laneID else { + try BoardWriter.updateIndex(inItemFolder: priorFolder, operation: .restore(title: nil)) { document in + document.remove(FrontmatterKeys.deleted) + document.set(FrontmatterKeys.order, to: .double(step.order)) + } + continue + } + try BoardWriter.restoreItem(at: priorFolder) + _ = try BoardWriter.moveItem( + at: priorFolder, + toParent: laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: step.order + ) + } + } } // MARK: - Selection (delegated) diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 22be831..927146f 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -979,6 +979,96 @@ public enum BoardWriter: Sendable { } } + // MARK: - Undoing a create + + /// An item's `index.md` as literal text — the bytes a create hands to its own redo + /// (13-native-undo.md ▸ Rules: "create → remove the created folder"). + /// + /// **Why the create path reads back what it just wrote.** The inverse of a create is a physical + /// removal, so the only way ⇧⌘Z can put the item back *with its identity* is for the step to be + /// holding the file's bytes — captured at the moment of the create, which is the write redo + /// re-performs (13: the redo closure replays the original). `readRawSource(ofCard:)` is the same + /// read one level narrower — it is the raw-source outlet's, and refuses anything that is not a + /// card folder — so this one exists rather than widening that contract for a caller with a + /// different reason. + /// + /// Strict UTF-8 like every read here: a file that does not decode is a loud error, never a lossy + /// guess. `operation` is the create being captured for, so a failure names that gesture. + public static func readIndexText( + ofItem itemFolder: URL, + operation: WriteOperation + ) throws(BoardWriteError) -> String { + let indexURL = itemFolder.appendingPathComponent(BoardLoader.indexFileName) + let data: Data + do { + data = try Data(contentsOf: indexURL) + } catch { + throw BoardWriteError( + operation: operation, + path: indexURL.path, + reason: .unreadable(message: "could not read file: \(error.localizedDescription)") + ) + } + guard let text = String(validating: data, as: UTF8.self) else { + throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8")) + } + return text + } + + /// Puts a removed item's folder back, at its own path and with its own bytes — the redo half of + /// an undone create (13-native-undo.md ▸ Rules). + /// + /// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and + /// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them + /// would produce a *different* item, and every step registered above this one on the stack + /// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the + /// path as given. + /// + /// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose lane + /// has since been purged must fail rather than conjure a bare lane folder around the card. + /// - **It refuses to clobber**, `createBoard`'s rule: a folder already at this path fails loudly + /// rather than overwriting whatever is living there now. + /// - **The bytes are written verbatim**, raw-source Apply's rule and for its reason: they are not + /// composed here, they are replayed, so nothing is stamped and nothing is re-serialized. + /// + /// `operation` is the caller's own vocabulary word — `.createLane` or `.createCard`, whichever + /// create is being replayed — so a failure banners as the gesture the user is redoing. + public static func recreateItem( + at itemFolder: URL, + indexText: String, + operation: WriteOperation + ) throws(BoardWriteError) { + try checkIsUUIDShaped(itemFolder, operation: operation) + guard !FileManager.default.fileExists(atPath: itemFolder.path) else { + throw BoardWriteError( + operation: operation, + path: itemFolder.path, + reason: .io(message: "something already exists here") + ) + } + do { + try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: false) + } catch { + throw BoardWriteError( + operation: operation, + path: itemFolder.path, + reason: .io(message: "could not create folder: \(error.localizedDescription)") + ) + } + do throws(BoardWriteError) { + try atomicReplace( + text: indexText, + at: itemFolder.appendingPathComponent(BoardLoader.indexFileName), + operation: operation + ) + } catch { + // `materializeItem`'s all-or-nothing rule: a half-made folder is pure residue, since + // nothing was there before. + try? FileManager.default.removeItem(at: itemFolder) + throw error + } + } + // MARK: - Task checkboxes /// Flips one task-list checkbox in an item's body — **a single-byte edit, and the only write diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index 3a3fdee..8ab7fe9 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -84,6 +84,27 @@ public final class CardBodyEditSession { @ObservationIgnored public var save: ((String) -> CardBodyWriteOutcome)? + /// Where this session's **one undo step** goes, called at `endEditSession()` with the body disk + /// held when the session's first save landed and the body it holds now (13-native-undo.md + /// ▸ Rules: "an Edit session is one step, registered at the Edit→Preview flip"). + /// + /// A closure for `save`'s reason exactly — this type is a buffer and a clock, and it stays + /// testable by having no idea what a board or an undo stack is. `CardWindowHost` points it at + /// `BoardStore.registerBodyEdit(inCard:priorBody:newBody:)`, which builds the step; `nil` is a + /// session whose window has not joined its board, and registers nothing. + @ObservationIgnored + public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)? + + /// What disk said before this session's **first** landed save — the step's before-value, held + /// from the first write until the session ends. + /// + /// `nil` means "no save has landed in this session", which is also what a session that only ever + /// read looks like: nothing was written, so there is nothing to undo and no step to register. It + /// is captured at the *write*, not at Edit entry, so that a session opened and abandoned leaves + /// the stack exactly as it found it. + @ObservationIgnored + private var sessionOriginBody: String? + @ObservationIgnored private var pending: Task? @@ -142,7 +163,17 @@ public final class CardBodyEditSession { /// doc comment). @discardableResult public func endEditSession() -> CardBodyWriteOutcome { - flush() + let outcome = flush() + // The session's one undo step, registered here and nowhere else — see `registerUndo`. It + // fires only when something of this session's actually landed: `sessionOriginBody` is set by + // the first successful write, and the guard against `disk` covers the session that typed its + // way back to where it started across several ticks (each of which wrote, so the origin is + // set, but whose net effect on the file is nothing to undo). + if let origin = sessionOriginBody, origin != disk { + registerUndo?(origin, disk) + } + sessionOriginBody = nil + return outcome } /// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of @@ -197,9 +228,19 @@ public final class CardBodyEditSession { guard let save else { return .vanished } saveAttempts += 1 + // Read before the write, because the write is what makes it stale: this is the body the + // session is about to start overwriting, and only the *first* landed save of a session may + // claim it (13-native-undo.md ▸ Rules — one step per session, not per tick). + let priorBody = disk let outcome = save(text) switch outcome { - case .written, .unchanged: + case .written: + if sessionOriginBody == nil { sessionOriginBody = priorBody } + disk = text + case .unchanged: + // Nothing was replaced — the file already read like the buffer, so this session has + // overwritten nothing yet and has nothing to hand an undo. (It is also the one outcome + // where `disk` was demonstrably wrong, so the bytes it held are not a state to restore.) disk = text case .suspended, .vanished, .failed: break diff --git a/KanbanTests/UndoWriteTests.swift b/KanbanTests/UndoWriteTests.swift new file mode 100644 index 0000000..c60522f --- /dev/null +++ b/KanbanTests/UndoWriteTests.swift @@ -0,0 +1,807 @@ +import Foundation +import Testing +@testable import Kanban + +/// The inverses registered at the Writer boundary (13-native-undo.md ▸ Rules) — one round trip per +/// operation: perform the gesture, cross it backwards, read the **bytes on disk**, cross it forwards +/// again. +/// +/// These drive a real store over a real temp board with a real `NativeHistoryProvider` behind it, and +/// assert against the files rather than the snapshot, like every other write suite here. That is the +/// only way to check the claim 13 actually makes: an undo is "an ordinary app-mediated write", not an +/// in-memory revert — so what has to come back is the *file*. +/// +/// What "equals prior" means differs by operation, exactly as the design does: **byte-level for a +/// body** (the body span is all a body write touches) and **field-level for frontmatter** (`modified` +/// is stamped by every app write, undo included, so a byte comparison would be asserting the opposite +/// of the storage contract). `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + project: lanework # agent overlay + created: 2026-01-01T09:00:00Z + deleted: \(deleted) + --- + \(title) body. + + """ +} + +private let styledCard = """ +--- +schema: 1 +title: Styled +order: 3072 +project: lanework # agent overlay +background: blue +icon: star +created: 2026-01-01T09:00:00Z +--- +Styled body. + +""" + +/// Two live lanes — the first with two live cards, a styled one and a tombstoned one; the second +/// with one card. Enough for every inverse in this file. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", styledCard) + try fixture.item("\(Ident.lane1)/\(Ident.card4)", tombstoned(order: "4096", title: "Trashed")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.indexless)", Item.rich(order: "1024", title: "Elsewhere")) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) +private let trashed = ItemID(rawValue: Ident.card4) +private let elsewhere = ItemID(rawValue: Ident.indexless) + +private let card1Path = "\(Ident.lane1)/\(Ident.card1)" +private let card2Path = "\(Ident.lane1)/\(Ident.card2)" +private let card3Path = "\(Ident.lane1)/\(Ident.card3)" +private let trashedPath = "\(Ident.lane1)/\(Ident.card4)" + +/// A store with a stack behind it. The provider is returned because `BoardStore.history` is **weak** +/// — the session owns the stack in the app, and a test that dropped it would watch its own steps +/// disappear. +@MainActor +private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) { + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + return (store, history) +} + +@MainActor +private func reload(_ store: BoardStore) async { + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() +} + +/// One item's frontmatter as the app reads it — the level "equals prior" is asserted at for +/// everything but a body. +private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { + try FrontmatterDocument.parse(fixture.indexText(relativePath)) +} + +/// The file's lines minus the ones every app-mediated write owns — what an inverse must leave +/// byte-identical, unknown keys and their comments included. +private func untouchedLines(_ text: String) -> [Substring] { + text.split(separator: "\n", omittingEmptySubsequences: false).filter { + !$0.hasPrefix("modified") + } +} + +// MARK: - Resize + +@MainActor +@Suite("Undo ▸ resize") +struct ResizeUndoTests { + + @Test("A width change undoes to the prior width and redoes to the new one") + func widthRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.setLaneWidth(lane1, units: 3) + #expect(try document(fixture, Ident.lane1).width.value == 3) + #expect(history.undoActionName == "Resize Lane") + + history.undo() + // The lane had no `width` key at all, so "prior" is its absence — not `width: 1`. + #expect(try document(fixture, Ident.lane1).width.isMissing) + + history.redo() + #expect(try document(fixture, Ident.lane1).width.value == 3) + } + + @Test("An explicit prior width comes back as the value it was, not as the app's default") + func priorValueIsRestoredVerbatim() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.setLaneWidth(lane1, units: 4) + // The second gesture reads its prior value off the snapshot, so it has to see the first — + // which is the one-way flow working, not a test artefact. + await reload(store) + + store.setLaneWidth(lane1, units: 2) + #expect(try document(fixture, Ident.lane1).width.value == 2) + + history.undo() + #expect(try document(fixture, Ident.lane1).width.value == 4) + } + + @Test("A multi-lane step is one step with a plural title") + func batchIsOneStep() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.stepLaneWidths([lane1, lane2], by: 1) + + #expect(history.undoActionName == "Resize 2 Lanes") + #expect(try document(fixture, Ident.lane1).width.value == 2) + #expect(try document(fixture, Ident.lane2).width.value == 2) + + history.undo() + #expect(try document(fixture, Ident.lane1).width.isMissing) + #expect(try document(fixture, Ident.lane2).width.isMissing) + #expect(history.canUndo == false, "one gesture, one step") + } +} + +// MARK: - Restyle + +@MainActor +@Suite("Undo ▸ restyle") +struct RestyleUndoTests { + + @Test("A style change undoes to the prior values — removing the keys that were not there") + func styleRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let before = try fixture.indexText(card1Path) + + store.applyStyle(to: .items([card1]), background: .set("red"), icon: .set("flag")) + #expect(try document(fixture, card1Path).background.value == "red") + #expect(history.undoActionName == "Restyle Card") + + history.undo() + let undone = try document(fixture, card1Path) + #expect(undone.background.isMissing) + #expect(undone.icon.isMissing) + // Nothing but the styled keys and the stamp moved — the unknown key with its comment, the + // reserved `labels`, `created`, and the body all came back through untouched. + #expect(untouchedLines(try fixture.indexText(card1Path)) == untouchedLines(before)) + + history.redo() + #expect(try document(fixture, card1Path).background.value == "red") + #expect(try document(fixture, card1Path).icon.value == "flag") + } + + @Test("A prior value is restored as itself, not removed") + func priorStyleValueComesBack() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.applyStyle(to: .items([card3]), background: .set("green")) + + history.undo() + let undone = try document(fixture, card3Path) + #expect(undone.background.value == "blue") + #expect(undone.icon.value == "star", "a dimension the gesture did not touch is not touched back") + } + + @Test("A styling batch is one step, with a plural title") + func batchIsOneStep() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.applyStyle(to: .items([card1, card2, card3]), background: .set("red")) + + #expect(history.undoActionName == "Restyle 3 Cards") + history.undo() + #expect(try document(fixture, card1Path).background.isMissing) + #expect(try document(fixture, card2Path).background.isMissing) + #expect(try document(fixture, card3Path).background.value == "blue") + #expect(history.canUndo == false) + } + + @Test("The board's own styling names the board") + func boardStyleIsNamedForTheBoard() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.applyStyle(to: .board, background: .set("graphite")) + + #expect(history.undoActionName == "Restyle Board") + history.undo() + #expect(try document(fixture, "").background.isMissing) + } +} + +// MARK: - Rename + +@MainActor +@Suite("Undo ▸ rename") +struct RenameUndoTests { + + @Test("A card rename undoes to the prior title and redoes to the new one") + func renameRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Renamed") + store.commitRename() + + #expect(try document(fixture, card1Path).title.value == "Renamed") + #expect(history.undoActionName == "Rename Card") + + history.undo() + #expect(try document(fixture, card1Path).title.value == "First") + + history.redo() + #expect(try document(fixture, card1Path).title.value == "Renamed") + } + + @Test("An emptied title undoes back to the title that was there") + func emptyRenameRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft(" ") + store.commitRename() + #expect(try document(fixture, card1Path).title.isMissing, "an empty commit removes the key") + + history.undo() + #expect(try document(fixture, card1Path).title.value == "First") + + history.redo() + #expect(try document(fixture, card1Path).title.isMissing) + } + + @Test("A lane rename is named for the lane; the board's for the board") + func namesFollowTheLevel() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.transient.beginRename(of: lane1, currentTitle: "Todo") + store.transient.updateRenameDraft("Backlog") + store.commitRename() + #expect(history.undoActionName == "Rename Lane") + + store.renameBoard("Project") + #expect(history.undoActionName == "Rename Board") + #expect(try document(fixture, "").title.value == "Project") + + history.undo() + #expect(try document(fixture, "").title.value == "Board") + history.undo() + #expect(try document(fixture, Ident.lane1).title.value == "Todo") + } + + @Test("An unchanged rename writes nothing and registers nothing") + func aNoOpRenameRegistersNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("First") + store.commitRename() + + #expect(history.canUndo == false) + } +} + +// MARK: - Create + +@MainActor +@Suite("Undo ▸ create") +struct CreateUndoTests { + + @Test("Undoing a lane create removes the folder; redo puts it back, identity and bytes intact") + func laneCreateRoundTrip() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let before = try fixture.entryNames("") + + store.createLane() + let created = try #require(try fixture.entryNames("").first { !before.contains($0) }) + let bytes = try fixture.indexData(created) + #expect(history.undoActionName == "Add Lane") + + history.undo() + #expect(fixture.exists(created) == false, "an undone create leaves no trace — not a tombstone") + + history.redo() + #expect(fixture.exists(created), "the same UUID, so every later step still names something") + #expect(try fixture.indexData(created) == bytes, "replayed verbatim — nothing re-serialized") + } + + @Test("Undoing a card create removes the folder, rank and all") + func cardCreateRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let before = try fixture.entryNames(Ident.lane1) + + store.transient.beginPlaceholder(inLane: lane1, after: card1) + store.transient.updateDraft("Fresh") + let created = try #require(store.commitPlaceholder()) + let path = "\(Ident.lane1)/\(created.rawValue)" + let bytes = try fixture.indexData(path) + #expect(history.undoActionName == "Add Card") + #expect(try fixture.entryNames(Ident.lane1).count == before.count + 1) + + history.undo() + #expect(fixture.exists(path) == false) + + history.redo() + #expect(try fixture.indexData(path) == bytes, "the rank it was placed at rides in its bytes") + } +} + +// MARK: - Move and reorder + +@MainActor +@Suite("Undo ▸ move and reorder") +struct MoveUndoTests { + + @Test("A cross-lane move undoes to the original lane at the original order") + func moveRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.moveCards([card1], toLane: lane2, at: 0) + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) + #expect(fixture.exists(card1Path) == false) + #expect(history.undoActionName == "Move Card") + + history.undo() + #expect(fixture.exists(card1Path), "the folder moved back") + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)") == false) + #expect(try document(fixture, card1Path).order.value == 1024, "at the rank it left") + + history.redo() + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card1)")) + } + + @Test("A multi-card move is one step with a plural title") + func multiCardMoveIsOneStep() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.moveCards([card1, card2, card3], toLane: lane2, at: 0) + + #expect(history.undoActionName == "Move 3 Cards") + history.undo() + #expect(fixture.exists(card1Path)) + #expect(fixture.exists(card2Path)) + #expect(fixture.exists(card3Path)) + #expect(try document(fixture, card1Path).order.value == 1024) + #expect(try document(fixture, card2Path).order.value == 2048) + #expect(try document(fixture, card3Path).order.value == 3072) + #expect(history.canUndo == false, "one gesture, one step") + } + + @Test("A drop that stays in its own lane is a Reorder, and undoes to the rank it held") + func sameLaneDropIsAReorder() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.moveCards([card1], toLane: lane1, at: 2) + + #expect(history.undoActionName == "Reorder Card") + #expect(try document(fixture, card1Path).order.value != 1024) + + history.undo() + #expect(try document(fixture, card1Path).order.value == 1024) + } + + @Test("A lane drag undoes to the lane's own prior rank") + func laneReorderRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.moveLane(lane1, toIndex: 1) + let moved = try #require(try document(fixture, Ident.lane1).order.value) + #expect(moved > 2048) + #expect(history.undoActionName == "Reorder Lane") + + history.undo() + #expect(try document(fixture, Ident.lane1).order.value == 1024) + + history.redo() + #expect(try document(fixture, Ident.lane1).order.value == moved) + } + + @Test("⌥⌘↓ undoes the whole permutation, siblings included") + func sortRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + store.select([card1], liveness: .live) + + store.sortSelection(.down) + + #expect(history.undoActionName == "Reorder Card") + #expect(try document(fixture, card1Path).order.value == 2048) + #expect(try document(fixture, card2Path).order.value == 1024) + + history.undo() + #expect(try document(fixture, card1Path).order.value == 1024) + #expect(try document(fixture, card2Path).order.value == 2048) + + history.redo() + #expect(try document(fixture, card1Path).order.value == 2048) + #expect(try document(fixture, card2Path).order.value == 1024) + } +} + +// MARK: - The trash pair + +@MainActor +@Suite("Undo ▸ the trash pair") +struct TrashUndoTests { + + @Test("Undoing a delete is Put Back — the key goes, and nothing else moves") + func deleteRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let before = try fixture.indexText(card1Path) + + store.delete([card1]) + #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(history.undoActionName == "Delete Card") + + history.undo() + let undone = try fixture.indexText(card1Path) + #expect(try FrontmatterDocument.parse(undone).deleted.isMissing) + // Byte-identical but for the stamp: the tombstone and its inverse are one key each. + #expect(untouchedLines(undone).filter { !$0.hasPrefix("deleted:") } == untouchedLines(before)) + + history.redo() + #expect(try document(fixture, card1Path).deleted.value != nil) + } + + @Test("A multi-item delete is one step with a plural title") + func batchDeleteIsOneStep() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.delete([card1, card2]) + + #expect(history.undoActionName == "Delete 2 Cards") + history.undo() + #expect(try document(fixture, card1Path).deleted.isMissing) + #expect(try document(fixture, card2Path).deleted.isMissing) + #expect(history.canUndo == false) + } + + @Test("A lane delete is named for the lane") + func laneDeleteIsNamedForTheLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.delete([lane2]) + + #expect(history.undoActionName == "Delete Lane") + history.undo() + #expect(try document(fixture, Ident.lane2).deleted.isMissing) + } + + @Test("Undoing a Put Back re-tombstones with the timestamp the row was filed under") + func putBackRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) + + store.putBack([trashed]) + #expect(try document(fixture, trashedPath).deleted.isMissing) + #expect(history.undoActionName == "Restore Card") + + history.undo() + #expect(try document(fixture, trashedPath).deleted.value == filedUnder, + "the trash sorts by this — a fresh stamp would reorder a list the user was reading") + + history.redo() + #expect(try document(fixture, trashedPath).deleted.isMissing) + } + + @Test("Drag-to-restore undoes the position half too") + func restoreByDragRoundTrip() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let filedUnder = try #require(try document(fixture, trashedPath).deleted.value) + + store.restoreByDrag(cardID: trashed, intoLane: lane2, at: 0) + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) + #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) + #expect(history.undoActionName == "Restore Card") + + history.undo() + #expect(fixture.exists(trashedPath), "back in the lane it was trashed in") + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)") == false) + let undone = try document(fixture, trashedPath) + #expect(undone.deleted.value == filedUnder) + #expect(undone.order.value == 4096, "at the rank it was trashed holding") + + history.redo() + #expect(fixture.exists("\(Ident.lane2)/\(Ident.card4)")) + #expect(try document(fixture, "\(Ident.lane2)/\(Ident.card4)").deleted.isMissing) + } +} + +// MARK: - The Edit session + +@MainActor +@Suite("Undo ▸ the Edit session") +struct BodyUndoTests { + + @Test("A session's saves are one step, registered at the flip, with the body it started from") + func sessionIsOneStep() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body + + let session = CardBodyEditSession() + session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } + session.registerUndo = { [weak store] prior, new in + store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) + } + session.adopt(diskBody: original) + + // Three debounced ticks inside one session — none of them a step. + session.edited("One.\n") + _ = session.flush() + session.edited("One two.\n") + _ = session.flush() + session.edited("One two three.\n") + _ = session.flush() + #expect(history.canUndo == false, "a save tick is not a step") + + session.endEditSession() + #expect(history.undoActionName == "Edit Card") + + history.undo() + #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original, + "byte-for-byte, across every tick the session made") + + history.redo() + #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "One two three.\n") + } + + @Test("A session that only read registers nothing") + func anUntouchedSessionRegistersNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body + + let session = CardBodyEditSession() + session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } + session.registerUndo = { [weak store] prior, new in + store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) + } + session.adopt(diskBody: original) + + session.endEditSession() + + #expect(history.canUndo == false) + } + + @Test("A session typed back to where it started registers nothing") + func aRevertedSessionRegistersNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let original = try FrontmatterDocument.parse(fixture.indexText(card1Path)).body + + let session = CardBodyEditSession() + session.save = { [weak store] text in store?.writeCardBody(inCard: card1, body: text) ?? .vanished } + session.registerUndo = { [weak store] prior, new in + store?.registerBodyEdit(inCard: card1, priorBody: prior, newBody: new) + } + session.adopt(diskBody: original) + + session.edited("Something else.\n") + _ = session.flush() + session.edited(original) + _ = session.flush() + session.endEditSession() + + #expect(history.canUndo == false, "the net effect on the file is nothing to undo") + #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == original) + } +} + +// MARK: - What registers nothing + +@MainActor +@Suite("Undo ▸ the operations that register nothing") +struct NotUndoableTests { + + @Test("Delete Immediately registers nothing — the confirm is the safety") + func purgeRegistersNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + store.delete([card1]) + let armed = try #require(history.undoActionName) + + store.deleteImmediately([trashed]) + + #expect(fixture.exists(trashedPath) == false) + #expect(store.purgeIsUnrecoverable) + #expect(history.undoActionName == armed, "the stack is exactly where the purge found it") + } + + @Test("Empty Trash registers nothing either") + func emptyTrashRegistersNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.emptyTrash() + + #expect(fixture.exists(trashedPath) == false) + #expect(history.canUndo == false) + } + + @Test("Attachment add and remove register nothing in v1") + func attachmentsRegisterNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + let source = try fixture.file("outside/note.txt", Data("hello".utf8)) + + store.importAttachments([source], toCard: card1) + #expect(fixture.exists("\(card1Path)/attachments") ) + #expect(history.canUndo == false) + + store.removeAttachment(named: "note.txt", fromCard: card1) + #expect(history.canUndo == false) + } + + @Test("A checkbox toggle and a raw-source Apply are outside 13's inventory") + func bodyAdjacentWritesRegisterNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + _ = store.applyCardSource(inCard: card1, text: "---\nschema: 1\norder: 1024\n---\nApplied.\n") + + #expect(try FrontmatterDocument.parse(fixture.indexText(card1Path)).body == "Applied.\n") + #expect(history.canUndo == false) + } + + @Test("A store with no stack behind it writes exactly as it always did") + func aStorelessBoardStillWrites() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.delete([card1]) + store.setLaneWidth(lane1, units: 2) + + #expect(try document(fixture, card1Path).deleted.value != nil) + #expect(try document(fixture, Ident.lane1).width.value == 2) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - The crossing is a write + +@MainActor +@Suite("Undo ▸ crossings are ordinary writes") +struct CrossingIsAWriteTests { + + @Test("An undo goes through the Writer: it stamps, and it echoes back through the reload") + func undoIsAnAppMediatedWrite() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.transient.beginRename(of: card1, currentTitle: "First") + store.transient.updateRenameDraft("Renamed") + store.commitRename() + + history.undo() + + let undone = try document(fixture, card1Path) + #expect(undone.title.value == "First") + let stamped = try #require(undone.modified.value) + #expect(stamped.timeIntervalSinceNow > -30, "an inverse is a real write, not an in-memory revert") + #expect(try fixture.indexText(card1Path).contains("modified-by") == false) + + // And the board sees it the only way it ever sees anything: through a reload. + await reload(store) + let card = try #require(store.snapshot.lanes.first?.cards.first { $0.id == card1 }) + #expect(card.title.value == "First") + } + + @Test("Undo, redo, undo — the classic dance over one file") + func theDanceRepeats() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (store, history) = try makeStore(fixture) + + store.delete([card1]) + for _ in 0 ..< 3 { + history.undo() + #expect(try document(fixture, card1Path).deleted.isMissing) + history.redo() + #expect(try document(fixture, card1Path).deleted.value != nil) + } + } +} + +// MARK: - The phrase vocabulary + +@Suite("Undo ▸ the phrase vocabulary") +struct HistoryPhraseTests { + + @Test("Singular and plural follow 06's folding") + func pluralFolding() { + #expect(HistoryPhrase.name(.move, kind: .card) == "Move Card") + #expect(HistoryPhrase.name(.move, kind: .card, count: 3) == "Move 3 Cards") + #expect(HistoryPhrase.name(.rename, kind: .lane) == "Rename Lane") + #expect(HistoryPhrase.name(.resize, kind: .lane, count: 2) == "Resize 2 Lanes") + #expect(HistoryPhrase.name(.delete, kind: .card, count: 12) == "Delete 12 Cards") + } + + @Test("A count of one or less is the singular, and the board is always singular") + func degenerateCounts() { + #expect(HistoryPhrase.name(.add, kind: .card, count: 1) == "Add Card") + #expect(HistoryPhrase.name(.add, kind: .card, count: 0) == "Add Card") + #expect(HistoryPhrase.name(.restyle, kind: .board, count: 4) == "Restyle Board") + } + + @Test("The phrase never spells the verb the platform composes") + func noUndoPrefix() { + for verb in HistoryPhrase.Verb.allCases { + let phrase = HistoryPhrase.name(verb, kind: .card) + #expect(phrase.hasPrefix("Undo") == false) + #expect(phrase.hasPrefix("Redo") == false) + } + } +}