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
181 lines
8.6 KiB
Swift
181 lines
8.6 KiB
Swift
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
|
|
}
|
|
}
|