import Foundation import Observation // MARK: - CommentEditSession /// One inline comment edit — **a body-edit session in miniature** (05-card-window.md ▸ The comments /// column: "no second draft mechanism: debounced saves to the comment's own file keep it crash-safe, /// Save (or ⌘↩) ends the session as its commit point, Cancel — or Escape, its keyboard twin — /// reverts to session-start bytes, window close flushes the session exactly as the body's does"). /// /// ### What it borrows from `CardBodyEditSession`, and what it adds /// /// Borrowed, deliberately verbatim: the buffer/disk pair, the single write predicate (*write if and /// only if the buffer differs from disk*), dirty-buffer-wins on `adopt(diskBody:)`, the ~700 ms /// trailing debounce, and the flush that a mode exit or a window close performs. **The 700 ms is /// right here**, and its being right here is what the slow cadence next door is a contrast to: the /// comment already exists as a file, so a save is an ordinary edit to it — it is the *draft* that /// must not become a commit stream (`CommentDraftSession`). /// /// Added, and the only genuinely new thing in this type: **session-start bytes**. The body has no /// Cancel — leaving Edit is a commit, and ⌘Z in the editor is the text view's own undo — while an /// inline comment edit has a Cancel button and an Escape that means it. 13-native-undo.md forbids /// byte capture *on the undo stack* in every tier, and this is not that: the capture is a live /// buffer's, held for the length of one session, discarded when the session ends, and never /// registered anywhere. `BoardStoreComments`' own note says so — "an inline edit's revert is its /// *session*'s … which is a live buffer, not a stack entry". /// /// ### The revert is a write, not an unwrite /// /// Cancel puts the captured bytes back **through the ordinary save** (`BoardStore.editComment`), so /// the file returns to what it said with one more `modified` stamp and one more bracketed write. That /// is the honest shape for a files-first app: the debounced saves genuinely happened, other windows /// and other machines have already seen them, and pretending otherwise would mean holding the file /// open for the length of a session. /// /// A cancel that has nothing to put back writes nothing — a session that only ever read leaves the /// file byte-identical, `mtime` included, which is the body's untouched gate applied to the exit. @MainActor @Observable public final class CommentEditSession { // MARK: Identity /// Which comment is open. The row renders an editor instead of its body while this session names /// it, and the drop carve-out aims at its `attachments/`. public let commentID: ItemID // MARK: State /// What the editor is showing. public private(set) var text: String /// What the last read said the comment's `index.md` holds. The write gate's other half. public private(set) var disk: String /// **The bytes this session opened on** — Cancel's destination, captured once at `init` and never /// updated. See the type's note for why this capture is not the one 13 forbids. @ObservationIgnored public let sessionStart: String public var isDirty: Bool { text != disk } // MARK: Seams /// The debounce interval — **~700 ms**, the body's own (05 ▸ Edit), and settable so a test does /// not have to spend it. @ObservationIgnored public var debounceInterval: Duration = .milliseconds(700) /// Where a save goes — `BoardStore.editComment(_:inCard:body:)`, filled in by the window. /// /// `true` means the bytes landed. `false` covers everything that means they did not — a failed /// write (already bannered by `performWrite`), a suspended one under the read-only lock, and a /// comment or card that has gone — and they are one case here for the reason 05 gives the window: /// each of them leaves the buffer dirty, which keeps the text, and none of them has a different /// thing for this type to do. @ObservationIgnored public var save: ((String) -> Bool)? /// Where this session's **one undo step** goes, called at its commit point with the bytes the /// session opened on and the bytes it leaves — `CardBodyEditSession.registerUndo`'s seam one level /// down, and for its reason exactly: a session is one step, never one per debounced tick /// (13-native-undo.md ▸ Rules ▸ coalescing). /// /// `CardComments.beginEdit` points it at `BoardStore.registerCommentEdit`, on the *window's* /// stack. `nil` — a session with no window behind it — registers nothing. @ObservationIgnored public var registerUndo: ((_ priorBody: String, _ newBody: String) -> Void)? /// How many saves have actually been attempted through `save`. @ObservationIgnored public private(set) var saveAttempts = 0 @ObservationIgnored private var pending: Task? /// Whether this session has ended. A session ends once — Save, Cancel, or the window close that /// beat both of them to it — and ending twice must not write twice. @ObservationIgnored public private(set) var hasEnded = false /// Opens a session over `body`, which is both the buffer's starting text and Cancel's /// destination. public init(commentID: ItemID, body: String) { self.commentID = commentID text = body disk = body sessionStart = body } // MARK: - Disk → buffer /// A thread read arrived. **Dirty-buffer-wins**, the body's single `if`: `disk` always follows the /// read; `text` follows it only when the buffer had nothing unsaved. public func adopt(diskBody: String) { let wasDirty = isDirty disk = diskBody guard !wasDirty else { return } if text != diskBody { text = diskBody } } // MARK: - Buffer → disk /// A keystroke. Restarts the debounce, or cancels it when the change brought the buffer back to /// what disk already says. 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 — the window /// close's flush, which "flushes the session exactly as the body's does". @discardableResult public func flush() -> Bool { cancelPending() return saveNow() } // MARK: - The two ends /// **Save, or ⌘↩** — the session's commit point (05 ▸ The comments column). Flushes and ends. /// /// A named call rather than a bare `flush()` for `CardBodyEditSession.endEditSession()`'s reason: /// this is the boundary a Pro auto-commit coalesces on, one commit per session and never per save /// tick (06-history-undo.md ▸ Rules ▸ Auto-commit). @discardableResult public func commit() -> Bool { guard !hasEnded else { return false } hasEnded = true let wrote = flush() registerStep() return wrote } /// **Cancel, or Escape** — reverts to session-start bytes and ends (05; 11-command-nexus.md's /// grammar table gives Escape as the button's keyboard twin). /// /// The revert is a write, and it is attempted only when something of this session's actually /// landed: `disk` is what the file says as far as this session knows, so `disk == sessionStart` /// is a session that has overwritten nothing and has nothing to put back. /// /// A *foreign* edit landing mid-session moves `disk` too, and Cancel then writes the session's /// start bytes over it — deliberate last-writer-wins, the same no-merge-UI philosophy the body's /// dirty-buffer rule states (05 ▸ Write rules). The alternative would be a merge prompt in a /// comment editor. @discardableResult public func cancel() -> Bool { guard !hasEnded else { return false } hasEnded = true cancelPending() guard disk != sessionStart else { return false } saveAttempts += 1 guard save?(sessionStart) == true else { return false } text = sessionStart disk = sessionStart return true } /// The window close's end: flush, then mark the session over — the same one-way latch Save and /// Cancel use, so a close that beat the buttons cannot be followed by a second write. /// /// It is **not** Cancel: a close is not an abandon (05 ▸ Deletion & lifecycle — "Dismissal never /// eats typed work silently where a save can land"), and reverting the user's typing because they /// closed a window would be the opposite of that promise. @discardableResult public func endOnClose() -> Bool { guard !hasEnded else { return false } hasEnded = true let wrote = flush() registerStep() return wrote } // MARK: - Private /// The session's one step, at whichever of its two ends got here first — and **only when the /// session actually changed the file**: `disk` is what this session knows the file says, so /// `disk == sessionStart` covers both the session that only read and the one that typed its way /// back to where it started (`CardBodyEditSession.endEditSession`'s guard, restated). /// /// Cancel deliberately never reaches here: it writes the start bytes back, so its net effect is /// nothing and a step would only offer to undo an undo. private func registerStep() { guard disk != sessionStart else { return } registerUndo?(sessionStart, disk) } 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 landing moves `disk` up to the text that /// landed; anything else leaves it, which keeps the buffer dirty and therefore keeps the text. private func saveNow() -> Bool { guard isDirty, let save else { return false } saveAttempts += 1 guard save(text) else { return false } disk = text return true } }