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/scripts/measure-canvas-asset-event-loop.mjs b/scripts/measure-canvas-asset-event-loop.mjs new file mode 100644 index 00000000..c2e868bd --- /dev/null +++ b/scripts/measure-canvas-asset-event-loop.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { once } from 'node:events'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; + +const fileSizeMb = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_MB || '32', 10); +const iterations = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_ITERATIONS || '8', 10); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-canvas-asset-delay-')); +const assetPath = path.join(tmpDir, 'asset.bin'); + +function summarize(histogram, elapsedMs) { + return { + elapsedMs: Number(elapsedMs.toFixed(3)), + meanDelayMs: Number((histogram.mean / 1e6).toFixed(3)), + maxDelayMs: Number((histogram.max / 1e6).toFixed(3)), + p95DelayMs: Number((histogram.percentile(95) / 1e6).toFixed(3)), + }; +} + +async function measure(label, fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + const started = performance.now(); + await fn(); + await new Promise((resolve) => setTimeout(resolve, 5)); + const elapsedMs = performance.now() - started; + histogram.disable(); + return { label, ...summarize(histogram, elapsedMs) }; +} + +async function readResponseBody(response) { + const reader = response.body?.getReader(); + if (!reader) return 0; + let bytes = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + } + return bytes; +} + +async function createServingServer(mode) { + const server = http.createServer(async (_req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + + if (mode === 'buffered') { + const data = await fs.promises.readFile(assetPath); + res.end(data); + return; + } + + fs.createReadStream(assetPath).pipe(res); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}/asset.bin`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +async function measureHttpServing(label, mode) { + const server = await createServingServer(mode); + try { + return await measure(label, async () => { + for (let index = 0; index < iterations; index += 1) { + const response = await fetch(server.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + await response.arrayBuffer(); + } + }); + } finally { + await server.close(); + } +} + +try { + fs.writeFileSync(assetPath, Buffer.alloc(fileSizeMb * 1024 * 1024, 7)); + + const syncRead = await measure('sync-readFileSync-baseline', async () => { + for (let index = 0; index < iterations; index += 1) { + fs.readFileSync(assetPath); + } + }); + + const asyncRead = await measure('async-fs.promises.readFile-baseline', async () => { + for (let index = 0; index < iterations; index += 1) { + await fs.promises.readFile(assetPath); + } + }); + + const bufferedResponse = await measure('old-buffered-response-readFile', async () => { + for (let index = 0; index < iterations; index += 1) { + const response = new Response(await fs.promises.readFile(assetPath), { + headers: { 'Content-Type': 'application/octet-stream' }, + }); + await readResponseBody(response); + } + }); + + const streamResponse = await measure('new-file-stream-response', async () => { + for (let index = 0; index < iterations; index += 1) { + const stream = fs.createReadStream(assetPath); + const response = new Response(Readable.toWeb(stream), { + headers: { + 'Content-Type': 'application/octet-stream', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + await readResponseBody(response); + } + }); + + const bufferedHttp = await measureHttpServing('old-buffered-http-serving', 'buffered'); + const streamHttp = await measureHttpServing('new-stream-http-serving', 'stream'); + + console.log(JSON.stringify({ + fileSizeMb, + iterations, + bytesServedPerMeasurement: fileSizeMb * 1024 * 1024 * iterations, + measurements: [ + syncRead, + asyncRead, + bufferedResponse, + streamResponse, + bufferedHttp, + streamHttp, + ], + }, null, 2)); +} finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +} diff --git a/scripts/measure-raycast-grid-render-churn.mjs b/scripts/measure-raycast-grid-render-churn.mjs new file mode 100644 index 00000000..fc801b90 --- /dev/null +++ b/scripts/measure-raycast-grid-render-churn.mjs @@ -0,0 +1,565 @@ +#!/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 gridItemsPath = path.join(repoRoot, 'src/renderer/src/raycast-api/grid-runtime-items.tsx'); + +const visibleCells = readNumberArg('--visible-cells', 112); +const itemCount = readNumberArg('--items', 4096); +const selectionSteps = readNumberArg('--selection-steps', 1200); +const iterations = readNumberArg('--iterations', 15); +const warmups = readNumberArg('--warmups', 3); +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, '.raycast-grid-render-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 { performance } from 'node:perf_hooks'; +import { createGridItemsRuntime } from ${JSON.stringify(gridItemsPath)}; + +type Metrics = { + cellRenderCount: number; +}; + +const GRID_DEFAULT_COLUMNS = 5; + +declare global { + // eslint-disable-next-line no-var + var __raycastGridCellMetrics: Metrics | undefined; +} + +const visibleCells = ${visibleCells}; +const itemCount = ${itemCount}; +const selectionSteps = ${selectionSteps}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; +const noop = () => {}; + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function percentile(values: number[], pct: number): number { + if (values.length === 0) return 0; + const sorted = values.slice().sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * pct) - 1)); + return sorted[index] || 0; +} + +function resolveGridIconSource(source: string): string { + const value = String(source || '').trim(); + if (!value) return ''; + if (value.startsWith('http') || value.startsWith('data:') || value.startsWith('sc-asset:') || value.startsWith('/')) { + return value; + } + if (/\\.(svg|png|jpe?g|gif|webp|ico|tiff?)$/i.test(value)) { + return \`sc-asset://ext-asset/benchmark/\${value}\`; + } + return value; +} + +const { GridItemRenderer } = createGridItemsRuntime(resolveGridIconSource); + +class MiniNode { + nodeType: number; + nodeName: string; + tagName: string; + ownerDocument: any; + childNodes: any[]; + parentNode: any; + style: Record; + attributes: Record; + namespaceURI: string; + _text: string; + + constructor(nodeType: number, nodeName: string, ownerDocument?: any) { + this.nodeType = nodeType; + this.nodeName = nodeName; + this.tagName = nodeName; + this.ownerDocument = ownerDocument || this; + this.childNodes = []; + this.parentNode = null; + this.style = {}; + this.attributes = {}; + this.namespaceURI = 'http://www.w3.org/1999/xhtml'; + this._text = ''; + } + + appendChild(child: any) { + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + insertBefore(child: any, before: any) { + const index = this.childNodes.indexOf(before); + if (index < 0) return this.appendChild(child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + removeChild(child: any) { + const index = this.childNodes.indexOf(child); + if (index >= 0) this.childNodes.splice(index, 1); + child.parentNode = null; + return child; + } + + setAttribute(name: string, value: string) { + this.attributes[name] = String(value); + } + + removeAttribute(name: string) { + delete this.attributes[name]; + } + + addEventListener() {} + removeEventListener() {} + focus() {} + + get firstChild() { + return this.childNodes[0] || null; + } + + get lastChild() { + return this.childNodes[this.childNodes.length - 1] || null; + } + + get textContent() { + return this._text; + } + + set textContent(value: string) { + this._text = String(value); + this.childNodes = []; + } +} + +class MiniText extends MiniNode { + nodeValue: string; + + constructor(text: string, ownerDocument: any) { + super(3, '#text', ownerDocument); + this.nodeValue = String(text); + } +} + +class MiniDocument extends MiniNode { + documentElement: MiniNode; + body: MiniNode; + defaultView: any; + + constructor() { + super(9, '#document'); + this.ownerDocument = this; + this.documentElement = new MiniNode(1, 'HTML', this); + this.body = new MiniNode(1, 'BODY', this); + this.defaultView = globalThis; + } + + createElement(tag: string) { + return new MiniNode(1, tag.toUpperCase(), this); + } + + createElementNS(namespaceURI: string, tag: string) { + const node = new MiniNode(1, tag.toUpperCase(), this); + node.namespaceURI = namespaceURI; + return node; + } + + createTextNode(text: string) { + return new MiniText(text, this); + } + + addEventListener() {} + removeEventListener() {} +} + +function installMiniDom() { + const document = new MiniDocument(); + (globalThis as any).document = document; + (globalThis as any).window = globalThis; + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'supercmd-grid-benchmark' }, + configurable: true, + }); + (globalThis as any).HTMLElement = MiniNode; + (globalThis as any).HTMLIFrameElement = class {}; +} + +function makeContent(index: number): any { + switch (index % 10) { + case 0: + return \`cover-\${index}.png\`; + case 1: + return { source: \`Icon.Circle\`, color: 'blue' }; + case 2: + return { source: \`https://img.example.test/\${index}.webp\`, fallback: 'Image', mask: 'roundedRectangle' }; + case 3: + return { value: \`Icon.Star\`, color: 'yellow' }; + case 4: + return { value: { fileIcon: \`/Applications/App \${index}.app\` } }; + case 5: + return { source: { light: \`light-\${index}.png\`, dark: \`dark-\${index}.png\` }, tintColor: 'green' }; + case 6: + return { color: index % 2 === 0 ? '#32d74b' : 'magenta' }; + case 7: + return { source: \`data:image/gif;base64,R0lGODlhAQABAAAAACw=\` }; + case 8: + return { fileIcon: \`/Users/test/Documents/file-\${index}.md\` }; + default: + return null; + } +} + +function makeAccessory(index: number): any { + switch (index % 6) { + case 0: + return { icon: 'Icon.CheckCircle', tooltip: \`Ready \${index}\` }; + case 1: + return { icon: { value: 'Icon.Tag', color: 'orange' }, tooltip: \`Tagged \${index}\` }; + case 2: + return { icon: { source: \`badge-\${index}.svg\`, tintColor: 'purple' }, tooltip: \`Badge \${index}\` }; + case 3: + return { icon: { source: { light: 'light-badge.png', dark: 'dark-badge.png' } } }; + default: + return undefined; + } +} + +const cells = Array.from({ length: visibleCells }, (_, index) => ({ + title: \`Grid Item \${String(index).padStart(4, '0')}\`, + subtitle: index % 3 === 0 ? \`Subtitle \${index}\` : undefined, + content: makeContent(index), + accessory: makeAccessory(index), + isSelected: false, + dataIdx: index, + itemHeight: 128 + (index % 3) * 24, + fit: index % 4 === 0 ? 'fill' : 'contain', + inset: index % 5 === 0 ? 'zero' : index % 5 === 1 ? 'md' : index % 5 === 2 ? 'lg' : 'sm', +})); + +function renderVisibleWindow(selectedIdx: number) { + globalThis.__raycastGridCellMetrics = { cellRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( +
+ {cells.map((cell) => ( + + ))} +
, + ); + + return { + durationMs: performance.now() - started, + cellRenderCount: globalThis.__raycastGridCellMetrics?.cellRenderCount || 0, + htmlLength: html.length, + }; +} + +function GridSelectionWindow({ selectedIdx }: { selectedIdx: number }) { + return ( +
+ {cells.map((cell) => ( + {}} + onActivate={() => {}} + onContextAction={() => {}} + /> + ))} +
+ ); +} + +async function measureClientSelectionChurn() { + installMiniDom(); + const [{ createRoot }, { flushSync }] = await Promise.all([ + import('react-dom/client'), + import('react-dom'), + ]); + const container = document.createElement('div'); + const root = createRoot(container); + + flushSync(() => { + root.render(); + }); + + globalThis.__raycastGridCellMetrics = { cellRenderCount: 0 }; + const started = performance.now(); + for (let step = 1; step <= selectionSteps; step += 1) { + flushSync(() => { + root.render(); + }); + } + + const durationMs = performance.now() - started; + root.unmount(); + + return { + durationMs, + cellRenderCount: globalThis.__raycastGridCellMetrics?.cellRenderCount || 0, + }; +} + +function makeGridGroups() { + const items = Array.from({ length: itemCount }, (_, index) => ({ + item: { + id: \`grid-\${index}\`, + order: index, + props: { + id: \`grid-\${index}\`, + title: \`Grid Item \${index}\`, + subtitle: index % 4 === 0 ? \`Subtitle \${index}\` : undefined, + content: makeContent(index), + accessory: makeAccessory(index), + }, + section: index < itemCount / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + }, + globalIdx: index, + })); + + return [ + { key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: items.slice(0, Math.floor(itemCount / 2)) }, + { + key: 'section-b', + title: 'Section B', + section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + items: items.slice(Math.floor(itemCount / 2)), + }, + ]; +} + +function buildMeasurementGridLayout(groups: ReturnType) { + const rows: Array<{ startIdx: number; endIdx: number; top: number; bottom: number; columns: number }> = []; + let top = 0; + for (const group of groups) { + const columns = Number(group.section?.columns) > 0 ? Number(group.section.columns) : GRID_DEFAULT_COLUMNS; + for (let offset = 0; offset < group.items.length; offset += columns) { + const rowItems = group.items.slice(offset, offset + columns); + const rowHeight = Math.max(...rowItems.map(({ item }) => 128 + (item.order % 3) * 24)); + const startIdx = rowItems[0]?.globalIdx ?? 0; + const endIdx = rowItems[rowItems.length - 1]?.globalIdx ?? startIdx; + rows.push({ startIdx, endIdx, top, bottom: top + rowHeight, columns }); + top += rowHeight + 8; + } + } + return { rows }; +} + +function findLayoutRow(layout: ReturnType, itemIndex: number) { + return layout.rows.find((row) => itemIndex >= row.startIdx && itemIndex <= row.endIdx) || layout.rows[0]; +} + +function getColumnsForItemIndex(layout: ReturnType, itemIndex: number, fallback: number): number { + return findLayoutRow(layout, itemIndex)?.columns || fallback; +} + +function getScrollTopForItemIndex( + layout: ReturnType, + itemIndex: number, + options: { currentScrollTop: number; viewportHeight: number }, +) { + const row = findLayoutRow(layout, itemIndex); + if (!row) return options.currentScrollTop; + if (row.top < options.currentScrollTop) return row.top; + if (row.bottom > options.currentScrollTop + options.viewportHeight) return row.bottom - options.viewportHeight; + return options.currentScrollTop; +} + +const layout = buildMeasurementGridLayout(makeGridGroups()); + +function measureSelectionScroll() { + let selectedIdx = 0; + let scrollTop = 0; + let scrollUpdates = 0; + let scrollDistance = 0; + let columnsChecksum = 0; + const viewportHeight = 640; + const started = performance.now(); + + for (let step = 0; step < selectionSteps; step += 1) { + const columns = getColumnsForItemIndex(layout, selectedIdx, GRID_DEFAULT_COLUMNS); + columnsChecksum += columns; + const direction = step % 29 === 0 ? -1 : 1; + selectedIdx = Math.max(0, Math.min(itemCount - 1, selectedIdx + direction * columns)); + const nextScrollTop = getScrollTopForItemIndex(layout, selectedIdx, { + currentScrollTop: scrollTop, + viewportHeight, + }); + if (Math.abs(nextScrollTop - scrollTop) >= 1) { + scrollUpdates += 1; + scrollDistance += Math.abs(nextScrollTop - scrollTop); + scrollTop = nextScrollTop; + } + } + + return { + durationMs: performance.now() - started, + selectionSteps, + scrollUpdates, + finalScrollTop: scrollTop, + scrollDistance, + columnsChecksum, + }; +} + +for (let i = 0; i < warmups; i += 1) { + renderVisibleWindow(i % visibleCells); + measureSelectionScroll(); +} + +const cellSamples = Array.from({ length: iterations }, (_, index) => renderVisibleWindow(index % visibleCells)); +const scrollSamples = Array.from({ length: iterations }, () => measureSelectionScroll()); +const selectionChurnSamples = []; +for (let index = 0; index < iterations; index += 1) { + selectionChurnSamples.push(await measureClientSelectionChurn()); +} +const cellDurations = cellSamples.map((sample) => sample.durationMs); +const scrollDurations = scrollSamples.map((sample) => sample.durationMs); +const selectionChurnDurations = selectionChurnSamples.map((sample) => sample.durationMs); + +const summary = { + visibleCells, + itemCount, + selectionSteps, + iterations, + cellRender: { + cellRenderCount: cellSamples[0]?.cellRenderCount || 0, + medianDurationMs: median(cellDurations), + p95DurationMs: percentile(cellDurations, 0.95), + minDurationMs: Math.min(...cellDurations), + maxDurationMs: Math.max(...cellDurations), + htmlLength: cellSamples[0]?.htmlLength || 0, + }, + selectionScroll: { + scrollUpdates: scrollSamples[0]?.scrollUpdates || 0, + finalScrollTop: scrollSamples[0]?.finalScrollTop || 0, + scrollDistance: scrollSamples[0]?.scrollDistance || 0, + columnsChecksum: scrollSamples[0]?.columnsChecksum || 0, + medianDurationMs: median(scrollDurations), + p95DurationMs: percentile(scrollDurations, 0.95), + minDurationMs: Math.min(...scrollDurations), + maxDurationMs: Math.max(...scrollDurations), + }, + selectionRenderChurn: { + cellRenderCount: selectionChurnSamples[0]?.cellRenderCount || 0, + medianDurationMs: median(selectionChurnDurations), + p95DurationMs: percentile(selectionChurnDurations, 0.95), + minDurationMs: Math.min(...selectionChurnDurations), + maxDurationMs: Math.max(...selectionChurnDurations), + }, +}; + +if (!jsonOnly) { + console.log(\`Raycast Grid render churn measurement (visibleCells=\${visibleCells}, items=\${itemCount}, iterations=\${iterations})\`); + console.log([ + '- visible cell render', + \`cellRenders=\${summary.cellRender.cellRenderCount}\`, + \`median=\${summary.cellRender.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.cellRender.p95DurationMs.toFixed(3)}ms\`, + \`min=\${summary.cellRender.minDurationMs.toFixed(3)}ms\`, + \`max=\${summary.cellRender.maxDurationMs.toFixed(3)}ms\`, + ].join(' | ')); + console.log([ + '- rapid selection scroll', + \`steps=\${selectionSteps}\`, + \`scrollUpdates=\${summary.selectionScroll.scrollUpdates}\`, + \`median=\${summary.selectionScroll.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.selectionScroll.p95DurationMs.toFixed(3)}ms\`, + ].join(' | ')); + console.log([ + '- selection render churn', + \`steps=\${selectionSteps}\`, + \`cellRenders=\${summary.selectionRenderChurn.cellRenderCount}\`, + \`median=\${summary.selectionRenderChurn.medianDurationMs.toFixed(3)}ms\`, + \`p95=\${summary.selectionRenderChurn.p95DurationMs.toFixed(3)}ms\`, + ].join(' | ')); +} + +console.log(JSON.stringify(summary, null, 2)); +`); + +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: [{ + name: 'instrument-grid-item-renderer', + setup(build) { + build.onLoad({ filter: /grid-runtime-items\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + const marker = ' const swatchColor = getGridColor(content);'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument GridItemRenderer render count.'); + } + source = source.replace( + marker, + ' const __gridMetrics = (globalThis.__raycastGridCellMetrics ||= { cellRenderCount: 0 });\n __gridMetrics.cellRenderCount += 1;\n' + marker, + ); + return { contents: source, loader: 'tsx' }; + }); + }, + }], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/test-canvas-asset-serving.mjs b/scripts/test-canvas-asset-serving.mjs new file mode 100644 index 00000000..dad70649 --- /dev/null +++ b/scripts/test-canvas-asset-serving.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import * as esbuild from 'esbuild'; +import { importTs } from './lib/ts-import.mjs'; + +async function importCanvasStore(root) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-canvas-store-bundle-')); + const bundlePath = path.join(tmpDir, 'canvas-store.mjs'); + try { + await esbuild.build({ + entryPoints: [path.join(root, 'src/main/canvas-store.ts')], + outfile: bundlePath, + bundle: true, + format: 'esm', + platform: 'node', + logLevel: 'silent', + plugins: [{ + name: 'electron-test-stub', + setup(build) { + build.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron-test-stub', namespace: 'electron-test-stub' })); + build.onLoad({ filter: /.*/, namespace: 'electron-test-stub' }, () => ({ + loader: 'js', + contents: ` + export const app = { getPath: () => process.env.SUPERCMD_TEST_USER_DATA || process.cwd() }; + export const dialog = { showSaveDialog: async () => ({ canceled: true }) }; + export class BrowserWindow {} + `, + })); + }, + }], + }); + return await import(pathToFileURL(bundlePath).href); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +test('canvas sc-asset serving helpers', async (t) => { + const root = process.cwd(); + const protocol = await importTs(path.join(root, 'src/main/sc-asset-protocol.ts')); + + await t.test('resolves canvas-lib files under the library root with content and cache headers', () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd canvas lib'); + const resolved = protocol.resolveCanvasLibAssetRequest( + 'sc-asset://canvas-lib/nested/excalidraw-bundle.js', + canvasLibDir + ); + + assert.equal(resolved.ok, true); + assert.equal(resolved.asset.filePath, path.join(canvasLibDir, 'nested', 'excalidraw-bundle.js')); + assert.equal(resolved.asset.headers['Content-Type'], 'application/javascript'); + assert.equal(resolved.asset.headers['Cache-Control'], 'public, max-age=31536000, immutable'); + }); + + await t.test('assigns expected content types and falls back to octet-stream', () => { + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/style.css'), 'text/css'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/icon.svg'), 'image/svg+xml'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/font.woff2'), 'font/woff2'); + assert.equal(protocol.getCanvasLibAssetContentType('/tmp/file.bin'), 'application/octet-stream'); + }); + + await t.test('rejects empty, malformed, and escaping canvas-lib paths', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd-canvas-lib'); + + const empty = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/', canvasLibDir); + assert.equal(empty.ok, false); + assert.equal(empty.response.status, 400); + + const malformed = protocol.resolveCanvasLibAssetRequest('not a url', canvasLibDir); + assert.equal(malformed.ok, false); + assert.equal(malformed.response.status, 400); + + const badEscape = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/%E0%A4%A', canvasLibDir); + assert.equal(badEscape.ok, false); + assert.equal(badEscape.response.status, 400); + + const traversal = protocol.resolveCanvasLibAssetRequest('sc-asset://canvas-lib/%2e%2e%2foutside.js', canvasLibDir); + assert.equal(traversal.ok, false); + assert.equal(traversal.response.status, 404); + }); + + await t.test('serves through the provided file fetcher without buffering in the helper', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd canvas lib'); + let fetchedUrl = ''; + + const response = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/excalidraw-bundle.css', + canvasLibDir, + async (fileUrl) => { + fetchedUrl = fileUrl; + return new Response('css body', { status: 200, headers: { 'Content-Type': 'text/plain' } }); + } + ); + + assert.equal(fetchedUrl, pathToFileURL(path.join(canvasLibDir, 'excalidraw-bundle.css')).toString()); + assert.equal(response.status, 200); + assert.equal(response.headers.get('Content-Type'), 'text/css'); + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=31536000, immutable'); + assert.equal(await response.text(), 'css body'); + }); + + await t.test('returns 404 when the file-backed fetch fails or misses', async () => { + const canvasLibDir = path.join(os.tmpdir(), 'supercmd-canvas-lib'); + + const missing = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/missing.js', + canvasLibDir, + async () => new Response('', { status: 404 }) + ); + assert.equal(missing.status, 404); + + const failed = await protocol.serveCanvasLibAssetFromFile( + 'sc-asset://canvas-lib/missing.js', + canvasLibDir, + async () => { + throw new Error('missing'); + } + ); + assert.equal(failed.status, 404); + }); +}); + +test('canvas store async scene and thumbnail reads', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-canvas-store-')); + const previousUserData = process.env.SUPERCMD_TEST_USER_DATA; + process.env.SUPERCMD_TEST_USER_DATA = tmpDir; + + try { + const root = process.cwd(); + const store = await importCanvasStore(root); + const dataDir = path.join(tmpDir, 'canvas', 'data'); + await fs.mkdir(dataDir, { recursive: true }); + + const scene = { + elements: [{ id: 'a' }], + appState: { viewBackgroundColor: '#fff' }, + files: { image: { id: 'image' } }, + }; + await fs.writeFile(path.join(dataDir, 'scene-id.excalidraw'), JSON.stringify(scene), 'utf-8'); + await fs.writeFile(path.join(dataDir, 'scene-id.thumb.svg'), '', 'utf-8'); + await fs.writeFile(path.join(dataDir, 'invalid.excalidraw'), '{', 'utf-8'); + + await assert.doesNotReject(store.getSceneAsync('scene-id')); + assert.deepEqual(await store.getSceneAsync('scene-id'), scene); + assert.deepEqual(await store.getSceneAsync('missing-id'), { elements: [], appState: {}, files: {} }); + + const originalConsoleError = console.error; + console.error = () => {}; + try { + assert.deepEqual(await store.getSceneAsync('invalid'), { elements: [], appState: {}, files: {} }); + } finally { + console.error = originalConsoleError; + } + + assert.equal(await store.getThumbnailAsync('scene-id'), ''); + assert.equal(await store.getThumbnailAsync('missing-id'), null); + } finally { + if (previousUserData === undefined) { + delete process.env.SUPERCMD_TEST_USER_DATA; + } else { + process.env.SUPERCMD_TEST_USER_DATA = previousUserData; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/test-form-runtime-snapshot-cleanup.mjs b/scripts/test-form-runtime-snapshot-cleanup.mjs new file mode 100644 index 00000000..0df9fed7 --- /dev/null +++ b/scripts/test-form-runtime-snapshot-cleanup.mjs @@ -0,0 +1,303 @@ +#!/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 { TextEncoder } from 'node:util'; + +const require = createRequire(import.meta.url); +const FORM_RUNTIME_PATH = 'src/renderer/src/raycast-api/form-runtime.tsx'; +const FORM_RUNTIME_CONTEXT_PATH = 'src/renderer/src/raycast-api/form-runtime-context.tsx'; + +let esbuild; +try { + esbuild = require('esbuild'); +} catch { + esbuild = null; +} + +function createFakeReact() { + let activeRuntime = null; + + const areDepsEqual = (left, right) => { + if (!left || !right || left.length !== right.length) return false; + return left.every((value, index) => Object.is(value, right[index])); + }; + + const react = { + createContext(defaultValue) { + const context = { _currentValue: defaultValue, _lastProviderValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + context._lastProviderValue = props.value; + return props.children ?? null; + }; + return context; + }, + + useContext(context) { + return context._currentValue; + }, + + useState(initialValue) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + if (!(index in runtime.hooks)) { + runtime.hooks[index] = typeof initialValue === 'function' ? initialValue() : initialValue; + } + const setState = (nextValue) => { + const previous = runtime.hooks[index]; + runtime.hooks[index] = typeof nextValue === 'function' ? nextValue(previous) : nextValue; + }; + return [runtime.hooks[index], setState]; + }, + + useRef(initialValue) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + if (!(index in runtime.hooks)) { + runtime.hooks[index] = { current: initialValue }; + } + return runtime.hooks[index]; + }, + + useMemo(factory, deps) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + const previous = runtime.hooks[index]; + if (previous && areDepsEqual(previous.deps, deps)) return previous.value; + const value = factory(); + runtime.hooks[index] = { deps, value }; + return value; + }, + + useCallback(callback, deps) { + return react.useMemo(() => callback, deps); + }, + + useEffect(effect, deps) { + const runtime = activeRuntime; + const index = runtime.hookIndex; + runtime.hookIndex += 1; + const previous = runtime.effects[index]; + if (previous && areDepsEqual(previous.deps, deps)) return; + runtime.pendingEffects.push({ index, deps, effect, previousCleanup: previous?.cleanup }); + }, + + createElement(type, props, ...children) { + const nextProps = { + ...(props || {}), + children: children.length <= 1 ? children[0] : children, + }; + if (typeof type === 'function') { + return type(nextProps); + } + return { type, props: nextProps }; + }, + }; + + react.Fragment = Symbol.for('react.fragment'); + + react.createRenderer = (Component, props) => { + const runtime = { + hooks: [], + effects: [], + hookIndex: 0, + pendingEffects: [], + output: null, + + render(nextProps = props) { + props = nextProps; + runtime.hookIndex = 0; + runtime.pendingEffects = []; + activeRuntime = runtime; + try { + runtime.output = Component(props); + } finally { + activeRuntime = null; + } + for (const pending of runtime.pendingEffects) { + pending.previousCleanup?.(); + runtime.effects[pending.index] = { + deps: pending.deps, + cleanup: pending.effect() || undefined, + }; + } + return runtime.output; + }, + + unmount() { + for (const effect of runtime.effects) { + effect?.cleanup?.(); + } + runtime.effects = []; + }, + }; + return runtime; + }; + + return react; +} + +function compileModule(modulePath, stubs) { + const source = fs.readFileSync(modulePath, 'utf8'); + const loader = path.extname(modulePath) === '.tsx' ? 'tsx' : 'ts'; + const { code } = esbuild.transformSync(source, { + format: 'cjs', + jsx: 'transform', + loader, + target: 'es2020', + }); + const module = { exports: {} }; + const localRequire = (id) => { + if (Object.prototype.hasOwnProperty.call(stubs, id)) return stubs[id]; + throw new Error(`Unexpected require(${id}) while compiling ${modulePath}`); + }; + + vm.runInNewContext(code, { + console, + exports: module.exports, + module, + require: localRequire, + Symbol, + TextEncoder, + window: { + addEventListener() {}, + removeEventListener() {}, + }, + }); + return module.exports; +} + +function createRuntimeHarness() { + const fakeReact = createFakeReact(); + const formRuntimeState = { + clearFormFieldError(previous, id) { + if (!Object.prototype.hasOwnProperty.call(previous, id)) return previous; + const next = { ...previous }; + delete next[id]; + return next; + }, + setFormFieldError(previous, id, error) { + if (previous[id] === error) return previous; + return { ...previous, [id]: error }; + }, + }; + const contextExports = compileModule(FORM_RUNTIME_CONTEXT_PATH, { react: fakeReact }); + const runtimeExports = compileModule(FORM_RUNTIME_PATH, { + react: fakeReact, + './form-runtime-context': contextExports, + './form-runtime-fields': { attachFormFields() {} }, + './form-runtime-state': formRuntimeState, + '../i18n': { useI18n: () => ({ t: (key) => key }) }, + }); + + const { Form } = runtimeExports.createFormRuntime({ + ExtensionInfoReactContext: fakeReact.createContext({}), + useNavigation: () => ({ pop() {} }), + useCollectedActions: () => ({ collectedActions: [], registryAPI: {} }), + ActionRegistryContext: fakeReact.createContext({}), + ActionPanelOverlay: () => null, + matchesShortcut: () => false, + isMetaK: () => false, + renderShortcut: () => null, + getExtensionContext: () => ({ extensionName: 'Snapshot Harness' }), + }); + + const renderer = fakeReact.createRenderer(Form, { + children: null, + actions: null, + navigationTitle: 'Snapshot Harness', + isLoading: false, + draftValues: {}, + }); + + return { contextExports, renderer }; +} + +function updateMountedForm(contextExports) { + const formContext = contextExports.FormContext._lastProviderValue; + formContext.setValue('submitted', 'value'.repeat(128)); + formContext.setValue('placeholder-backed', ''); + formContext.setPlaceholder('placeholder-backed', 'fallback'.repeat(96)); + formContext.setError('submitted', 'Required'.repeat(32)); +} + +function exerciseSnapshotLifecycle({ runUnmountCleanup }) { + const { contextExports, renderer } = createRuntimeHarness(); + renderer.render(); + const afterMount = contextExports.getFormSnapshotRetentionMetrics(); + + updateMountedForm(contextExports); + renderer.render(); + const beforeUnmount = contextExports.getFormSnapshotRetentionMetrics(); + const submittedValues = contextExports.getFormValues(); + const submittedErrors = contextExports.getFormErrors(); + + if (runUnmountCleanup) { + renderer.unmount(); + } + + return { + afterMount, + beforeUnmount, + afterUnmount: contextExports.getFormSnapshotRetentionMetrics(), + submittedValues, + submittedErrors, + }; +} + +if (process.argv.includes('--report')) { + if (!esbuild) { + console.log(JSON.stringify({ skipped: 'esbuild dependency is not installed' }, null, 2)); + } else { + console.log( + JSON.stringify( + { + previousBehavior: exerciseSnapshotLifecycle({ runUnmountCleanup: false }), + fixedBehavior: exerciseSnapshotLifecycle({ runUnmountCleanup: true }), + }, + null, + 2, + ), + ); + } +} else if (!esbuild) { + test('Form runtime unmount clears retained global snapshots', { skip: 'esbuild dependency is not installed' }, () => {}); +} else { + test('Form runtime unmount clears retained global snapshots', () => { + const previousBehavior = exerciseSnapshotLifecycle({ runUnmountCleanup: false }); + assert.equal(previousBehavior.beforeUnmount.totalKeys > 0, true, 'mounted form should publish global snapshot keys'); + assert.equal( + previousBehavior.afterUnmount.totalKeys, + previousBehavior.beforeUnmount.totalKeys, + 'without an unmount cleanup, form snapshot keys remain retained', + ); + assert.equal( + previousBehavior.afterUnmount.serializedBytes, + previousBehavior.beforeUnmount.serializedBytes, + 'without an unmount cleanup, serialized snapshot bytes remain retained', + ); + + const fixedBehavior = exerciseSnapshotLifecycle({ runUnmountCleanup: true }); + assert.equal( + fixedBehavior.submittedValues['placeholder-backed'], + 'fallback'.repeat(96), + 'placeholder fallback should remain available while the form is mounted', + ); + assert.equal(fixedBehavior.submittedErrors.submitted, 'Required'.repeat(32)); + assert.equal(fixedBehavior.beforeUnmount.totalKeys, previousBehavior.beforeUnmount.totalKeys); + assert.equal(fixedBehavior.beforeUnmount.serializedBytes, previousBehavior.beforeUnmount.serializedBytes); + assert.equal(fixedBehavior.afterUnmount.valueKeys, 0); + assert.equal(fixedBehavior.afterUnmount.errorKeys, 0); + assert.equal(fixedBehavior.afterUnmount.placeholderKeys, 0); + assert.equal(fixedBehavior.afterUnmount.totalKeys, 0); + assert.equal(fixedBehavior.afterUnmount.serializedBytes, 43); + }); +} diff --git a/scripts/test-i18n-locale-loading.mjs b/scripts/test-i18n-locale-loading.mjs new file mode 100644 index 00000000..c1734f4d --- /dev/null +++ b/scripts/test-i18n-locale-loading.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function loadBundledRuntime() { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-i18n-runtime-')); + const outfile = path.join(tempDir, 'runtime.mjs'); + await build({ + entryPoints: [path.join(root, 'src/renderer/src/i18n/runtime.ts')], + outfile, + bundle: true, + platform: 'browser', + format: 'esm', + loader: { + '.json': 'json', + }, + logLevel: 'silent', + }); + + return import(`${pathToFileURL(outfile).href}?${Date.now()}`); +} + +test('locale catalogs load asynchronously and fall back to English before loading', async () => { + const runtime = await loadBundledRuntime(); + + assert.equal(runtime.isAppLocaleLoaded('en'), true); + assert.equal(runtime.isAppLocaleLoaded('de'), false); + assert.equal(runtime.translateMessage('de', 'settings.general.language.title'), 'Language'); + + await runtime.loadAppLocale('de'); + + assert.equal(runtime.isAppLocaleLoaded('de'), true); + assert.equal(runtime.translateMessage('de', 'settings.general.language.title'), 'Sprache'); +}); + +test('English fallback preserves interpolation for unloaded locales', async () => { + const runtime = await loadBundledRuntime(); + + assert.equal( + runtime.translateMessage('fr', 'settings.general.about.version', { version: '1.2.3' }), + 'SuperCmd v1.2.3' + ); +}); diff --git a/scripts/test-registry-microtask-lifecycle.mjs b/scripts/test-registry-microtask-lifecycle.mjs new file mode 100644 index 00000000..87c1fa66 --- /dev/null +++ b/scripts/test-registry-microtask-lifecycle.mjs @@ -0,0 +1,465 @@ +#!/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)), '..'); + +function reactStubPlugin() { + return { + name: 'react-stub', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^react$/ }, () => ({ path: 'react-stub', namespace: 'react-stub' })); + pluginBuild.onResolve({ filter: /^react\/jsx-(dev-)?runtime$/ }, () => ({ path: 'react-jsx-runtime-stub', namespace: 'react-stub' })); + pluginBuild.onLoad({ filter: /^react-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + function runtime() { + const current = globalThis.__SUPERCMD_REACT_RUNTIME__; + if (!current) throw new Error('React hook runtime is not installed'); + return current; + } + + export const Fragment = Symbol.for('react.fragment'); + export function createContext(defaultValue) { + const context = { _currentValue: defaultValue }; + context.Provider = function Provider(props) { + context._currentValue = props.value; + return props.children; + }; + return context; + } + export function createElement(type, props, ...children) { + return { $$typeof: Symbol.for('react.element'), type, props: { ...(props || {}), children } }; + } + export function isValidElement(value) { + return Boolean(value && value.$$typeof === Symbol.for('react.element')); + } + export function useCallback(callback, deps) { + return runtime().useCallback(callback, deps); + } + export function useContext(context) { + return runtime().useContext(context); + } + export function useEffect(effect, deps) { + return runtime().useEffect(effect, deps); + } + export function useMemo(factory, deps) { + return runtime().useMemo(factory, deps); + } + export function useRef(initialValue) { + return runtime().useRef(initialValue); + } + export function useState(initialValue) { + return runtime().useState(initialValue); + } + + const React = { + Fragment, + createContext, + createElement, + isValidElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + }; + export default React; + `, + })); + pluginBuild.onLoad({ filter: /^react-jsx-runtime-stub$/, namespace: 'react-stub' }, () => ({ + loader: 'js', + contents: ` + import { Fragment, createElement } from 'react'; + export { Fragment }; + export function jsx(type, props, key) { + return createElement(type, { ...(props || {}), ...(key === undefined ? {} : { key }) }); + } + export function jsxs(type, props, key) { + return jsx(type, props, key); + } + export function jsxDEV(type, props, key) { + return jsx(type, props, key); + } + `, + })); + }, + }; +} + +function menuBarStubsPlugin() { + return { + name: 'menubar-stubs', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-config$/ }, () => ({ path: 'menubar-runtime-config', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-payload-cache$/ }, () => ({ path: 'menubar-runtime-payload-cache', namespace: 'menubar-stubs' })); + pluginBuild.onResolve({ filter: /^\.\/menubar-runtime-shared$/ }, () => ({ path: 'menubar-runtime-shared', namespace: 'menubar-stubs' })); + + pluginBuild.onLoad({ filter: /^menubar-runtime-config$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function getMenuBarRuntimeDeps() { + return globalThis.__SUPERCMD_MENUBAR_DEPS__; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-payload-cache$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + export function createMenuBarVisiblePayloadHashCache() { + return {}; + } + export function shouldSendMenuBarVisiblePayload() { + return true; + } + `, + })); + pluginBuild.onLoad({ filter: /^menubar-runtime-shared$/, namespace: 'menubar-stubs' }, () => ({ + loader: 'js', + contents: ` + import { createContext } from 'react'; + export const MBRegistryContext = createContext(null); + export function initMenuBarClickListener() {} + export function removeMenuBarActions() {} + export function resetMenuBarOrderCounters() {} + export function setMenuBarActions() {} + export async function toMenuBarIconPayloadAsync() { + return undefined; + } + `, + })); + }, + }; +} + +async function importBundledModule(relativePath, plugins = []) { + const result = await build({ + entryPoints: [path.join(root, relativePath)], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + jsx: 'transform', + tsconfigRaw: { + compilerOptions: { + jsx: 'react', + }, + }, + plugins: [reactStubPlugin(), ...plugins], + }); + return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}`); +} + +function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); +} + +function createHookRuntime(label) { + const hooks = []; + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + }; + let hookIndex = 0; + let mounted = true; + + const runtime = { + counters, + render(callback) { + mounted = true; + hookIndex = 0; + return callback(); + }, + cleanupEffects({ markUnmounted = false } = {}) { + if (markUnmounted) mounted = false; + for (let index = hooks.length - 1; index >= 0; index -= 1) { + const cleanup = hooks[index]?.cleanup; + if (typeof cleanup === 'function') { + cleanup(); + hooks[index].cleanup = undefined; + } + } + }, + rerunEffects() { + mounted = true; + for (const hook of hooks) { + if (typeof hook?.effect !== 'function') continue; + const cleanup = hook.effect(); + hook.cleanup = cleanup; + } + }, + unmount() { + runtime.cleanupEffects({ markUnmounted: true }); + }, + useCallback(callback, deps) { + return runtime.useMemo(() => callback, deps); + }, + useContext(context) { + return context?._currentValue; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return; + if (typeof existing?.cleanup === 'function') existing.cleanup(); + const cleanup = effect(); + hooks[index] = { deps, effect, cleanup }; + }, + useMemo(factory, deps) { + const index = hookIndex; + hookIndex += 1; + const existing = hooks[index]; + if (existing && !depsChanged(existing.deps, deps)) return existing.value; + const value = factory(); + hooks[index] = { deps, value }; + return value; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + if (!hooks[index]) { + const state = { value: typeof initialValue === 'function' ? initialValue() : initialValue }; + const setState = (nextValue) => { + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + state.value = typeof nextValue === 'function' ? nextValue(state.value) : nextValue; + }; + hooks[index] = { state, setState }; + } + return [hooks[index].state.value, hooks[index].setState]; + }, + }; + + return runtime; +} + +function createLegacyRegistryScheduler(label) { + const counters = { + label, + setStateCalls: 0, + postUnmountSetStateCalls: 0, + pendingCallbacks: 0, + }; + let mounted = true; + let pending = false; + + return { + counters, + schedule() { + if (pending) return; + pending = true; + counters.pendingCallbacks += 1; + queueMicrotask(() => { + pending = false; + counters.setStateCalls += 1; + if (!mounted) counters.postUnmountSetStateCalls += 1; + }); + }, + unmount() { + mounted = false; + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function installRuntime(runtime) { + globalThis.__SUPERCMD_REACT_RUNTIME__ = runtime; +} + +test('legacy registry microtask scheduler calls setState after unmount', async () => { + const legacySurfaces = ['actions', 'list', 'grid', 'menubar'].map(createLegacyRegistryScheduler); + + for (const surface of legacySurfaces) { + surface.schedule(); + surface.unmount(); + } + await flushMicrotasks(); + + const metrics = Object.fromEntries(legacySurfaces.map((surface) => [surface.counters.label, surface.counters])); + for (const surface of legacySurfaces) { + assert.equal(surface.counters.pendingCallbacks, 1, `${surface.counters.label} should have queued one callback`); + assert.equal(surface.counters.postUnmountSetStateCalls, 1, `${surface.counters.label} legacy callback should set state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-legacy-reproduction', metrics }, null, 2)); +}); + +test('registry microtasks scheduled before unmount are suppressed by runtime hooks', async () => { + const [actionModule, listModule, gridModule, menuBarModule] = await Promise.all([ + importBundledModule('src/renderer/src/raycast-api/action-runtime-registry.tsx'), + importBundledModule('src/renderer/src/raycast-api/list-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/grid-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/menubar-runtime-parent.tsx', [menuBarStubsPlugin()]), + ]); + + const actionRuntime = createHookRuntime('actions'); + installRuntime(actionRuntime); + const actionRegistryRuntime = actionModule.createActionRegistryRuntime({ + snapshotExtensionContext: () => ({}), + withExtensionContext: (_ctx, callback) => callback(), + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'view' } }, + getFormValues: () => ({}), + Clipboard: { copy: () => {} }, + trash: () => {}, + getGlobalNavigation: () => ({ push: () => {} }), + }); + const actionHook = actionRuntime.render(() => actionRegistryRuntime.useCollectedActions()); + actionHook.registryAPI.register('action-1', { title: 'Action', execute: () => {}, order: 1 }); + actionRuntime.unmount(); + actionHook.registryAPI.register('action-after-unmount', { title: 'Late', execute: () => {}, order: 2 }); + + const listRuntime = createHookRuntime('list'); + installRuntime(listRuntime); + const listHook = listRuntime.render(() => listModule.useListRegistry()); + listHook.registryAPI.set('list-1', { props: { id: 'list-1', title: 'List' }, order: 1 }); + listRuntime.unmount(); + listHook.registryAPI.set('list-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const gridRuntime = createHookRuntime('grid'); + installRuntime(gridRuntime); + const gridHook = gridRuntime.render(() => gridModule.useGridRegistry()); + gridHook.registryAPI.set('grid-1', { props: { id: 'grid-1', title: 'Grid' }, order: 1 }); + gridRuntime.unmount(); + gridHook.registryAPI.set('grid-after-unmount', { props: { id: 'late', title: 'Late' }, order: 2 }); + + const menuBarRuntime = createHookRuntime('menubar'); + globalThis.window = { electron: { removeMenuBar: () => {}, updateMenuBar: () => {} } }; + globalThis.__SUPERCMD_MENUBAR_DEPS__ = { + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'menu-bar' } }, + getExtensionContext: () => ({ extensionName: 'ext', commandName: 'cmd', assetsPath: '', commandMode: 'menu-bar' }), + setExtensionContext: () => {}, + isEmojiOrSymbol: () => false, + }; + installRuntime(menuBarRuntime); + const menuTree = menuBarRuntime.render(() => menuBarModule.MenuBarExtraComponent({ children: null, title: 'Menu' })); + const menuRegistryAPI = menuTree.props.value; + menuRegistryAPI.register({ id: 'menu-1', type: 'item', title: 'Menu', order: 1 }); + menuBarRuntime.unmount(); + menuRegistryAPI.register({ id: 'menu-after-unmount', type: 'item', title: 'Late', order: 2 }); + + await flushMicrotasks(); + + const metrics = { + actions: actionRuntime.counters, + list: listRuntime.counters, + grid: gridRuntime.counters, + menubar: menuBarRuntime.counters, + }; + for (const [label, counters] of Object.entries(metrics)) { + assert.equal(counters.setStateCalls, 0, `${label} should suppress queued post-unmount updates`); + assert.equal(counters.postUnmountSetStateCalls, 0, `${label} should not call state after unmount`); + } + + console.log(JSON.stringify({ mode: 'registry-microtask-lifecycle-fixed', metrics }, null, 2)); +}); + +test('registry APIs republish static registrations across StrictMode passive cleanup replay', async () => { + const [actionModule, listModule, gridModule, menuBarModule] = await Promise.all([ + importBundledModule('src/renderer/src/raycast-api/action-runtime-registry.tsx'), + importBundledModule('src/renderer/src/raycast-api/list-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/grid-runtime-hooks.ts'), + importBundledModule('src/renderer/src/raycast-api/menubar-runtime-parent.tsx', [menuBarStubsPlugin()]), + ]); + + const actionRuntime = createHookRuntime('actions-strictmode'); + installRuntime(actionRuntime); + const actionRegistryRuntime = actionModule.createActionRegistryRuntime({ + snapshotExtensionContext: () => ({}), + withExtensionContext: (_ctx, callback) => callback(), + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'view' } }, + getFormValues: () => ({}), + Clipboard: { copy: () => {} }, + trash: () => {}, + getGlobalNavigation: () => ({ push: () => {} }), + }); + let actionHook = actionRuntime.render(() => actionRegistryRuntime.useCollectedActions()); + actionHook.registryAPI.register('action-1', { title: 'Action', execute: () => {}, order: 1 }); + actionRuntime.cleanupEffects(); + actionHook.registryAPI.register('action-1', { title: 'Action', execute: () => {}, order: 1 }); + actionRuntime.rerunEffects(); + await flushMicrotasks(); + actionHook = actionRuntime.render(() => actionRegistryRuntime.useCollectedActions()); + assert.deepEqual(actionHook.collectedActions.map((item) => item.title), ['Action']); + + const listRuntime = createHookRuntime('list-strictmode'); + installRuntime(listRuntime); + let listHook = listRuntime.render(() => listModule.useListRegistry()); + listHook.registryAPI.set('list-1', { props: { id: 'list-1', title: 'List' }, order: 1 }); + listRuntime.cleanupEffects(); + listHook.registryAPI.set('list-1', { props: { id: 'list-1', title: 'List' }, order: 1 }); + listRuntime.rerunEffects(); + await flushMicrotasks(); + listHook = listRuntime.render(() => listModule.useListRegistry()); + assert.deepEqual(listHook.allItems.map((item) => item.props.title), ['List']); + + const gridRuntime = createHookRuntime('grid-strictmode'); + installRuntime(gridRuntime); + let gridHook = gridRuntime.render(() => gridModule.useGridRegistry()); + gridHook.registryAPI.set('grid-1', { props: { id: 'grid-1', title: 'Grid' }, order: 1 }); + gridRuntime.cleanupEffects(); + gridHook.registryAPI.set('grid-1', { props: { id: 'grid-1', title: 'Grid' }, order: 1 }); + gridRuntime.rerunEffects(); + await flushMicrotasks(); + gridHook = gridRuntime.render(() => gridModule.useGridRegistry()); + assert.deepEqual(gridHook.allItems.map((item) => item.props.title), ['Grid']); + + const menuPayloads = []; + const menuBarRuntime = createHookRuntime('menubar-strictmode'); + globalThis.window = { + electron: { + removeMenuBar: () => {}, + updateMenuBar: (payload) => menuPayloads.push(payload), + }, + }; + globalThis.__SUPERCMD_MENUBAR_DEPS__ = { + ExtensionInfoReactContext: { _currentValue: { extId: 'ext/cmd', assetsPath: '', commandMode: 'menu-bar' } }, + getExtensionContext: () => ({ extensionName: 'ext', commandName: 'cmd', assetsPath: '', commandMode: 'menu-bar' }), + setExtensionContext: () => {}, + isEmojiOrSymbol: () => false, + }; + installRuntime(menuBarRuntime); + let menuTree = menuBarRuntime.render(() => menuBarModule.MenuBarExtraComponent({ children: null, title: 'Menu' })); + const menuRegistryAPI = menuTree.props.value; + menuRegistryAPI.register({ id: 'menu-1', type: 'item', title: 'Menu Item', order: 1 }); + menuBarRuntime.cleanupEffects(); + menuRegistryAPI.register({ id: 'menu-1', type: 'item', title: 'Menu Item', order: 1 }); + menuBarRuntime.rerunEffects(); + await flushMicrotasks(); + menuTree = menuBarRuntime.render(() => menuBarModule.MenuBarExtraComponent({ children: null, title: 'Menu' })); + assert.ok(menuTree.props.value, 'menubar registry provider should remain available after StrictMode replay'); + await flushMicrotasks(); + assert.ok( + menuPayloads.some((payload) => payload.items?.some((item) => item.title === 'Menu Item')), + 'menubar payload should include the static item registered during StrictMode replay', + ); + + const metrics = { + actions: actionRuntime.counters, + list: listRuntime.counters, + grid: gridRuntime.counters, + menubar: menuBarRuntime.counters, + }; + for (const [label, counters] of Object.entries(metrics)) { + assert.equal(counters.postUnmountSetStateCalls, 0, `${label} should not count StrictMode replay as post-unmount state`); + } + + console.log(JSON.stringify({ mode: 'registry-strictmode-passive-replay', metrics }, null, 2)); +}); diff --git a/scripts/test-use-frecency-sorting.mjs b/scripts/test-use-frecency-sorting.mjs new file mode 100644 index 00000000..4e6ca45e --- /dev/null +++ b/scripts/test-use-frecency-sorting.mjs @@ -0,0 +1,351 @@ +#!/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'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const hookPath = path.resolve('src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts'); +const contextScopePath = path.resolve('src/renderer/src/raycast-api/context-scope-runtime.ts'); + +function createLocalStorage(initial = {}) { + const store = new Map(Object.entries(initial)); + + return { + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(key, String(value)); + }, + removeItem(key) { + store.delete(key); + }, + clear() { + store.clear(); + }, + entries() { + return Array.from(store.entries()); + }, + }; +} + +function createDateController(initialNow) { + let now = initialNow; + let nowCalls = 0; + + class FakeDate extends Date { + constructor(...args) { + if (args.length === 0) { + super(now); + return; + } + super(...args); + } + + static now() { + nowCalls += 1; + return now; + } + } + + return { + Date: FakeDate, + get nowCalls() { + return nowCalls; + }, + resetNowCalls() { + nowCalls = 0; + }, + setNow(nextNow) { + now = nextNow; + }, + }; +} + +function createReactMock() { + const hooks = []; + let hookIndex = 0; + + function depsChanged(previous, next) { + if (!previous || !next || previous.length !== next.length) return true; + return previous.some((value, index) => !Object.is(value, next[index])); + } + + function memoize(factory, deps) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index] || depsChanged(hooks[index].deps, deps)) { + hooks[index] = { + deps: deps ? [...deps] : deps, + value: factory(), + }; + } + + return hooks[index].value; + } + + return { + reset() { + hookIndex = 0; + }, + react: { + useState(initializer) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) { + hooks[index] = { + value: typeof initializer === 'function' ? initializer() : initializer, + }; + } + + const setState = (nextValue) => { + hooks[index].value = typeof nextValue === 'function' ? nextValue(hooks[index].value) : nextValue; + }; + + return [hooks[index].value, setState]; + }, + useMemo(factory, deps) { + return memoize(factory, deps); + }, + useCallback(callback, deps) { + return memoize(() => callback, deps); + }, + }, + }; +} + +function loadHook({ + extensionName = 'default-extension', + initialNow = Date.UTC(2026, 0, 1, 12), + localStorage = createLocalStorage(), +} = {}) { + const dateController = createDateController(initialNow); + const reactMock = createReactMock(); + const moduleCache = new Map(); + + function resolveTsModule(request, fromPath) { + if (!request.startsWith('.')) return null; + const resolved = path.resolve(path.dirname(fromPath), request); + const candidates = [resolved, `${resolved}.ts`, `${resolved}.tsx`, path.join(resolved, 'index.ts')]; + const match = candidates.find((candidate) => fs.existsSync(candidate)); + if (!match) throw new Error(`Unable to resolve ${request} from ${fromPath}`); + return match; + } + + function loadTsModule(filePath) { + if (moduleCache.has(filePath)) return moduleCache.get(filePath).exports; + + const source = fs.readFileSync(filePath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + moduleCache.set(filePath, module); + + const sandbox = { + module, + exports: module.exports, + require: (request) => { + if (request === 'react') return reactMock.react; + const resolved = resolveTsModule(request, filePath); + return resolved ? loadTsModule(resolved) : require(request); + }, + console, + Date: dateController.Date, + JSON, + localStorage, + Math, + Object, + Promise, + String, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports; + } + + const contextScope = loadTsModule(contextScopePath); + contextScope.configureContextScopeRuntime({ + getExtensionContext: () => ({ + extensionName, + extensionDisplayName: extensionName, + extensionIconDataUrl: '', + commandName: 'test-command', + assetsPath: '', + supportPath: '/tmp/supercmd-test', + owner: 'test-owner', + preferences: {}, + preferenceDefinitions: [], + commandMode: 'view', + }), + setExtensionContext: () => {}, + }); + + const hookModule = loadTsModule(hookPath); + + return { + dateController, + localStorage, + render(data, options) { + reactMock.reset(); + return hookModule.useFrecencySorting(data, options); + }, + }; +} + +function ids(items) { + return Array.from(items, (item) => item.id); +} + +function serializedBytes(localStorage, key) { + return Buffer.byteLength(localStorage.getItem(key) || '', 'utf8'); +} + +function retainedEntryCount(localStorage, key) { + return Object.keys(JSON.parse(localStorage.getItem(key) || '{}')).length; +} + +test('useFrecencySorting reuses sorted data on stable rerender with the default key', () => { + const harness = loadHook(); + const data = [{ id: 'charlie' }, { id: 'alpha' }, { id: 'bravo' }]; + let comparisons = 0; + const options = { + namespace: 'stable-rerender', + sortUnvisited: (a, b) => { + comparisons += 1; + return a.id.localeCompare(b.id); + }, + }; + + const firstRender = harness.render(data, options); + + assert.deepEqual(ids(firstRender.data), ['alpha', 'bravo', 'charlie']); + assert.ok(comparisons > 0, 'initial render should sort the unvisited items'); + + const initialComparisons = comparisons; + const secondRender = harness.render(data, options); + + assert.deepEqual(ids(secondRender.data), ['alpha', 'bravo', 'charlie']); + assert.equal(comparisons, initialComparisons, 'stable rerenders should not recompute the sort'); + assert.strictEqual(secondRender.data, firstRender.data, 'stable rerenders should keep the memoized array'); +}); + +test('useFrecencySorting reads the current time once per sorting recompute', () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'single-now'; + const harness = loadHook({ + initialNow: now, + localStorage: createLocalStorage({ + [`sc-frecency:default-extension:${namespace}`]: JSON.stringify({ + alpha: { count: 1, lastVisited: now - 1_000 }, + bravo: { count: 3, lastVisited: now - 1_000 }, + charlie: { count: 2, lastVisited: now - 1_000 }, + }), + }), + }); + + harness.dateController.resetNowCalls(); + const result = harness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'charlie' }], { namespace }); + + assert.deepEqual(ids(result.data), ['bravo', 'charlie', 'alpha']); + assert.equal(harness.dateController.nowCalls, 1, 'sorting should capture one timestamp per recompute'); +}); + +test('useFrecencySorting preserves visit tracking and reset behavior', async () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'visit-tracking'; + const data = [{ id: 'alpha' }, { id: 'bravo' }, { id: 'charlie' }]; + const harness = loadHook({ initialNow: now }); + let result = harness.render(data, { namespace }); + + harness.dateController.setNow(now - 4 * 60 * 60 * 1_000); + await result.visitItem(data[0]); + harness.dateController.setNow(now - 60 * 60 * 1_000); + await result.visitItem(data[1]); + harness.dateController.setNow(now - 30 * 60 * 1_000); + await result.visitItem(data[1]); + + harness.dateController.setNow(now); + result = harness.render(data, { namespace }); + + assert.deepEqual(ids(result.data), ['bravo', 'alpha', 'charlie']); + assert.deepEqual(JSON.parse(harness.localStorage.getItem(`sc-frecency:default-extension:${namespace}`)), { + alpha: { count: 1, lastVisited: now - 4 * 60 * 60 * 1_000 }, + bravo: { count: 2, lastVisited: now - 30 * 60 * 1_000 }, + }); + + await result.resetRanking(data[1]); + result = harness.render(data, { namespace }); + + assert.equal(result.data[0].id, 'alpha'); + assert.deepEqual(JSON.parse(harness.localStorage.getItem(`sc-frecency:default-extension:${namespace}`)), { + alpha: { count: 1, lastVisited: now - 4 * 60 * 60 * 1_000 }, + }); +}); + +test('useFrecencySorting scopes storage by extension and migrates legacy rankings without loss', async () => { + const now = Date.UTC(2026, 0, 1, 12); + const namespace = 'shared-namespace'; + const legacyKey = `sc-frecency-${namespace}`; + const alphaKey = `sc-frecency:alpha-extension:${namespace}`; + const bravoKey = `sc-frecency:bravo-extension:${namespace}`; + const localStorage = createLocalStorage({ + [legacyKey]: JSON.stringify({ + alpha: { count: 2, lastVisited: now - 2 * 60 * 60 * 1_000 }, + legacyOnly: { count: 1, lastVisited: now - 6 * 60 * 60 * 1_000 }, + }), + }); + const legacyBytes = serializedBytes(localStorage, legacyKey); + + assert.equal(retainedEntryCount(localStorage, legacyKey), 2, 'legacy fixture should start with two retained entries'); + assert.equal(legacyBytes, 102, 'legacy fixture should start with 102 serialized bytes'); + + const alphaHarness = loadHook({ extensionName: 'alpha-extension', initialNow: now, localStorage }); + let alphaResult = alphaHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(alphaResult.data), ['alpha', 'legacyOnly', 'bravo']); + assert.deepEqual(JSON.parse(localStorage.getItem(alphaKey)), JSON.parse(localStorage.getItem(legacyKey))); + assert.equal(retainedEntryCount(localStorage, alphaKey), 2, 'migration should retain all legacy entries'); + assert.equal(serializedBytes(localStorage, alphaKey), legacyBytes, 'migration should copy the full serialized payload'); + + alphaHarness.dateController.setNow(now + 1_000); + await alphaResult.visitItem({ id: 'bravo' }); + alphaResult = alphaHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(alphaResult.data), ['alpha', 'bravo', 'legacyOnly']); + assert.equal(retainedEntryCount(localStorage, alphaKey), 3, 'new visits should preserve migrated rankings'); + assert.equal(serializedBytes(localStorage, alphaKey), 150, 'alpha scoped storage should retain three serialized entries'); + assert.equal(retainedEntryCount(localStorage, legacyKey), 2, 'legacy migration should not mutate the legacy payload'); + + const bravoHarness = loadHook({ extensionName: 'bravo-extension', initialNow: now, localStorage }); + let bravoResult = bravoHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(bravoResult.data), ['alpha', 'legacyOnly', 'bravo']); + assert.deepEqual(JSON.parse(localStorage.getItem(bravoKey)), JSON.parse(localStorage.getItem(legacyKey))); + assert.notDeepEqual(JSON.parse(localStorage.getItem(bravoKey)), JSON.parse(localStorage.getItem(alphaKey))); + + bravoHarness.dateController.setNow(now + 2_000); + await bravoResult.visitItem({ id: 'legacyOnly' }); + bravoResult = bravoHarness.render([{ id: 'alpha' }, { id: 'bravo' }, { id: 'legacyOnly' }], { namespace }); + + assert.deepEqual(ids(bravoResult.data), ['legacyOnly', 'alpha', 'bravo']); + assert.equal(retainedEntryCount(localStorage, bravoKey), 2, 'visiting an existing migrated entry should not prune entries'); + assert.equal(serializedBytes(localStorage, bravoKey), legacyBytes, 'bravo scoped storage should retain two serialized entries'); + assert.equal(retainedEntryCount(localStorage, alphaKey), 3, 'other extension scoped storage should be isolated'); + assert.ok(serializedBytes(localStorage, alphaKey) > serializedBytes(localStorage, legacyKey)); + assert.ok(serializedBytes(localStorage, bravoKey) >= serializedBytes(localStorage, legacyKey)); +}); diff --git a/src/main/canvas-store.ts b/src/main/canvas-store.ts index 983a5ea9..a7a0785d 100644 --- a/src/main/canvas-store.ts +++ b/src/main/canvas-store.ts @@ -245,12 +245,7 @@ export function getScene(id: string): CanvasScene { const scenePath = getScenePath(id); if (fs.existsSync(scenePath)) { const data = fs.readFileSync(scenePath, 'utf-8'); - const parsed = JSON.parse(data); - return { - elements: Array.isArray(parsed.elements) ? parsed.elements : [], - appState: parsed.appState || {}, - files: parsed.files || {}, - }; + return parseSceneData(data); } } catch (e) { console.error(`Failed to load scene for canvas ${id}:`, e); @@ -258,6 +253,27 @@ export function getScene(id: string): CanvasScene { return { elements: [], appState: {}, files: {} }; } +export async function getSceneAsync(id: string): Promise { + try { + const data = await fsp.readFile(getScenePath(id), 'utf-8'); + return parseSceneData(data); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + console.error(`Failed to load scene for canvas ${id}:`, e); + } + } + return { elements: [], appState: {}, files: {} }; +} + +function parseSceneData(data: string): CanvasScene { + const parsed = JSON.parse(data); + return { + elements: Array.isArray(parsed.elements) ? parsed.elements : [], + appState: parsed.appState || {}, + files: parsed.files || {}, + }; +} + export async function saveScene(id: string, scene: CanvasScene): Promise { try { await fsp.writeFile(getScenePath(id), JSON.stringify(scene), 'utf-8'); @@ -296,6 +312,17 @@ export function getThumbnail(id: string): string | null { return null; } +export async function getThumbnailAsync(id: string): Promise { + try { + return await fsp.readFile(getThumbnailPath(id), 'utf-8'); + } catch (e: any) { + if (e?.code !== 'ENOENT') { + console.error(`Failed to load thumbnail for canvas ${id}:`, e); + } + } + return null; +} + // ─── Export ───────────────────────────────────────────────────────── export async function exportCanvas( diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..7be84a31 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -204,14 +204,15 @@ import { deleteCanvas, duplicateCanvas, togglePinCanvas, - getScene, + getSceneAsync, saveScene, saveThumbnail, - getThumbnail, + getThumbnailAsync, exportCanvas, isCanvasLibInstalled, getCanvasLibDir, } from './canvas-store'; +import { serveCanvasLibAssetFromFile } from './sc-asset-protocol'; import { type RaycastImportProgress, executeRaycastConfigImport, @@ -13657,30 +13658,9 @@ app.whenReady().then(async () => { const url = new URL(request.url); // canvas-lib: serve files from the canvas-lib directory if (url.hostname === 'canvas-lib') { - let relPath = decodeURIComponent(url.pathname || '').replace(/^\//, ''); - if (!relPath) return new Response('Bad Request', { status: 400 }); - const canvasLibPath = path.join(app.getPath('userData'), 'canvas-lib'); - const fullPath = path.join(canvasLibPath, relPath); - console.log('[sc-asset:canvas-lib] Serving:', fullPath); - try { - const data = fs.readFileSync(fullPath); - const ext = path.extname(fullPath).toLowerCase(); - const mimeTypes: Record = { - '.js': 'application/javascript', - '.css': 'text/css', - '.svg': 'image/svg+xml', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', - '.png': 'image/png', - }; - return new Response(data, { - headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }, - }); - } catch (e) { - console.error('[sc-asset:canvas-lib] File not found:', fullPath); - return new Response('Not Found', { status: 404 }); - } + return serveCanvasLibAssetFromFile(request.url, getCanvasLibDir(), (fileUrl) => + net.fetch(fileUrl) + ); } if (url.hostname !== 'ext-asset') { @@ -17264,8 +17244,8 @@ if let tiff = image?.tiffRepresentation { return togglePinCanvas(id); }); - ipcMain.handle('canvas-get-scene', (_event: any, id: string) => { - return getScene(id); + ipcMain.handle('canvas-get-scene', async (_event: any, id: string) => { + return getSceneAsync(id); }); ipcMain.handle('canvas-save-scene', async (_event: any, id: string, scene: any) => { @@ -17287,8 +17267,8 @@ if let tiff = image?.tiffRepresentation { mainWindow?.webContents.send('canvas-thumbnail-updated', id); }); - ipcMain.handle('canvas-get-thumbnail', (_event: any, id: string) => { - return getThumbnail(id); + ipcMain.handle('canvas-get-thumbnail', async (_event: any, id: string) => { + return getThumbnailAsync(id); }); ipcMain.handle('open-canvas-window', (_event: any, mode?: string, canvasJson?: string) => { diff --git a/src/main/sc-asset-protocol.ts b/src/main/sc-asset-protocol.ts new file mode 100644 index 00000000..02cc730a --- /dev/null +++ b/src/main/sc-asset-protocol.ts @@ -0,0 +1,93 @@ +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +const CANVAS_LIB_CACHE_CONTROL = 'public, max-age=31536000, immutable'; + +const CANVAS_LIB_MIME_TYPES: Record = { + '.js': 'application/javascript', + '.css': 'text/css', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.ttf': 'font/ttf', + '.png': 'image/png', +}; + +export interface CanvasLibAssetResolution { + filePath: string; + headers: Record; +} + +export type CanvasLibAssetResolveResult = + | { ok: true; asset: CanvasLibAssetResolution } + | { ok: false; response: Response }; + +export type FetchFileResponse = (fileUrl: string) => Promise; + +export function getCanvasLibAssetContentType(filePath: string): string { + return CANVAS_LIB_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +export function resolveCanvasLibAssetRequest( + requestUrl: string, + canvasLibDir: string +): CanvasLibAssetResolveResult { + let url: URL; + try { + url = new URL(requestUrl); + } catch { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + let relPath: string; + try { + relPath = decodeURIComponent(url.pathname || '').replace(/^\/+/, ''); + } catch { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + if (!relPath) { + return { ok: false, response: new Response('Bad Request', { status: 400 }) }; + } + + const basePath = path.resolve(canvasLibDir); + const filePath = path.resolve(basePath, relPath); + const relativeToBase = path.relative(basePath, filePath); + if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) { + return { ok: false, response: new Response('Not Found', { status: 404 }) }; + } + + return { + ok: true, + asset: { + filePath, + headers: { + 'Content-Type': getCanvasLibAssetContentType(filePath), + 'Cache-Control': CANVAS_LIB_CACHE_CONTROL, + }, + }, + }; +} + +export async function serveCanvasLibAssetFromFile( + requestUrl: string, + canvasLibDir: string, + fetchFile: FetchFileResponse +): Promise { + const resolved = resolveCanvasLibAssetRequest(requestUrl, canvasLibDir); + if (!resolved.ok) return resolved.response; + + try { + const upstream = await fetchFile(pathToFileURL(resolved.asset.filePath).toString()); + if (!upstream.ok) { + return new Response('Not Found', { status: 404 }); + } + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: resolved.asset.headers, + }); + } catch { + return new Response('Not Found', { status: 404 }); + } +} diff --git a/src/renderer/src/i18n/I18nProvider.tsx b/src/renderer/src/i18n/I18nProvider.tsx index 4f57f777..149eb5c3 100644 --- a/src/renderer/src/i18n/I18nProvider.tsx +++ b/src/renderer/src/i18n/I18nProvider.tsx @@ -1,6 +1,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { DEFAULT_APP_LANGUAGE, + loadAppLocale, normalizeAppLanguage, resolveAppLocale, translateMessage, @@ -19,6 +20,7 @@ const I18nContext = createContext(null); export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [language, setLanguage] = useState(DEFAULT_APP_LANGUAGE); + const [catalogVersion, setCatalogVersion] = useState(0); useEffect(() => { let disposed = false; @@ -45,9 +47,28 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children [language] ); + useEffect(() => { + let disposed = false; + loadAppLocale(locale) + .then(() => { + if (!disposed) { + setCatalogVersion((version) => version + 1); + } + }) + .catch((error) => { + if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') { + console.warn(`[i18n] Failed to load locale "${locale}"`, error); + } + }); + + return () => { + disposed = true; + }; + }, [locale]); + const t = useCallback( (key: string, values?: TranslationValues) => translateMessage(locale, key, values), - [locale] + [locale, catalogVersion] ); const value = useMemo(() => ({ language, locale, t }), [language, locale, t]); diff --git a/src/renderer/src/i18n/runtime.ts b/src/renderer/src/i18n/runtime.ts index edf181c9..7f278337 100644 --- a/src/renderer/src/i18n/runtime.ts +++ b/src/renderer/src/i18n/runtime.ts @@ -1,36 +1,37 @@ import enMessages from './locales/en.json'; -import zhHansMessages from './locales/zh-Hans.json'; -import zhHantMessages from './locales/zh-Hant.json'; -import jaMessages from './locales/ja.json'; -import koMessages from './locales/ko.json'; -import frMessages from './locales/fr.json'; -import deMessages from './locales/de.json'; -import esMessages from './locales/es.json'; -import ruMessages from './locales/ru.json'; -import itMessages from './locales/it.json'; export type SupportedAppLocale = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'fr' | 'de' | 'es' | 'ru' | 'it'; export type AppLanguageSetting = 'system' | SupportedAppLocale; export type TranslationValues = Record; -type MessageTree = Record; +interface MessageTree { + [key: string]: string | MessageTree; +} export const DEFAULT_APP_LANGUAGE: AppLanguageSetting = 'system'; export const FALLBACK_APP_LOCALE: SupportedAppLocale = 'en'; export const APP_LANGUAGE_OPTIONS: AppLanguageSetting[] = ['system', 'en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'fr', 'de', 'es', 'ru', 'it']; -const MESSAGE_CATALOG: Record = { +type LocaleModule = { default: MessageTree } | MessageTree; + +const MESSAGE_CATALOG: Partial> = { en: enMessages as MessageTree, - 'zh-Hans': zhHansMessages as MessageTree, - 'zh-Hant': zhHantMessages as MessageTree, - 'ja': jaMessages as MessageTree, - 'ko': koMessages as MessageTree, - 'fr': frMessages as MessageTree, - 'de': deMessages as MessageTree, - 'es': esMessages as MessageTree, - 'ru': ruMessages as MessageTree, - 'it': itMessages as MessageTree, }; +const LOCALE_LOADERS: Record Promise> = { + en: () => Promise.resolve(enMessages as MessageTree), + 'zh-Hans': () => import('./locales/zh-Hans.json'), + 'zh-Hant': () => import('./locales/zh-Hant.json'), + ja: () => import('./locales/ja.json'), + ko: () => import('./locales/ko.json'), + fr: () => import('./locales/fr.json'), + de: () => import('./locales/de.json'), + es: () => import('./locales/es.json'), + ru: () => import('./locales/ru.json'), + it: () => import('./locales/it.json'), +}; + +const PENDING_LOCALE_LOADS: Partial>> = {}; + function resolveMessage(tree: MessageTree, key: string): string | null { const segments = String(key || '') .split('.') @@ -52,6 +53,36 @@ function interpolateMessage(template: string, values?: TranslationValues): strin }); } +function readLocaleModule(module: LocaleModule): MessageTree { + return ('default' in module ? module.default : module) as MessageTree; +} + +export function isAppLocaleLoaded(locale: SupportedAppLocale): boolean { + return Boolean(MESSAGE_CATALOG[locale]); +} + +export function loadAppLocale(locale: SupportedAppLocale): Promise { + const loadedCatalog = MESSAGE_CATALOG[locale]; + if (loadedCatalog) return Promise.resolve(loadedCatalog); + + const pendingLoad = PENDING_LOCALE_LOADS[locale]; + if (pendingLoad) return pendingLoad; + + const loader = LOCALE_LOADERS[locale]; + const load = loader() + .then((module) => { + const catalog = readLocaleModule(module); + MESSAGE_CATALOG[locale] = catalog; + return catalog; + }) + .finally(() => { + delete PENDING_LOCALE_LOADS[locale]; + }); + + PENDING_LOCALE_LOADS[locale] = load; + return load; +} + export function normalizeAppLanguage(value: unknown): AppLanguageSetting { const normalized = String(value || '') .trim() @@ -123,9 +154,11 @@ export function translateMessage( key: string, values?: TranslationValues ): string { + const localeCatalog = MESSAGE_CATALOG[locale]; + const fallbackCatalog = MESSAGE_CATALOG[FALLBACK_APP_LOCALE]; const localeMessage = - resolveMessage(MESSAGE_CATALOG[locale], key) ?? - resolveMessage(MESSAGE_CATALOG[FALLBACK_APP_LOCALE], key); + (localeCatalog ? resolveMessage(localeCatalog, key) : null) ?? + (fallbackCatalog ? resolveMessage(fallbackCatalog, key) : null); if (localeMessage == null) { if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'development') { console.warn(`[i18n] Missing translation key: "${key}"`); diff --git a/src/renderer/src/raycast-api/action-runtime-registry.tsx b/src/renderer/src/raycast-api/action-runtime-registry.tsx index 5c8c8db8..1dbf6e1b 100644 --- a/src/renderer/src/raycast-api/action-runtime-registry.tsx +++ b/src/renderer/src/raycast-api/action-runtime-registry.tsx @@ -348,14 +348,29 @@ export function createActionRegistryRuntime(deps: RegistryDeps) { function useCollectedActions() { const registryRef = useRef(new Map()); const [version, setVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + lastSnapshotRef.current = ''; + }; + }, []); + const scheduleUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const entries = Array.from(registryRef.current.values()); const snapshot = entries.map((entry) => `${entry.id}:${entry.title}:${entry.sectionTitle || ''}`).join('|'); diff --git a/src/renderer/src/raycast-api/form-runtime-context.tsx b/src/renderer/src/raycast-api/form-runtime-context.tsx index 9545c387..33e5da74 100644 --- a/src/renderer/src/raycast-api/form-runtime-context.tsx +++ b/src/renderer/src/raycast-api/form-runtime-context.tsx @@ -28,19 +28,54 @@ export const FormContext = createContext({ let currentFormValues: Record = {}; let currentFormErrors: Record = {}; let currentFormPlaceholders: Record = {}; +let currentFormSnapshotOwner: symbol | null = null; -export function setCurrentFormValues(values: Record) { +export function setCurrentFormValues(values: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormValues = values; } -export function setCurrentFormErrors(errors: Record) { +export function setCurrentFormErrors(errors: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormErrors = errors; } -export function setCurrentFormPlaceholders(placeholders: Record) { +export function setCurrentFormPlaceholders(placeholders: Record, owner?: symbol) { + if (owner) currentFormSnapshotOwner = owner; currentFormPlaceholders = placeholders; } +export function clearCurrentFormSnapshot(owner?: symbol) { + if (owner && currentFormSnapshotOwner && currentFormSnapshotOwner !== owner) { + return false; + } + + currentFormSnapshotOwner = null; + currentFormValues = {}; + currentFormErrors = {}; + currentFormPlaceholders = {}; + return true; +} + +export function getFormSnapshotRetentionMetrics() { + const serialized = JSON.stringify({ + values: currentFormValues, + errors: currentFormErrors, + placeholders: currentFormPlaceholders, + }); + + return { + valueKeys: Object.keys(currentFormValues).length, + errorKeys: Object.keys(currentFormErrors).length, + placeholderKeys: Object.keys(currentFormPlaceholders).length, + totalKeys: + Object.keys(currentFormValues).length + + Object.keys(currentFormErrors).length + + Object.keys(currentFormPlaceholders).length, + serializedBytes: new TextEncoder().encode(serialized).byteLength, + }; +} + export function getFormValues(): Record { // Fall back to placeholder for fields the user didn't fill. Lets extensions // declare a sensible default like `placeholder="0"` and have it submitted diff --git a/src/renderer/src/raycast-api/form-runtime.tsx b/src/renderer/src/raycast-api/form-runtime.tsx index 46ddbf0f..06740eb4 100644 --- a/src/renderer/src/raycast-api/form-runtime.tsx +++ b/src/renderer/src/raycast-api/form-runtime.tsx @@ -5,9 +5,10 @@ * all field subcomponents. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { attachFormFields } from './form-runtime-fields'; import { + clearCurrentFormSnapshot, FormContext, setCurrentFormErrors, setCurrentFormPlaceholders, @@ -56,44 +57,52 @@ export function createFormRuntime(deps: FormRuntimeDeps) { const [errors, setErrors] = useState>({}); const [placeholders, setPlaceholders] = useState>({}); const [showActions, setShowActions] = useState(false); + const formSnapshotOwnerRef = useRef(Symbol('FormRuntime')); + const formSnapshotOwner = formSnapshotOwnerRef.current; const { pop } = useNavigation(); const setValue = useCallback((id: string, value: any) => { setValues((previous) => { const next = { ...previous, [id]: value }; - setCurrentFormValues(next); + setCurrentFormValues(next, formSnapshotOwner); return next; }); setErrors((previous) => { const next = { ...previous }; delete next[id]; - setCurrentFormErrors(next); + setCurrentFormErrors(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); const setError = useCallback((id: string, error: string) => { setErrors((previous) => { const next = { ...previous, [id]: error }; - setCurrentFormErrors(next); + setCurrentFormErrors(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); const setPlaceholder = useCallback((id: string, placeholder: string) => { setPlaceholders((previous) => { if (previous[id] === placeholder) return previous; const next = { ...previous, [id]: placeholder }; - setCurrentFormPlaceholders(next); + setCurrentFormPlaceholders(next, formSnapshotOwner); return next; }); - }, []); + }, [formSnapshotOwner]); useEffect(() => { - setCurrentFormValues(values); - setCurrentFormErrors(errors); - setCurrentFormPlaceholders(placeholders); - }, [values, errors, placeholders]); + setCurrentFormValues(values, formSnapshotOwner); + setCurrentFormErrors(errors, formSnapshotOwner); + setCurrentFormPlaceholders(placeholders, formSnapshotOwner); + }, [errors, formSnapshotOwner, placeholders, values]); + + useEffect(() => { + return () => { + clearCurrentFormSnapshot(formSnapshotOwner); + }; + }, [formSnapshotOwner]); const { collectedActions: formActions, registryAPI: formActionRegistry } = useCollectedActions(); const primaryAction = formActions[0]; diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..e7bf6df8 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,19 +4,33 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; export function useGridRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + }; + }, []); + const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const snapshot = Array.from(registryRef.current.values()) .map((entry) => { diff --git a/src/renderer/src/raycast-api/grid-runtime-items.tsx b/src/renderer/src/raycast-api/grid-runtime-items.tsx index d06d0269..ca83d634 100644 --- a/src/renderer/src/raycast-api/grid-runtime-items.tsx +++ b/src/renderer/src/raycast-api/grid-runtime-items.tsx @@ -8,6 +8,116 @@ import React, { createContext, useContext, useLayoutEffect, useRef } from 'react import { resolveTintColor } from './icon-runtime-assets'; import { renderIcon } from './icon-runtime-render'; +type ResolveGridIconSource = (src: string) => string; + +function isImageLikeSourceString(value: string): boolean { + const source = String(value || '').trim(); + if (!source) return false; + if ( + source.startsWith('http') || + source.startsWith('data:') || + source.startsWith('sc-asset:') || + source.startsWith('file://') || + source.startsWith('/') || + /^[a-zA-Z]:[\\/]/.test(source) || + source.startsWith('\\\\') + ) { + return true; + } + return /\.(svg|png|jpe?g|gif|webp|ico|tiff?)(\?.*)?$/i.test(source); +} + +function getGridColor(value: any): string | null { + if (!value || typeof value !== 'object') return null; + const hasVisualSource = value.source !== undefined || value.value !== undefined || value.fileIcon !== undefined; + if (hasVisualSource) return null; + return resolveTintColor(value.color) || null; +} + +function normalizeGridSourceString(value: string, resolveIconSrc: ResolveGridIconSource): string { + const source = String(value || '').trim(); + if (!source) return ''; + const resolved = resolveIconSrc(source); + return resolved || source; +} + +function toRenderableGridContent(value: any, resolveIconSrc: ResolveGridIconSource): any { + if (!value) return null; + if (typeof value === 'string') { + const normalized = normalizeGridSourceString(value, resolveIconSrc); + return normalized || null; + } + + if (typeof value !== 'object') return null; + + if (typeof value.fileIcon === 'string' && value.fileIcon.trim()) { + return { fileIcon: value.fileIcon.trim() }; + } + + if (value.source !== undefined) { + const sourceValue = value.source; + if (typeof sourceValue === 'string') { + const normalizedSource = normalizeGridSourceString(sourceValue, resolveIconSrc); + if (!normalizedSource) return null; + const sourceTint = + value.tintColor + || (!isImageLikeSourceString(normalizedSource) ? value.color : undefined); + return { + source: normalizedSource, + tintColor: sourceTint, + mask: value.mask, + fallback: value.fallback, + }; + } + if (sourceValue && typeof sourceValue === 'object') { + return { + source: sourceValue, + tintColor: value.tintColor, + mask: value.mask, + fallback: value.fallback, + }; + } + } + + if (value.value !== undefined) { + const nestedValue = value.value; + if (typeof nestedValue === 'string') { + const normalizedNested = normalizeGridSourceString(nestedValue, resolveIconSrc); + if (!normalizedNested) return null; + const nestedTint = + !isImageLikeSourceString(normalizedNested) ? value.color : undefined; + return nestedTint + ? { source: normalizedNested, tintColor: nestedTint } + : normalizedNested; + } + if (nestedValue && typeof nestedValue === 'object') { + if (typeof nestedValue.fileIcon === 'string' && nestedValue.fileIcon.trim()) { + return { fileIcon: nestedValue.fileIcon.trim() }; + } + + if (nestedValue.source !== undefined) { + return nestedValue; + } + + if (nestedValue.light !== undefined || nestedValue.dark !== undefined) { + return { source: nestedValue }; + } + } + } + + return null; +} + +function areGridItemRendererPropsEqual(previous: any, next: any): boolean { + return ( + previous.title === next.title + && previous.subtitle === next.subtitle + && previous.content === next.content + && previous.isSelected === next.isSelected + && previous.dataIdx === next.dataIdx + ); +} + export interface GridItemRegistration { id: string; props: { @@ -57,107 +167,18 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) return {children}; } - function GridItemRenderer({ title, subtitle, content, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { - const isImageLikeSourceString = (value: string): boolean => { - const source = String(value || '').trim(); - if (!source) return false; - if ( - source.startsWith('http') || - source.startsWith('data:') || - source.startsWith('sc-asset:') || - source.startsWith('file://') || - source.startsWith('/') || - /^[a-zA-Z]:[\\/]/.test(source) || - source.startsWith('\\\\') - ) { - return true; - } - return /\.(svg|png|jpe?g|gif|webp|ico|tiff?)(\?.*)?$/i.test(source); - }; - - const getGridColor = (value: any): string | null => { - if (!value || typeof value !== 'object') return null; - const hasVisualSource = value.source !== undefined || value.value !== undefined || value.fileIcon !== undefined; - if (hasVisualSource) return null; - return resolveTintColor(value.color) || null; - }; - - const normalizeSourceString = (value: string): string => { - const source = String(value || '').trim(); - if (!source) return ''; - const resolved = resolveIconSrc(source); - return resolved || source; - }; - - const toRenderableContent = (value: any): any => { - if (!value) return null; - if (typeof value === 'string') { - const normalized = normalizeSourceString(value); - return normalized || null; - } - - if (typeof value !== 'object') return null; - - if (typeof value.fileIcon === 'string' && value.fileIcon.trim()) { - return { fileIcon: value.fileIcon.trim() }; - } - - if (value.source !== undefined) { - const sourceValue = value.source; - if (typeof sourceValue === 'string') { - const normalizedSource = normalizeSourceString(sourceValue); - if (!normalizedSource) return null; - const sourceTint = - value.tintColor - || (!isImageLikeSourceString(normalizedSource) ? value.color : undefined); - return { - source: normalizedSource, - tintColor: sourceTint, - mask: value.mask, - fallback: value.fallback, - }; - } - if (sourceValue && typeof sourceValue === 'object') { - return { - source: sourceValue, - tintColor: value.tintColor, - mask: value.mask, - fallback: value.fallback, - }; - } - } - - if (value.value !== undefined) { - const nestedValue = value.value; - if (typeof nestedValue === 'string') { - const normalizedNested = normalizeSourceString(nestedValue); - if (!normalizedNested) return null; - const nestedTint = - !isImageLikeSourceString(normalizedNested) ? value.color : undefined; - return nestedTint - ? { source: normalizedNested, tintColor: nestedTint } - : normalizedNested; - } - if (nestedValue && typeof nestedValue === 'object') { - if (typeof nestedValue.fileIcon === 'string' && nestedValue.fileIcon.trim()) { - return { fileIcon: nestedValue.fileIcon.trim() }; - } - - if (nestedValue.source !== undefined) { - return nestedValue; - } - - if (nestedValue.light !== undefined || nestedValue.dark !== undefined) { - return { source: nestedValue }; - } - } - } - - return null; - }; - + const GridItemRenderer = React.memo(function GridItemRenderer({ + title, + subtitle, + content, + isSelected, + dataIdx, + onSelect, + onActivate, + onContextAction, + }: any) { const swatchColor = getGridColor(content); - const renderableContent = swatchColor ? null : toRenderableContent(content); + const renderableContent = swatchColor ? null : toRenderableGridContent(content, resolveIconSrc); return (
string) )}
); - } + }, areGridItemRendererPropsEqual); return { GridRegistryContext, diff --git a/src/renderer/src/raycast-api/hooks/storage-scope.ts b/src/renderer/src/raycast-api/hooks/storage-scope.ts index 2898c8eb..406a3c6d 100644 --- a/src/renderer/src/raycast-api/hooks/storage-scope.ts +++ b/src/renderer/src/raycast-api/hooks/storage-scope.ts @@ -44,6 +44,13 @@ export function getScopedLocalStorageKeys(key: string): { scopedKey: string; leg }; } +export function getScopedFrecencyStorageKeys(namespace: string): { scopedKey: string; legacyKeys: string[] } { + return { + scopedKey: `sc-frecency:${getExtensionStorageScope()}:${namespace}`, + legacyKeys: [`sc-frecency-${namespace}`], + }; +} + export function readScopedJsonState( scopedKey: string, legacyKeys: string[], diff --git a/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts b/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts index 5cd648bb..dd2a3883 100644 --- a/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts +++ b/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts @@ -4,14 +4,20 @@ */ import { useCallback, useMemo, useState } from 'react'; +import { getScopedFrecencyStorageKeys, readScopedJsonState } from './storage-scope'; interface FrecencyEntry { count: number; lastVisited: number; } -function computeFrecencyScore(entry: FrecencyEntry): number { - const ageHours = (Date.now() - entry.lastVisited) / (1000 * 60 * 60); +function getDefaultFrecencyKey(item: unknown): string { + const itemId = (item as { id?: unknown } | null | undefined)?.id; + return itemId == null ? String(item) : String(itemId); +} + +function computeFrecencyScore(entry: FrecencyEntry, now: number): number { + const ageHours = (now - entry.lastVisited) / (1000 * 60 * 60); const decay = Math.pow(0.5, ageHours / 72); return entry.count * decay; } @@ -29,25 +35,21 @@ export function useFrecencySorting( resetRanking: (item: T) => Promise; } { const ns = options?.namespace || 'default'; - const storageKey = `sc-frecency-${ns}`; - const getKey = options?.key || ((item: any) => item?.id ?? String(item)); + const { scopedKey, legacyKeys } = getScopedFrecencyStorageKeys(ns); + const getKey = options?.key || getDefaultFrecencyKey; const [frecencyMap, setFrecencyMap] = useState>(() => { - try { - const stored = localStorage.getItem(storageKey); - return stored ? JSON.parse(stored) : {}; - } catch { - return {}; - } + const stored = readScopedJsonState>(scopedKey, legacyKeys); + return stored === undefined ? {} : stored; }); const persistMap = useCallback((map: Record) => { try { - localStorage.setItem(storageKey, JSON.stringify(map)); + localStorage.setItem(scopedKey, JSON.stringify(map)); } catch { // best-effort } - }, [storageKey]); + }, [scopedKey]); const sortedData = useMemo(() => { if (!data) return []; @@ -60,13 +62,22 @@ export function useFrecencySorting( } const items = [...data]; + let scoreNow: number | undefined; + const getScoreNow = () => { + scoreNow ??= Date.now(); + return scoreNow; + }; + items.sort((a, b) => { const keyA = getKey(a); const keyB = getKey(b); const entryA = frecencyMap[keyA]; const entryB = frecencyMap[keyB]; - if (entryA && entryB) return computeFrecencyScore(entryB) - computeFrecencyScore(entryA); + if (entryA && entryB) { + const now = getScoreNow(); + return computeFrecencyScore(entryB, now) - computeFrecencyScore(entryA, now); + } if (entryA && !entryB) return -1; if (!entryA && entryB) return 1; if (options?.sortUnvisited) return options.sortUnvisited(a, b); diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..45961a4b 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -4,7 +4,7 @@ * Extracted list registry/grouping helpers to keep List container module small. */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; function getReactTypeName(type: any): string { @@ -45,13 +45,27 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s export function useListRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const lastSnapshotRef = useRef(''); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + }; + }, []); + const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } pendingRef.current = false; const snapshot = Array.from(registryRef.current.values()).map((item) => { const actionType = item.props.actions?.type as any; diff --git a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx index d12ee5fe..83ebb96e 100644 --- a/src/renderer/src/raycast-api/menubar-runtime-parent.tsx +++ b/src/renderer/src/raycast-api/menubar-runtime-parent.tsx @@ -3,7 +3,7 @@ * Purpose: MenuBarExtra parent component and native menu serialization/effects. */ -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { getMenuBarRuntimeDeps } from './menubar-runtime-config'; import { createMenuBarVisiblePayloadHashCache, @@ -35,6 +35,7 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); + const mountedRef = useRef(true); const pendingRef = useRef(false); const visiblePayloadHashCacheRef = useRef(createMenuBarVisiblePayloadHashCache()); @@ -44,28 +45,39 @@ export function MenuBarExtraComponent({ children, icon, title, tooltip, isLoadin if (isMenuBar) initMenuBarClickListener(); }, [isMenuBar]); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + pendingRef.current = false; + }; + }, []); + + const scheduleRegistryUpdate = useCallback(() => { + if (pendingRef.current) return; + + pendingRef.current = true; + queueMicrotask(() => { + if (!mountedRef.current) { + pendingRef.current = false; + return; + } + pendingRef.current = false; + setRegistryVersion((v) => v + 1); + }); + }, []); + const registryAPI = useMemo(() => ({ register: (item: MBItemRegistration) => { registryRef.current.set(item.id, item); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, unregister: (id: string) => { registryRef.current.delete(id); - if (!pendingRef.current) { - pendingRef.current = true; - queueMicrotask(() => { - pendingRef.current = false; - setRegistryVersion((v) => v + 1); - }); - } + scheduleRegistryUpdate(); }, - }), []); + }), [scheduleRegistryUpdate]); useEffect(() => { if (!isMenuBar) return;