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
+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".