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