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:
@@ -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 2–4 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?
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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