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 = [] /// 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 } }