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
251 lines
12 KiB
Swift
251 lines
12 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
// MARK: - CardBodyEditSession
|
|
|
|
/// One card window's Edit buffer: the text the user is typing, what disk last said, and the
|
|
/// debounced save between them (05-card-window.md ▸ Edit, ▸ Write rules).
|
|
///
|
|
/// ### One comparison is the whole write rule
|
|
///
|
|
/// 05 states three gates — "untouched → never re-serialized; reverted → not written; echo of an
|
|
/// external edit → not written back" — and they are three faces of a single predicate: **write if
|
|
/// and only if the buffer differs from what is on disk**.
|
|
///
|
|
/// - *Untouched*: the user opened Edit, read, and left. `text == disk`, so nothing is written and
|
|
/// the file stays byte-identical, `mtime` included.
|
|
/// - *Reverted*: they typed and undid it. The debounce is cancelled the moment `text` matches `disk`
|
|
/// again, so the timer that was going to write does not survive the revert.
|
|
/// - *Echo*: our own save lands, the watcher reloads, and the snapshot arrives carrying the text we
|
|
/// just wrote. `adopt(diskBody:)` moves `disk` to it, the buffer is already equal, and nothing is
|
|
/// written back — which is what stops a save from ringing forever through the one-way flow.
|
|
///
|
|
/// `BoardWriter.writeBody` re-checks the same equality against the bytes it reads fresh, so the
|
|
/// guarantee holds even against a caller that skipped this type. Belt and braces on purpose: this is
|
|
/// the promise a file-backed app cannot afford to get subtly wrong.
|
|
///
|
|
/// ### Dirty-buffer-wins, as one branch
|
|
///
|
|
/// "A dirty Edit buffer is never reloaded under the cursor: while the user has unsaved keystrokes,
|
|
/// watcher reloads update everything else (board, Preview, other windows) but leave the buffer
|
|
/// alone; the debounced save then writes it — deliberate last-writer-wins. A clean buffer follows
|
|
/// disk" (05 ▸ Write rules). That is `adopt(diskBody:)`'s single `if`: `disk` always follows the
|
|
/// snapshot, and `text` follows it only when the two agreed before the snapshot arrived.
|
|
///
|
|
/// Keeping `disk` current *even while dirty* is the deliberate half. It means "dirty" reads as
|
|
/// "differs from the file", not "differs from what the file said when I started" — so a foreign edit
|
|
/// that happens to arrive at the text the user typed lands the buffer clean and writes nothing,
|
|
/// rather than re-stamping a file that already says the right thing.
|
|
///
|
|
/// ### The undo and commit seams
|
|
///
|
|
/// ⌘Z is the *editor's* undo and lives in the text view (`CardBodySurface` gives it an
|
|
/// `NSUndoManager` of its own, which is what makes it session-scoped). What lives here is the other
|
|
/// half of 05 ▸ Edit's undo sentence: the **session**, whose end is the effective Save.
|
|
/// `endEditSession()` is that moment — the Edit→Preview flip, raw-source entry, or the window
|
|
/// closing — and it is deliberately a named call rather than a side effect of `flush()`, because
|
|
/// pro-m1's auto-commit coalesces exactly here: every debounced tick inside one session rides its
|
|
/// own `performWrite` bracket, and the committer's rule is one commit per *session*, "never per save
|
|
/// tick" (06-history-undo.md ▸ Rules ▸ Auto-commit). On the base edition there is no committer, so
|
|
/// the two calls do the same work today; the seam is what keeps them from having to be pulled apart
|
|
/// later.
|
|
@MainActor
|
|
@Observable
|
|
public final class CardBodyEditSession {
|
|
|
|
// MARK: State
|
|
|
|
/// What the editor is showing — and, once the window has opened, the truest text there is: it is
|
|
/// the buffer when the buffer is dirty and disk when it is not, which is precisely the order 05
|
|
/// settles. Preview renders it too, so "the preview never lags the text that produced it" needs
|
|
/// no separate mechanism.
|
|
public private(set) var text: String = ""
|
|
|
|
/// What the last snapshot said is on disk. The write gate's other half; never shown.
|
|
public private(set) var disk: String = ""
|
|
|
|
/// Whether the buffer holds keystrokes the file does not.
|
|
public var isDirty: Bool { text != disk }
|
|
|
|
// MARK: Seams
|
|
|
|
/// The debounce interval — **~700 ms** (05 ▸ Edit), and settable so a test does not have to
|
|
/// spend it. `DragSession.holdTimeout`'s precedent: a production default on the property, and
|
|
/// the suite dialling it down to milliseconds.
|
|
@ObservationIgnored
|
|
public var debounceInterval: Duration = .milliseconds(700)
|
|
|
|
/// Where a save goes. Filled in by the window once it has a store and a card to aim at
|
|
/// (`CardWindowHost`), which is also why it is a closure rather than a store reference: this type
|
|
/// is a buffer and a clock, and it stays testable by having no idea what a board is.
|
|
///
|
|
/// `nil` is a session with nowhere to write — before the window has joined its board, and after
|
|
/// it has left. A flush then keeps the buffer dirty rather than reporting success.
|
|
@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<Void, Never>?
|
|
|
|
/// How many saves have actually been attempted through `save` — the debounce's own testimony,
|
|
/// which a test would otherwise have to infer from `mtime`s.
|
|
@ObservationIgnored
|
|
public private(set) var saveAttempts = 0
|
|
|
|
public init() {}
|
|
|
|
// MARK: - Disk → buffer
|
|
|
|
/// A snapshot arrived. **Dirty-buffer-wins**: `disk` always follows it; `text` follows it only
|
|
/// when the buffer had nothing unsaved.
|
|
///
|
|
/// Called on every snapshot the window renders, including the first, which is how the buffer is
|
|
/// filled at all — a card window opens by adopting its card's body.
|
|
public func adopt(diskBody: String) {
|
|
let wasDirty = isDirty
|
|
disk = diskBody
|
|
guard !wasDirty else { return }
|
|
// Assigning an equal string would still notify observers, and an observer here is a text
|
|
// view that would replace its contents under the cursor.
|
|
if text != diskBody { text = diskBody }
|
|
}
|
|
|
|
// MARK: - Buffer → disk
|
|
|
|
/// The editor changed. Restarts the debounce — or cancels it outright, when the change brought
|
|
/// the buffer back to what disk already says (05's *reverted* gate: a revert must not leave a
|
|
/// timer standing that would then write nothing but a `modified` stamp).
|
|
public func edited(_ newText: String) {
|
|
guard text != newText else { return }
|
|
text = newText
|
|
guard isDirty else {
|
|
cancelPending()
|
|
return
|
|
}
|
|
scheduleSave()
|
|
}
|
|
|
|
/// Saves now if there is anything to save, cancelling the pending debounce first — "leaving Edit
|
|
/// flushes the debounce (mode flip, raw-source entry, window close)" (05 ▸ Mode grammar).
|
|
///
|
|
/// Synchronous, because the write is: `BoardWriter` is a temp file and a rename, and a flush that
|
|
/// returned before the bytes landed would be no flush at all — the close path in particular has
|
|
/// to know the answer before it lets the window go.
|
|
@discardableResult
|
|
public func flush() -> CardBodyWriteOutcome {
|
|
cancelPending()
|
|
return saveNow()
|
|
}
|
|
|
|
/// The end of one Edit session — the flip back to Preview, raw-source entry, or the window
|
|
/// closing. Flushes, and marks the boundary pro-m1's auto-commit coalesces on (see the type's
|
|
/// doc comment).
|
|
@discardableResult
|
|
public func endEditSession() -> CardBodyWriteOutcome {
|
|
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
|
|
/// reported.
|
|
///
|
|
/// The three non-failures deliberately do *not* throw, because each of them is a state in which
|
|
/// blocking the close would be dishonest:
|
|
///
|
|
/// - `.written` / `.unchanged` — the text is on disk.
|
|
/// - `.vanished` — the card's folder is gone, so there is nowhere for the save to land; 05 ▸
|
|
/// Deletion & lifecycle answers exactly this case with "nowhere left to write", and a modal
|
|
/// offering Try Again against a deleted folder would be a button that can only fail.
|
|
/// - `.suspended` — the board is locked read-only, which is 05's "where a save can land"
|
|
/// qualifier failing rather than a write failing: no write was attempted, the lock row has been
|
|
/// standing the whole time the user was typing, and the lock's own clearing rule (a successful
|
|
/// reload) is not something a close can wait on.
|
|
public func flushOrThrow() throws(BoardWriteError) {
|
|
switch flush() {
|
|
case .written, .unchanged, .vanished, .suspended:
|
|
return
|
|
case let .failed(error):
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func scheduleSave() {
|
|
cancelPending()
|
|
let interval = debounceInterval
|
|
pending = Task { [weak self] in
|
|
try? await Task.sleep(for: interval)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.pending = nil
|
|
_ = self.saveNow()
|
|
}
|
|
}
|
|
|
|
private func cancelPending() {
|
|
pending?.cancel()
|
|
pending = nil
|
|
}
|
|
|
|
/// The gate, and the one place `save` is called.
|
|
///
|
|
/// A successful landing moves `disk` up to the text that landed, so the echo arriving a reload
|
|
/// later finds the buffer already clean. A failure, a suspension and a vanished card all leave
|
|
/// `disk` where it was, which keeps the buffer dirty — and therefore keeps the text, which is the
|
|
/// whole point.
|
|
private func saveNow() -> CardBodyWriteOutcome {
|
|
guard isDirty else { return .unchanged }
|
|
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:
|
|
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
|
|
}
|
|
return outcome
|
|
}
|
|
}
|