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: 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 } }