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
169 lines
6.5 KiB
Swift
169 lines
6.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|