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