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:
2026-07-28 14:24:31 -04:00
parent 93fad2ef1e
commit 2148ebb379
8 changed files with 1732 additions and 64 deletions
+43 -2
View File
@@ -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 EditPreview 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<Void, Never>?
@@ -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