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). In the free tier 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? /// 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 } }