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
+168
View File
@@ -0,0 +1,168 @@
import AppKit
import SwiftUI
// MARK: - The chip row
/// A comment's or the draft's `attachments/`, as **chips** (05-card-window.md The comments column:
/// "attachment chips when its `attachments/` is non-empty (Quick Look, the sidebar section's
/// pattern)").
///
/// ### Chips, not rows and the same parts
///
/// The sidebar's inventory is a vertical list because it is a *complete* listing of a card's files in
/// a narrow column. A comment's files are a handful of things said in passing, so they wrap
/// horizontally under the text that mentions them. What does not change is the anatomy the small
/// QuickLook thumbnail with its Finder-icon fallback, the middle-truncated filename, Space/click to
/// Quick Look because that is what "the sidebar section's pattern" names, and a user who has
/// learned the sidebar has learned this.
///
/// ### The one difference that is a rule
///
/// > Chips on an authoring surface carry remove (to the **system** Trash the sidebar row's rule); a
/// > posted comment's chips are read-only, Quick Look only Edit the comment to change its files.
///
/// `onRemove` is that sentence: `nil` is a posted comment's chip and there is no remove affordance at
/// all not a disabled one, because the file is not un-removable, it is simply not removable *here*.
struct CommentAttachmentChips: View {
let names: [String]
/// Where each name lives the pane resolves it, since only it knows which authoring surface (or
/// which posted comment) these belong to.
let url: (String) -> URL?
let thumbnails: AttachmentThumbnailCache
/// `nil` on a posted comment's read-only chips; the remove write on an authoring surface's.
var onRemove: ((String) -> Void)?
@Environment(\.displayScale) private var displayScale
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
// Wrapping, because a chip row is horizontal and a comment can carry more files than fit:
// `Layout`-free wrapping through a flexible `WrappingHStack` would be a new layout to own, so
// this leans on SwiftUI's own a `LazyVGrid` with adaptive columns wraps and needs nothing.
LazyVGrid(
columns: [GridItem(
.adaptive(minimum: CardWindowMetrics.commentChipMinimumWidth(bodyPointSize: pointSize)),
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize),
alignment: .leading
)],
alignment: .leading,
spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
) {
ForEach(names, id: \.self) { name in
chip(name)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func chip(_ name: String) -> some View {
let fileURL = url(name)
let side = CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize)
let padding = CardWindowMetrics.previewPadding(bodyPointSize: pointSize)
HStack(spacing: padding) {
CommentChipThumbnail(
url: fileURL,
side: side,
thumbnails: thumbnails,
displayScale: displayScale
)
.frame(width: side, height: side)
Text(name)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
if let onRemove {
Button {
onRemove(name)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Remove")
.accessibilityLabel("Remove \(name)")
}
}
.padding(.horizontal, padding)
.padding(.vertical, padding / 2)
.background(.quaternary, in: Capsule(style: .continuous))
.contentShape(Capsule(style: .continuous))
// Quick Look on click, the chip being small enough that a select-then-Space dance would be
// ceremony over a thing you can already point at. The panel's / then walk this surface's
// files, exactly as Space over the sidebar walks the card's.
.onTapGesture {
quickLook(name)
}
.contextMenu {
Button("Open") {
guard let fileURL else { return }
NSWorkspace.shared.open(fileURL)
}
Button("Reveal in Finder") {
guard let fileURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
}
if let onRemove {
Divider()
Button("Remove") { onRemove(name) }
}
}
.help(name)
.accessibilityElement(children: .combine)
.accessibilityLabel(name)
}
private func quickLook(_ name: String) {
guard let index = names.firstIndex(of: name) else { return }
AttachmentQuickLook.shared.toggle(urls: names.compactMap(url), at: index)
}
}
// MARK: - One chip's thumbnail
/// The generated QuickLook thumbnail once there is one, the file's Finder icon until then and
/// forever, for anything QuickLook declines. `AttachmentRow`'s own fallback ladder, shared by being
/// written the same way rather than by being the same view: the sidebar's row is a row, this is a
/// chip, and only the picture is common.
private struct CommentChipThumbnail: View {
let url: URL?
let side: CGFloat
let thumbnails: AttachmentThumbnailCache
let displayScale: CGFloat
private var slot: AttachmentThumbnailKey.Slot? {
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
}
var body: some View {
content
.task(id: url?.path) {
guard let slot, let url else { return }
await thumbnails.load(slot, url: url, scale: displayScale)
}
}
@ViewBuilder
private var content: some View {
if let slot, let image = thumbnails.thumbnail(for: slot) {
Image(decorative: image, scale: displayScale)
.resizable()
.aspectRatio(contentMode: .fit)
} else if let url {
Image(nsImage: thumbnails.icon(forFileAt: url))
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "doc")
.foregroundStyle(.secondary)
}
}
}