Comments, phase 2 — the pane, the composer, and the inline session

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
This commit is contained in:
2026-07-30 20:19:52 -04:00
parent f68ac3668e
commit fe3ffac48e
27 changed files with 4496 additions and 24 deletions
+221
View File
@@ -0,0 +1,221 @@
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
}
}