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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user