The editing surface: the same hosted TextKit-1 text view gains an editable branch with a per-keystroke line-scanner highlighter — chosen over a parser re-parse because a mid-typing buffer is usually invalid Markdown and 05 wants the delimiters themselves dimmed; apply only sets attributes, so presentation-never-transforms is structural. Saves ride a ~700ms injectable debounce through BoardWriter.writeBody — toggleTaskMarker's idiom widened to the body span, frontmatter bytes untouched, refusing to write when disk already holds that body, which enforces all three gates (untouched, reverted, echo) at the layer that owns the bytes with one isDirty predicate above it. Mode grammar lands whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape returns, and every flip flushes first; window close flushes through the existing retry/save-copy/discard modal, and the dismissal flush deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always follows the snapshot, the buffer only when clean, both surfaces render the buffer. Undo is the editor's own session-scoped NSUndoManager; endEditSession names the pro-m1 one-commit-per-session boundary. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
210 lines
9.7 KiB
Swift
210 lines
9.7 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)?
|
|
|
|
@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 {
|
|
flush()
|
|
}
|
|
|
|
/// `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
|
|
let outcome = save(text)
|
|
switch outcome {
|
|
case .written, .unchanged:
|
|
disk = text
|
|
case .suspended, .vanished, .failed:
|
|
break
|
|
}
|
|
return outcome
|
|
}
|
|
}
|