import Foundation import Observation // MARK: - CommentDraftSession /// The composer's buffer: the text the user is typing into `comments/.draft/`, what disk last said, /// and the **slow** cadence between them (05-card-window.md ▸ The comments column). /// /// ### It is not the body's session, and the difference is the whole point /// /// `CardBodyEditSession` is a 700 ms trailing debounce: the body is the card, and a card should be on /// disk almost as fast as it is typed. A draft is neither. /// /// > **Draft saves are slow-cadence, never prompted** (flow breakage minimized): the draft writes on /// > composer blur, window close, quit, and a lazy interval (~30 s) — not the body editor's 700 ms, /// > so a Pro user's typing never becomes a commit stream. /// /// So the timer here is a **lazy interval, not a debounce**: it is armed the moment the buffer first /// goes dirty and it is *not* restarted by the keystrokes after it. A debounce would never fire while /// someone was typing steadily and would then fire the instant they paused — which is exactly the /// commit stream the rule exists to prevent, and exactly the wrong moment to interrupt them. An /// interval fires on its own schedule, at most once per period, whatever the typing is doing. /// /// ### Escape is not here, and that is a ruling /// /// "**Escape moves focus out of the composer, draft untouched**" (ruled 2026-07-29 — Escape never /// discards: the draft is a durable file, so 'abandon' has no meaning here; emptying the draft is the /// discard gesture). There is therefore no `cancel()` on this type at all — the absence is the /// design, not an omission, and adding one later would be adding a way to lose a file. /// /// ### The emptied draft deletes itself, and this type does not know that /// /// "A draft emptied of text with no attachments deletes its folder — no litter." That rule lives in /// `BoardWriter.saveCommentDraft`, which is why an emptied composer here simply *saves empty text* /// and reports `.deleted` back. Re-deriving the condition would be a second place for "no text and no /// attachments" to mean something slightly different. @MainActor @Observable public final class CommentDraftSession { // MARK: State /// What the composer is showing — the buffer when it is dirty, disk when it is not, exactly the /// order `CardBodyEditSession` settles for the body. public private(set) var text: String = "" /// What the last read said `comments/.draft/index.md` holds. The write gate's other half; never /// shown. public private(set) var disk: String = "" /// The draft's `attachments/`, republished from every thread read — the chips the composer draws, /// and half of what decides whether there is anything to post. /// /// It lives here rather than beside the thread because the *rule* it feeds is this session's: a /// draft with no text but a file in it is still a draft (it does not delete, and it does post). public var attachments: [String] = [] /// Whether the buffer holds keystrokes the file does not. public var isDirty: Bool { text != disk } /// **Whether ⌘↩ / the Comment button have anything to post** — the emptied-draft rule read /// forwards: a save of this buffer would delete the folder exactly when there is nothing to post, /// so the two questions have one answer (`CommentDraft.isEmpty`). public var canPost: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty } // MARK: Seams /// The lazy interval — **~30 s** (05 ▸ The comments column), and settable so a test does not have /// to spend it. `CardBodyEditSession.debounceInterval`'s precedent, with its production default /// on the property. @ObservationIgnored public var saveInterval: Duration = .seconds(30) /// Where a save goes — `BoardStore.saveCommentDraft(inCard:body:)`, filled in by the window once /// it has a store and a card to aim at. /// /// A closure for `CardBodyEditSession.save`'s reason exactly: this type is a buffer and a clock, /// and it stays testable by having no idea what a board is. `nil` — or a `nil` answer, which is /// what a failed write and a vanished card both give — is a save that did not land, and the /// buffer stays dirty rather than reporting success. @ObservationIgnored public var save: ((String) -> CommentDraftOutcome?)? /// The post — `BoardStore.postComment(inCard:)`, which renames `.draft` to a fresh UUID and /// restamps in one bracket. `nil` answers a post that did not happen. @ObservationIgnored public var post: (() -> ItemID?)? /// How many saves have actually been attempted through `save` — the cadence's own testimony, /// which a test would otherwise have to infer from `mtime`s. @ObservationIgnored public private(set) var saveAttempts = 0 @ObservationIgnored private var pending: Task? public init() {} // MARK: - Disk → buffer /// A thread read arrived. **Dirty-buffer-wins**, the body's rule applied here for its reason: the /// composer is a text surface with a cursor in it, and a reload landing another machine's draft /// under that cursor would be the app eating keystrokes. /// /// `nil` is a card with no draft at all — the ordinary state before the first keystroke, and the /// state a post leaves behind. Disk is then the empty string, which is what a clean composer /// shows. public func adopt(draft: CommentDraft?) { let wasDirty = isDirty attachments = draft?.attachments ?? [] disk = draft?.body ?? "" 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 != disk { text = disk } } // MARK: - Buffer → disk /// The composer changed. **Arms the interval; never restarts it** — see the type's note. /// /// A change that brings the buffer back to what disk already says cancels the armed save outright, /// `CardBodyEditSession.edited(_:)`'s *reverted* gate: a tick that fired on a no-op would stamp /// `modified` on a draft nobody touched. public func edited(_ newText: String) { guard text != newText else { return } text = newText guard isDirty else { cancelPending() return } armInterval() } /// **Composer blur** — the first of 05's four cadence moments. Named rather than folded into /// `flush()` because it is the one a view calls, and because the other three are the window's. @discardableResult public func blurred() -> CommentDraftOutcome? { flush() } /// Saves now if there is anything to save, disarming the interval first — window close, app quit, /// and blur all land here. /// /// Synchronous, because the write is: the close path has to know the answer before it lets the /// window go (`CardBodyEditSession.flush()`'s reason, unchanged). @discardableResult public func flush() -> CommentDraftOutcome? { cancelPending() return saveNow() } /// **⌘↩ and the Comment button** — one gesture: flush the buffer into `.draft/`, then rename it /// into the thread (05 ▸ The comments column: "posting renames `.draft` → a fresh lowercase UUID /// and **restamps** `created`/`modified` in the same write bracket … one gesture, one commit"). /// /// **The flush comes first and is not optional.** The post is a rename of a *folder*, so whatever /// the composer has not yet written would simply not be in the comment — the one place the slow /// cadence would otherwise be visible as lost text. /// /// Nothing to post is a no-op rather than a refusal: the button is disabled and ⌘↩ in an empty /// composer should do nothing at all, not post an empty comment and not raise anything. /// /// **After a post the composer empties**, because the draft is gone — the folder it was editing is /// now a comment in the thread. Clearing `disk` too is what keeps the buffer clean rather than /// dirty-against-a-file-that-no-longer-exists. @discardableResult public func postNow() -> ItemID? { guard canPost else { return nil } flush() guard let posted = post?() else { return nil } cancelPending() text = "" disk = "" attachments = [] return posted } // MARK: - Private /// Arms the interval **once**. A second dirty keystroke inside the period rides the timer that is /// already running, which is the difference between an interval and a debounce. private func armInterval() { guard pending == nil else { return } let interval = saveInterval 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, so the thread read arriving a moment later /// finds the buffer already clean. A `nil` — a failure, a suspension under the read-only lock, a /// vanished card — leaves `disk` where it was, which keeps the buffer dirty and therefore keeps /// the text. /// /// `.deleted` is a landing like any other: the folder is gone *because* the buffer was empty, so /// disk and the buffer agree perfectly. private func saveNow() -> CommentDraftOutcome? { guard isDirty else { return nil } guard let save else { return nil } saveAttempts += 1 let outcome = save(text) if outcome != nil { disk = text } return outcome } }