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
+41 -4
View File
@@ -1852,13 +1852,35 @@ public enum BoardWriter: Sendable {
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsUUIDShaped(cardFolder, operation: batchOperation)
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
return try importFiles(sourceURLs, intoAttachmentsOf: cardFolder, batchOperation: batchOperation)
}
/// **Steps 24 of `importAttachments`, with the shape guard left to the caller** the copy
/// itself, which is identical wherever an `attachments/` folder hangs.
///
/// It exists because a *comment* has an `attachments/` too (01-storage-format.md § Enhanced
/// schema "a card's anatomy one level down"), and comment attachments author in-window
/// (05-card-window.md The comments column, ruled 2026-07-29). The alternative was a second
/// importer beside this one, which is exactly what `CardAttachments`' own note forbids for the
/// card level: "one import path, one set of banners, one Finder-style collision rename". The
/// generalization is therefore the *smallest* one that keeps that true the folder shape is what
/// differs between a card and a comment, and it is the only thing the callers still decide.
///
/// Everything the batch promises is here rather than at either entry point: `attachments/` minted
/// on first import, each source validated before it is touched, the Finder-style collision-free
/// name, the non-atomic copy with best-effort cleanup, and the import receipt.
static func importFiles(
_ sourceURLs: [URL],
intoAttachmentsOf itemFolder: URL,
batchOperation: WriteOperation
) throws(BoardWriteError) -> [ImportedAttachment] {
let attachmentsFolder = itemFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: batchOperation,
path: cardFolder.path,
path: itemFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
}
@@ -2325,9 +2347,24 @@ public enum BoardWriter: Sendable {
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsUUIDShaped(cardFolder, operation: operation)
guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil }
return try trashAttachment(named: name, fromItemFolder: cardFolder, operation: operation)
}
let fileURL = cardFolder
/// `removeAttachment`'s body with the shape guard left to the caller `importFiles`' split, for
/// its reason: a comment's authoring chips carry Remove too, "to the **system** Trash the
/// sidebar row's rule" (05-card-window.md The comments column), and one Trash-not-delete
/// promise is worth more than two implementations of it.
///
/// The listing check, the never-hard-delete guarantee and the a-name-that-is-gone-is-not-a-failure
/// rule all live here, so they hold at both levels without either caller restating them.
static func trashAttachment(
named name: String,
fromItemFolder itemFolder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> URL? {
guard BoardLoader.attachmentNames(in: itemFolder).contains(name) else { return nil }
let fileURL = itemFolder
.appendingPathComponent(attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
var trashedURL: NSURL?