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) + } + } +}