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