Build the attachments sidebar section

The card's complete file inventory: compact QuickLook-thumbnail rows
over Card.attachments — no reference tracking, subfolders tolerated
and unsurfaced — with a quiet header add affordance and the drop hint
empty state. The whole window is the file-drop surface, Edit mode
included (the editor's drag types were already filtered; now tested),
sharing the board's folder-refusal semantics literally: FinderDrop
moved verbatim into its own file so both windows run the same
partition and loss row. Dragged text still lands at the caret and is
inert elsewhere — the window delegate accepts file payloads only.
Rows open on double-click or Return, drag out their file URL, and
Remove is a bracketed write through FileManager.trashItem — the system
Trash, never a hard delete, returning the in-Trash URL so the promise
is testable; the attachment listing is the guard, so traversal and
subfolder names refuse in one line. Keyboard-native per 05: the
section is one Tab stop, arrows walk rows by name, Space toggles the
shared QuickLook panel, Backspace removes. File > Add Attachment
(shift-cmd-A) comes alive through the same import path.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 11:52:37 -04:00
parent 40c0a75c24
commit 46397c740e
16 changed files with 1823 additions and 214 deletions
+362
View File
@@ -0,0 +1,362 @@
import AppKit
import Quartz
import SwiftUI
import UniformTypeIdentifiers
// MARK: - The section header
/// A stacked small-caps header over a sidebar section (05-card-window.md The attributes sidebar:
/// "Stacked sections under small-caps headers"), with room for one quiet trailing affordance.
///
/// Shared by all five sections rather than restated in each: the attachments section is the only one
/// with an accessory today, and the header's type, weight and rule have to stay identical across the
/// stack or the accessory would be the reason one section looks different from the rest.
struct CardSidebarSectionHeader<Accessory: View>: View {
let title: String
@ViewBuilder var accessory: Accessory
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text(title)
.font(.caption.weight(.semibold))
.textCase(.uppercase)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
accessory
}
Divider()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
extension CardSidebarSectionHeader where Accessory == EmptyView {
init(title: String) {
self.init(title: title) { EmptyView() }
}
}
// MARK: - CardAttachmentsSection
/// The sidebar's **Attachments** section (05-card-window.md Attachments).
///
/// ### What it shows, and why it does not look
///
/// Every top-level file of `attachments/`, in Finder order, **including files also embedded in the
/// body** "the section is the card's complete file inventory, no reference-tracking magic; an
/// image appearing in both places is honest, not a bug". Subfolders are tolerated and not surfaced.
///
/// The list itself is `Card.attachments` off the snapshot, republished into `CardAttachments.names`
/// by the host. This view never lists a directory: the loader did that on the last reload, the board
/// face's chip reads the very same field, and a second listing here would be a surface able to
/// disagree with the first. Every write in this section is bracketed (`performWrite`), so the reload
/// that refreshes the list is app-mediated and arrives by itself.
///
/// ### Keyboard-native, which is what is new in the rewrite
///
/// "The section is focusable; arrows move between rows, **Space QuickLooks** the selected row,
/// Return opens it, removes it" the pathfinder's strip was pointer-only. The substrate is
/// SwiftUI's own focus system (`focusable` + `@FocusState` + `onKeyPress`) over one focusable
/// container, rather than an `NSTableView` or a row-per-focusable list: 05 says *the section* is
/// focusable, one focus stop is what a Tab user wants out of a five-section sidebar, and the rest of
/// this window is already SwiftUI. The board's keyboard grammar is a different window's and shares
/// nothing here.
struct CardAttachmentsSection: View {
let attachments: CardAttachments
let thumbnails: AttachmentThumbnailCache
/// Whether the section has the keyboard. Mirrored into `CardAttachments.isFocused` because File
/// Reveal in Finder's card-window scope turns on it.
@FocusState private var isFocused: Bool
@Environment(\.displayScale) private var displayScale
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
private var names: [String] { attachments.names }
var body: some View {
VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
CardSidebarSectionHeader(title: "Attachments") { addAffordance }
if names.isEmpty {
emptyHint
} else {
rows
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: isFocused, initial: true) { _, focused in
attachments.isFocused = focused
}
}
// MARK: - Header
/// The header's **quiet add affordance** "a pointer twin of File Add Attachment, no
/// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
/// does rather than opening a panel of its own.
///
/// Disabled under the read-only lock, where the menu row is disabled too: 02-architecture.md's
/// every-entry-point predicate does not care which entry point.
private var addAffordance: some View {
Button {
attachments.add()
} label: {
Image(systemName: "plus")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!AddAttachmentCommand.isEnabled(attachments))
.help("Add Attachment…")
.accessibilityLabel("Add Attachment")
}
// MARK: - Empty
/// "Empty, the section stays with a one-line hint (drop files, or File Add Attachment,
/// A)" the section never disappears, because the drop surface it advertises is the whole
/// window and a hint the user cannot find teaches nothing.
private var emptyHint: some View {
Text("Drop files anywhere, or File ▸ Add Attachment… (⇧⌘A)")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: - Rows
private var rows: some View {
VStack(alignment: .leading, spacing: 1) {
ForEach(names, id: \.self) { name in
row(name)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// One focus stop for the whole list 05's "the section is focusable".
.focusable()
.focused($isFocused)
.onKeyPress(.upArrow) { attachments.moveSelection(by: -1); return .handled }
.onKeyPress(.downArrow) { attachments.moveSelection(by: 1); return .handled }
.onKeyPress(.space) { quickLookSelected(); return .handled }
.onKeyPress(.return) { openSelected(); return .handled }
// , and only : 05 gives the section one destructive key and the board's own delete
// grammar is a different window's.
.onKeyPress(.delete) { removeSelected(); return .handled }
.accessibilityLabel("Attachments")
}
@ViewBuilder
private func row(_ name: String) -> some View {
let url = attachments.url(for: name)
let isSelected = attachments.selected == name
AttachmentRow(
name: name,
url: url,
isSelected: isSelected,
isSectionFocused: isFocused,
thumbnails: thumbnails,
pointSize: pointSize,
displayScale: displayScale
)
.contentShape(Rectangle())
// Double-click opens, a single click selects (05 Attachments; the window's click grammar,
// where clicking selects and never edits). The two-count gesture is declared first so
// SwiftUI gives it the chance to claim the second click.
.onTapGesture(count: 2) {
isFocused = true
attachments.selected = name
attachments.open(name)
}
.onTapGesture {
isFocused = true
attachments.selected = name
}
// **Rows drag out their file URL** (05 Attachments; 11-command-nexus.md Pointer-only
// affordances) which is what makes drag-to-Finder and drag-into-another-app work with no
// export path of this app's own. An empty provider for a row whose file has gone refuses the
// drag rather than starting one that would resolve to nothing.
.onDrag {
guard let url else { return NSItemProvider() }
return NSItemProvider(contentsOf: url) ?? NSItemProvider()
}
.contextMenu { menu(for: name) }
}
/// The attachment row's context menu **Open, Reveal in Finder, Remove** (11-command-nexus.md
/// Context menus), twins of the focused section's grammar keys (Return / ) and of File Reveal
/// in Finder in its attachments-focused context. No new store method, no parallel
/// implementation: every row here calls exactly what the keyboard calls.
///
/// It acts on **its own row**, not on the selection, which is what makes a right-click on an
/// unselected row unambiguous without a select-first dance.
@ViewBuilder
private func menu(for name: String) -> some View {
Button("Open") { attachments.open(name) }
Button("Reveal in Finder") { attachments.reveal(name) }
Divider()
Button("Remove") { attachments.remove(name) }
.disabled(!attachments.isEditable)
}
// MARK: - The grammar keys
private func openSelected() {
guard let selected = attachments.selected else { return }
attachments.open(selected)
}
private func removeSelected() {
guard let selected = attachments.selected else { return }
attachments.remove(selected)
}
/// **Space QuickLooks the selected row** (05 Attachments) Finder's own key, and Finder's own
/// panel: `QLPreviewPanel` previews every row of the section with the selected one showing, so
/// the panel's / walk the card's attachments exactly as it walks a Finder selection.
private func quickLookSelected() {
guard let selected = attachments.selected,
let index = names.firstIndex(of: selected)
else { return }
AttachmentQuickLook.shared.toggle(
urls: names.compactMap { attachments.url(for: $0) },
at: index
)
}
}
// MARK: - One row
/// A compact row: **small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename**
/// (05-card-window.md Attachments).
///
/// Middle truncation rather than tail, because a filename's tail is its extension and a sidebar
/// 26 characters wide would otherwise turn every screenshot into `"Screen Shot 2026-07-2"` the
/// one part of the name that says what the file *is* is the part that would go.
private struct AttachmentRow: View {
let name: String
let url: URL?
let isSelected: Bool
let isSectionFocused: Bool
let thumbnails: AttachmentThumbnailCache
let pointSize: CGFloat
let displayScale: CGFloat
private var side: CGFloat { CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize) }
private var padding: CGFloat { CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize) }
private var slot: AttachmentThumbnailKey.Slot? {
url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
}
var body: some View {
HStack(spacing: padding) {
thumbnail
.frame(width: side, height: side)
Text(name)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.padding(.horizontal, padding)
.padding(.vertical, padding / 2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(selectionFill, in: RoundedRectangle(cornerRadius: 4, style: .continuous))
.foregroundStyle(isSelected && isSectionFocused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
.help(name)
.accessibilityElement(children: .combine)
.accessibilityLabel(name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.task(id: url?.path) {
guard let slot, let url else { return }
await thumbnails.load(slot, url: url, scale: displayScale)
}
}
/// The selection fill follows **focus**, the standard macOS list treatment: the accent colour
/// while the section has the keyboard, a quiet grey when it does not, so a selected row never
/// claims to be the thing the arrow keys are about to move.
private var selectionFill: AnyShapeStyle {
guard isSelected else { return AnyShapeStyle(.clear) }
return isSectionFocused ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)
}
/// The generated thumbnail once there is one, the file's Finder icon until then and forever,
/// for anything QuickLook declines (05: "small QuickLook thumbnail (Finder-icon fallback)").
@ViewBuilder
private var thumbnail: 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)
}
}
}
// MARK: - QuickLook
/// The Space key's panel `QLPreviewPanel`, the system's own, shared across every card window.
///
/// **One shared presenter, because there is one shared panel**: `QLPreviewPanel.shared()` is a
/// process-wide singleton, so a per-window data source would be a set of objects racing to be the
/// one it points at. Space in a second card window simply re-points the panel at that window's
/// files, which is also what Finder does across two windows.
///
/// The panel is driven by setting its data source directly rather than through the responder
/// chain's `acceptsPreviewPanelControl(_:)` dance: this app's key view at that moment is a SwiftUI
/// focusable, which is not an `NSResponder` we own, and the direct route is the one that does not
/// depend on where SwiftUI happens to put its hosting views.
@MainActor
final class AttachmentQuickLook: NSObject, QLPreviewPanelDataSource {
static let shared = AttachmentQuickLook()
private var items: [URL] = []
/// Space **toggles**, Finder's own behaviour: pressing it again on the row already showing puts
/// the panel away rather than re-opening it.
func toggle(urls: [URL], at index: Int) {
guard let panel = QLPreviewPanel.shared() else { return }
guard !urls.isEmpty, urls.indices.contains(index) else { return }
if panel.isVisible, items == urls, panel.currentPreviewItemIndex == index {
panel.orderOut(nil)
return
}
items = urls
panel.dataSource = self
panel.reloadData()
panel.currentPreviewItemIndex = index
panel.makeKeyAndOrderFront(nil)
}
nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
MainActor.assumeIsolated { items.count }
}
/// The bridge to `NSURL` happens **outside** the isolation hop on purpose: `any QLPreviewItem`
/// is not `Sendable`, so it may not be the thing `assumeIsolated` returns; `URL` is, so the
/// value that crosses is the plain one and the Objective-C cast is done here.
nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
let url: URL? = MainActor.assumeIsolated {
items.indices.contains(index) ? items[index] : nil
}
guard let url else { return nil }
return url as NSURL
}
}