The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
222 lines
9.9 KiB
Swift
222 lines
9.9 KiB
Swift
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<Void, Never>?
|
|
|
|
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
|
|
}
|
|
}
|