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?
+62
View File
@@ -52,6 +52,37 @@ public struct Comment: Identifiable, Sendable, Equatable {
}
}
// MARK: - CommentDraft
/// The card's single draft, as the composer needs it: the text to restore, and the chips to draw.
///
/// **Two fields and no identity**, which is the difference between a draft and a comment stated as a
/// type: `comments/.draft/` is a claimed name the composer edits in place, and it becomes a `Comment`
/// only at the post, where the rename mints the id (`BoardWriter.postComment`).
public struct CommentDraft: Sendable, Equatable {
/// `index.md`'s body what the composer shows when the window opens.
public let body: String
/// The draft's `attachments/`, through the same enumeration a comment's listing uses, so the
/// composer's chips and a posted comment's chips can never disagree about what a folder holds.
public let attachments: [String]
public init(body: String, attachments: [String]) {
self.body = body
self.attachments = attachments
}
/// Whether a save of `body` would delete the folder **the emptied-draft rule, asked before the
/// write** (01-storage-format.md § Enhanced schema; `BoardWriter.saveCommentDraft`'s own gate).
///
/// It is the composer's Post validation read backwards: there is nothing to post exactly when
/// there would be nothing to keep.
public var isEmpty: Bool {
body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty
}
}
// MARK: - CommentThread
/// A card's comment thread, read from disk **window-scoped, outside the board snapshot**
@@ -265,6 +296,37 @@ public struct CommentThread: Sendable, Equatable {
)
}
/// **The composer's own file, read** `comments/.draft/`, which the thread listing deliberately
/// excludes (see the type's note) and which the composer therefore has to ask for by name.
///
/// It answers a pair rather than a `Comment` because a draft has **no identity**: it is a claimed
/// *name*, not a UUID (`CommentPath.Kind.draft` "the one member of the thread that is a name
/// rather than an id"), and minting an `ItemID` for it here would put a lie in the one type whose
/// whole job is that a comment's folder name *is* its id.
///
/// Total, like `load(inCard:path:)`: a card with no draft, an unreadable one, or a `.draft` held
/// by a file answers `nil` which is what "nothing to restore" looks like and is not a defect
/// this read invents (the squatted-name case is reported by the thread read beside it).
///
/// **This is the whole of restore-on-reopen**: the composer's backing file is the draft, so
/// reading it at open is all the mechanism there is (05-card-window.md The comments column).
public static func loadDraft(inCard cardFolder: URL) -> CommentDraft? {
let folder = draftFolder(inCard: cardFolder)
guard IntegrityRules.node(at: folder) == .directory else { return nil }
let indexURL = folder.appendingPathComponent(IntegrityRules.indexFileName)
let attachments = BoardLoader.attachmentNames(in: folder)
// A folder with no readable `index.md` is a draft that exists the two-step-create shape, and
// the shape a drop-before-a-keystroke leaves. Its body is empty, its chips are real, and
// reporting `nil` would hide files the user can see in Finder.
guard let data = try? Data(contentsOf: indexURL),
let document = try? BoardLoader.parseDocument(data, path: IntegrityRules.commentDraftFolderName)
else {
return CommentDraft(body: "", attachments: attachments)
}
return CommentDraft(body: document.body, attachments: attachments)
}
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
/// ruled 2026-07-29): "the thread sorts by `created` ascending ties and missing/malformed
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
+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