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
+180
View File
@@ -0,0 +1,180 @@
import AppKit
import Observation
import QuickLookThumbnailing
// MARK: - AttachmentThumbnailKey
/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which
/// bytes** (05-card-window.md Attachments "small QuickLook thumbnail (Finder-icon fallback)").
///
/// The third component is what makes the cache honest rather than merely fast. A card folder is a
/// live view over a directory anyone may write to (02-architecture.md), so a cache keyed on the path
/// alone would happily show a picture of a file that has since been replaced. Modification date
/// *and* size, because either alone misses a case: a same-second rewrite keeps the date, and an edit
/// that preserves the length keeps the size.
///
/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd it went
/// away between the listing and the render, a volume dropped keys as `(nil, nil)` and simply
/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than
/// a second error surface.
struct AttachmentThumbnailKey: Hashable, Sendable {
/// The half a *view* can name without touching the disk the path and the size it is drawn at.
///
/// The cache is looked up by this during a render and completed by the full key in a task,
/// which is the whole reason it is a type: a render must never `stat`, and a stale-check must
/// never be skipped.
struct Slot: Hashable, Sendable {
let path: String
let side: Int
/// The row's thumbnail is square and **buckets to whole points**: the row height derives
/// from the body font's metrics (`CardWindowMetrics`), which can land on a fraction, and a
/// thumbnail regenerated because the row grew by a third of a point would be a cache that
/// never hits.
init(path: String, side: CGFloat) {
self.path = path
self.side = max(1, Int(side.rounded()))
}
}
let slot: Slot
let modified: Date?
let size: Int64?
}
// MARK: - AttachmentThumbnailCache
/// One card window's thumbnail memory **per window**, held by `CardWindowHost` and read by the
/// attachments section beneath it.
///
/// ### Why the window
///
/// Per *row* would defeat the point: rows are rebuilt on every snapshot the store applies (a body
/// edit in another window, a watcher reload, a lane move), and a cache that died with the view would
/// regenerate every thumbnail each time. Per *app* would outlive the thing it is caching a card
/// window closing is the natural moment to forget its files. The window is the scope where "the
/// files I am looking at" is true.
///
/// ### What it does not do
///
/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a
/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is
/// deliberately no invalidation path to keep honest the same posture `Card.attachments` takes.
///
/// This is the **minimal** re-creation of a cache the board face once had and that went with the
/// carousel (commit `1020d9f`, sole consumer): small square rows instead of page-sized pictures, and
/// nothing about paging, so the cap and the drawn size are both an order smaller.
@MainActor
@Observable
final class AttachmentThumbnailCache {
/// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a
/// card may hold hundreds of attachments; a plain insertion-ordered drop rather than a recency
/// policy because a sidebar's access pattern is "the rows on screen", which is the recent set.
static let limit = 128
/// The resolved stamp for each drawn slot the render-time half of the lookup.
private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:]
private var images: [AttachmentThumbnailKey: CGImage] = [:]
/// Insertion order over `images`, for the cap.
private var order: [AttachmentThumbnailKey] = []
/// Keys QuickLook declined an unknown type, an unreadable one. Remembered so a
/// non-previewable attachment costs one generation attempt per version of itself rather than one
/// per redraw; the row shows its Finder icon and stops asking.
private var unpreviewable: Set<AttachmentThumbnailKey> = []
/// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one
/// is filled *during* a render, because an icon is the fallback a row draws while its thumbnail
/// is still being made, and a tracked write there would invalidate the view that just read it.
/// Nothing depends on an icon changing `NSWorkspace` answers the same image for the same path
/// until the app is relaunched.
@ObservationIgnored private var icons: [String: NSImage] = [:]
// MARK: - Reading, during a render
/// This slot's thumbnail, or `nil` when there is not one *yet* the two dictionary reads a
/// render is allowed to do. A miss is not a failure; it is the icon fallback's cue.
func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? {
guard let key = keys[slot] else { return nil }
return images[key]
}
/// The file's Finder icon 05's "Finder-icon fallback", shown while a thumbnail is being
/// generated and kept for anything QuickLook cannot preview.
///
/// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder
/// itself shows, custom icons and application bundles included, and an attachment that looks
/// different in Finder than in the sidebar would be the app disagreeing with the substrate it is
/// a view over.
func icon(forFileAt url: URL) -> NSImage {
if let cached = icons[url.path] { return cached }
let icon = NSWorkspace.shared.icon(forFile: url.path)
icons[url.path] = icon
return icon
}
// MARK: - Filling, off the render
/// Resolves this slot's key and generates its thumbnail if that key has none the whole of the
/// cache's write side, called from a row's `.task` and never from a body.
///
/// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is
/// waiting on this task, and the generation because it is a round trip to a QuickLook extension
/// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the
/// generator hands out is not, and never leaves the function that made it.
func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, scale: CGFloat) async {
let key = await Self.resolve(slot, url: url)
keys[slot] = key
guard images[key] == nil, !unpreviewable.contains(key) else { return }
let side = CGFloat(slot.side)
guard let image = await Self.generate(url: url, size: CGSize(width: side, height: side), scale: scale) else {
unpreviewable.insert(key)
return
}
remember(image, for: key)
}
private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) {
if images.updateValue(image, forKey: key) == nil {
order.append(key)
}
while order.count > Self.limit {
images.removeValue(forKey: order.removeFirst())
}
}
private nonisolated static func resolve(
_ slot: AttachmentThumbnailKey.Slot,
url: URL
) async -> AttachmentThumbnailKey {
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
return AttachmentThumbnailKey(
slot: slot,
modified: values?.contentModificationDate,
size: values?.fileSize.map(Int64.init)
)
}
/// One QuickLook request, at the row's own size. `.thumbnail` alone rather than `.all`: the icon
/// representations QuickLook would fall back to are exactly what `icon(forFileAt:)` already
/// draws, and taking them here would cache a decorated icon under a thumbnail's key and never
/// try for a real one again. `iconMode` is off for the same reason the row wants the picture,
/// not a page-curled document.
private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? {
let request = QLThumbnailGenerator.Request(
fileAt: url,
size: size,
scale: scale,
representationTypes: .thumbnail
)
request.iconMode = false
guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
else { return nil }
return representation.cgImage
}
}
+241
View File
@@ -0,0 +1,241 @@
import AppKit
import Observation
import SwiftUI
// MARK: - The window's attachments, as a handle
/// One card window's attachments section, reduced to what things *outside* it need: which files
/// there are, which row the keyboard is on, and the two writes the section can start
/// (05-card-window.md Attachments).
///
/// `CardBodyPresentation`'s shape and for its reason one per window, `@State` in the host,
/// published through the focus system so **menu items** (File Add Attachment, A; File Reveal
/// in Finder's third scope) can reach the frontmost card window without anyone keeping a
/// which-window-is-key register. It is deliberately not on `BoardStore`: the store is the *board's*,
/// shared by every window on it, and two card windows open on two cards of one board have two
/// different selections.
///
/// ### What it is not
///
/// It is **not** the listing's source of truth. `names` is republished from every snapshot the store
/// applies (`Card.attachments`, which the loader fills from `attachments/`'s top-level files in
/// Finder order), so the section shows what the last reload found and nothing else the one-way
/// flow, with no second listing able to disagree with the board face's chip. Nothing here reads a
/// directory; nothing here writes a file. Both writes go out through the seams below, which the host
/// fills with the store's own bracketed methods.
@MainActor
@Observable
public final class CardAttachments {
/// The card's own folder `<root>/<lane>/<card>`. `nil` until the window has joined its board,
/// which is also exactly while there is nothing to add an attachment *to*.
public var cardFolder: URL?
/// The files the section shows, in Finder order `Card.attachments`, straight from the
/// snapshot. **Every top-level file of `attachments/`**, body-embedded ones included: "the
/// section is the card's complete file inventory, no reference-tracking magic" (05).
public var names: [String] = [] {
didSet { selected = Self.settle(selected, was: oldValue, is: names) }
}
/// Whether the section's mutations are offered at all `!store.isReadOnly`. Under the lock the
/// add affordance, Remove and disable in place, which is 02-architecture.md's every-entry-point
/// predicate applied to this section (05 names it: "the attachment row's /Remove shares the
/// posture").
public var isEditable = false
/// Which row the keyboard is on, by name names are unique within one folder, so a name is a
/// stabler identity than an index across a reload that inserted a file above it.
public var selected: String?
/// Whether the section currently holds keyboard focus. Read by File Reveal in Finder, whose
/// card-window scope is "the card's folder the selected attachment's file instead when the
/// attachments section is focused" (11-command-nexus.md).
public var isFocused = false
/// Imports files into this window's card filled by the host with `BoardStore
/// .importAttachments(_:toCard:)`, the **same** store method the board window's Finder drop
/// rides. One import path, one set of banners, one Finder-style collision rename.
public var importFiles: (([URL]) -> Void)?
/// Moves one attachment to the system Trash filled by the host with `BoardStore
/// .removeAttachment(named:fromCard:)`.
public var removeFile: ((String) -> Void)?
public init() {}
// MARK: - Derived
/// Where `name` lives on disk, or `nil` when this window has no folder yet.
public func url(for name: String) -> URL? {
guard let cardFolder, names.contains(name) else { return nil }
return cardFolder
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.appendingPathComponent(name)
}
/// The selected row's file, when there is one.
public var selectedURL: URL? {
selected.flatMap { url(for: $0) }
}
// MARK: - The two writes
/// File Add Attachment (A) and the header's quiet add affordance **one act with two
/// pointers at it** (11-command-nexus.md: the affordance "is a pointer twin of File Add
/// Attachment, no separate behavior").
///
/// A cancelled panel imports nothing and says nothing; a panel that returns files hands them to
/// the very same store method a whole-window drop uses.
public func add() {
guard isEditable, cardFolder != nil else { return }
let urls = Self.chooseFiles()
guard !urls.isEmpty else { return }
// The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `start` answers false for a
// URL that carries no scope of its own, so only the ones that opened are closed again.
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
importFiles?(urls)
}
/// Remove / the system Trash, never a hard delete (05 Attachments).
public func remove(_ name: String) {
guard isEditable, names.contains(name) else { return }
removeFile?(name)
}
// MARK: - Row actions that are not writes
/// Double-click, Return, and the context menu's Open: the file's default app (05 Attachments).
/// Enabled under the read-only lock like every other read opening a file mutates nothing here.
public func open(_ name: String) {
guard let url = url(for: name) else { return }
NSWorkspace.shared.open(url)
}
public func reveal(_ name: String) {
guard let url = url(for: name) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
// MARK: - Keyboard
/// / over the rows. Moving with nothing selected selects the first row (going down) or the
/// last (going up), which is what makes the section usable the instant it takes focus.
public func moveSelection(by delta: Int) {
selected = Self.moved(selected, by: delta, in: names)
}
// MARK: - The pure rules
/// Where / lands: **clamped, never wrapping** a list is not a carousel, and an arrow at the
/// end of a short list should not silently jump to the other end of it.
///
/// A selection that is not in `names` (a file removed under the cursor) is treated as no
/// selection, so the next arrow re-enters the list from its edge.
public nonisolated static func moved(_ selected: String?, by delta: Int, in names: [String]) -> String? {
guard !names.isEmpty else { return nil }
guard let selected, let index = names.firstIndex(of: selected) else {
return delta < 0 ? names.last : names.first
}
return names[min(max(0, index + delta), names.count - 1)]
}
/// Where the selection goes when the listing changes underneath it **the row that took its
/// place**, which is the behaviour every list in macOS has after a delete: remove the third of
/// five files and the selection lands on the new third, not on nothing and not on the top.
///
/// A selection that survived the change keeps its row (the common case: another window's import
/// added a file elsewhere). An empty listing selects nothing. A selection that was never set
/// stays unset a reload must not select a row the user did not.
public nonisolated static func settle(_ selected: String?, was previous: [String], is names: [String]) -> String? {
guard let selected else { return nil }
if names.contains(selected) { return selected }
guard !names.isEmpty, let index = previous.firstIndex(of: selected) else { return nil }
return names[min(index, names.count - 1)]
}
/// What File Reveal in Finder reveals in a card window: **the selected attachment's file when
/// the attachments section is focused, the card's folder otherwise** (11-command-nexus.md).
///
/// `[]` which is the row's `disabled` condition only when there is no card folder at all: a
/// window on its way out. A focused section with nothing selected still reveals the card, which
/// is the honest fallback rather than a row that goes dead when the user tabs into a list.
public nonisolated static func revealURLs(
cardFolder: URL?,
selectedURL: URL?,
isSectionFocused: Bool
) -> [URL] {
if isSectionFocused, let selectedURL { return [selectedURL] }
guard let cardFolder else { return [] }
return [cardFolder]
}
// MARK: - The panel
/// The multi-select open panel behind Add Attachment **every file type**, because a card's
/// `attachments/` takes anything (01-storage-format.md § Attachments) and a filter here would be
/// this app deciding what the user may keep beside their card.
///
/// Directories are not choosable, which is the panel's own spelling of the same refusal a
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
private static func chooseFiles() -> [URL] {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.resolvesAliases = true
panel.prompt = "Add"
panel.message = "Choose files to attach to this card."
guard panel.runModal() == .OK else { return [] }
return panel.urls
}
}
// MARK: - File Add Attachment
/// File Add Attachment (A) card window only (11-command-nexus.md; 05-card-window.md
/// Attachments).
///
/// The diff `FutureCommands` predicted, exactly: the title and the chord did not move, the
/// validation and the action filled in.
///
/// Validation is **scope plus the lock**. With no card window in front there is no `cardAttachments`
/// focused value and the row disables; with the board read-only it disables too, because this is a
/// mutation and 02-architecture.md's every-entry-point predicate covers menu rows as much as
/// affordances. (Contrast Edit Body, which is not a mutation and stays live under the lock.)
struct AddAttachmentCommand: View {
@FocusedValue(\.cardAttachments) private var attachments
/// The row's validation, as a value a test can hold `EditBodyCommand.isEnabled`'s shape, for
/// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
guard let attachments else { return false }
return attachments.isEditable && attachments.cardFolder != nil
}
var body: some View {
Button("Add Attachment…") {
attachments?.add()
}
.keyboardShortcut("a", modifiers: [.shift, .command])
.disabled(!Self.isEnabled(attachments))
}
}
// MARK: - The focused value
/// The focused card window's attachments section, beside `FocusedValues.cardBody` see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardAttachmentsKey: FocusedValueKey {
typealias Value = CardAttachments
}
extension FocusedValues {
var cardAttachments: CardAttachments? {
get { self[FocusedCardAttachmentsKey.self] }
set { self[FocusedCardAttachmentsKey.self] = newValue }
}
}
+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
}
}
+130
View File
@@ -0,0 +1,130 @@
import SwiftUI
import UniformTypeIdentifiers
// MARK: - The payload split
/// **Drop precedence is split by payload** (05-card-window.md Attachments, settled): "file drops
/// import as attachments anywhere in the window Edit mode included, the text editor never
/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret within its bounds as
/// ordinary insertion, and is inert elsewhere in the window."
///
/// ### The split is enforced from both ends, and neither end knows about the other
///
/// - **The editor declines files.** `CardBodyTextView.acceptableDragTypes` filters `public.file-url`
/// (and the Carbon-era `NSFilenamesPboardType` AppKit still puts beside it) out of what the text
/// view registers for, so AppKit's drag hit-test walks *past* the text view to the window-level
/// drop target above it. That is the whole of "the text editor never intercepts a file drop" a
/// deregistration, not a handler that re-dispatches.
/// - **The window declines text.** The delegate below is attached with `.onDrop(of: [.fileURL], )`
/// and its `validateDrop` asks the predicate here, so a text drag never matches it at all and
/// AppKit offers the drag to the text view instead which takes it as an ordinary insertion at
/// the caret, `NSTextView`'s own behaviour, untouched. Outside the editor's bounds nothing accepts
/// it, which is 05's "inert everywhere else".
///
/// Two deregistrations meeting in the middle, rather than one arbiter deciding: there is no third
/// state where both accept, and no ordering between them to get wrong.
enum CardWindowDrop {
/// Whether a drag's declared types make it a **file** payload the window's as opposed to
/// text, which is the editor's.
///
/// Conformance to `public.file-url`, not equality: a Finder drag registers the concrete type
/// (`public.png`) beside the file URL, and a synthetic drag may register a subtype of it. A URL
/// dragged out of a browser is `public.url`, which does *not* conform to `public.file-url` so
/// it stays the editor's, exactly as the rule says a dragged link should.
nonisolated static func isFilePayload(typeIdentifiers: [String]) -> Bool {
typeIdentifiers.contains { identifier in
UTType(identifier)?.conforms(to: .fileURL) ?? false
}
}
/// Whether the window will import this drag: **at least one payload that is a file and not a
/// directory.**
///
/// The folder half is `FinderDrop.isDirectory(typeIdentifiers:)` the board's own hover read,
/// called rather than re-derived, so "a package is a directory" cannot come to mean two things
/// in one app. A folders-only drag answers `false` here and is therefore an incompatible payload
/// at the cursor: no highlight, a refusal cursor, and nothing written. A mixed drag answers
/// `true`, imports its files, and names the folders it skipped in a loss row (`FinderDrop.land`)
/// the board-side refusal semantics, applied here because the importer they protect is the
/// same one.
nonisolated static func accepts(payloads: [[String]]) -> Bool {
payloads.contains { types in
isFilePayload(typeIdentifiers: types) && !FinderDrop.isDirectory(typeIdentifiers: types)
}
}
}
// MARK: - The window-wide drop delegate
/// **The drop surface is the whole window** (05-card-window.md Attachments) one delegate over
/// the card window's entire content area, body column and sidebar alike, Edit mode and the
/// raw-source outlet included.
///
/// It is attached at the top of `CardWindowView`'s body rather than to the attachments section,
/// which is what the design asks for and what makes the rule cheap: there is exactly one drop region
/// in this window, so there is no single-target-dispatch problem to solve here at all (contrast the
/// board, where lane, card and strip regions overlap `BoardDrops`). The text view is *inside* this
/// region and simply does not accept the file types, so the drag falls through to it.
///
/// The write is `BoardStore.importAttachments(_:toCard:)`, reached through `FinderDrop.land` the
/// same store method, the same Finder-style collision rename, the same banners as the board window's
/// drop onto a card face. Nothing about importing an attachment is re-implemented here; only *which
/// card* is decided differently, and in a card window that is not a decision at all.
struct CardWindowDropDelegate: DropDelegate {
let store: BoardStore
let cardID: ItemID
/// **The mutating-gesture rule, applied to the one gesture that arrives from outside the app**
/// (`BoardDropContext.acceptsFileDrops`): under the read-only lock a file drop refuses at the
/// window, with no proposal the board's own posture, and the same predicate. The board's
/// second clause (an inline title editor focused) has no card-window counterpart: this window's
/// editors are the body and the raw-source outlet, and 05 puts a file drop *through* both of
/// them on purpose.
private var acceptsFileDrops: Bool {
!store.isReadOnly
}
func validateDrop(info: DropInfo) -> Bool {
guard acceptsFileDrops else { return false }
return CardWindowDrop.accepts(
payloads: info.itemProviders(for: [.fileURL]).map(\.registeredTypeIdentifiers)
)
}
/// Always `.copy` for a payload we will take importing a file leaves the original where it
/// was, which is what the badge should say and `.cancel` for one we will not.
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: validateDrop(info: info) ? .copy : .cancel)
}
/// Commits the drop. `BoardDropContext.commitFileDrop`'s shape, minus the board's hover state:
/// a provider's file URL loads asynchronously (it is never synchronous for a Finder drag), the
/// store call happens back on the main actor, and the resolved URLs not the declared types
/// are the authority on what is a folder.
func performDrop(info: DropInfo) -> Bool {
guard acceptsFileDrops else { return false }
let providers = info.itemProviders(for: [.fileURL])
guard !providers.isEmpty else { return false }
let store = store
let cardID = cardID
Task { @MainActor in
var urls: [URL] = []
for provider in providers {
if let url = await FileDropLoading.url(from: provider) { urls.append(url) }
}
guard !urls.isEmpty else { return }
// The sandbox's half: a Finder drag hands the app an extension for what it dropped, and
// the copy is the read that needs it. `start` answers false for a URL that carries no
// scope of its own, so only the ones that opened are closed again.
let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
FinderDrop.land(urls, landing: .attach(cardID: cardID), into: store)
}
return true
}
}
+21
View File
@@ -79,6 +79,27 @@ enum CardWindowMetrics {
columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize)
}
// MARK: - The attachments section
/// An attachment row's thumbnail: a **small** square, one and a half ems on a side
/// (05-card-window.md Attachments: "Compact rows: small QuickLook thumbnail (Finder-icon
/// fallback) + middle-truncated filename, one row per file").
///
/// Derived rather than a point size, like everything else here, and deliberately *small*: this
/// is an inventory, not a gallery "viewing media is the card window's job" was settled about
/// the window, not about this list, and a row tall enough to see a screenshot in would push the
/// four sections beneath it off the sidebar.
static func attachmentThumbnailSide(bodyPointSize: CGFloat) -> CGFloat {
(bodyPointSize * 1.5).rounded()
}
/// The inset inside an attachment row, and the gap between its thumbnail and its filename
/// half a gutter, the rendered body's rhythm, so the sidebar's list and the body's blocks are
/// spaced by the same unit.
static func attachmentRowPadding(bodyPointSize: CGFloat) -> CGFloat {
previewPadding(bodyPointSize: bodyPointSize)
}
// MARK: - The rendered body
/// One step of structural indent in Preview a list level, a quote level. One and a half ems,
+48 -12
View File
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
// MARK: - CardWindowView
@@ -52,6 +53,14 @@ struct CardWindowView: View {
let rawSource: CardRawSourceSession
/// Whether a checkbox may write `false` under the board's read-only lock.
let isEditable: Bool
/// This window's attachments section: the listing, the selection, and the two writes it starts
/// (05 Attachments).
let attachments: CardAttachments
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
/// window**"). `nil` only where a caller has no store to import through.
let fileDrop: CardWindowDropDelegate?
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
let onToggleTask: (Int, Bool) -> Void
@@ -74,6 +83,18 @@ struct CardWindowView: View {
/// does survive is the buffer, which is the Edit session's, not the view's and it was flushed
/// to disk on the way in regardless.
var body: some View {
content
// **The drop surface is the whole window** (05 Attachments), which is why it hangs
// here outside the raw-source swap, so a file dropped while the source outlet is open
// still imports rather than on the attachments section it fills. The body editor lets
// file drags through to this by declining the file types
// (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and
// so is never offered here at all, which is the other half of the payload split.
.modifier(WindowFileDrop(delegate: fileDrop))
}
@ViewBuilder
private var content: some View {
if rawSource.isActive {
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
@@ -183,9 +204,7 @@ struct CardWindowView: View {
private var sidebar: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
// m6-card-attachments: every top-level file of `attachments/`, compact rows with a
// QuickLook thumbnail, keyboard-navigable.
section("Attachments")
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
// m6-card-sidebar: the embedded style editor the same component the board popover
// and Style already host (`StyleEditor`).
section("Style")
@@ -204,16 +223,33 @@ struct CardWindowView: View {
}
/// A stacked small-caps header over the space its section will occupy (05: "Stacked sections
/// under small-caps headers").
/// under small-caps headers") the same header the Attachments section fills in for real, so
/// the four still-empty ones cannot drift from it.
private func section(_ title: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption.weight(.semibold))
.textCase(.uppercase)
.foregroundStyle(.secondary)
Divider()
CardSidebarSectionHeader(title: title)
.accessibilityElement(children: .combine)
}
}
// MARK: - The window-wide drop
/// Attaches the whole-window file drop, or nothing at all.
///
/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same*
/// view identity in both cases: a window whose store arrives a turn after its view would otherwise
/// re-mount its entire content when the drop target appeared, throwing away the body's scroll
/// position for nothing.
private struct WindowFileDrop: ViewModifier {
let delegate: CardWindowDropDelegate?
func body(content: Content) -> some View {
if let delegate {
// `.fileURL` alone: a text drag never matches, so it is never offered here and falls to
// the Edit editor, where `NSTextView` inserts it at the caret (05 Attachments).
content.onDrop(of: [.fileURL], delegate: delegate)
} else {
content
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}