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
+73
View File
@@ -364,6 +364,79 @@ extension BoardWriter {
try restampComment(at: posted, to: instant, operation: operation)
}
// MARK: - The authoring surfaces' attachments
/// **Imports files into one comment's (or the draft's) `attachments/`** the write behind the
/// composer's and the inline editor's drop carve-out and their paperclip affordance
/// (05-card-window.md The comments column, ruled 2026-07-29: "a file dropped within the
/// composer's bounds imports to the draft's `attachments/` and the same pair applies within an
/// inline comment edit session, targeting that comment's").
///
/// **It is `importAttachments` with a different shape guard and nothing else** the collision
/// rename, the per-file validation, the partial cleanup and the import receipt are all
/// `BoardWriter.importFiles`', shared rather than restated, so a file added to a comment behaves
/// exactly like one added to a card.
///
/// **No stamp, deliberately.** `index.md` is never opened: importing a file says nothing about the
/// comment's text, and `relocateLooseFiles` already settles that reading one level up ("relocating
/// a stray says nothing about the card's content, so no `modified` stamp is written"). It also
/// keeps the draft's own rule honest a draft that has only ever been dropped on still has the
/// `created`/`modified` pair the post is about to restamp.
@discardableResult
public static func importCommentAttachments(
_ sourceURLs: [URL],
intoComment folder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
try checkIsAuthoringFolder(folder, operation: batchOperation)
return try importFiles(sourceURLs, intoAttachmentsOf: folder, batchOperation: batchOperation)
}
/// **Moves one of a comment's (or the draft's) attachments to the system Trash** the authoring
/// chip's Remove (05-card-window.md The comments column: "Chips on an authoring surface carry
/// remove (to the **system** Trash the sidebar row's rule); a posted comment's chips are
/// read-only").
///
/// The *posted*-chip half of that sentence is enforced in the view, not here: the Writer's job is
/// that the file goes to the Trash rather than being destroyed, and an inline edit session over a
/// posted comment is an authoring surface too. `BoardWriter.trashAttachment` is the whole body.
@discardableResult
public static func removeCommentAttachment(
named name: String,
fromComment folder: URL
) throws(BoardWriteError) -> URL? {
let operation = WriteOperation.removeAttachment(filename: name)
try checkIsAuthoringFolder(folder, operation: operation)
return try trashAttachment(named: name, fromItemFolder: folder, operation: operation)
}
/// Refuses any folder that is not a place a user can author attachments into: a **posted
/// comment**, or the card's single **draft**.
///
/// `checkIsCommentFolder` widened by exactly one name, and widened here rather than there on
/// purpose: `editComment` and `deleteComment` must keep refusing `.draft` (the composer owns it,
/// and posting is its only way into the thread), while an attachment import has no such
/// asymmetry the draft is precisely where the composer's chips land. `comments/.trash/` stays
/// out of both, because a deleted comment is undo's and never a surface.
private static func checkIsAuthoringFolder(
_ folder: URL,
operation: WriteOperation
) throws(BoardWriteError) {
try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
let name = folder.lastPathComponent
guard folder.deletingLastPathComponent().lastPathComponent.lowercased()
== IntegrityRules.commentsFolderName,
IntegrityRules.isIdentityShaped(name)
|| name.lowercased() == IntegrityRules.commentDraftFolderName
else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a comment")
)
}
}
// MARK: - Shared mechanics
/// The frontmatter text for a comment the app is minting outright `newDocumentText`'s twin, and