diff --git a/scripts/test-icon-runtime-file-icon-cache.mjs b/scripts/test-icon-runtime-file-icon-cache.mjs new file mode 100644 index 00000000..1402be42 --- /dev/null +++ b/scripts/test-icon-runtime-file-icon-cache.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-render.tsx'); +let importNonce = 0; + +const REACT_EFFECT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: (effect) => { + effect(); + }, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const REACT_DOM_SERVER_STUB = ` +export function renderToStaticMarkup() { + return ""; +} +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const PHOSPHOR_STUB = ` +module.exports = {}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function installBrowserGlobals(electron) { + const document = { + documentElement: { + classList: { + contains: () => false, + }, + }, + body: { + appendChild: () => {}, + removeChild: () => {}, + }, + createElement: () => ({ + style: {}, + setAttribute: () => {}, + appendChild: () => {}, + remove: () => {}, + }), + }; + + globalThis.document = document; + globalThis.window = { + document, + electron, + matchMedia: () => ({ matches: true }), + getComputedStyle: () => ({ + color: 'rgb(255, 255, 255)', + getPropertyValue: () => '', + }), + }; +} + +function exposeIconRuntimeRenderHooksPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-runtime-render-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-render\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimeRender = { + FILE_ICON_CACHE_MAX_ENTRIES, + FileIcon, + fileIconCache, + inFlightFileIconRequests, + loadFileIconDataUrl, + readCachedFileIcon, + renderIcon, + clearFileIconCaches: () => { + fileIconCache.clear(); + inFlightFileIconRequests.clear(); + }, + cacheStats: () => ({ + cacheSize: fileIconCache.size, + inFlightSize: inFlightFileIconRequests.size, + keys: Array.from(fileIconCache.keys()), + limit: FILE_ICON_CACHE_MAX_ENTRIES, + }), +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function iconRuntimeStubPlugin() { + const stubs = new Map([ + ['@phosphor-icons/react', PHOSPHOR_STUB], + ['react', REACT_EFFECT_STUB], + ['react-dom/server', REACT_DOM_SERVER_STUB], + ['react/jsx-dev-runtime', JSX_RUNTIME_STUB], + ['react/jsx-runtime', JSX_RUNTIME_STUB], + ]); + + return { + name: 'icon-runtime-file-cache-stubs', + setup(buildApi) { + buildApi.onResolve({ filter: /.*/ }, (args) => { + if (!stubs.has(args.path)) return null; + return { path: args.path, namespace: 'icon-runtime-file-cache-stub' }; + }); + + buildApi.onLoad({ filter: /.*/, namespace: 'icon-runtime-file-cache-stub' }, (args) => ({ + contents: stubs.get(args.path) || '', + loader: 'js', + })); + }, + }; +} + +async function importIconRuntimeHarness(electron = {}) { + installBrowserGlobals(electron); + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + logLevel: 'silent', + banner: { + js: 'var window = globalThis.window; var document = globalThis.document;', + }, + plugins: [iconRuntimeStubPlugin(), exposeIconRuntimeRenderHooksPlugin()], + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#icon-runtime-file-cache-${importNonce++}`, + ].join(''); + const module = await import(dataUrl); + const runtime = module.__testIconRuntimeRender; + assert.ok(runtime, 'expected icon runtime render test hooks to be exposed'); + runtime.clearFileIconCaches(); + return runtime; +} + +test('file icon runtime cache', async (t) => { + await t.test('coalesces 100 concurrently mounted identical file icons into one IPC call', async () => { + const filePath = '/tmp/supercmd-same-file.txt'; + const dataUrl = 'data:image/png;base64,same-file'; + const gate = deferred(); + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + getFileIconDataUrl(requestedPath, size) { + calls += 1; + assert.equal(requestedPath, filePath); + assert.equal(size, 20); + return gate.promise; + }, + statSync: () => ({ exists: true, isDirectory: false }), + }); + + for (let index = 0; index < 100; index += 1) { + runtime.FileIcon({ filePath, className: 'w-4 h-4' }); + } + + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().inFlightSize, 1); + + const pending = Array.from(runtime.inFlightFileIconRequests.values())[0]; + gate.resolve(dataUrl); + await pending; + + assert.equal(runtime.cacheStats().cacheSize, 1); + assert.equal(runtime.cacheStats().inFlightSize, 0); + assert.equal(runtime.readCachedFileIcon(filePath), dataUrl); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), dataUrl); + assert.equal(calls, 1, 'cached file icon should not issue another IPC call'); + }); + + await t.test('does not keep rejected IPC results as permanent negative cache entries', async () => { + const filePath = '/tmp/supercmd-late-success.txt'; + const recoveredDataUrl = 'data:image/png;base64,recovered'; + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + if (calls === 1) throw new Error('temporary icon IPC failure'); + return recoveredDataUrl; + }, + }); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), null); + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().cacheSize, 0); + assert.equal(runtime.cacheStats().inFlightSize, 0); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(runtime.cacheStats().cacheSize, 1); + }); + + await t.test('caps unique file icon cache entries and evicts least recently used paths', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl(filePath) { + calls += 1; + return `data:image/png;base64,${Buffer.from(filePath).toString('base64')}`; + }, + }); + + const { limit } = runtime.cacheStats(); + assert.ok(limit < 5_000, 'test expects a bounded cache below the old 5k growth case'); + + for (let index = 0; index < limit; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + assert.equal(runtime.cacheStats().cacheSize, limit); + + const firstPath = '/tmp/supercmd-icon-0'; + const secondPath = '/tmp/supercmd-icon-1'; + const overflowPath = `/tmp/supercmd-icon-${limit}`; + assert.ok(runtime.readCachedFileIcon(firstPath), 'reading should refresh LRU order'); + + await runtime.loadFileIconDataUrl(overflowPath); + + const afterOverflow = runtime.cacheStats(); + assert.equal(afterOverflow.cacheSize, limit); + assert.equal(calls, limit + 1); + assert.ok(afterOverflow.keys.includes(firstPath), 'recently read path should survive overflow'); + assert.ok(!afterOverflow.keys.includes(secondPath), 'least recently used path should be evicted'); + assert.ok(afterOverflow.keys.includes(overflowPath), 'new path should be cached'); + + for (let index = limit + 1; index < 5_000; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + + assert.equal(runtime.cacheStats().cacheSize, limit); + }); + + await t.test('leaves non-file image icons on the existing render path without IPC', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + return 'data:image/png;base64,file-icon'; + }, + }); + + const remoteIcon = runtime.renderIcon('https://example.com/icon.png', 'w-5 h-5'); + + assert.equal(calls, 0); + assert.equal(remoteIcon?.props?.src, 'https://example.com/icon.png'); + assert.equal(remoteIcon?.props?.className, 'w-5 h-5'); + }); +}); diff --git a/src/renderer/src/raycast-api/icon-runtime-render.tsx b/src/renderer/src/raycast-api/icon-runtime-render.tsx index 5a4d77ce..88469b02 100644 --- a/src/renderer/src/raycast-api/icon-runtime-render.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-render.tsx @@ -7,30 +7,81 @@ import React, { useEffect, useState } from 'react'; import { isRaycastIconName, renderPhosphorIcon } from './icon-runtime-phosphor'; import { isEmojiOrSymbol, renderTintedAssetIcon, resolveIconSrc, resolveTintColor } from './icon-runtime-assets'; +const FILE_ICON_CACHE_MAX_ENTRIES = 1024; const fileIconCache = new Map(); +const inFlightFileIconRequests = new Map>(); + +function peekCachedFileIcon(filePath: string): string | null | undefined { + if (!fileIconCache.has(filePath)) return undefined; + return fileIconCache.get(filePath) ?? null; +} + +function readCachedFileIcon(filePath: string): string | null | undefined { + const cached = peekCachedFileIcon(filePath); + if (cached === undefined) return undefined; + fileIconCache.delete(filePath); + fileIconCache.set(filePath, cached); + return cached; +} + +function writeCachedFileIcon(filePath: string, src: string | null) { + if (fileIconCache.has(filePath)) fileIconCache.delete(filePath); + fileIconCache.set(filePath, src); + + while (fileIconCache.size > FILE_ICON_CACHE_MAX_ENTRIES) { + const oldestKey = fileIconCache.keys().next().value; + if (oldestKey === undefined) break; + fileIconCache.delete(oldestKey); + } +} + +function loadFileIconDataUrl(filePath: string): Promise { + const cached = readCachedFileIcon(filePath); + if (cached !== undefined) return Promise.resolve(cached); + + const pending = inFlightFileIconRequests.get(filePath); + if (pending) return pending; + + const getFileIconDataUrl = typeof window !== 'undefined' + ? (window as any).electron?.getFileIconDataUrl + : undefined; + if (typeof getFileIconDataUrl !== 'function') return Promise.resolve(null); + + let request: Promise; + try { + request = Promise.resolve(getFileIconDataUrl(filePath, 20)) + .then((iconSrc: string | null | undefined) => { + const normalized = iconSrc || null; + writeCachedFileIcon(filePath, normalized); + return normalized; + }) + .catch(() => null) + .finally(() => { + inFlightFileIconRequests.delete(filePath); + }); + } catch { + return Promise.resolve(null); + } + + inFlightFileIconRequests.set(filePath, request); + return request; +} function FileIcon({ filePath, className }: { filePath: string; className: string }) { - const [src, setSrc] = useState(() => fileIconCache.get(filePath) ?? null); + const [src, setSrc] = useState(() => peekCachedFileIcon(filePath) ?? null); useEffect(() => { let cancelled = false; - const cached = fileIconCache.get(filePath); + const cached = readCachedFileIcon(filePath); if (cached !== undefined) { setSrc(cached); return; } - (window as any).electron?.getFileIconDataUrl?.(filePath, 20) - .then((iconSrc: string | null) => { - if (cancelled) return; - fileIconCache.set(filePath, iconSrc || null); - setSrc(iconSrc || null); - }) - .catch(() => { - if (cancelled) return; - fileIconCache.set(filePath, null); - setSrc(null); - }); + loadFileIconDataUrl(filePath).then((iconSrc) => { + if (cancelled) return; + setSrc(iconSrc); + }); return () => { cancelled = true;