import AppKit import Observation import QuickLookThumbnailing import SwiftUI // MARK: - AttachmentThumbnailKey /// What a generated thumbnail is filed under: **which file, at which drawn size, as of which /// bytes** (03-board-ui.md § Card face — "QuickLook thumbnails for anything previewable"). /// /// The third component is what makes the cache honest rather than merely fast. A board is a live /// view over folders 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 — the one failure mode this /// app treats as a bug rather than a staleness. 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 width 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. The cache holds one `Slot → Key` entry per file it has ever resolved, so /// the lookup on screen stays two dictionary reads. struct Slot: Hashable, Sendable { let path: String let width: Int /// Widths **bucket to whole points**: a card face is laid out by a `Layout` and its content /// width can land on a fraction, and a thumbnail regenerated because a card grew by a third /// of a point would be a cache that never hits. init(path: String, width: CGFloat) { self.path = path self.width = max(1, Int(width.rounded())) } } let slot: Slot let modified: Date? let size: Int64? } // MARK: - AttachmentThumbnailCache /// The board window's thumbnail memory — **one per board window**, held by `BoardView` and read /// through the environment by the faces beneath it. /// /// ### Why the window and not the app, and not the card /// /// Per *card* would defeat the point: 03-board-ui.md § Card face expands the carousel on selection, /// so a user walking a lane with the arrow keys reselects cards constantly, and a cache that died /// with the view would regenerate every thumbnail on every visit. Per *app* would outlive the thing /// it is caching — a board window closing is the natural moment to forget its files, and a /// process-wide dictionary keyed by path would keep a closed board's images alive for the session. /// 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 /// ("freshness is the watcher's, by construction"). @MainActor @Observable final class AttachmentThumbnailCache { /// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a /// board can hold thousands of attachments and a decoded image is not small; a plain /// insertion-ordered drop rather than a recency policy because the access pattern this serves is /// "the card I just selected, and the one before it". static let limit = 96 /// 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 — a plain-text file, an unknown type, an unreadable one. Remembered /// so a non-previewable attachment costs one generation attempt per version of itself rather /// than one per reselection; the face 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 page 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 — "Finder icon fallback" (03-board-ui.md § Card face), 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 on the card 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 page'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, height: CGFloat, scale: CGFloat) async { let key = await Self.resolve(slot, url: url) keys[slot] = key guard images[key] == nil, !unpreviewable.contains(key) else { return } let size = CGSize(width: CGFloat(slot.width), height: height) guard let image = await Self.generate(url: url, size: size, 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 page'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 page 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 } }