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
+72
View File
@@ -1,5 +1,35 @@
import Foundation
// MARK: - CommentTarget
/// Which of a card's two **authoring surfaces** a write is aimed at: the composer's draft, or one
/// posted comment being edited inline (05-card-window.md The comments column the hover-target
/// carve-out's two destinations).
///
/// It exists because the pair is a *choice the view makes* and the store must not re-derive: which
/// surface the pointer was over when a file was dropped is knowledge only the window has, and the
/// alternative two near-identical store methods would put the choice in the call site's name
/// instead of in a value a test can hold.
///
/// `comments/.trash/` is deliberately not a case: a deleted comment is undo's backing store and
/// "never a UI surface" (01-storage-format.md § Enhanced schema), so there is no gesture that could
/// aim at one.
public enum CommentTarget: Sendable, Equatable {
/// `comments/.draft/` the composer's backing file.
case draft
/// `comments/<uuid>/` a posted comment with an inline edit session open over it.
case comment(ItemID)
/// Where the target lives, given the card's folder. One resolution, so a view and a write can
/// never disagree about which folder "the composer" means.
public func folder(inCard cardFolder: URL) -> URL {
switch self {
case .draft: CommentThread.draftFolder(inCard: cardFolder)
case let .comment(id): CommentThread.commentFolder(id, inCard: cardFolder)
}
}
}
// MARK: - The comment gestures
/// **The comment thread at the store boundary** every comment write the app makes, bracketed, with
@@ -43,6 +73,16 @@ extension BoardStore {
return CommentThread.load(inCard: card.folder, path: card.path)
}
/// **The composer's own file, read** `comments/.draft/`, excluded from the thread listing and
/// therefore asked for by name (`CommentThread.loadDraft`).
///
/// `nil` for a card with no draft, an unreadable one, or an id that names no card the same
/// vanished-target answer `commentThread(inCard:)` gives, and the same "nothing to restore".
public func commentDraft(inCard id: ItemID) -> CommentDraft? {
guard let card = commentSubject(id) else { return nil }
return CommentThread.loadDraft(inCard: card.folder)
}
// MARK: The draft
/// Saves the composer's draft one bracket, no step.
@@ -192,6 +232,38 @@ extension BoardStore {
}
}
// MARK: The authoring surfaces' attachments
/// **Imports files into the draft's or one comment's `attachments/`** the composer's and the
/// inline editor's drop carve-out and paperclip (05-card-window.md The comments column).
///
/// One bracket, **no step**: an attachment import registers nothing at card level either
/// (`importAttachments`), and 13-native-undo.md's inventory does not grow because a file landed
/// one folder deeper.
///
/// A vanished card, or a target folder that is not an authoring surface, writes nothing the
/// Writer's own guard, reached through the ordinary bracket so a failure banners like any other.
public func importCommentAttachments(_ urls: [URL], inCard id: ItemID, target: CommentTarget) {
guard !urls.isEmpty, let card = commentSubject(id) else { return }
let folder = target.folder(inCard: card.folder)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.importCommentAttachments(urls, intoComment: folder)
}
}
/// **Moves one authoring chip's file to the system Trash** never a hard delete, the sidebar
/// row's rule one level down (05-card-window.md The comments column).
///
/// A name that is no longer there is a silent no-op rather than a failure: the reload is the
/// authority on what a folder holds (`BoardWriter.trashAttachment`).
public func removeCommentAttachment(named name: String, inCard id: ItemID, target: CommentTarget) {
guard !name.isEmpty, let card = commentSubject(id) else { return }
let folder = target.folder(inCard: card.folder)
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.removeCommentAttachment(named: name, fromComment: folder)
}
}
// MARK: The thread's claimed names
/// Displaces the claimed names one thread read found squatted `comments/.draft`,