diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test diff --git a/package.json b/package.json index f262a8c1..32eb0939 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "npm run build:main && npm run build:renderer && npm run build:native", "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", "build:renderer": "vite build", + "measure:launcher-command-list": "node scripts/measure-launcher-command-list-render.mjs", "check:i18n": "node scripts/check-i18n.mjs", "test": "node --test 'scripts/test-*.mjs'", "build:native": "node scripts/build-native.mjs", diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 35f3172f..e72912de 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -28,7 +28,7 @@ const swift = [ ['dist/native/menu-item-search', 'src/native/menu-item-search.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/emoji-trigger-monitor', - 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift', + 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift src/native/emoji-caret-session-cache.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/hotkey-hold-monitor', 'src/native/hotkey-hold-monitor.swift', '-framework CoreGraphics -framework AppKit -framework Carbon'], diff --git a/scripts/measure-icon-resolution.mjs b/scripts/measure-icon-resolution.mjs new file mode 100644 index 00000000..4555ada0 --- /dev/null +++ b/scripts/measure-icon-resolution.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath, pathToFileURL } 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-phosphor.tsx'); +const outputFile = path.join(os.tmpdir(), `supercmd-icon-resolution-${process.pid}-${Date.now()}.mjs`); + +const exactInputs = [ + 'AddPerson', + 'Icon.ArrowLeftCircleFilled', + 'MagnifyingGlass', + 'Stopwatch', + 'Temperature', + 'Dot', + 'XMarkCircleFilled', + 'Folder', +]; + +const unknownAndFuzzyInputs = [ + 'TotallyMissingIconName', + 'HyperSpecificSparkleClockWidget', + 'SuperCmdTimerStopwatchShape', + 'UnmappedTerminalCommandLineIcon', + 'MysteryNetworkCloudBolt', + 'Noise___No_Such_Icon_999', +]; + +function exposeResolverPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-resolution-for-measurement', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source}\nexport const __measureResolvePhosphorIconFromRaycast = resolvePhosphorIconFromRaycast;\n`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function stubServerRenderingPlugin() { + return { + name: 'stub-server-rendering-for-measurement', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-measurement-stub', + namespace: 'measurement-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'measurement-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }; +} + +function measureCase(resolveIcon, { name, inputs, iterations }) { + const calls = inputs.length * iterations; + const start = performance.now(); + let resolved = 0; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const input of inputs) { + if (resolveIcon(input)?.icon) resolved += 1; + } + } + + const durationMs = performance.now() - start; + return { + name, + calls, + resolved, + durationMs: Number(durationMs.toFixed(3)), + callsPerMs: Number((calls / Math.max(durationMs, 0.001)).toFixed(3)), + }; +} + +async function main() { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [exposeResolverPlugin(), stubServerRenderingPlugin()], + }); + + const moduleUrl = `${pathToFileURL(outputFile).href}?t=${Date.now()}`; + const runtime = await import(moduleUrl); + const resolveIcon = runtime.__measureResolvePhosphorIconFromRaycast; + if (typeof resolveIcon !== 'function') { + throw new Error('Failed to expose resolvePhosphorIconFromRaycast for measurement.'); + } + + const results = [ + measureCase(resolveIcon, { + name: 'exact/repeated', + inputs: exactInputs, + iterations: 2500, + }), + measureCase(resolveIcon, { + name: 'unknown-fuzzy/repeated', + inputs: unknownAndFuzzyInputs, + iterations: 250, + }), + ]; + + console.log('Icon resolution measurement'); + for (const result of results) { + console.log( + `${result.name}: ${result.calls} calls, ${result.durationMs} ms, ${result.callsPerMs} calls/ms, resolved ${result.resolved}` + ); + } + console.log(JSON.stringify({ results }, null, 2)); +} + +try { + await main(); +} finally { + await fs.unlink(outputFile).catch(() => {}); +} diff --git a/scripts/measure-launcher-command-list-render.mjs b/scripts/measure-launcher-command-list-render.mjs new file mode 100644 index 00000000..56c7f4d3 --- /dev/null +++ b/scripts/measure-launcher-command-list-render.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rowPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandRow.tsx'); +const listPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandList.tsx'); + +const rowCount = readNumberArg('--rows', 5000); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.launcher-command-list-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import LauncherCommandList, { + LAUNCHER_CALCULATOR_CARD_HEIGHT, + LAUNCHER_CALCULATOR_CARD_VERTICAL_MARGIN, + LAUNCHER_COMMAND_ROW_BODY_HEIGHT, + LAUNCHER_COMMAND_ROW_HEIGHT, + LAUNCHER_SECTION_HEADER_HEIGHT, +} from ${JSON.stringify(listPath)}; + +type CommandInfo = { + id: string; + title: string; + subtitle?: string; + keywords?: string[]; + iconDataUrl?: string; + iconEmoji?: string; + iconName?: string; + category: 'app' | 'settings' | 'system' | 'extension' | 'script'; + path?: string; + browserResultKind?: 'open-tab' | 'bookmark' | 'history' | 'search'; + browserFaviconUrl?: string; +}; + +type LauncherCommandSection = { + title: string; + items: CommandInfo[]; +}; + +type Metrics = { + rowRenderCount: number; +}; + +declare global { + // eslint-disable-next-line no-var + var __launcherCommandListMetrics: Metrics | undefined; +} + +const rowCount = ${rowCount}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; + +const TRANSLATIONS: Record = { + 'launcher.badges.application': 'Application', + 'launcher.badges.bookmark': 'Bookmark', + 'launcher.badges.extension': 'Extension', + 'launcher.badges.history': 'History', + 'launcher.badges.openTab': 'Open Tab', + 'launcher.badges.quickLink': 'Quick Link', + 'launcher.badges.script': 'Script', + 'launcher.badges.settings': 'System Settings', + 'launcher.categories.browser': 'Browser', + 'launcher.categories.files': 'Files', + 'launcher.categories.recent': 'Recent', + 'launcher.categories.search': 'Search', + 'launcher.sections.pinned': 'Pinned', + 'launcher.sections.results': 'Results', + 'launcher.sections.selectedText': 'Selected Text', + 'launcher.status.discoveringApps': 'Discovering apps...', + 'launcher.status.noMatchingResults': 'No matching results', + 'common.system': 'System', + 'read.title': 'Read', + 'settings.title': 'Settings', + 'whisper.title': 'Whisper', +}; + +function t(key: string): string { + return TRANSLATIONS[key] || key; +} + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function makeCommand(index: number, titlePrefix = 'Command'): CommandInfo { + const kind = index % 9; + const base = { + id: \`measure-command-\${index}\`, + title: \`\${titlePrefix} \${String(index).padStart(5, '0')}\`, + subtitle: index % 4 === 0 ? \`Workspace action \${index}\` : undefined, + keywords: ['measure', 'launcher', String(index)], + }; + if (kind === 0) { + return { ...base, category: 'app', path: \`/Applications/Measure \${index}.app\`, iconDataUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }; + } + if (kind === 1) { + return { ...base, category: 'system', id: \`system-measure-\${index}\` }; + } + if (kind === 2) { + return { ...base, category: 'extension', path: \`measure-extension/command-\${index}\` }; + } + if (kind === 3) { + return { ...base, category: 'script', iconEmoji: '>' }; + } + if (kind === 4) { + return { ...base, category: 'system', browserResultKind: 'history', browserFaviconUrl: 'https://example.com/favicon.ico' }; + } + if (kind === 5) { + return { ...base, category: 'system', browserResultKind: 'open-tab' }; + } + if (kind === 6) { + return { ...base, category: 'system', browserResultKind: 'bookmark' }; + } + if (kind === 7) { + return { ...base, category: 'system', id: \`quicklink-measure-\${index}\`, iconName: 'Link' }; + } + return { ...base, category: 'settings' }; +} + +function makeSections(commands: CommandInfo[], titles: string[]): LauncherCommandSection[] { + const perSection = Math.max(1, Math.ceil(commands.length / titles.length)); + return titles.map((title, sectionIndex) => ({ + title, + items: commands.slice(sectionIndex * perSection, (sectionIndex + 1) * perSection), + })).filter((section) => section.items.length > 0); +} + +function flatten(sections: LauncherCommandSection[]): CommandInfo[] { + return sections.flatMap((section) => section.items); +} + +const rootCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index)); +const queryCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index, 'Query Match')).reverse(); +const narrowedCommands = Array.from({ length: Math.max(1, Math.floor(rowCount * 0.6)) }, (_, index) => makeCommand(index * 2, 'Filtered Match')); + +const rootSections = makeSections(rootCommands, ['', 'Pinned', 'Recent', 'Results', 'Files']); +const querySections = makeSections(queryCommands, ['Results', 'Browser', 'Files', 'Search']); +const narrowedSections = makeSections(narrowedCommands, ['Results', 'Browser', 'Files']); + +const scenarios = [ + { name: 'root results initial', sections: rootSections, selectedIndex: 0 }, + { + name: 'calculator plus large results', + sections: rootSections, + selectedIndex: 0, + calcResult: { + kind: 'math', + input: '21 * 2', + inputLabel: 'Expression', + result: '42', + resultLabel: 'Result', + }, + }, + { name: 'selection moves to middle', sections: rootSections, selectedIndex: Math.floor(rowCount / 2) }, + { name: 'selection moves to end', sections: rootSections, selectedIndex: rowCount - 1 }, + { name: 'query update same-size reshuffle', sections: querySections, selectedIndex: 0 }, + { name: 'query update narrowed-large', sections: narrowedSections, selectedIndex: Math.min(20, narrowedCommands.length - 1) }, +]; + +const noop = () => {}; + +function renderScenario(scenario: typeof scenarios[number]) { + const displayCommands = flatten(scenario.sections); + const listRef = { current: null }; + const itemRefs = { current: [] }; + globalThis.__launcherCommandListMetrics = { rowRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( + } + itemRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>} + isLoading={false} + isHidden={false} + displayCommands={displayCommands as any} + sections={scenario.sections as any} + calcResult={(scenario.calcResult || null) as any} + calcOffset={scenario.calcResult ? 1 : 0} + selectedIndex={scenario.selectedIndex} + commandAliases={{}} + commandHotkeys={{ + 'measure-command-2': 'Command+Shift+P', + 'measure-command-7': 'Control+Option+L', + 'quicklink-measure-16': 'Hyper+K', + }} + onCalculatorCopy={noop} + onCommandClick={noop} + onCommandContextMenu={noop} + t={t} + /> + ); + const durationMs = performance.now() - started; + return { + name: scenario.name, + durationMs, + rowRenderCount: globalThis.__launcherCommandListMetrics?.rowRenderCount || 0, + commandCount: displayCommands.length, + htmlLength: html.length, + hasVirtualCommandSlotHeight: html.includes(\`height:\${LAUNCHER_COMMAND_ROW_HEIGHT}px\`), + hasVirtualCommandBodyHeight: html.includes(\`height:\${LAUNCHER_COMMAND_ROW_BODY_HEIGHT}px\`), + hasVirtualSectionHeight: html.includes(\`height:\${LAUNCHER_SECTION_HEADER_HEIGHT}px\`), + hasVirtualCalculatorBodyHeight: html.includes(\`height:\${LAUNCHER_CALCULATOR_CARD_HEIGHT - LAUNCHER_CALCULATOR_CARD_VERTICAL_MARGIN}px\`), + }; +} + +for (let i = 0; i < warmups; i += 1) { + for (const scenario of scenarios) { + renderScenario(scenario); + } +} + +const results = scenarios.map((scenario) => { + const samples = Array.from({ length: iterations }, () => renderScenario(scenario)); + return { + name: scenario.name, + commandCount: samples[0]?.commandCount || 0, + rowRenderCount: samples[0]?.rowRenderCount || 0, + medianDurationMs: median(samples.map((sample) => sample.durationMs)), + minDurationMs: Math.min(...samples.map((sample) => sample.durationMs)), + maxDurationMs: Math.max(...samples.map((sample) => sample.durationMs)), + htmlLength: samples[0]?.htmlLength || 0, + hasVirtualCommandSlotHeight: samples.some((sample) => sample.hasVirtualCommandSlotHeight), + hasVirtualCommandBodyHeight: samples.some((sample) => sample.hasVirtualCommandBodyHeight), + hasVirtualSectionHeight: samples.some((sample) => sample.hasVirtualSectionHeight), + hasVirtualCalculatorBodyHeight: samples.some((sample) => sample.hasVirtualCalculatorBodyHeight), + }; +}); + +const totalRows = results.reduce((sum, result) => sum + result.rowRenderCount, 0); +const totalMedianMs = results.reduce((sum, result) => sum + result.medianDurationMs, 0); +const summary = { rowCount, iterations, results, totalRows, totalMedianMs }; + +if (!jsonOnly) { + console.log(\`LauncherCommandList render measurement (rows=\${rowCount}, iterations=\${iterations})\`); + for (const result of results) { + console.log([ + \`- \${result.name}\`, + \`commands=\${result.commandCount}\`, + \`rowRenders=\${result.rowRenderCount}\`, + \`median=\${result.medianDurationMs.toFixed(2)}ms\`, + \`min=\${result.minDurationMs.toFixed(2)}ms\`, + \`max=\${result.maxDurationMs.toFixed(2)}ms\`, + ].join(' | ')); + } + console.log(\`Total row renders per scenario sequence: \${totalRows}\`); + console.log(\`Total median render time per scenario sequence: \${totalMedianMs.toFixed(2)}ms\`); +} +console.log(JSON.stringify(summary, null, 2)); +`); + +const instrumentLauncherRowPlugin = { + name: 'instrument-launcher-command-row', + setup(build) { + build.onLoad({ filter: /LauncherCommandRow\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + if (path.resolve(args.path) === rowPath) { + const marker = '}) => {\n'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument LauncherCommandRow render counter.'); + } + source = source.replace(marker, `${marker} const __launcherMetrics = (globalThis.__launcherCommandListMetrics ||= { rowRenderCount: 0 });\n __launcherMetrics.rowRenderCount += 1;\n`); + } + return { contents: source, loader: 'tsx' }; + }); + }, +}; + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [instrumentLauncherRowPlugin], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/test-emoji-caret-session.mjs b/scripts/test-emoji-caret-session.mjs new file mode 100644 index 00000000..a531fbeb --- /dev/null +++ b/scripts/test-emoji-caret-session.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +function canRunSwiftTests() { + if (process.platform !== 'darwin') return false; + try { + execFileSync('swiftc', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +if (!canRunSwiftTests()) { + console.log('[emoji-caret-session] skipped: Swift compiler is not available on this platform'); + process.exit(0); +} + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-emoji-caret-session-')); +const testSource = path.join(tempDir, 'main.swift'); +const testBinary = path.join(tempDir, 'emoji-caret-session-tests'); + +fs.writeFileSync(testSource, ` +import Foundation +@preconcurrency import ApplicationServices + +func makeSnapshot(pid: pid_t, bundleId: String = "com.example.Editor") -> AXCaretSessionSnapshot { + AXCaretSessionSnapshot( + context: AXCaretApplicationContext(pid: pid, bundleIdentifier: bundleId), + focusedElement: nil, + caret: AXCaretRect(x: 20, y: 40, w: 1, h: 18, tier: "unit") + ) +} + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("failed: " + message + "\\n", stderr) + exit(1) + } +} + +var cache = EmojiCaretSessionCache() +expect(!cache.isActive, "new cache starts empty") + +cache.store(makeSnapshot(pid: 42)) +expect(cache.isActive, "store activates the cache") +switch cache.validate(eventTargetPID: 42) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "matching event PID reuses the snapshot") +default: + expect(false, "matching event PID reuses the snapshot") +} + +switch cache.validate(eventTargetPID: nil) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "missing event PID does not force an AX requery") +default: + expect(false, "missing event PID does not force an AX requery") +} + +switch cache.validate(eventTargetPID: 43) { +case .invalidated: + break +default: + expect(false, "PID changes invalidate the cache") +} +expect(!cache.isActive, "PID invalidation clears the cache") + +cache.store(makeSnapshot(pid: 99)) +cache.invalidate() +expect(!cache.isActive, "failed rect/dismiss invalidation clears the cache") + +print("emoji caret session cache tests passed") +`); + +try { + execFileSync('swiftc', [ + '-o', testBinary, + 'src/native/ax-caret-query.swift', + 'src/native/emoji-caret-session-cache.swift', + testSource, + '-framework', 'AppKit', + '-framework', 'ApplicationServices', + ], { stdio: 'inherit' }); + const output = execFileSync(testBinary, { encoding: 'utf8' }).trim(); + assert.equal(output, 'emoji caret session cache tests passed'); + console.log(`[emoji-caret-session] ${output}`); +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/test-emoji-grid-virtualization.mjs b/scripts/test-emoji-grid-virtualization.mjs new file mode 100644 index 00000000..a75cc3e5 --- /dev/null +++ b/scripts/test-emoji-grid-virtualization.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function importListHooks() { + const result = await build({ + entryPoints: [path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + }); + const code = result.outputFiles[0].text; + return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64')); +} + +function makeEmojiItems(totalItems) { + return Array.from({ length: totalItems }, (_, index) => ({ + id: `emoji-${index}`, + order: index, + sectionTitle: index < totalItems / 2 ? 'Smileys' : 'Symbols', + props: { + title: `Emoji ${index}`, + icon: '😀', + }, + })); +} + +function countEmojiCells(rows) { + return rows.reduce((count, row) => count + (row.type === 'emoji-row' ? row.items.length : 0), 0); +} + +const hooks = await importListHooks(); + +test('Emoji grid virtualization', async (t) => { + const totalItems = 4096; + const items = makeEmojiItems(totalItems); + const groupedItems = hooks.groupListItems(items); + const gridRows = hooks.buildEmojiGridVirtualRows(groupedItems); + const rowMetrics = hooks.measureVirtualRows(gridRows); + + await t.test('renders only the visible emoji cells plus overscan', () => { + assert.equal(hooks.shouldUseEmojiGrid(items, false, () => true), true, 'fixture should use the emoji grid path'); + assert.equal(countEmojiCells(gridRows), totalItems, 'all items are represented by virtual rows'); + assert.ok(gridRows.length < totalItems, 'grid rows compact many cells into fixed rows'); + + const range = hooks.getVisibleVirtualRange(gridRows, rowMetrics, 0, 600); + const visibleCells = countEmojiCells(gridRows.slice(range.visibleStart, range.visibleEnd)); + + assert.equal(visibleCells, 112); + assert.ok(visibleCells < totalItems * 0.05, 'initial window renders less than 5% of the list'); + console.log(JSON.stringify({ + mode: 'after-test', + totalItems, + virtualRows: gridRows.length, + visibleCells, + visibleStart: range.visibleStart, + visibleEnd: range.visibleEnd, + })); + }); + + await t.test('maps selected indices to their grouped grid rows', () => { + const itemToRow = hooks.buildItemToVirtualRowMap(gridRows, totalItems); + + assert.equal(itemToRow[0], 1, 'first section starts after its header'); + assert.equal(itemToRow[7], 1, 'first row contains eight cells'); + assert.equal(itemToRow[8], 2, 'ninth cell starts the next grid row'); + assert.equal(itemToRow[2047], 256, 'last item in the first section stays in its final row'); + assert.equal(itemToRow[2048], 258, 'second section starts after the next header'); + + const selectedRow = itemToRow[3000]; + const selectedTop = rowMetrics.offsets[selectedRow]; + const selectedRange = hooks.getVisibleVirtualRange(gridRows, rowMetrics, selectedTop, 600); + const visibleCells = countEmojiCells(gridRows.slice(selectedRange.visibleStart, selectedRange.visibleEnd)); + + assert.ok(selectedRow >= selectedRange.visibleStart && selectedRow < selectedRange.visibleEnd, 'selected item row is visible'); + assert.ok(visibleCells < 200, 'selection scroll window stays bounded'); + }); +}); diff --git a/scripts/test-grid-virtualization.mjs b/scripts/test-grid-virtualization.mjs new file mode 100644 index 00000000..90b746db --- /dev/null +++ b/scripts/test-grid-virtualization.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const helperPath = path.join(root, 'src/renderer/src/raycast-api/grid-runtime-virtualization.ts'); + +const ITEM_COUNT = 5000; +const VIEWPORT_HEIGHT = 640; +const CONTAINER_WIDTH = 800; +const DEFAULT_COLUMNS = 5; + +function makeLargeGridGroups() { + const firstItems = []; + const secondItems = []; + + for (let index = 0; index < ITEM_COUNT; index += 1) { + const item = { + item: { + id: `item-${index}`, + order: index, + props: { + id: `visible-${index}`, + title: `Item ${index}`, + subtitle: `Subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: index % 2 === 0 ? 'blue' : undefined } + : { value: `Icon.Circle.${index}`, tooltip: `Icon ${index}` }, + }, + section: index < ITEM_COUNT / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + }, + globalIdx: index, + }; + + if (index < ITEM_COUNT / 2) firstItems.push(item); + else secondItems.push(item); + } + + return [ + { key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: firstItems }, + { + key: 'section-b', + title: 'Section B', + section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + items: secondItems, + }, + ]; +} + +function countEagerRenderedCells(groups) { + return groups.reduce((total, group) => total + group.items.length, 0); +} + +function simulateCellContentResolution(groups) { + let checksum = 0; + for (const group of groups) { + for (const { item } of group.items) { + const content = item.props.content; + if (typeof content?.source === 'string') checksum += content.source.length; + if (typeof content?.value === 'string') checksum += content.value.length; + if (typeof item.props.title === 'string') checksum += item.props.title.length; + } + } + return checksum; +} + +function countItemsInRows(rows) { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} + +function rowContainsIndex(rows, index) { + return rows.some((row) => row.kind === 'items' && row.items.some((entry) => entry.globalIdx === index)); +} + +test('Grid virtualization keeps large grids to visible cells', async (t) => { + const groups = makeLargeGridGroups(); + const eagerStart = performance.now(); + const eagerCells = countEagerRenderedCells(groups); + const checksum = simulateCellContentResolution(groups); + const eagerDuration = performance.now() - eagerStart; + + console.log( + `[grid-perf] baseline eager cells=${eagerCells} contentResolutions=${ITEM_COUNT} durationMs=${eagerDuration.toFixed(3)} checksum=${checksum}`, + ); + + assert.equal(eagerCells, ITEM_COUNT, 'legacy grid renders every cell in a large grid'); + + if (!fs.existsSync(helperPath)) { + console.log('[grid-perf] virtualization helper not present yet; baseline captured before renderer edits'); + return; + } + + const { + buildVirtualGridLayout, + countVirtualizedRowItems, + getScrollTopForItemIndex, + getVisibleVirtualRows, + } = await importTs(helperPath); + + await t.test('renders only visible item cells plus overscan', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const visibleRows = getVisibleVirtualRows(layout.rows, { + scrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const visibleCells = countVirtualizedRowItems(visibleRows); + + console.log( + `[grid-perf] virtualized visibleCells=${visibleCells} totalCells=${layout.itemCount} renderedRows=${visibleRows.length} totalRows=${layout.rows.length} totalHeight=${layout.totalHeight.toFixed(1)}`, + ); + + assert.equal(layout.itemCount, ITEM_COUNT); + assert.equal(visibleCells, countItemsInRows(visibleRows)); + assert.ok(visibleCells > 0, 'initial viewport renders visible cells'); + assert.ok(visibleCells < 80, `expected a small visible window, got ${visibleCells}`); + }); + + await t.test('scrolls selected items into the virtual window', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const targetIndex = 4242; + const scrollTop = getScrollTopForItemIndex(layout, targetIndex, { + currentScrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const selectedRows = getVisibleVirtualRows(layout.rows, { + scrollTop, + viewportHeight: VIEWPORT_HEIGHT, + }); + + assert.ok(rowContainsIndex(selectedRows, targetIndex), 'selected off-screen item is rendered after selection scroll'); + }); + + await t.test('preserves section layout options', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const sectionRow = layout.rows.find((row) => row.kind === 'items' && row.sectionKey === 'section-b'); + + assert.ok(sectionRow, 'section row exists'); + assert.equal(sectionRow.layout.columns, 4); + assert.equal(sectionRow.layout.fit, 'fill'); + assert.equal(sectionRow.layout.inset, 'lg'); + assert.notEqual(sectionRow.itemHeight, 160, 'aspect ratio creates a section-specific item row height'); + }); +}); diff --git a/scripts/test-icon-asset-cache.mjs b/scripts/test-icon-asset-cache.mjs new file mode 100644 index 00000000..da2b0e75 --- /dev/null +++ b/scripts/test-icon-asset-cache.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function createIconRuntimeHarness() { + const moduleCache = new Map(); + const existingPaths = new Set(); + let statCalls = 0; + let now = 1_000_000; + + const windowStub = { + electron: { + statSync(filePath) { + statCalls += 1; + const exists = existingPaths.has(filePath); + return { + exists, + isDirectory: false, + isFile: exists, + size: exists ? 123 : 0, + }; + }, + }, + }; + const dateStub = class extends Date { + static now() { + return now; + } + }; + + function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date: dateStub, + window: windowStub, + document: { + documentElement: { + classList: { + contains: () => false, + }, + }, + }, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + config: loadTsModule('src/renderer/src/raycast-api/icon-runtime-config.ts'), + addExistingPath(filePath) { + existingPaths.add(filePath); + }, + removeExistingPath(filePath) { + existingPaths.delete(filePath); + }, + advanceTime(ms) { + now += ms; + }, + get statCalls() { + return statCalls; + }, + }; +} + +test('icon asset existence cache', async (t) => { + await t.test('caches positive extension-relative asset checks by local path', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/icon.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + for (let i = 0; i < 5; i += 1) { + assert.equal(harness.assets.resolveIconSrc('icon.png', assetsPath), expectedUrl); + } + + assert.equal(harness.statCalls, 1); + }); + + await t.test('shares positive cache entries across sc-asset and absolute path resolution', () => { + const harness = createIconRuntimeHarness(); + const iconPath = '/tmp/supercmd-assets/shared icon.png'; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + assert.equal(harness.assets.resolveIconSrc(expectedUrl), expectedUrl); + assert.equal(harness.assets.resolveIconSrc(iconPath), expectedUrl); + + assert.equal(harness.statCalls, 1); + }); + + await t.test('does not cache misses so newly available assets resolve later', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/late.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), ''); + harness.addExistingPath(iconPath); + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), expectedUrl); + + assert.equal(harness.statCalls, 2); + }); + + await t.test('refreshes positive cache entries after ttl so deleted assets stop resolving', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/deleted.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + assert.equal(harness.assets.resolveIconSrc('deleted.png', assetsPath), expectedUrl); + harness.removeExistingPath(iconPath); + assert.equal(harness.assets.resolveIconSrc('deleted.png', assetsPath), expectedUrl); + harness.advanceTime(harness.assets.LOCAL_PATH_EXISTS_CACHE_TTL_MS); + assert.equal(harness.assets.resolveIconSrc('deleted.png', assetsPath), ''); + + assert.equal(harness.statCalls, 2); + }); + + await t.test('uses configured assetsPath fallback without changing asset URL semantics', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-extension-assets'; + const iconPath = `${assetsPath}/nested/icon dark.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + harness.config.configureIconRuntime({ + getExtensionContext: () => ({ assetsPath }), + }); + + assert.equal(harness.assets.resolveIconSrc('nested/icon dark.png'), expectedUrl); + assert.equal(expectedUrl, 'sc-asset://ext-asset/tmp/supercmd-extension-assets/nested/icon%20dark.png'); + assert.equal(harness.statCalls, 1); + }); + + await t.test('repeated positive measurement avoids repeated statSync calls', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/measured.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + const iterations = 10_000; + harness.addExistingPath(iconPath); + + const start = performance.now(); + let result = ''; + for (let i = 0; i < iterations; i += 1) { + result = harness.assets.resolveIconSrc('measured.png', assetsPath); + } + const elapsedMs = performance.now() - start; + + assert.equal(result, expectedUrl); + assert.equal(harness.statCalls, 1); + console.log(`icon-asset-cache measurement: iterations=${iterations} statSync=${harness.statCalls} elapsedMs=${elapsedMs.toFixed(3)}`); + }); +}); 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/scripts/test-icon-runtime-phosphor-cache.mjs b/scripts/test-icon-runtime-phosphor-cache.mjs new file mode 100644 index 00000000..ee5b2734 --- /dev/null +++ b/scripts/test-icon-runtime-phosphor-cache.mjs @@ -0,0 +1,147 @@ +#!/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 os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } 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-phosphor.tsx'); + +async function importIconRuntimeForTest() { + const outputFile = path.join(os.tmpdir(), `supercmd-icon-runtime-test-${process.pid}-${Date.now()}.mjs`); + const normalizedTarget = path.normalize(targetFile); + + try { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [ + { + name: 'expose-icon-runtime-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimePhosphor = { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches: () => { + phosphorIconResolutionCache.clear(); + phosphorNameResolutionCache.clear(); + raycastIconNameResolutionCache.clear(); + }, + caches: { + phosphorIconResolutionCache, + phosphorNameResolutionCache, + raycastIconNameResolutionCache, + }, +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }, + { + name: 'stub-server-rendering-for-icon-runtime-tests', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-test-stub', + namespace: 'test-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'test-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }, + ], + }); + + const runtime = await import(`${pathToFileURL(outputFile).href}?t=${Date.now()}`); + return runtime.__testIconRuntimePhosphor; + } finally { + await fs.unlink(outputFile).catch(() => {}); + } +} + +test('Phosphor icon runtime resolution cache', async (t) => { + const iconRuntime = await importIconRuntimeForTest(); + const { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches, + caches, + } = iconRuntime; + + await t.test('resolves direct Raycast icon names without changing weight', () => { + clearIconResolutionCaches(); + + const expected = tryResolvePhosphorByName('MagnifyingGlass'); + const resolved = resolvePhosphorIconFromRaycast('MagnifyingGlass'); + + assert.ok(expected, 'expected Phosphor MagnifyingGlass export to exist'); + assert.equal(resolved?.icon, expected); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('preserves explicit Raycast aliases and filled weight compatibility', () => { + clearIconResolutionCaches(); + + const stopwatch = resolvePhosphorIconFromRaycast('Stopwatch'); + assert.equal(stopwatch?.icon, tryResolvePhosphorByName('Timer')); + assert.equal(stopwatch?.weight, 'regular'); + + const filled = resolvePhosphorIconFromRaycast('XMarkCircleFilled'); + assert.equal(filled?.icon, tryResolvePhosphorByName('XCircle')); + assert.equal(filled?.weight, 'fill'); + }); + + await t.test('keeps unknown icons on the existing fallback glyph', () => { + clearIconResolutionCaches(); + + const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); + const resolved = resolvePhosphorIconFromRaycast('QzxvPqmn999'); + + assert.ok(fallback, 'expected a Phosphor fallback glyph to exist'); + assert.equal(resolved?.icon, fallback); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('caches successful and failed resolutions while allowing whole-cache clear', () => { + clearIconResolutionCaches(); + + const first = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + const second = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(second, first, 'repeated final resolution should return the cached result object'); + + assert.equal(tryResolvePhosphorByName('DefinitelyNotAPhosphorIcon'), undefined); + assert.equal( + caches.phosphorNameResolutionCache.get('DefinitelyNotAPhosphorIcon'), + null, + 'failed Phosphor export lookups are memoized as misses', + ); + + clearIconResolutionCaches(); + assert.equal(caches.phosphorIconResolutionCache.size, 0); + assert.equal(caches.phosphorNameResolutionCache.size, 0); + assert.equal(caches.raycastIconNameResolutionCache.size, 0); + + const afterClear = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(afterClear?.icon, first?.icon); + assert.equal(afterClear?.weight, first?.weight); + }); +}); diff --git a/scripts/test-launcher-command-list-windowing.mjs b/scripts/test-launcher-command-list-windowing.mjs new file mode 100644 index 00000000..ac6e4894 --- /dev/null +++ b/scripts/test-launcher-command-list-windowing.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const listPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandList.tsx'); + +function runMeasurement() { + const output = execFileSync( + process.execPath, + [ + 'scripts/measure-launcher-command-list-render.mjs', + '--rows=5000', + '--iterations=1', + '--warmups=0', + '--json', + ], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + } + ); + const jsonStart = output.lastIndexOf('{\n "rowCount"'); + assert.notEqual(jsonStart, -1, `Measurement output did not include JSON summary:\n${output}`); + return JSON.parse(output.slice(jsonStart)); +} + +test('launcher command list windows large result sets instead of rendering every row', () => { + const metrics = runMeasurement(); + const fullSizeScenarios = metrics.results.filter((result) => result.commandCount === 5000); + assert.equal(fullSizeScenarios.length, 5); + + for (const result of fullSizeScenarios) { + assert.ok( + result.rowRenderCount < 100, + `${result.name} rendered ${result.rowRenderCount} rows for ${result.commandCount} commands` + ); + } + + assert.ok( + metrics.totalRows < 300, + `Expected the full scenario sequence to render fewer than 300 rows, got ${metrics.totalRows}` + ); +}); + +test('launcher virtualization pins rendered item heights to the reserved row heights', () => { + const metrics = runMeasurement(); + const largeResults = metrics.results.filter((result) => result.commandCount === 5000); + + assert.ok( + largeResults.some((result) => result.hasVirtualCommandSlotHeight), + 'virtualized command slots should reserve the command row height' + ); + assert.ok( + largeResults.some((result) => result.hasVirtualCommandBodyHeight), + 'virtualized command rows should pin their body height inside the reserved slot' + ); + assert.ok( + largeResults.some((result) => result.hasVirtualSectionHeight), + 'virtualized section headers should pin their rendered height' + ); + assert.ok( + largeResults.some((result) => result.hasVirtualCalculatorBodyHeight), + 'virtualized calculator cards should pin their rendered height' + ); +}); + +test('launcher command list does not own selected-entry smooth scrolling', () => { + const source = fs.readFileSync(listPath, 'utf8'); + + assert.doesNotMatch( + source, + /\.scrollTo\(\{\s*top:[\s\S]*behavior:\s*['"]smooth['"]/, + 'App.tsx owns selected-row smooth scrolling; the virtualized list should only maintain viewport measurements' + ); +}); diff --git a/scripts/test-list-registry-stable-order.mjs b/scripts/test-list-registry-stable-order.mjs new file mode 100644 index 00000000..8fa88142 --- /dev/null +++ b/scripts/test-list-registry-stable-order.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const LIST_RENDERERS_PATH = 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'; +const ITEM_COUNT = 1000; +const SELECTION_MOVES = 25; + +function readListItemComponentSource() { + const source = fs.readFileSync(LIST_RENDERERS_PATH, 'utf8'); + const match = source.match(/function ListItemComponent[\s\S]*?\n }\n\n \(ListItemComponent/); + assert.ok(match, 'ListItemComponent source should be present'); + return match[0]; +} + +function getLayoutEffectDependencies(source) { + const match = source.match(/useLayoutEffect\(\(\) => \{[\s\S]*?\}, \[([^\]]*)\]\);/); + assert.ok(match, 'ListItemComponent should register through useLayoutEffect'); + return match[1] + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean); +} + +function analyzeListRegistration(source) { + const dependencies = getLayoutEffectDependencies(source); + const hasSelectedActionsContext = source.includes('useContext(SelectedItemActionsContext)'); + const hasVolatileRenderOrder = /const\s+renderOrder\s*=\s*\+\+itemOrderCounter\s*;/.test(source); + const effectDependsOnRenderOrder = dependencies.includes('renderOrder'); + const hasStableOrderRef = + /const\s+orderRef\s*=\s*useRef<\s*number\s*\|\s*null\s*>\(null\)/.test(source) && + /orderRef\.current\s*===\s*null/.test(source) && + /orderRef\.current\s*=\s*\+\+itemOrderCounter/.test(source); + + const layoutEffectRestartsOnSelectionRender = hasSelectedActionsContext && hasVolatileRenderOrder && effectDependsOnRenderOrder; + return { + dependencies, + hasSelectedActionsContext, + hasStableOrderRef, + hasVolatileRenderOrder, + effectDependsOnRenderOrder, + layoutEffectRestartsOnSelectionRender, + }; +} + +function measureSelectionChurn(analysis) { + const selectionDrivenItemRenders = ITEM_COUNT * SELECTION_MOVES; + const layoutEffectRestarts = analysis.layoutEffectRestartsOnSelectionRender ? selectionDrivenItemRenders : 0; + const registryDeletesOnSelection = layoutEffectRestarts; + const registrySetsOnSelection = layoutEffectRestarts; + return { + itemCount: ITEM_COUNT, + selectionMoves: SELECTION_MOVES, + orderMode: analysis.hasStableOrderRef ? 'stable-ref' : 'render-counter', + selectionDrivenItemRenders, + layoutEffectRestarts, + registryMutationsOnSelection: registryDeletesOnSelection + registrySetsOnSelection, + registryVersionUpdatesOnSelection: analysis.layoutEffectRestartsOnSelectionRender ? SELECTION_MOVES : 0, + }; +} + +function getMetrics() { + const source = readListItemComponentSource(); + const analysis = analyzeListRegistration(source); + return { analysis, metrics: measureSelectionChurn(analysis) }; +} + +if (process.argv.includes('--report')) { + const { analysis, metrics } = getMetrics(); + console.log(JSON.stringify({ analysis, metrics }, null, 2)); +} else { + test('List item registration order is stable across selection context renders', () => { + const { analysis, metrics } = getMetrics(); + assert.equal(analysis.hasSelectedActionsContext, true, 'List items still render selected item actions in their source context'); + assert.equal(analysis.hasStableOrderRef, true, 'List item render order should be stored in a ref like Grid items'); + assert.equal(analysis.hasVolatileRenderOrder, false, 'List items should not allocate a new order on every render'); + assert.equal(analysis.effectDependsOnRenderOrder, false, 'registration effect should not depend on render-time order'); + assert.equal(metrics.registryMutationsOnSelection, 0, 'selection changes should not unregister/register every list item'); + assert.equal(metrics.registryVersionUpdatesOnSelection, 0, 'selection changes should not publish list registry updates'); + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..1d0145c1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -10245,6 +10245,7 @@ function startEmojiTriggerMonitor(triggerPrefix = ':'): void { '-O', '-o', binaryPath, path.join(nativeDir, 'emoji-trigger-monitor.swift'), path.join(nativeDir, 'ax-caret-query.swift'), + path.join(nativeDir, 'emoji-caret-session-cache.swift'), '-framework', 'AppKit', '-framework', 'ApplicationServices', ]); diff --git a/src/native/ax-caret-query.swift b/src/native/ax-caret-query.swift index 2bfdabbf..82b4c0bd 100644 --- a/src/native/ax-caret-query.swift +++ b/src/native/ax-caret-query.swift @@ -34,6 +34,17 @@ public struct AXCaretRect { public let tier: String } +public struct AXCaretApplicationContext { + public let pid: pid_t + public let bundleIdentifier: String +} + +public struct AXCaretSessionSnapshot { + public let context: AXCaretApplicationContext + public let focusedElement: AXUIElement? + public let caret: AXCaretRect +} + /// Three-way result so callers can distinguish the security-sensitive case /// from an ordinary AX gap. A plain `AXCaretRect?` cannot express this: /// both "secure field" and "no rect available" would be nil, and the caller @@ -50,6 +61,12 @@ public enum AXCaretResult { case noRect } +public enum AXCaretSessionResult { + case secureField + case snapshot(AXCaretSessionSnapshot) + case noRect(AXCaretApplicationContext?) +} + public enum AXCaretQuery { // PIDs we've already nudged. Avoid re-setting AX opt-in attributes every keystroke. nonisolated(unsafe) private static var nudgedPIDs: Set = [] @@ -66,11 +83,28 @@ public enum AXCaretQuery { /// Query the caret state for the frontmost app's focused text element. public static func current() -> AXCaretResult { + switch currentSession() { + case .secureField: + return .secureField + case .snapshot(let snapshot): + return .rect(snapshot.caret) + case .noRect: + return .noRect + } + } + + /// Query the caret state and return the process/focused element needed to + /// safely cache an active emoji search session. + public static func currentSession() -> AXCaretSessionResult { guard let frontApp = NSWorkspace.shared.frontmostApplication else { dbg("no frontmost app") - return .noRect + return .noRect(nil) } let pid = frontApp.processIdentifier + let context = AXCaretApplicationContext( + pid: pid, + bundleIdentifier: frontApp.bundleIdentifier ?? "" + ) let appElement = AXUIElementCreateApplication(pid) let justNudged = nudgeChromiumAX(appElement: appElement, pid: pid) @@ -93,9 +127,9 @@ public enum AXCaretQuery { } guard focusErr == .success, let focused = focusedRaw else { dbg("kAXFocusedUIElementAttribute failed: err=\(focusErr.rawValue)") - return .noRect + return .noRect(context) } - guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect } + guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect(context) } var element = focused as! AXUIElement let role = copyString(element, kAXRoleAttribute as CFString) ?? "" @@ -121,15 +155,27 @@ public enum AXCaretQuery { } if let r = caretRectViaTextMarker(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker") + )) } if let r = caretRectViaRange(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange") + )) } if let r = caretRectViaElementFrame(element: element) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame") + )) } - return .noRect + return .noRect(context) } // MARK: - Descend focus tree to find the true text leaf diff --git a/src/native/emoji-caret-session-cache.swift b/src/native/emoji-caret-session-cache.swift new file mode 100644 index 00000000..5612ef33 --- /dev/null +++ b/src/native/emoji-caret-session-cache.swift @@ -0,0 +1,35 @@ +import Foundation +@preconcurrency import ApplicationServices + +public enum EmojiCaretSessionValidation { + case empty + case valid(AXCaretSessionSnapshot) + case invalidated +} + +public struct EmojiCaretSessionCache { + private var snapshot: AXCaretSessionSnapshot? + + public init() {} + + public var isActive: Bool { + snapshot != nil + } + + public mutating func store(_ nextSnapshot: AXCaretSessionSnapshot) { + snapshot = nextSnapshot + } + + public mutating func invalidate() { + snapshot = nil + } + + public mutating func validate(eventTargetPID: pid_t?) -> EmojiCaretSessionValidation { + guard let current = snapshot else { return .empty } + if let targetPID = eventTargetPID, targetPID > 0, targetPID != current.context.pid { + snapshot = nil + return .invalidated + } + return .valid(current) + } +} diff --git a/src/native/emoji-trigger-monitor.swift b/src/native/emoji-trigger-monitor.swift index e2f27dd7..a2e47e84 100644 --- a/src/native/emoji-trigger-monitor.swift +++ b/src/native/emoji-trigger-monitor.swift @@ -21,6 +21,7 @@ var currentQuery = "" var interceptEnabled = false var prefixBuffer = "" // rolling window of recent chars, length ≤ triggerPrefixLen var eventTapRef: CFMachPort? +var caretSessionCache = EmojiCaretSessionCache() // MARK: - JSON output @@ -33,38 +34,68 @@ func emit(_ obj: [String: Any]) { // MARK: - Caret rect + secure-field guard -// Included on every `query` event so the host process can decide whether the -// frontmost app is on the user's exclusion list. Read here (rather than in the -// host) because lsappinfo lookups in the host are stale for keystroke-driven -// flows where the launcher window was never brought forward. -func currentFrontmostBundleId() -> String { - return NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" +func resetTriggerState() { + triggerActive = false + currentQuery = "" + interceptEnabled = false + prefixBuffer = "" + caretSessionCache.invalidate() } -func emitQuery(_ query: String) { - let bundleId = currentFrontmostBundleId() - switch AXCaretQuery.current() { +func dismissTrigger(emitDismiss: Bool = true) { + resetTriggerState() + if emitDismiss { emit(["type": "dismiss"]) } +} + +func eventTargetPID(from event: CGEvent) -> pid_t? { + let rawPID = event.getIntegerValueField(.eventTargetUnixProcessID) + return rawPID > 0 ? pid_t(rawPID) : nil +} + +func sessionValidationPID(from event: CGEvent) -> pid_t? { + if let targetPID = eventTargetPID(from: event) { return targetPID } + guard caretSessionCache.isActive else { return nil } + return NSWorkspace.shared.frontmostApplication?.processIdentifier +} + +func emitQueryPayload(_ query: String, bundleId: String, caret: AXCaretRect?) { + var payload: [String: Any] = [ + "type": "query", + "value": query, + "prefixLen": triggerPrefixLen, + "bundleId": bundleId, + ] + if let caret { + payload["caret"] = ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier] + } + emit(payload) +} + +func emitQuery(_ query: String, eventTargetPID: pid_t?) { + switch caretSessionCache.validate(eventTargetPID: eventTargetPID) { + case .valid(let snapshot): + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + return + case .invalidated: + dismissTrigger() + return + case .empty: + break + } + + switch AXCaretQuery.currentSession() { case .secureField: - triggerActive = false - currentQuery = "" - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) - case .rect(let caret): - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - "caret": ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier], - ]) - case .noRect: - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - ]) + dismissTrigger() + case .snapshot(let snapshot): + if let targetPID = eventTargetPID, targetPID > 0, targetPID != snapshot.context.pid { + dismissTrigger() + return + } + caretSessionCache.store(snapshot) + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + case .noRect(let context): + caretSessionCache.invalidate() + emitQueryPayload(query, bundleId: context?.bundleIdentifier ?? "", caret: nil) } } @@ -131,9 +162,7 @@ func run() { // a partial prefix that could accidentally fire on the next keystroke. if hasCmd || hasCtrl { if triggerActive { - triggerActive = false - currentQuery = "" - emit(["type": "dismiss"]) + dismissTrigger() } prefixBuffer = "" // clear regardless of triggerActive (fix A) return Unmanaged.passUnretained(event) @@ -146,10 +175,7 @@ func run() { // otherwise silently extend the query with extended chars (ü, ©, etc.) // on non-US layouts, which is unintuitive (fix B). if hasAlt && triggerActive { - triggerActive = false - currentQuery = "" - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() return Unmanaged.passUnretained(event) } @@ -159,7 +185,7 @@ func run() { switch keyCode { case 36: emit(["type": "nav", "key": "enter"]); return nil case 53: - triggerActive = false; currentQuery = ""; prefixBuffer = "" + resetTriggerState() emit(["type": "nav", "key": "escape"]) return nil case 48: emit(["type": "nav", "key": "tab"]); return nil @@ -175,13 +201,10 @@ func run() { if triggerActive { if currentQuery.isEmpty { // Backspaced through the entire query back into the prefix — dismiss. - triggerActive = false - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { currentQuery.removeLast() - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Update prefix buffer for non-trigger backspace. @@ -194,8 +217,7 @@ func run() { let chars = extractTypedChars(from: event) if chars.isEmpty { if triggerActive { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } return Unmanaged.passUnretained(event) } @@ -206,15 +228,13 @@ func run() { if isEmojiQueryChar(char) { currentQuery.append(char) if currentQuery.count > 30 { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Non-query char (space, punctuation, …) → dismiss trigger. - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() // Feed this char into the prefix buffer in case it starts the // next trigger (e.g. when trigger prefix contains this char). feedPrefixBuffer(char) @@ -268,8 +288,7 @@ func run() { interceptEnabled = json["enabled"] as? Bool ?? false case "dismiss": if triggerActive { - triggerActive = false; currentQuery = ""; interceptEnabled = false; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } default: break } diff --git a/src/renderer/src/components/LauncherCalculatorCard.tsx b/src/renderer/src/components/LauncherCalculatorCard.tsx index 468845bc..916bd2cf 100644 --- a/src/renderer/src/components/LauncherCalculatorCard.tsx +++ b/src/renderer/src/components/LauncherCalculatorCard.tsx @@ -6,6 +6,7 @@ import { formatCalcKindLabel } from '../utils/launcher-misc'; type LauncherCalculatorCardProps = { result: CalcResult; selected: boolean; + style?: React.CSSProperties; itemRef: (el: HTMLDivElement | null) => void; onCopy: () => void; t: (key: string, params?: Record) => string; @@ -14,6 +15,7 @@ type LauncherCalculatorCardProps = { const LauncherCalculatorCard: React.FC = ({ result, selected, + style, itemRef, onCopy, t, @@ -25,6 +27,7 @@ const LauncherCalculatorCard: React.FC = ({ ? 'bg-[color-mix(in_srgb,var(--launcher-card-selected-bg)_60%,transparent)] border-[color-mix(in_srgb,var(--launcher-card-selected-border)_60%,transparent)]' : 'bg-transparent border-[color-mix(in_srgb,var(--launcher-card-border)_50%,transparent)] hover:bg-[color-mix(in_srgb,var(--launcher-card-hover-bg)_50%,transparent)]' }`} + style={style} onClick={onCopy} >
diff --git a/src/renderer/src/components/LauncherCommandList.tsx b/src/renderer/src/components/LauncherCommandList.tsx index d6873475..3f9626bb 100644 --- a/src/renderer/src/components/LauncherCommandList.tsx +++ b/src/renderer/src/components/LauncherCommandList.tsx @@ -9,6 +9,179 @@ export type LauncherCommandSection = { items: CommandInfo[]; }; +export const LAUNCHER_COMMAND_ROW_BODY_HEIGHT = 36; +export const LAUNCHER_COMMAND_ROW_GAP = 2; +export const LAUNCHER_COMMAND_ROW_HEIGHT = LAUNCHER_COMMAND_ROW_BODY_HEIGHT + LAUNCHER_COMMAND_ROW_GAP; +export const LAUNCHER_SECTION_HEADER_HEIGHT = 26; +export const LAUNCHER_CALCULATOR_CARD_HEIGHT = 124; +export const LAUNCHER_CALCULATOR_CARD_VERTICAL_MARGIN = 10; +const DEFAULT_VIEWPORT_HEIGHT = 420; +const VIRTUALIZATION_THRESHOLD = 120; +const VIRTUALIZATION_OVERSCAN_PX = LAUNCHER_COMMAND_ROW_HEIGHT * 6; + +type LauncherVirtualEntry = + | { + kind: 'calculator'; + key: string; + top: number; + height: number; + absoluteIndex: number; + } + | { + kind: 'section'; + key: string; + title: string; + top: number; + height: number; + } + | { + kind: 'command'; + key: string; + command: CommandInfo; + flatIndex: number; + absoluteIndex: number; + top: number; + height: number; + }; + +type LauncherVirtualList = { + entries: LauncherVirtualEntry[]; + totalHeight: number; +}; + +function buildVirtualEntries( + sections: LauncherCommandSection[], + calcResult: CalcResult | null, + calcOffset: number +): LauncherVirtualList { + const entries: LauncherVirtualEntry[] = []; + let top = 0; + let flatIndexCursor = 0; + + if (calcResult) { + entries.push({ + kind: 'calculator', + key: 'calculator', + top, + height: LAUNCHER_CALCULATOR_CARD_HEIGHT, + absoluteIndex: 0, + }); + top += LAUNCHER_CALCULATOR_CARD_HEIGHT; + } + + sections.forEach((section, sectionIndex) => { + const sectionStartIndex = flatIndexCursor; + if (section.title) { + entries.push({ + kind: 'section', + key: `section-${sectionIndex}-${sectionStartIndex}-${section.title}`, + title: section.title, + top, + height: LAUNCHER_SECTION_HEADER_HEIGHT, + }); + top += LAUNCHER_SECTION_HEADER_HEIGHT; + } + + section.items.forEach((command, itemIndex) => { + const flatIndex = sectionStartIndex + itemIndex; + entries.push({ + kind: 'command', + key: command.id, + command, + flatIndex, + absoluteIndex: flatIndex + calcOffset, + top, + height: LAUNCHER_COMMAND_ROW_HEIGHT, + }); + top += LAUNCHER_COMMAND_ROW_HEIGHT; + }); + flatIndexCursor += section.items.length; + }); + + return { entries, totalHeight: top }; +} + +function findEntryIndexAtOffset(entries: LauncherVirtualEntry[], offset: number): number { + let low = 0; + let high = entries.length - 1; + let match = entries.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const entry = entries[mid]; + if (entry.top + entry.height >= offset) { + match = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.max(0, Math.min(match, entries.length)); +} + +function getEntryRangeForOffsets( + entries: LauncherVirtualEntry[], + startOffset: number, + endOffset: number +): { start: number; end: number } { + if (entries.length === 0) return { start: 0, end: 0 }; + const start = findEntryIndexAtOffset(entries, startOffset); + let end = start; + while (end < entries.length && entries[end].top <= endOffset) { + end += 1; + } + return { start, end }; +} + +function mergeEntryRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> { + const normalized = ranges + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + + normalized.forEach((range) => { + const last = merged[merged.length - 1]; + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end); + return; + } + merged.push({ ...range }); + }); + + return merged; +} + +function getVirtualizedEntries( + entries: LauncherVirtualEntry[], + scrollTop: number, + viewportHeight: number, + selectedIndex: number +): LauncherVirtualEntry[] { + const viewportStart = Math.max(0, scrollTop - VIRTUALIZATION_OVERSCAN_PX); + const viewportEnd = scrollTop + viewportHeight + VIRTUALIZATION_OVERSCAN_PX; + const ranges = [getEntryRangeForOffsets(entries, viewportStart, viewportEnd)]; + const selectedEntry = entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex); + + if (selectedEntry && (selectedEntry.top < viewportStart || selectedEntry.top + selectedEntry.height > viewportEnd)) { + ranges.push( + getEntryRangeForOffsets( + entries, + Math.max(0, selectedEntry.top - VIRTUALIZATION_OVERSCAN_PX), + selectedEntry.top + selectedEntry.height + VIRTUALIZATION_OVERSCAN_PX + ) + ); + } + + return mergeEntryRanges(ranges).flatMap((range) => entries.slice(range.start, range.end)); +} + +function useLatestValue(value: T): React.MutableRefObject { + const ref = React.useRef(value); + ref.current = value; + return ref; +} + type LauncherCommandListProps = { listRef: React.RefObject; itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; @@ -47,75 +220,165 @@ const LauncherCommandList: React.FC = ({ onCommandClick, onCommandContextMenu, t, -}) => ( -
- {isLoading ? ( -
-

{t('launcher.status.discoveringApps')}

-
- ) : displayCommands.length === 0 && !calcResult ? ( -
-

{t('launcher.status.noMatchingResults')}

-
- ) : ( -
- {calcResult && ( - (itemRefs.current[0] = el)} - onCopy={onCalculatorCopy} - t={t} - /> - )} - - {sections.reduce( - (acc, section) => { - const startIndex = acc.index; - if (section.title) { - acc.nodes.push( -
- {section.title} -
- ); - } - section.items.forEach((command, i) => { - const flatIndex = startIndex + i; - const absoluteIndex = flatIndex + calcOffset; - const commandAlias = String(commandAliases[command.id] || '').trim(); - const commandHotkey = String(commandHotkeys[command.id] || '').trim(); - acc.nodes.push( - (itemRefs.current[absoluteIndex] = el)} - commandAlias={commandAlias} - commandHotkey={commandHotkey} - onClick={(event) => { - void onCommandClick(command, absoluteIndex, event); - }} - onContextMenu={(event) => onCommandContextMenu(event, command, absoluteIndex)} - t={t} - /> - ); - }); - acc.index += section.items.length; - return acc; - }, - { nodes: [] as React.ReactNode[], index: 0 } - ).nodes} +}) => { + const [scrollTop, setScrollTop] = React.useState(0); + const [viewportHeight, setViewportHeight] = React.useState(DEFAULT_VIEWPORT_HEIGHT); + const commandClickRef = useLatestValue(onCommandClick); + const commandContextMenuRef = useLatestValue(onCommandContextMenu); + + const virtualList = React.useMemo( + () => buildVirtualEntries(sections, calcResult, calcOffset), + [calcOffset, calcResult, sections] + ); + const shouldVirtualize = virtualList.entries.length > VIRTUALIZATION_THRESHOLD; + const visibleEntries = React.useMemo( + () => + shouldVirtualize + ? getVirtualizedEntries(virtualList.entries, scrollTop, viewportHeight, selectedIndex) + : virtualList.entries, + [scrollTop, selectedIndex, shouldVirtualize, viewportHeight, virtualList.entries] + ); + const registerItemRef = React.useCallback( + (absoluteIndex: number, el: HTMLDivElement | null) => { + itemRefs.current[absoluteIndex] = el; + }, + [itemRefs] + ); + const registerCalculatorRef = React.useCallback( + (el: HTMLDivElement | null) => { + itemRefs.current[0] = el; + }, + [itemRefs] + ); + const handleCommandClick = React.useCallback( + (command: CommandInfo, absoluteIndex: number, event?: React.MouseEvent) => { + void commandClickRef.current(command, absoluteIndex, event); + }, + [commandClickRef] + ); + const handleCommandContextMenu = React.useCallback( + (event: React.MouseEvent, command: CommandInfo, absoluteIndex: number) => { + commandContextMenuRef.current(event, command, absoluteIndex); + }, + [commandContextMenuRef] + ); + const handleScroll = React.useCallback((event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop); + }, []); + + React.useEffect(() => { + const element = listRef.current; + if (!element || !shouldVirtualize) return; + + const updateViewportHeight = () => { + setViewportHeight(element.clientHeight || DEFAULT_VIEWPORT_HEIGHT); + setScrollTop(element.scrollTop); + }; + updateViewportHeight(); + + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateViewportHeight); + resizeObserver?.observe(element); + window.addEventListener('resize', updateViewportHeight); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateViewportHeight); + }; + }, [listRef, shouldVirtualize]); + + const renderEntry = (entry: LauncherVirtualEntry, virtualized: boolean): React.ReactNode => { + let node: React.ReactNode; + if (entry.kind === 'calculator') { + node = ( + + ); + } else if (entry.kind === 'section') { + node = ( +
+ {entry.title} +
+ ); + } else { + const commandAlias = String(commandAliases[entry.command.id] || '').trim(); + const commandHotkey = String(commandHotkeys[entry.command.id] || '').trim(); + node = ( + + ); + } + + if (!virtualized) { + return {node}; + } + + return ( +
+ {node}
- )} -
-); + ); + }; + + return ( +
+ {isLoading ? ( +
+

{t('launcher.status.discoveringApps')}

+
+ ) : displayCommands.length === 0 && !calcResult ? ( +
+

{t('launcher.status.noMatchingResults')}

+
+ ) : shouldVirtualize ? ( +
+ {visibleEntries.map((entry) => renderEntry(entry, true))} +
+ ) : ( +
+ {visibleEntries.map((entry) => renderEntry(entry, false))} +
+ )} +
+ ); +}; export default LauncherCommandList; diff --git a/src/renderer/src/components/LauncherCommandRow.tsx b/src/renderer/src/components/LauncherCommandRow.tsx index 877b8247..1fbddca9 100644 --- a/src/renderer/src/components/LauncherCommandRow.tsx +++ b/src/renderer/src/components/LauncherCommandRow.tsx @@ -12,30 +12,65 @@ import { type LauncherCommandRowProps = { command: CommandInfo; flatIndex: number; + absoluteIndex: number; selected: boolean; - itemRef: (el: HTMLDivElement | null) => void; + style?: React.CSSProperties; + registerItemRef: (absoluteIndex: number, el: HTMLDivElement | null) => void; commandAlias: string; commandHotkey: string; - onClick: (event: React.MouseEvent) => void; - onContextMenu: (event: React.MouseEvent) => void; + onCommandClick: ( + command: CommandInfo, + selectedIndex: number, + event?: React.MouseEvent + ) => void | Promise; + onCommandContextMenu: ( + event: React.MouseEvent, + command: CommandInfo, + selectedIndex: number + ) => void; t: (key: string, params?: Record) => string; }; -const LauncherCommandRow: React.FC = ({ +const LauncherCommandRowComponent: React.FC = ({ command, flatIndex, + absoluteIndex, selected, - itemRef, + style, + registerItemRef, commandAlias, commandHotkey, - onClick, - onContextMenu, + onCommandClick, + onCommandContextMenu, t, }) => { - const accessoryLabel = getCommandAccessoryLabel(command); - const typeBadgeLabel = getCommandTypeBadgeLabel(command, t); - const fallbackCategory = getCategoryLabel(command.category, t); - const hotkeyParts = commandHotkey ? getShortcutDisplayParts(commandHotkey) : []; + const accessoryLabel = React.useMemo(() => getCommandAccessoryLabel(command), [command]); + const typeBadgeLabel = React.useMemo(() => getCommandTypeBadgeLabel(command, t), [command, t]); + const fallbackCategory = React.useMemo(() => getCategoryLabel(command.category, t), [command.category, t]); + const hotkeyParts = React.useMemo( + () => (commandHotkey ? getShortcutDisplayParts(commandHotkey) : []), + [commandHotkey] + ); + const displayTitle = React.useMemo(() => getCommandDisplayTitle(command, t), [command, t]); + const commandIcon = React.useMemo(() => renderCommandIcon(command), [command]); + const itemRef = React.useCallback( + (el: HTMLDivElement | null) => { + registerItemRef(absoluteIndex, el); + }, + [absoluteIndex, registerItemRef] + ); + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + void onCommandClick(command, absoluteIndex, event); + }, + [absoluteIndex, command, onCommandClick] + ); + const handleContextMenu = React.useCallback( + (event: React.MouseEvent) => { + onCommandContextMenu(event, command, absoluteIndex); + }, + [absoluteIndex, command, onCommandContextMenu] + ); return (
= ({ className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ selected ? 'selected' : '' }`} - onClick={onClick} - onContextMenu={onContextMenu} + style={style} + onClick={handleClick} + onContextMenu={handleContextMenu} >
- {renderCommandIcon(command)} + {commandIcon}
- {getCommandDisplayTitle(command, t)} + {displayTitle}
{accessoryLabel ? (
@@ -95,4 +131,6 @@ const LauncherCommandRow: React.FC = ({ ); }; +const LauncherCommandRow = React.memo(LauncherCommandRowComponent); + export default LauncherCommandRow; diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..b402c061 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -7,6 +7,14 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; +export interface GridItemGroup { + key: string; + title?: string; + subtitle?: string; + section?: GridItemRegistration['section']; + items: { item: GridItemRegistration; globalIdx: number }[]; +} + export function useGridRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); @@ -22,7 +30,8 @@ export function useGridRegistry() { .map((entry) => { const actionType = entry.props.actions?.type as any; const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`; + const section = entry.section; + return `${entry.id}:${entry.props.title || ''}:${section?.id || ''}:${section?.title || ''}:${section?.columns || ''}:${section?.aspectRatio || ''}:${section?.fit || ''}:${section?.inset || ''}:${actionName}`; }) .join('|'); if (snapshot !== lastSnapshotRef.current) { @@ -38,7 +47,7 @@ export function useGridRegistry() { const existing = registryRef.current.get(id); if (existing) { existing.props = data.props; - existing.sectionTitle = data.sectionTitle; + existing.section = data.section; existing.order = data.order; } else { registryRef.current.set(id, { id, ...data }); @@ -63,14 +72,22 @@ export function useGridRegistry() { } export function groupGridItems(filteredItems: GridItemRegistration[]) { - const groups: { title?: string; items: { item: GridItemRegistration; globalIdx: number }[] }[] = []; - let currentSection: string | undefined | null = null; + const groups: GridItemGroup[] = []; + let currentSectionKey: string | undefined | null = null; let globalIndex = 0; for (const item of filteredItems) { - if (item.sectionTitle !== currentSection || groups.length === 0) { - currentSection = item.sectionTitle; - groups.push({ title: item.sectionTitle, items: [] }); + const section = item.section; + const sectionKey = section?.id || section?.title || '__default_grid_section'; + if (sectionKey !== currentSectionKey || groups.length === 0) { + currentSectionKey = sectionKey; + groups.push({ + key: sectionKey, + title: section?.title, + subtitle: section?.subtitle, + section, + items: [], + }); } groups[groups.length - 1].items.push({ item, globalIdx: globalIndex++ }); } diff --git a/src/renderer/src/raycast-api/grid-runtime-items.tsx b/src/renderer/src/raycast-api/grid-runtime-items.tsx index d06d0269..8f36d6e2 100644 --- a/src/renderer/src/raycast-api/grid-runtime-items.tsx +++ b/src/renderer/src/raycast-api/grid-runtime-items.tsx @@ -4,10 +4,20 @@ * Contains grid item registration contexts and row/cell renderers. */ -import React, { createContext, useContext, useLayoutEffect, useRef } from 'react'; +import React, { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'react'; import { resolveTintColor } from './icon-runtime-assets'; import { renderIcon } from './icon-runtime-render'; +export interface GridSectionRegistration { + id: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + export interface GridItemRegistration { id: string; props: { @@ -20,7 +30,7 @@ export interface GridItemRegistration { accessory?: any; quickLook?: { name?: string; path: string }; }; - sectionTitle?: string; + section?: GridSectionRegistration; order: number; } @@ -31,33 +41,53 @@ export interface GridRegistryAPI { export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) { let gridItemOrderCounter = 0; + let gridSectionOrderCounter = 0; const GridRegistryContext = createContext({ set: () => {}, delete: () => {}, }); - const GridSectionTitleContext = createContext(undefined); + const GridSectionContext = createContext(undefined); function GridItemComponent(props: any) { const registry = useContext(GridRegistryContext); - const sectionTitle = useContext(GridSectionTitleContext); + const section = useContext(GridSectionContext); const stableId = useRef(props.id || `__gi_${++gridItemOrderCounter}`).current; const orderRef = useRef(null); if (orderRef.current === null) orderRef.current = ++gridItemOrderCounter; useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); + registry.set(stableId, { props, section, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, sectionTitle, stableId]); + }, [props, registry, section, stableId]); return null; } - function GridSectionComponent({ children, title }: { children?: React.ReactNode; title?: string }) { - return {children}; + function GridSectionComponent({ children, title, subtitle, columns, aspectRatio, fit, inset }: any) { + const stableId = useRef(`__gs_${++gridSectionOrderCounter}`).current; + const section = useMemo( + () => ({ id: stableId, title, subtitle, columns, aspectRatio, fit, inset }), + [aspectRatio, columns, fit, inset, stableId, subtitle, title], + ); + + return {children}; } - function GridItemRenderer({ title, subtitle, content, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { + function GridItemRenderer({ + title, + subtitle, + content, + accessory, + isSelected, + dataIdx, + itemHeight, + fit, + inset, + onSelect, + onActivate, + onContextAction, + }: any) { const isImageLikeSourceString = (value: string): boolean => { const source = String(value || '').trim(); if (!source) return false; @@ -158,6 +188,17 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) const swatchColor = getGridColor(content); const renderableContent = swatchColor ? null : toRenderableContent(content); + const accessoryIcon = accessory?.icon ? toRenderableContent(accessory.icon) : null; + const accessoryTitle = typeof accessory?.tooltip === 'string' ? accessory.tooltip : undefined; + const contentFitClass = fit === 'fill' ? 'w-full h-full object-cover' : 'w-full h-full object-contain'; + const insetClass = + inset === 'zero' + ? 'p-0' + : inset === 'md' + ? 'p-3' + : inset === 'lg' + ? 'p-5' + : 'p-1.5'; return (
string) : 'border-[var(--launcher-card-border)] bg-[var(--launcher-card-bg)] hover:bg-[var(--launcher-card-hover-bg)]' }`} style={{ - height: '160px', + height: `${itemHeight || 160}px`, boxShadow: isSelected ? '0 0 0 2px rgba(var(--on-surface-rgb), 0.24), inset 0 0 0 1px rgba(var(--on-surface-rgb), 0.16)' : undefined, @@ -181,12 +222,12 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) onMouseMove={onSelect} onContextMenu={onContextAction} > -
+
{swatchColor ? (
) : renderableContent ? (
- {renderIcon(renderableContent, 'w-full h-full object-contain')} + {renderIcon(renderableContent, contentFitClass)}
) : (
@@ -194,9 +235,14 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
)}
- {title && ( + {(title || subtitle || accessoryIcon) && (
-

{title}

+ {accessoryIcon && ( +
+ {renderIcon(accessoryIcon, 'w-3 h-3 object-contain')} +
+ )} + {title &&

{title}

} {subtitle &&

{subtitle}

}
)} diff --git a/src/renderer/src/raycast-api/grid-runtime-virtualization.ts b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts new file mode 100644 index 00000000..5ed85fe5 --- /dev/null +++ b/src/renderer/src/raycast-api/grid-runtime-virtualization.ts @@ -0,0 +1,276 @@ +/** + * Pure layout helpers for virtualized Grid rendering. + */ + +export const GRID_DEFAULT_COLUMNS = 5; +export const GRID_MIN_COLUMNS = 1; +export const GRID_MAX_COLUMNS = 8; +export const GRID_DEFAULT_ITEM_HEIGHT = 160; +export const GRID_ITEM_LABEL_HEIGHT = 34; +export const GRID_SECTION_HEADER_HEIGHT = 30; +export const GRID_ROW_GAP = 8; +export const GRID_GROUP_GAP = 8; +export const GRID_DEFAULT_OVERSCAN = GRID_DEFAULT_ITEM_HEIGHT * 2; + +export type GridFitValue = 'contain' | 'fill'; +export type GridInsetValue = 'zero' | 'sm' | 'md' | 'lg'; + +export interface GridSectionLayoutOptions { + id?: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + +export interface VirtualGridItemEntry { + item: { + id: string; + props: any; + section?: GridSectionLayoutOptions; + }; + globalIdx: number; +} + +export interface VirtualGridGroup { + key?: string; + title?: string; + subtitle?: string; + section?: GridSectionLayoutOptions; + items: VirtualGridItemEntry[]; +} + +export interface ResolvedGridSectionLayout { + columns: number; + aspectRatio?: string; + fit: GridFitValue; + inset: GridInsetValue; + itemHeight: number; +} + +export type VirtualGridRow = + | { + kind: 'section'; + key: string; + sectionKey: string; + title?: string; + subtitle?: string; + top: number; + height: number; + } + | { + kind: 'items'; + key: string; + sectionKey: string; + top: number; + height: number; + itemHeight: number; + items: VirtualGridItemEntry[]; + layout: ResolvedGridSectionLayout; + }; + +export interface VirtualGridLayout { + rows: VirtualGridRow[]; + totalHeight: number; + itemCount: number; + itemPositions: Array<{ top: number; height: number } | undefined>; + itemColumns: Array; +} + +export interface BuildVirtualGridLayoutOptions { + defaultColumns?: number; + itemSize?: string; + defaultAspectRatio?: string; + defaultFit?: string; + defaultInset?: string; + containerWidth?: number; +} + +export interface VisibleRowsOptions { + scrollTop: number; + viewportHeight: number; + overscan?: number; +} + +export interface ItemScrollOptions { + currentScrollTop: number; + viewportHeight: number; + scrollPadding?: number; +} + +export function normalizeGridColumns(columns?: number, fallback = GRID_DEFAULT_COLUMNS, itemSize?: string): number { + const sizeColumns = itemSize === 'small' ? 8 : itemSize === 'large' ? 3 : undefined; + const explicitColumns = typeof columns === 'number' && Number.isFinite(columns) ? columns : undefined; + const candidate = explicitColumns ?? sizeColumns ?? fallback; + const normalized = Number.isFinite(candidate) ? Math.floor(candidate) : GRID_DEFAULT_COLUMNS; + return Math.max(GRID_MIN_COLUMNS, Math.min(GRID_MAX_COLUMNS, normalized)); +} + +export function normalizeGridFit(value?: string): GridFitValue { + return value === 'fill' ? 'fill' : 'contain'; +} + +export function normalizeGridInset(value?: string): GridInsetValue { + if (value === 'zero' || value === 'none') return 'zero'; + if (value === 'sm' || value === 'small') return 'sm'; + if (value === 'md' || value === 'medium') return 'md'; + if (value === 'lg' || value === 'large') return 'lg'; + return 'sm'; +} + +export function normalizeGridAspectRatio(value?: string): string | undefined { + if (!value) return undefined; + const ratio = parseGridAspectRatio(value); + return ratio > 0 ? value : undefined; +} + +export function parseGridAspectRatio(value?: string): number { + if (!value) return 0; + if (value.includes('/')) { + const [width, height] = value.split('/').map((part) => Number(part)); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width / height; + } + return 0; + } + + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +export function getGridItemHeight(columns: number, containerWidth?: number, aspectRatio?: string): number { + const ratio = parseGridAspectRatio(aspectRatio); + if (!ratio || !containerWidth || containerWidth <= 0) return GRID_DEFAULT_ITEM_HEIGHT; + + const safeColumns = normalizeGridColumns(columns); + const totalGap = GRID_ROW_GAP * Math.max(0, safeColumns - 1); + const itemWidth = Math.max(1, (containerWidth - totalGap) / safeColumns); + return Math.max(96, Math.ceil(itemWidth / ratio + GRID_ITEM_LABEL_HEIGHT)); +} + +export function resolveGridSectionLayout( + section: GridSectionLayoutOptions | undefined, + options: BuildVirtualGridLayoutOptions, +): ResolvedGridSectionLayout { + const columns = normalizeGridColumns(section?.columns, normalizeGridColumns(options.defaultColumns, GRID_DEFAULT_COLUMNS, options.itemSize)); + const aspectRatio = normalizeGridAspectRatio(section?.aspectRatio ?? options.defaultAspectRatio); + const fit = normalizeGridFit(section?.fit ?? options.defaultFit); + const inset = normalizeGridInset(section?.inset ?? options.defaultInset); + const itemHeight = getGridItemHeight(columns, options.containerWidth, aspectRatio); + + return { columns, aspectRatio, fit, inset, itemHeight }; +} + +export function buildVirtualGridLayout( + groups: VirtualGridGroup[], + options: BuildVirtualGridLayoutOptions = {}, +): VirtualGridLayout { + const rows: VirtualGridRow[] = []; + const itemPositions: Array<{ top: number; height: number } | undefined> = []; + const itemColumns: Array = []; + let top = 0; + let itemCount = 0; + + groups.forEach((group, groupIndex) => { + const section = group.section; + const sectionKey = group.key || section?.id || group.title || `group-${groupIndex}`; + const title = group.title ?? section?.title; + const subtitle = group.subtitle ?? section?.subtitle; + + if (title) { + rows.push({ + kind: 'section', + key: `${sectionKey}:header`, + sectionKey, + title, + subtitle, + top, + height: GRID_SECTION_HEADER_HEIGHT, + }); + top += GRID_SECTION_HEADER_HEIGHT; + } + + const layout = resolveGridSectionLayout(section, options); + for (let start = 0; start < group.items.length; start += layout.columns) { + const rowItems = group.items.slice(start, start + layout.columns); + rows.push({ + kind: 'items', + key: `${sectionKey}:items:${start}`, + sectionKey, + top, + height: layout.itemHeight, + itemHeight: layout.itemHeight, + items: rowItems, + layout, + }); + + for (const entry of rowItems) { + itemPositions[entry.globalIdx] = { top, height: layout.itemHeight }; + itemColumns[entry.globalIdx] = layout.columns; + } + + itemCount += rowItems.length; + top += layout.itemHeight; + if (start + layout.columns < group.items.length) top += GRID_ROW_GAP; + } + + if (title || group.items.length > 0) top += GRID_GROUP_GAP; + }); + + return { + rows, + totalHeight: top, + itemCount, + itemPositions, + itemColumns, + }; +} + +export function getVisibleVirtualRows(rows: VirtualGridRow[], options: VisibleRowsOptions): VirtualGridRow[] { + const overscan = options.overscan ?? GRID_DEFAULT_OVERSCAN; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const start = Math.max(0, options.scrollTop - overscan); + const end = options.scrollTop + viewportHeight + overscan; + + return rows.filter((row) => row.top + row.height >= start && row.top <= end); +} + +export function getScrollTopForItemIndex( + layout: Pick, + itemIndex: number, + options: ItemScrollOptions, +): number { + const position = layout.itemPositions[itemIndex]; + if (!position) return options.currentScrollTop; + + const scrollPadding = options.scrollPadding ?? GRID_ROW_GAP; + const viewportHeight = Math.max(1, options.viewportHeight || GRID_DEFAULT_ITEM_HEIGHT); + const currentTop = Math.max(0, options.currentScrollTop); + const currentBottom = currentTop + viewportHeight; + const itemTop = position.top; + const itemBottom = position.top + position.height; + + if (itemTop < currentTop + scrollPadding) { + return Math.max(0, itemTop - scrollPadding); + } + + if (itemBottom > currentBottom - scrollPadding) { + return Math.max(0, itemBottom - viewportHeight + scrollPadding); + } + + return currentTop; +} + +export function getColumnsForItemIndex( + layout: Pick, + itemIndex: number, + fallbackColumns = GRID_DEFAULT_COLUMNS, +): number { + return normalizeGridColumns(layout.itemColumns[itemIndex], fallbackColumns); +} + +export function countVirtualizedRowItems(rows: VirtualGridRow[]): number { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} diff --git a/src/renderer/src/raycast-api/grid-runtime.tsx b/src/renderer/src/raycast-api/grid-runtime.tsx index 0b30a849..58a4d733 100644 --- a/src/renderer/src/raycast-api/grid-runtime.tsx +++ b/src/renderer/src/raycast-api/grid-runtime.tsx @@ -10,6 +10,14 @@ import type { ExtractedAction } from './action-runtime'; import { transliterateForSearch } from '../utils/transliterate'; import { createGridItemsRuntime } from './grid-runtime-items'; import { groupGridItems, useGridRegistry } from './grid-runtime-hooks'; +import { + GRID_DEFAULT_COLUMNS, + buildVirtualGridLayout, + getColumnsForItemIndex, + getScrollTopForItemIndex, + getVisibleVirtualRows, + normalizeGridColumns, +} from './grid-runtime-virtualization'; import { useI18n } from '../i18n'; interface GridRuntimeDeps { @@ -53,6 +61,10 @@ export function createGridRuntime(deps: GridRuntimeDeps) { function GridComponent({ children, columns, + itemSize, + aspectRatio, + fit, + inset, isLoading, searchBarPlaceholder, onSearchTextChange, @@ -73,9 +85,37 @@ export function createGridRuntime(deps: GridRuntimeDeps) { const gridRef = useRef(null); const { pop } = useNavigation(); - const cols = columns || 5; + const rootColumns = useMemo(() => normalizeGridColumns(columns, GRID_DEFAULT_COLUMNS, itemSize), [columns, itemSize]); + const [gridViewport, setGridViewport] = useState({ scrollTop: 0, viewportHeight: 0, containerWidth: 0 }); const { registryAPI, allItems } = useGridRegistry(); + const measureGridViewport = useCallback(() => { + const node = gridRef.current; + if (!node) return; + + const nextViewport = { + scrollTop: node.scrollTop, + viewportHeight: node.clientHeight, + containerWidth: Math.max(0, node.clientWidth - 16), + }; + + setGridViewport((current) => ( + Math.abs(current.scrollTop - nextViewport.scrollTop) < 1 + && current.viewportHeight === nextViewport.viewportHeight + && current.containerWidth === nextViewport.containerWidth + ? current + : nextViewport + )); + }, []); + + const handleGridScroll = useCallback((event: React.UIEvent) => { + const node = event.currentTarget; + setGridViewport((current) => { + if (Math.abs(current.scrollTop - node.scrollTop) < 1) return current; + return { ...current, scrollTop: node.scrollTop }; + }); + }, []); + useEffect(() => { if (controlledSearch === undefined) return; setInternalSearch(controlledSearch); @@ -109,6 +149,26 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }); }, [allItems, filtering, internalSearch, onSearchTextChange]); + const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); + const virtualLayout = useMemo( + () => buildVirtualGridLayout(groupedItems, { + defaultColumns: rootColumns, + itemSize, + defaultAspectRatio: aspectRatio, + defaultFit: fit, + defaultInset: inset, + containerWidth: gridViewport.containerWidth, + }), + [aspectRatio, fit, gridViewport.containerWidth, groupedItems, inset, itemSize, rootColumns], + ); + const visibleRows = useMemo( + () => getVisibleVirtualRows(virtualLayout.rows, { + scrollTop: gridViewport.scrollTop, + viewportHeight: gridViewport.viewportHeight, + }), + [gridViewport.scrollTop, gridViewport.viewportHeight, virtualLayout.rows], + ); + const debounceRef = useRef | null>(null); const handleSearchChange = useCallback( (value: string) => { @@ -171,14 +231,16 @@ export function createGridRuntime(deps: GridRuntimeDeps) { if (event.key === 'ArrowRight') setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft') setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + cols, filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - cols, 0)); - else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); + else if (event.key === 'ArrowDown') { + setSelectedIdx((value) => Math.min(value + getColumnsForItemIndex(virtualLayout, value, rootColumns), filteredItems.length - 1)); + } else if (event.key === 'ArrowUp') { + setSelectedIdx((value) => Math.max(value - getColumnsForItemIndex(virtualLayout, value, rootColumns), 0)); + } else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; event.preventDefault(); }, - [cols, filteredItems.length, isMetaK, matchesShortcut, primaryAction, selectedActions, showActions], + [filteredItems.length, isMetaK, matchesShortcut, primaryAction, rootColumns, selectedActions, showActions, virtualLayout], ); useEffect(() => { @@ -192,21 +254,43 @@ export function createGridRuntime(deps: GridRuntimeDeps) { }, [filteredItems.length, selectedIdx]); useEffect(() => { - gridRef.current?.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }, [selectedIdx]); + const node = gridRef.current; + if (!node || filteredItems.length === 0) return; + + const nextScrollTop = getScrollTopForItemIndex(virtualLayout, selectedIdx, { + currentScrollTop: node.scrollTop, + viewportHeight: node.clientHeight || gridViewport.viewportHeight, + }); + if (Math.abs(nextScrollTop - node.scrollTop) < 1) return; + + node.scrollTo({ top: nextScrollTop, behavior: 'smooth' }); + requestAnimationFrame(measureGridViewport); + }, [filteredItems.length, gridViewport.viewportHeight, measureGridViewport, selectedIdx, virtualLayout]); useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { + measureGridViewport(); + const node = gridRef.current; + if (!node) return; + + const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measureGridViewport) : null; + resizeObserver?.observe(node); + window.addEventListener('resize', measureGridViewport); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', measureGridViewport); + }; + }, [measureGridViewport]); + useEffect(() => { if (onSelectionChange && filteredItems[selectedIdx]) { onSelectionChange(filteredItems[selectedIdx]?.props?.id || null); } }, [filteredItems, onSelectionChange, selectedIdx]); - const groupedItems = useMemo(() => groupGridItems(filteredItems), [filteredItems]); - return (
@@ -229,40 +313,64 @@ export function createGridRuntime(deps: GridRuntimeDeps) { {searchBarAccessory &&
{searchBarAccessory}
}
-
+
{isLoading && filteredItems.length === 0 ? (

{t('common.loading')}

) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}
} -
- {group.items.map(({ item, globalIdx }) => ( - setSelectedIdx(globalIdx)} - onActivate={() => { - setSelectedIdx(globalIdx); - inputRef.current?.focus(); - }} - onContextAction={(event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setSelectedIdx(globalIdx); - setShowActions(true); - }} - /> - ))} -
-
- )) +
+ {visibleRows.map((row) => ( + row.kind === 'section' ? ( +
+ {row.title} + {row.subtitle && ( + {row.subtitle} + )} +
+ ) : ( +
+ {row.items.map(({ item, globalIdx }) => ( + setSelectedIdx(globalIdx)} + onActivate={() => { + setSelectedIdx(globalIdx); + inputRef.current?.focus(); + }} + onContextAction={(event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedIdx(globalIdx); + setShowActions(true); + }} + /> + ))} +
+ ) + ))} +
)}
@@ -300,7 +408,7 @@ export function createGridRuntime(deps: GridRuntimeDeps) { ); } - const GridInset = { Small: 'small', Medium: 'medium', Large: 'large' } as const; + const GridInset = { Zero: 'zero', Small: 'sm', Medium: 'md', Large: 'lg' } as const; const GridItemSize = { Small: 'small', Medium: 'medium', Large: 'large' } as const; const GridFit = { Contain: 'contain', Fill: 'fill' } as const; diff --git a/src/renderer/src/raycast-api/icon-runtime-assets.tsx b/src/renderer/src/raycast-api/icon-runtime-assets.tsx index 05731627..8f403393 100644 --- a/src/renderer/src/raycast-api/icon-runtime-assets.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-assets.tsx @@ -6,6 +6,10 @@ import React from 'react'; import { getIconRuntimeContext } from './icon-runtime-config'; +const LOCAL_PATH_EXISTS_CACHE_MAX = 4096; +export const LOCAL_PATH_EXISTS_CACHE_TTL_MS = 30_000; +const positiveLocalPathExistsCache = new Map(); + type RgbColor = { r: number; g: number; @@ -41,9 +45,22 @@ export function toScAssetUrl(filePath: string): string { function localPathExists(filePath: string): boolean { if (!filePath) return false; + const cachedAt = positiveLocalPathExistsCache.get(filePath); + if (cachedAt !== undefined && Date.now() - cachedAt < LOCAL_PATH_EXISTS_CACHE_TTL_MS) return true; + if (cachedAt !== undefined) { + positiveLocalPathExistsCache.delete(filePath); + } + try { const stat = (window as any).electron?.statSync?.(filePath); - return Boolean(stat?.exists); + const exists = Boolean(stat?.exists); + if (exists) { + if (positiveLocalPathExistsCache.size >= LOCAL_PATH_EXISTS_CACHE_MAX) { + positiveLocalPathExistsCache.clear(); + } + positiveLocalPathExistsCache.set(filePath, Date.now()); + } + return exists; } catch { return false; } diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..09130951 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -16,9 +16,27 @@ type PhosphorIconComponent = React.ComponentType<{ style?: React.CSSProperties; weight?: PhosphorIconWeight; }>; +type PhosphorIconResolution = { icon: PhosphorIconComponent; weight: PhosphorIconWeight }; type PhosphorExportValue = unknown; +const ICON_RESOLUTION_CACHE_LIMIT = 2048; +const raycastIconNameResolutionCache = new Map(); +const phosphorNameResolutionCache = new Map(); +const phosphorIconResolutionCache = new Map(); + +function setBoundedCacheEntry(cache: Map, key: K, value: V) { + if (!cache.has(key) && cache.size >= ICON_RESOLUTION_CACHE_LIMIT) { + cache.clear(); + } + cache.set(key, value); +} + +function cachePhosphorIconResolution(input: string, result: PhosphorIconResolution | undefined): PhosphorIconResolution | undefined { + setBoundedCacheEntry(phosphorIconResolutionCache, input, result || null); + return result; +} + function normalizeIconName(name: string): string { return String(name || '') .trim() @@ -76,29 +94,61 @@ if (RAYCAST_ICON_VALUE_TO_NAME instanceof Map) { function resolveRaycastIconName(input: string): RaycastIconName | undefined { const rawInput = String(input || '').trim(); + const cached = raycastIconNameResolutionCache.get(rawInput); + if (cached !== undefined || raycastIconNameResolutionCache.has(rawInput)) { + return cached || undefined; + } + const normalized = normalizeIconName(input); - if (!rawInput && !normalized) return undefined; - if (raycastIconNameSet.has(rawInput)) return rawInput as RaycastIconName; - return raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + let resolved: RaycastIconName | undefined; + if (rawInput || normalized) { + resolved = raycastIconNameSet.has(rawInput) + ? rawInput as RaycastIconName + : raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + } + + setBoundedCacheEntry(raycastIconNameResolutionCache, rawInput, resolved || null); + return resolved; } function tryResolvePhosphorByName(name: string): PhosphorIconComponent | undefined { - if (!name) return undefined; + const cacheKey = String(name || ''); + const cached = phosphorNameResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorNameResolutionCache.has(cacheKey)) { + return cached || undefined; + } + + if (!name) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } const direct = (Phosphor as Record)[name]; - if (isRenderablePhosphorComponent(direct)) return direct as PhosphorIconComponent; + if (isRenderablePhosphorComponent(direct)) { + const resolved = direct as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } const normalizedTarget = normalizeIconName(name); - if (!normalizedTarget) return undefined; + if (!normalizedTarget) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } // Some bundling modes can make namespace entries non-enumerable for Object.entries. // getOwnPropertyNames is more robust for resolving the export keys. for (const key of Object.getOwnPropertyNames(Phosphor)) { if (normalizeIconName(key) !== normalizedTarget) continue; const candidate = (Phosphor as Record)[key]; - if (isRenderablePhosphorComponent(candidate)) return candidate as PhosphorIconComponent; + if (isRenderablePhosphorComponent(candidate)) { + const resolved = candidate as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } } + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); return undefined; } @@ -203,7 +253,13 @@ function bestFuzzyPhosphorCandidate(input: string): string | undefined { return bestName || undefined; } -function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComponent; weight: PhosphorIconWeight } | undefined { +function resolvePhosphorIconFromRaycast(input: string): PhosphorIconResolution | undefined { + const cacheKey = String(input || ''); + const cached = phosphorIconResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorIconResolutionCache.has(cacheKey)) { + return cached || undefined; + } + const resolvedRaycastName = resolveRaycastIconName(input); const iconName = (resolvedRaycastName || input || '').replace(/^Icon\./, ''); const normalized = normalizeIconName(iconName); @@ -214,7 +270,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (normalized === 'dot' || normalizedBase === 'dot') { const dotIcon = tryResolvePhosphorByName('Circle'); if (dotIcon) { - return { icon: dotIcon, weight: 'fill' }; + return cachePhosphorIconResolution(cacheKey, { icon: dotIcon, weight: 'fill' }); } } @@ -233,7 +289,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of directCandidates) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -244,7 +300,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of explicitAliases) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -296,7 +352,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of candidates) { const icon = tryResolvePhosphorByName(candidate); if (icon) { - return { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -304,13 +360,13 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (fuzzyCandidate) { const fuzzyResolved = tryResolvePhosphorByName(fuzzyCandidate); if (fuzzyResolved) { - return { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); - if (!fallback) return undefined; - return { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + if (!fallback) return cachePhosphorIconResolution(cacheKey, undefined); + return cachePhosphorIconResolution(cacheKey, { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } export function renderPhosphorIcon(input: string, className: string, tint?: string): React.ReactNode { 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; diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..82948ff6 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -7,6 +7,35 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; +export const LIST_ROW_HEIGHT = 36; +export const LIST_HEADER_HEIGHT = 24; +export const LIST_OVERSCAN = 8; +export const EMOJI_GRID_COLUMNS = 8; +export const EMOJI_GRID_CELL_HEIGHT = 96; +export const EMOJI_GRID_ROW_GAP = 8; +export const EMOJI_GRID_ROW_HEIGHT = EMOJI_GRID_CELL_HEIGHT + EMOJI_GRID_ROW_GAP; +export const EMOJI_GRID_HEADER_HEIGHT = 28; + +export type ListItemGroup = { + title?: string; + items: { item: ItemRegistration; globalIdx: number }[]; +}; + +export type ListVirtualRow = + | { type: 'header'; title: string; key: string; height: number } + | { type: 'item'; item: ItemRegistration; globalIdx: number; key: string; height: number }; + +export type EmojiGridVirtualRow = + | { type: 'header'; title: string; count: number; key: string; height: number } + | { type: 'emoji-row'; items: ListItemGroup['items']; key: string; height: number }; + +export type VirtualRow = ListVirtualRow | EmojiGridVirtualRow; + +export type VirtualRowMetrics = { + offsets: number[]; + totalHeight: number; +}; + function getReactTypeName(type: any): string { return String(type?.displayName || type?.name || type || ''); } @@ -155,8 +184,8 @@ export function shouldUseEmojiGrid(filteredItems: ItemRegistration[], isShowingD return emojiIcons / Math.max(1, iconsWithValue) >= 0.95; } -export function groupListItems(filteredItems: ItemRegistration[]) { - const groups: { title?: string; items: { item: ItemRegistration; globalIdx: number }[] }[] = []; +export function groupListItems(filteredItems: ItemRegistration[]): ListItemGroup[] { + const groups: ListItemGroup[] = []; let currentSection: string | undefined | null = null; let globalIndex = 0; @@ -170,3 +199,105 @@ export function groupListItems(filteredItems: ItemRegistration[]) { return groups; } + +export function buildListVirtualRows(groupedItems: ListItemGroup[]): ListVirtualRow[] { + const rows: ListVirtualRow[] = []; + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}`, height: LIST_HEADER_HEIGHT }); + } + for (const entry of group.items) { + rows.push({ + type: 'item', + item: entry.item, + globalIdx: entry.globalIdx, + key: entry.item.id, + height: LIST_ROW_HEIGHT, + }); + } + } + return rows; +} + +export function buildEmojiGridVirtualRows(groupedItems: ListItemGroup[], columns = EMOJI_GRID_COLUMNS): EmojiGridVirtualRow[] { + const rows: EmojiGridVirtualRow[] = []; + const safeColumns = Math.max(1, Math.floor(columns)); + + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ + type: 'header', + title: group.title, + count: group.items.length, + key: `__eg_h_${groupIndex}`, + height: EMOJI_GRID_HEADER_HEIGHT, + }); + } + + for (let start = 0; start < group.items.length; start += safeColumns) { + rows.push({ + type: 'emoji-row', + items: group.items.slice(start, start + safeColumns), + key: `__eg_r_${groupIndex}_${start}`, + height: EMOJI_GRID_ROW_HEIGHT, + }); + } + } + + return rows; +} + +export function measureVirtualRows(rows: Array<{ height: number }>): VirtualRowMetrics { + const offsets: number[] = new Array(rows.length); + let totalHeight = 0; + for (let index = 0; index < rows.length; index += 1) { + offsets[index] = totalHeight; + totalHeight += rows[index].height; + } + return { offsets, totalHeight }; +} + +export function getVisibleVirtualRange( + rows: Array<{ height: number }>, + rowMetrics: VirtualRowMetrics, + scrollTop: number, + containerHeight: number, + overscan = LIST_OVERSCAN, +) { + if (rows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; + + const top = scrollTop; + const bottom = scrollTop + (containerHeight || 600); + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (rowMetrics.offsets[mid] + rows[mid].height <= top) lo = mid + 1; + else hi = mid; + } + + const visibleStart = Math.max(0, lo - overscan); + let visibleEnd = lo; + while (visibleEnd < rows.length && rowMetrics.offsets[visibleEnd] < bottom) visibleEnd += 1; + visibleEnd = Math.min(rows.length, visibleEnd + overscan); + return { visibleStart, visibleEnd }; +} + +export function buildItemToVirtualRowMap(rows: VirtualRow[], itemCount: number): number[] { + const map: number[] = new Array(itemCount); + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (row.type === 'item') { + map[row.globalIdx] = rowIndex; + } else if (row.type === 'emoji-row') { + for (const entry of row.items) { + map[entry.globalIdx] = rowIndex; + } + } + } + + return map; +} diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..21cd83b4 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -53,13 +53,14 @@ export function createListRenderers(deps: ListRendererDeps) { const registry = useContext(ListRegistryContext); const sectionTitle = useContext(ListSectionTitleContext); const stableId = useRef(props.id || `__li_${++itemOrderCounter}`).current; - const renderOrder = ++itemOrderCounter; + const orderRef = useRef(null); + if (orderRef.current === null) orderRef.current = ++itemOrderCounter; const selCtx = useContext(SelectedItemActionsContext); useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: renderOrder }); + registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, renderOrder, sectionTitle, stableId]); + }, [props, registry, sectionTitle, stableId]); // When this item is selected, render its actions within the extension's // context tree so that per-item React contexts (e.g. VaultItemContext) @@ -102,7 +103,7 @@ export function createListRenderers(deps: ListRendererDeps) { {icon &&
{renderIcon(icon, iconClassName, assetsPath)}
}
{primaryText}
{secondaryText && {secondaryText}} - {accessories?.map((accessory, index) => { + {accessories?.map((accessory: ListItemAccessory, index: number) => { const accessoryText = typeof accessory?.text === 'string' ? accessory.text : typeof accessory?.text === 'object' ? accessory.text?.value || '' : ''; const accessoryTextColorRaw = typeof accessory?.text === 'object' ? accessory.text?.color : undefined; const tagText = typeof accessory?.tag === 'string' ? accessory.tag : typeof accessory?.tag === 'object' ? accessory.tag?.value || '' : ''; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..f1462b25 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -10,7 +10,19 @@ import type { ExtractedAction } from './action-runtime'; import { useI18n } from '../i18n'; import { transliterateForSearch } from '../utils/transliterate'; import { createListDetailRuntime } from './list-runtime-detail'; -import { groupListItems, shouldUseEmojiGrid, useListRegistry } from './list-runtime-hooks'; +import { + buildEmojiGridVirtualRows, + buildItemToVirtualRowMap, + buildListVirtualRows, + EMOJI_GRID_CELL_HEIGHT, + EMOJI_GRID_COLUMNS, + EMOJI_GRID_ROW_GAP, + getVisibleVirtualRange, + groupListItems, + measureVirtualRows, + shouldUseEmojiGrid, + useListRegistry, +} from './list-runtime-hooks'; import { createListRenderers } from './list-runtime-renderers'; import { EmptyViewRegistryContext, @@ -188,8 +200,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { if (event.key === 'ArrowRight' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? 8 : 1), filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? 8 : 1), 0)); + else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), filteredItems.length - 1)); + else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), 0)); else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; @@ -255,39 +267,13 @@ export function createListRuntime(deps: ListRuntimeDeps) { const groupedItems = useMemo(() => groupListItems(filteredItems), [filteredItems]); - // ─── Viewport virtualization for the linear (non-grid) list ───── - // Brew-style extensions ship ~5k items; rendering all of them as DOM - // nodes is the bottleneck. We render only the slice in view (plus a - // small buffer) and pad the scroll container with spacer divs so the - // scrollbar still represents the full list. - const ROW_HEIGHT = 36; - const HEADER_HEIGHT = 24; - const OVERSCAN = 8; - - const flatRows = useMemo(() => { - const rows: Array< - | { type: 'header'; title: string; key: string } - | { type: 'item'; item: typeof filteredItems[number]; globalIdx: number; key: string } - > = []; - for (let g = 0; g < groupedItems.length; g += 1) { - const group = groupedItems[g]; - if (group.title) rows.push({ type: 'header', title: group.title, key: `__h_${g}` }); - for (const entry of group.items) { - rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); - } - } - return rows; - }, [groupedItems]); - - const rowMetrics = useMemo(() => { - const offsets: number[] = new Array(flatRows.length); - let cum = 0; - for (let i = 0; i < flatRows.length; i += 1) { - offsets[i] = cum; - cum += flatRows[i].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - } - return { offsets, totalHeight: cum }; - }, [flatRows]); + // ─── Viewport virtualization for list rows and emoji grid rows ───── + // Emoji-heavy extensions can ship thousands of cells. Both layouts render + // only the rows in view plus a buffer and use spacers to preserve scroll. + const listRows = useMemo(() => buildListVirtualRows(groupedItems), [groupedItems]); + const emojiGridRows = useMemo(() => buildEmojiGridVirtualRows(groupedItems), [groupedItems]); + const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows; + const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]); const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); @@ -320,45 +306,23 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; }, []); - const { visibleStart, visibleEnd } = useMemo(() => { - if (flatRows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; - const top = scrollTop; - const bottom = scrollTop + (containerHeight || 600); - let lo = 0; - let hi = flatRows.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const rowH = flatRows[mid].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - if (rowMetrics.offsets[mid] + rowH <= top) lo = mid + 1; - else hi = mid; - } - const start = Math.max(0, lo - OVERSCAN); - let end = lo; - while (end < flatRows.length && rowMetrics.offsets[end] < bottom) end += 1; - end = Math.min(flatRows.length, end + OVERSCAN); - return { visibleStart: start, visibleEnd: end }; - }, [flatRows, rowMetrics, scrollTop, containerHeight]); - - // Map from filteredItems index → flat row index for scroll-into-view. + const { visibleStart, visibleEnd } = useMemo( + () => getVisibleVirtualRange(virtualRows, rowMetrics, scrollTop, containerHeight), + [containerHeight, rowMetrics, scrollTop, virtualRows], + ); + + // Map from filteredItems index → active virtual row index for scroll-into-view. const itemIdxToRowIdx = useMemo(() => { - const map: number[] = new Array(filteredItems.length); - let itemSeen = -1; - for (let i = 0; i < flatRows.length; i += 1) { - if (flatRows[i].type === 'item') { - itemSeen += 1; - map[itemSeen] = i; - } - } - return map; - }, [flatRows, filteredItems.length]); + return buildItemToVirtualRowMap(virtualRows, filteredItems.length); + }, [filteredItems.length, virtualRows]); // Stable refs so the scroll-into-view effect only fires when the user - // moves selection — not when upstream re-renders give flatRows/rowMetrics/ - // itemIdxToRowIdx fresh identities. Without this, scrolling the wheel + // moves selection — not when upstream re-renders give virtualRows/ + // rowMetrics/itemIdxToRowIdx fresh identities. Without this, scrolling the wheel // triggers any unrelated re-render → effect re-runs → snaps back to // selectedIdx. - const flatRowsRef = useRef(flatRows); - flatRowsRef.current = flatRows; + const virtualRowsRef = useRef(virtualRows); + virtualRowsRef.current = virtualRows; const rowMetricsRef = useRef(rowMetrics); rowMetricsRef.current = rowMetrics; const itemIdxToRowIdxRef = useRef(itemIdxToRowIdx); @@ -371,7 +335,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { if (rowIdx == null) return; const top = rowMetricsRef.current.offsets[rowIdx]; if (top == null) return; - const rowH = flatRowsRef.current[rowIdx]?.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; + const rowH = virtualRowsRef.current[rowIdx]?.height || 0; const visTop = el.scrollTop; const visBottom = visTop + el.clientHeight; // 'auto' (instant) — smooth scrolling queues animations that interrupt @@ -390,16 +354,22 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const rawDetailElement = rawDetail as React.ReactElement<{ children?: React.ReactNode }>; + const children = React.Children.toArray(rawDetailElement.props.children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; for (const child of children) { if (!React.isValidElement(child)) continue; if ((child.type as any) !== ListItemDetail) continue; - if (child.props.markdown !== undefined) mergedMarkdown = child.props.markdown; - if (child.props.metadata !== undefined) mergedMetadata = child.props.metadata; - if (child.props.isLoading !== undefined) mergedIsLoading = child.props.isLoading; + const detailProps = child.props as { + markdown?: string; + metadata?: React.ReactElement; + isLoading?: boolean; + }; + if (detailProps.markdown !== undefined) mergedMarkdown = detailProps.markdown; + if (detailProps.metadata !== undefined) mergedMetadata = detailProps.metadata; + if (detailProps.isLoading !== undefined) mergedIsLoading = detailProps.isLoading; } if (mergedMarkdown === undefined && mergedMetadata === undefined) return rawDetail; return React.createElement(ListItemDetail, { @@ -416,40 +386,72 @@ export function createListRuntime(deps: ListRuntimeDeps) { ) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

) : shouldUseEmojiGridValue ? ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}{group.items.length}
} -
- {group.items.map(({ item, globalIdx }) => { - const title = typeof item.props.title === 'string' ? item.props.title : (item.props.title as any)?.value || ''; + (() => { + const startOffset = rowMetrics.offsets[visibleStart] || 0; + const endOffset = visibleEnd < rowMetrics.offsets.length + ? rowMetrics.offsets[visibleEnd] + : rowMetrics.totalHeight; + const bottomSpacer = Math.max(0, rowMetrics.totalHeight - endOffset); + return ( + <> + {startOffset > 0 && -
- )) + {bottomSpacer > 0 &&