The store is the Writer boundary, so it computes and registers inverses: a weak history sink bound at session composition, one HistoryStep per gesture at exactly the brackets that were already one performWrite each — multi-card moves, style batches, width pairs, and multi-row restores each undo as one plurally-titled step, and the Edit session registers once at the flip from the bytes disk held before its first landed write, debounce ticks registering nothing. Crossings run through performWrite, so an undo brackets the watcher, echoes through the reload, and reaches every window; every closure captures values, never snapshots. The inventory follows 13 exactly: moves return to origin lane and order, renames restore or remove the title key, restyles and resizes restore field values or absence, tombstones and restores swap with captured timestamps, and an undone create is a real removal — no trace — with redo re-materializing the same UUID from bytes captured at gesture time. Purge, attachments, repair, bookkeeping, checkbox flips, raw Apply, and the whole arrival family register nothing, each exclusion documented where it lives. Step names speak 06's verb vocabulary through the new HistoryPhrase. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
198 lines
11 KiB
Swift
198 lines
11 KiB
Swift
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<String>, 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<Int>, 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<Date>, in document: inout FrontmatterDocument) {
|
|
document.set(FrontmatterKeys.deleted, to: .date(prior.value ?? Date()))
|
|
}
|
|
}
|