Register inverse operations at the Writer boundary
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
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user