diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..36d4a79c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,9 @@ on: jobs: claude-review: + # Fork PRs do not receive the OIDC token/secrets required by + # anthropics/claude-code-action on pull_request workflows. + 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 +44,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/project-checks.yml b/.github/workflows/project-checks.yml new file mode 100644 index 00000000..28304e1d --- /dev/null +++ b/.github/workflows/project-checks.yml @@ -0,0 +1,56 @@ +name: Project Checks + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: project-checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-i18n-build: + name: Tests, i18n, and build + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CI: true + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + SUPERCMD_SKIP_ELECTRON_TESTS: '1' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies without native postinstall scripts + # package.json pins darwin esbuild packages for release packaging; --force lets Ubuntu install the lockfile. + run: npm ci --ignore-scripts --force + + - name: Run tests + run: npm test + + - name: Check i18n + run: npm run check:i18n + + - name: Build main process + run: npm run build:main + + - name: Build renderer + run: npm run build:renderer diff --git a/package.json b/package.json index f262a8c1..76411c0b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "build": "npm run build:main && npm run build:renderer && npm run build:native", "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", "build:renderer": "vite build", + "measure:launcher-command-list": "node scripts/measure-launcher-command-list-render.mjs", "check:i18n": "node scripts/check-i18n.mjs", + "perf:file-search": "node scripts/file-search-perf-harness.mjs", "test": "node --test 'scripts/test-*.mjs'", "build:native": "node scripts/build-native.mjs", "ensure:cross-arch-esbuild": "node scripts/ensure-cross-arch-esbuild.mjs", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..99aab3a6 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + electron: true + esbuild: true + extract-file-icon: true + node-window-manager: true diff --git a/scripts/bench-ax-bfs-queue.swift b/scripts/bench-ax-bfs-queue.swift new file mode 100644 index 00000000..585691e1 --- /dev/null +++ b/scripts/bench-ax-bfs-queue.swift @@ -0,0 +1,106 @@ +import Foundation + +private struct Node { + let firstChild: Int + let childCount: Int +} + +private let depthLimit = 8 +private let branching = 3 +private var nodes: [Node] = [] +private var frontier: [(index: Int, depth: Int)] = [(0, 0)] + +nodes.reserveCapacity(9_841) +nodes.append(Node(firstChild: -1, childCount: 0)) + +var frontierIndex = 0 +while frontierIndex < frontier.count { + let (nodeIndex, depth) = frontier[frontierIndex] + frontierIndex += 1 + guard depth < depthLimit else { continue } + + let firstChild = nodes.count + for _ in 0.. Int { + var queue = roots.map { ($0, 0) } + var inspected = 0 + var checksum = 0 + + while let (nodeIndex, depth) = queue.first { + queue.removeFirst() + inspected += 1 + if inspected > maxElements { break } + + checksum &+= nodeIndex &+ depth + if depth >= maxDepth { continue } + + let node = nodes[nodeIndex] + if node.childCount > 0 { + for offset in 0.. Int { + var queue = roots.map { ($0, 0) } + var queueIndex = 0 + var inspected = 0 + var checksum = 0 + + while queueIndex < queue.count { + let (nodeIndex, depth) = queue[queueIndex] + queueIndex += 1 + inspected += 1 + if inspected > maxElements { break } + + checksum &+= nodeIndex &+ depth + if depth >= maxDepth { continue } + + let node = nodes[nodeIndex] + if node.childCount > 0 { + for offset in 0.. Int) -> (label: String, ms: Double, checksum: Int) { + var checksum = 0 + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0.. item.trim()).filter(Boolean)); +const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`).sort(); +const defaultStrictLocales = localeFiles.map((file) => file.replace(/\.json$/, '')); +const strictLocales = new Set( + (process.env.SUPERCMD_STRICT_LOCALES || defaultStrictLocales.join(',')).split(',').map((item) => item.trim()).filter(Boolean), +); function isObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); @@ -41,7 +45,6 @@ function walk(baseNode, localeNode, currentPath, result) { } const baseMessages = JSON.parse(fs.readFileSync(path.join(localesDir, `${baseLocale}.json`), 'utf8')); -const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json') && file !== `${baseLocale}.json`); let hasStrictFailure = false; diff --git a/scripts/file-search-perf-harness.mjs b/scripts/file-search-perf-harness.mjs new file mode 100644 index 00000000..904e5564 --- /dev/null +++ b/scripts/file-search-perf-harness.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import { importTs } from './lib/ts-import.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const FILE_SEARCH_INDEX_TS = path.join(REPO_ROOT, 'src/main/file-search-index.ts'); + +const DEFAULT_CONFIG = { + projects: 24, + modulesPerProject: 4, + filesPerModule: 8, + queryRuns: 5, + updateCount: 32, + deleteCount: 32, + limit: 12, + keep: false, + json: false, + output: '', + includeProtectedHomeRoots: true, + thresholds: {}, +}; + +const FILE_NAME_PATTERNS = [ + 'command-palette-{project}-{module}-{file}.ts', + 'launch-plan-alpha-{project}-{module}-{file}.md', + 'invoice-report-{project}-{module}-{file}.json', + 'notes-search-index-{project}-{module}-{file}.txt', + 'shortcut-resolver-{project}-{module}-{file}.tsx', + 'settings-migration-{project}-{module}-{file}.ts', + 'quick-action-workflow-{project}-{module}-{file}.md', + 'browser-history-cache-{project}-{module}-{file}.json', +]; + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function formatPattern(pattern, values) { + return pattern + .replaceAll('{project}', values.project) + .replaceAll('{module}', values.module) + .replaceAll('{file}', values.file); +} + +function readNumber(value, fallback, min = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function readOptionalNumber(value) { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function getArgValue(args, name) { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + if (index >= 0 && index + 1 < args.length) return args[index + 1]; + return undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function getUsage() { + return [ + 'Usage: node scripts/file-search-perf-harness.mjs [options]', + '', + 'Options:', + ' --projects Number of synthetic projects', + ' --modules Modules per project', + ' --files-per-module Files per module', + ' --query-runs Query repetitions per query', + ' --updates Watcher-style added paths', + ' --deletes Deleted paths in the tombstone batch', + ' --limit Search result limit', + ' --json Print JSON output', + ' --output Write the same output to a file', + ' --keep Keep the temp fixture for inspection', + ' --threshold-index-ms Fail if initial indexing exceeds n ms', + ' --threshold-normal-query-p95-ms Fail if normal query p95 exceeds n ms', + ' --threshold-path-query-p95-ms Fail if path-like query p95 exceeds n ms', + ' --threshold-watch-update-ms Fail if watcher-style batch exceeds n ms', + ' --threshold-delete-ms Fail if delete batch exceeds n ms', + ].join('\n'); +} + +export function parseFileSearchPerfHarnessArgs(args = process.argv.slice(2)) { + return { + projects: readNumber(getArgValue(args, '--projects'), DEFAULT_CONFIG.projects), + modulesPerProject: readNumber(getArgValue(args, '--modules'), DEFAULT_CONFIG.modulesPerProject), + filesPerModule: readNumber(getArgValue(args, '--files-per-module'), DEFAULT_CONFIG.filesPerModule), + queryRuns: readNumber(getArgValue(args, '--query-runs'), DEFAULT_CONFIG.queryRuns), + updateCount: readNumber(getArgValue(args, '--updates'), DEFAULT_CONFIG.updateCount), + deleteCount: readNumber(getArgValue(args, '--deletes'), DEFAULT_CONFIG.deleteCount), + limit: readNumber(getArgValue(args, '--limit'), DEFAULT_CONFIG.limit), + keep: hasFlag(args, '--keep'), + json: hasFlag(args, '--json'), + output: getArgValue(args, '--output') || '', + includeProtectedHomeRoots: !hasFlag(args, '--exclude-protected-home-roots'), + thresholds: { + initialIndexMs: readOptionalNumber(getArgValue(args, '--threshold-index-ms')), + normalQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-normal-query-p95-ms')), + pathQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-path-query-p95-ms')), + watchUpdateBatchMs: readOptionalNumber(getArgValue(args, '--threshold-watch-update-ms')), + deleteBatchMs: readOptionalNumber(getArgValue(args, '--threshold-delete-ms')), + }, + }; +} + +function mergeConfig(overrides = {}) { + return { + ...DEFAULT_CONFIG, + ...overrides, + thresholds: { + ...DEFAULT_CONFIG.thresholds, + ...(overrides.thresholds || {}), + }, + }; +} + +async function writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function createSyntheticHome(config) { + const tempRootRaw = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-file-search-perf-')); + const tempRoot = await fs.realpath(tempRootRaw); + const homeDir = path.join(tempRoot, 'home'); + const benchRoot = path.join(homeDir, 'BenchRoot'); + const projectsRoot = path.join(benchRoot, 'Projects'); + const archiveRoot = path.join(benchRoot, 'Archive'); + const referenceRoot = path.join(benchRoot, 'References'); + const filePaths = []; + const deleteCandidates = []; + + await fs.mkdir(projectsRoot, { recursive: true }); + await fs.mkdir(archiveRoot, { recursive: true }); + await fs.mkdir(referenceRoot, { recursive: true }); + + for (let projectIndex = 0; projectIndex < config.projects; projectIndex += 1) { + const project = `project-${pad(projectIndex)}`; + for (let moduleIndex = 0; moduleIndex < config.modulesPerProject; moduleIndex += 1) { + const moduleName = `module-${pad(moduleIndex, 2)}`; + const srcDir = path.join(projectsRoot, project, moduleName, 'src'); + const docsDir = path.join(projectsRoot, project, moduleName, 'docs'); + + for (let fileIndex = 0; fileIndex < config.filesPerModule; fileIndex += 1) { + const file = pad(fileIndex, 2); + const pattern = FILE_NAME_PATTERNS[fileIndex % FILE_NAME_PATTERNS.length]; + const targetDir = fileIndex % 2 === 0 ? srcDir : docsDir; + const fileName = formatPattern(pattern, { project, module: moduleName, file }); + const filePath = path.join(targetDir, fileName); + await writeFile( + filePath, + [ + `project=${project}`, + `module=${moduleName}`, + `file=${file}`, + `intent=${fileName}`, + '', + ].join('\n') + ); + filePaths.push(filePath); + if (fileName.startsWith('launch-plan-alpha') || fileName.startsWith('invoice-report')) { + deleteCandidates.push(filePath); + } + } + } + + await writeFile( + path.join(archiveRoot, `release-notes-command-palette-${project}.md`), + `release notes for ${project}\n` + ); + await writeFile( + path.join(referenceRoot, `path-query-reference-${project}.txt`), + `path reference for ${project}\n` + ); + } + + return { + tempRoot, + homeDir, + benchRoot, + projectsRoot, + filePaths, + deleteCandidates, + }; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1)); + return sorted[index]; +} + +function summarizeDurations(samples) { + const totalMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0); + const durations = samples.map((sample) => sample.durationMs); + return { + runs: samples.length, + minMs: Number(Math.min(...durations).toFixed(3)), + meanMs: Number((totalMs / Math.max(1, samples.length)).toFixed(3)), + medianMs: Number(percentile(durations, 50).toFixed(3)), + p95Ms: Number(percentile(durations, 95).toFixed(3)), + maxMs: Number(Math.max(...durations).toFixed(3)), + totalResults: samples.reduce((sum, sample) => sum + sample.resultCount, 0), + }; +} + +async function measureDuration(fn) { + const startedAt = performance.now(); + const result = await fn(); + return { + durationMs: performance.now() - startedAt, + result, + }; +} + +async function measureQueries(searchIndexedFiles, queries, config) { + const samples = []; + for (let run = 0; run < config.queryRuns; run += 1) { + for (const query of queries) { + const measured = await measureDuration(() => searchIndexedFiles(query, { limit: config.limit })); + samples.push({ + query, + run, + durationMs: measured.durationMs, + resultCount: measured.result.length, + }); + } + } + return { + ...summarizeDurations(samples), + samples: samples.map((sample) => ({ + ...sample, + durationMs: Number(sample.durationMs.toFixed(3)), + })), + }; +} + +function buildQueries(homeDir) { + return { + normal: [ + 'command palette', + 'launch plan alpha', + 'invoice report', + 'notes search index', + ], + pathLike: [ + '~/BenchRoot/Projects/project-', + '~/BenchRoot/Archive/release-notes-command', + `${homeDir}/BenchRoot/References/path-query-reference`, + ], + }; +} + +async function createUpdateFiles(fixture, config) { + const updatePaths = []; + for (let index = 0; index < config.updateCount; index += 1) { + const project = `project-${pad(index % config.projects)}`; + const moduleName = `module-${pad(index % config.modulesPerProject, 2)}`; + const filePath = path.join( + fixture.projectsRoot, + project, + moduleName, + 'src', + `watcher-added-command-${pad(index)}.ts` + ); + await writeFile(filePath, `watcher added command ${index}\n`); + updatePaths.push(filePath); + } + return updatePaths; +} + +async function deleteFixtureFiles(fixture, config) { + const deletePaths = fixture.deleteCandidates.slice(0, config.deleteCount); + for (const filePath of deletePaths) { + await fs.rm(filePath, { force: true }); + } + return deletePaths; +} + +function evaluateThresholds(metrics, thresholds = {}) { + const checks = [ + ['initialIndexMs', metrics.initialIndexMs, thresholds.initialIndexMs], + ['normalQueryP95Ms', metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms], + ['pathQueryP95Ms', metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms], + ['watchUpdateBatchMs', metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs], + ['deleteBatchMs', metrics.deleteBatchMs, thresholds.deleteBatchMs], + ]; + const failures = checks + .filter(([, actual, threshold]) => typeof threshold === 'number' && actual > threshold) + .map(([name, actual, threshold]) => `${name} ${actual.toFixed(3)}ms exceeded ${threshold}ms`); + + return { + passed: failures.length === 0, + failures, + applied: Object.fromEntries( + checks + .filter(([, , threshold]) => typeof threshold === 'number') + .map(([name, , threshold]) => [name, threshold]) + ), + }; +} + +function roundMetric(value) { + return Number(value.toFixed(3)); +} + +function toDisplayLines(summary) { + const thresholdLine = summary.thresholds.applied && Object.keys(summary.thresholds.applied).length > 0 + ? summary.thresholds.passed + ? 'Thresholds: passed' + : `Thresholds: failed (${summary.thresholds.failures.join('; ')})` + : 'Thresholds: not applied'; + + return [ + 'File search perf harness', + `Home fixture: ${summary.fixture.homeDir}`, + `Indexed entries: ${summary.fixture.initialEntryCount}`, + `Initial indexing: ${summary.metrics.initialIndexMs.toFixed(3)}ms`, + `Normal queries: mean ${summary.metrics.normalQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.normalQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.normalQueries.totalResults}`, + `Path-like queries: mean ${summary.metrics.pathLikeQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.pathLikeQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.pathLikeQueries.totalResults}`, + `Watcher-style updates: ${summary.metrics.watchUpdateBatchMs.toFixed(3)}ms for ${summary.fixture.updateCount} paths`, + `Delete batch: ${summary.metrics.deleteBatchMs.toFixed(3)}ms for ${summary.fixture.deleteCount} paths`, + `Post-update query: ${summary.metrics.postUpdateQueryMs.toFixed(3)}ms (${summary.verification.postUpdateResultCount} results)`, + `Deleted exact matches after tombstone: ${summary.verification.deletedExactMatches}`, + thresholdLine, + ]; +} + +export async function runFileSearchPerfHarness(overrides = {}) { + const config = mergeConfig(overrides); + const fixture = await createSyntheticHome(config); + let fileSearchIndexPerfHarness = null; + let summary = null; + let cleanupCompleted = false; + try { + const fileSearchIndex = await importTs(FILE_SEARCH_INDEX_TS); + const { + __fileSearchIndexPerfHarness, + getFileSearchIndexStatus, + searchIndexedFiles, + } = fileSearchIndex; + fileSearchIndexPerfHarness = __fileSearchIndexPerfHarness; + + if (!fileSearchIndexPerfHarness) { + throw new Error('Missing __fileSearchIndexPerfHarness export from file-search-index.ts'); + } + + const queries = buildQueries(fixture.homeDir); + const initialIndex = await measureDuration(() => + fileSearchIndexPerfHarness.rebuild({ + homeDir: fixture.homeDir, + includeProtectedHomeRoots: config.includeProtectedHomeRoots, + }) + ); + const statusAfterIndex = getFileSearchIndexStatus(); + + const normalQueries = await measureQueries(searchIndexedFiles, queries.normal, config); + const pathLikeQueries = await measureQueries(searchIndexedFiles, queries.pathLike, config); + + const updatePaths = await createUpdateFiles(fixture, config); + const watchUpdate = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(updatePaths) + ); + const postUpdateQuery = await measureDuration(() => + searchIndexedFiles('watcher added command', { limit: config.limit }) + ); + + const deletePaths = await deleteFixtureFiles(fixture, config); + const deleteBatch = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(deletePaths) + ); + const deletedNeedle = deletePaths[0] ? path.basename(deletePaths[0]) : ''; + const postDeleteQuery = await measureDuration(() => + deletedNeedle ? searchIndexedFiles(deletedNeedle, { limit: config.limit }) : Promise.resolve([]) + ); + const deletedExactMatches = deletedNeedle + ? postDeleteQuery.result.filter((result) => result.name === deletedNeedle).length + : 0; + + const metrics = { + initialIndexMs: roundMetric(initialIndex.durationMs), + normalQueries, + pathLikeQueries, + watchUpdateBatchMs: roundMetric(watchUpdate.durationMs), + postUpdateQueryMs: roundMetric(postUpdateQuery.durationMs), + deleteBatchMs: roundMetric(deleteBatch.durationMs), + postDeleteQueryMs: roundMetric(postDeleteQuery.durationMs), + }; + const realTempDir = await fs.realpath(os.tmpdir()); + + summary = { + config: { + projects: config.projects, + modulesPerProject: config.modulesPerProject, + filesPerModule: config.filesPerModule, + queryRuns: config.queryRuns, + updateCount: config.updateCount, + deleteCount: config.deleteCount, + limit: config.limit, + }, + fixture: { + tempRoot: fixture.tempRoot, + homeDir: fixture.homeDir, + initialFileCount: fixture.filePaths.length + config.projects * 2, + initialEntryCount: statusAfterIndex.indexedEntryCount, + updateCount: updatePaths.length, + deleteCount: deletePaths.length, + }, + metrics, + verification: { + postUpdateResultCount: postUpdateQuery.result.length, + postDeleteResultCount: postDeleteQuery.result.length, + deletedExactMatches, + homeDirectoryUsed: statusAfterIndex.homeDirectory, + homeIsTempDirectory: fixture.homeDir.startsWith(realTempDir), + }, + thresholds: evaluateThresholds(metrics, config.thresholds), + cleanup: { + keptFixture: Boolean(config.keep), + completed: false, + }, + }; + + return summary; + } finally { + fileSearchIndexPerfHarness?.reset(); + if (!config.keep) { + await fs.rm(fixture.tempRoot, { recursive: true, force: true }); + cleanupCompleted = true; + } + if (summary) { + summary.cleanup.completed = cleanupCompleted; + } + } +} + +async function runCli() { + if (hasFlag(process.argv, '--help')) { + process.stdout.write(`${getUsage()}\n`); + return; + } + + const config = parseFileSearchPerfHarnessArgs(); + const summary = await runFileSearchPerfHarness(config); + const output = config.json ? `${JSON.stringify(summary, null, 2)}\n` : `${toDisplayLines(summary).join('\n')}\n`; + + if (config.output) { + await fs.writeFile(path.resolve(config.output), output); + } + process.stdout.write(output); + + if (!summary.thresholds.passed) { + process.exitCode = 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..f6ee9b67 --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,178 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + globalThis[metricsKey] = metrics; + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..89523936 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,293 @@ -// Import a self-contained TypeScript module from a test by transpiling it with -// esbuild on the fly. This lets the recovery tests run the *real* production -// source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. -// -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. - -import { transform } from 'esbuild'; -import fs from 'node:fs'; - -export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +// Import a TypeScript/TSX module from a test by bundling it with esbuild on the +// fly. Bundling lets tests execute selected production modules that have local +// imports instead of falling back to grep-only assertions. + +import { build } from 'esbuild'; +import path from 'node:path'; + +let importNonce = 0; + +const DEFAULT_STUB_MODULES = new Set([ + '@phosphor-icons/react', + '@raycast/api', + 'electron', + 'electron-liquid-glass', + 'lucide-react', + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', +]); + +const ASSET_RE = /\.(?:css|less|sass|scss|svg|png|jpe?g|gif|webp|ico|icns|woff2?|ttf|otf|eot)$/i; + +export async function importTs(absPath, options = {}) { + const resolvedPath = path.resolve(absPath); + const { code } = await bundleTs(resolvedPath, options); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(code).toString('base64'), + `#${encodeURIComponent(resolvedPath)}-${importNonce++}`, + ].join(''); return import(dataUrl); } + +export async function bundleTs(absPath, options = {}) { + const result = await build({ + absWorkingDir: options.root || process.cwd(), + entryPoints: [path.resolve(absPath)], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: options.target || 'node20', + sourcemap: options.sourcemap || false, + logLevel: 'silent', + banner: options.browserGlobals === false ? undefined : { js: BROWSER_GLOBALS_BANNER }, + define: { + 'process.env.NODE_ENV': '"test"', + ...(options.define || {}), + }, + loader: { + '.js': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + '.json': 'json', + ...(options.loader || {}), + }, + external: options.external || [], + plugins: [ + tsImportStubPlugin(options), + ...(options.plugins || []), + ], + }); + + return { code: result.outputFiles[0].text }; +} + +function tsImportStubPlugin(options) { + const stubModules = new Set([...DEFAULT_STUB_MODULES, ...Object.keys(options.stubs || {})]); + for (const specifier of options.stubModules || []) stubModules.add(specifier); + + return { + name: 'ts-import-test-stubs', + setup(esbuild) { + esbuild.onResolve({ filter: /.*/ }, (args) => { + if (stubModules.has(args.path) || ASSET_RE.test(args.path)) { + return { path: args.path, namespace: 'ts-import-stub' }; + } + return null; + }); + + esbuild.onLoad({ filter: /.*/, namespace: 'ts-import-stub' }, (args) => ({ + contents: options.stubs?.[args.path] || defaultStubFor(args.path), + loader: 'js', + })); + }, + }; +} + +function defaultStubFor(specifier) { + if (specifier === 'electron') return ELECTRON_STUB; + if (specifier === 'react') return REACT_STUB; + if (specifier === 'react/jsx-runtime' || specifier === 'react/jsx-dev-runtime') return JSX_RUNTIME_STUB; + if (specifier === 'react-dom' || specifier === 'react-dom/client') return REACT_DOM_STUB; + if (ASSET_RE.test(specifier)) return ASSET_STUB; + return GENERIC_PROXY_STUB; +} + +const BROWSER_GLOBALS_BANNER = ` +const __scNoop = () => {}; +const __scMakeStorage = () => { + const map = new Map(); + return { + getItem: (key) => map.has(String(key)) ? map.get(String(key)) : null, + setItem: (key, value) => { map.set(String(key), String(value)); }, + removeItem: (key) => { map.delete(String(key)); }, + clear: () => { map.clear(); }, + }; +}; +var document = globalThis.document || { + addEventListener: __scNoop, + removeEventListener: __scNoop, + createElement: () => ({ style: {}, setAttribute: __scNoop, appendChild: __scNoop, remove: __scNoop }), + body: { appendChild: __scNoop, removeChild: __scNoop }, +}; +var navigator = globalThis.navigator || { platform: 'test', userAgent: 'node' }; +var localStorage = globalThis.localStorage || __scMakeStorage(); +var sessionStorage = globalThis.sessionStorage || __scMakeStorage(); +var window = globalThis.window || { + document, + navigator, + localStorage, + sessionStorage, + addEventListener: __scNoop, + removeEventListener: __scNoop, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload: __scNoop }, +}; +var requestAnimationFrame = globalThis.requestAnimationFrame || ((callback) => setTimeout(() => callback(Date.now()), 0)); +var cancelAnimationFrame = globalThis.cancelAnimationFrame || ((id) => clearTimeout(id)); +`; + +const ELECTRON_STUB = ` +const noop = () => {}; +const asyncNoop = async () => undefined; +const home = process.env.HOME || process.cwd(); +const paths = { + appData: process.cwd(), + desktop: home + '/Desktop', + documents: home + '/Documents', + downloads: home + '/Downloads', + home, + temp: process.env.TMPDIR || process.cwd(), + userData: process.env.SUPERCMD_TEST_USER_DATA || process.cwd(), +}; +const app = { + isPackaged: false, + getAppPath: () => process.cwd(), + getName: () => 'SuperCmd Test', + getPath: (name) => paths[name] || process.cwd(), + getVersion: () => '0.0.0-test', + isReady: () => true, + whenReady: asyncNoop, + on: noop, + once: noop, + quit: noop, + relaunch: noop, + requestSingleInstanceLock: () => true, + setAsDefaultProtocolClient: () => true, +}; +class BrowserWindow { + constructor() { + this.webContents = { + executeJavaScript: asyncNoop, + on: noop, + once: noop, + send: noop, + setWindowOpenHandler: noop, + }; + } + loadFile() { return Promise.resolve(); } + loadURL() { return Promise.resolve(); } + on() {} + once() {} + show() {} + hide() {} + close() {} + destroy() {} + isDestroyed() { return false; } +} +const ipcMain = { handle: noop, on: noop, once: noop, removeAllListeners: noop, removeHandler: noop }; +const ipcRenderer = { invoke: asyncNoop, send: noop, on: noop, once: noop, removeAllListeners: noop, removeListener: noop }; +const shell = { openExternal: asyncNoop, openPath: async () => '', showItemInFolder: noop }; +const dialog = { + showErrorBox: noop, + showMessageBox: async () => ({ response: 0 }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showSaveDialog: async () => ({ canceled: true, filePath: '' }), +}; +const clipboard = { + readText: () => '', + writeText: noop, + readImage: () => ({ isEmpty: () => true, toDataURL: () => '' }), + writeImage: noop, +}; +const nativeTheme = { shouldUseDarkColors: false, on: noop, removeListener: noop }; +const screen = { + getAllDisplays: () => [], + getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1024, height: 768 }, workArea: { x: 0, y: 0, width: 1024, height: 768 }, scaleFactor: 1 }), +}; +const safeStorage = { + decryptString: (value) => Buffer.from(value).toString('utf8'), + encryptString: (value) => Buffer.from(String(value)), + isEncryptionAvailable: () => false, +}; +module.exports = { app, BrowserWindow, clipboard, dialog, ipcMain, ipcRenderer, nativeTheme, safeStorage, screen, shell }; +`; + +const REACT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: noop, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const REACT_DOM_STUB = ` +const root = { render() {}, unmount() {} }; +module.exports = { + createPortal: (children) => children, + createRoot: () => root, + hydrateRoot: () => root, + render() {}, + unmountComponentAtNode() {}, +}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const ASSET_STUB = ` +module.exports = ''; +module.exports.default = ''; +`; + +const GENERIC_PROXY_STUB = ` +const makeStub = (name = 'stub') => new Proxy(function stub() {}, { + apply: () => undefined, + construct: () => ({}), + get: (_target, prop) => { + if (prop === '__esModule') return true; + if (prop === Symbol.toStringTag) return 'Module'; + return makeStub(String(prop)); + }, +}); +module.exports = makeStub(); +module.exports.default = module.exports; +`; diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs new file mode 100644 index 00000000..695347ae --- /dev/null +++ b/scripts/measure-extension-bundle-cache.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import { fileURLToPath, pathToFileURL } from 'node:url'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { build } from 'esbuild'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const iterations = Number.parseInt(process.env.SUPERCMD_EXTENSION_BUNDLE_MEASURE_ITERATIONS || '20', 10); + +export function createFixture() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-extension-bundle-cache-')); + const userDataDir = path.join(tmpDir, 'userData'); + const extensionsDir = path.join(userDataDir, 'extensions'); + const extDir = path.join(extensionsDir, 'bundle-cache-fixture'); + const buildDir = path.join(extDir, '.sc-build'); + + fs.mkdirSync(buildDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: 'bundle-cache-fixture', + title: 'Bundle Cache Fixture', + description: 'Fixture for extension bundle cache measurement', + owner: 'codex', + commands: [ + { + name: 'background', + title: 'Background', + description: 'No-view command', + mode: 'no-view', + preferences: [ + { + name: 'freshCommandPref', + title: 'Fresh Command Pref', + type: 'textfield', + default: 'default-command', + }, + ], + }, + { + name: 'menu', + title: 'Menu', + description: 'Menu-bar command', + mode: 'menu-bar', + }, + ], + preferences: [ + { + name: 'freshExtensionPref', + title: 'Fresh Extension Pref', + type: 'textfield', + default: 'default-extension', + }, + ], + }, + null, + 2 + ) + ); + fs.writeFileSync(path.join(buildDir, 'background.js'), 'module.exports = { name: "background" };\n'); + fs.writeFileSync(path.join(buildDir, 'menu.js'), 'module.exports = { name: "menu" };\n'); + + return { tmpDir, userDataDir, extDir }; +} + +export async function bundleExtensionRunner(userDataDir) { + const outFile = path.join( + os.tmpdir(), + `sc-extension-runner-measure-${process.pid}-${Date.now()}.cjs` + ); + + const stubModule = (filter, source) => ({ + name: `stub-${filter}`, + setup(pluginBuild) { + pluginBuild.onResolve({ filter }, () => ({ + path: filter.source, + namespace: `stub-${filter.source}`, + })); + pluginBuild.onLoad({ filter: /.*/, namespace: `stub-${filter.source}` }, () => ({ + contents: source, + loader: 'js', + })); + }, + }); + + await build({ + entryPoints: [path.join(root, 'src/main/extension-runner.ts')], + outfile: outFile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + logLevel: 'silent', + plugins: [ + stubModule( + /^electron$/, + ` + module.exports = { + app: { + getPath(name) { + if (name === 'userData') return ${JSON.stringify(userDataDir)}; + return ${JSON.stringify(userDataDir)}; + } + } + }; + ` + ), + stubModule( + /extension-preferences-store$/, + ` + module.exports = { + getExtensionPreferences(extName, cmdName) { + const values = globalThis.__extensionPreferenceValues || {}; + if (cmdName) return values[extName + '/' + cmdName] || {}; + return values[extName] || {}; + } + }; + ` + ), + stubModule( + /settings-store$/, + ` + module.exports = { + loadSettings() { + return { customExtensionFolders: [] }; + } + }; + ` + ), + ], + }); + + return outFile; +} + +export function createCounters(extDir) { + const counters = { + readFileSync: 0, + packageJsonReads: 0, + bundleReads: 0, + jsonParseCalls: 0, + existsSync: 0, + statSync: 0, + readdirSync: 0, + }; + const restore = []; + const packageJsonPath = path.join(extDir, 'package.json'); + const buildDir = path.join(extDir, '.sc-build'); + + const patch = (target, key, wrapper) => { + const original = target[key]; + target[key] = wrapper(original); + restore.push(() => { + target[key] = original; + }); + }; + + patch(fs, 'readFileSync', (original) => function patchedReadFileSync(filePath, ...args) { + const normalizedPath = typeof filePath === 'string' ? filePath : String(filePath); + counters.readFileSync++; + if (path.resolve(normalizedPath) === packageJsonPath) counters.packageJsonReads++; + if (path.resolve(normalizedPath).startsWith(buildDir + path.sep)) counters.bundleReads++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'existsSync', (original) => function patchedExistsSync(filePath) { + counters.existsSync++; + return original.call(this, filePath); + }); + patch(fs, 'statSync', (original) => function patchedStatSync(filePath, ...args) { + counters.statSync++; + return original.call(this, filePath, ...args); + }); + patch(fs, 'readdirSync', (original) => function patchedReaddirSync(filePath, ...args) { + counters.readdirSync++; + return original.call(this, filePath, ...args); + }); + patch(JSON, 'parse', (original) => function patchedJsonParse(source, ...args) { + counters.jsonParseCalls++; + return original.call(this, source, ...args); + }); + + return { + counters, + restore() { + while (restore.length > 0) restore.pop()(); + }, + }; +} + +export async function measure(label, extDir, fn) { + const { counters, restore } = createCounters(extDir); + const started = performance.now(); + try { + await fn(); + } finally { + counters.elapsedMs = Number((performance.now() - started).toFixed(3)); + restore(); + } + return { label, iterations, ...counters }; +} + +async function main() { + const fixture = createFixture(); + let bundledRunner; + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-1' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-1' }, + }; + + const noView = await measure('repeated-getExtensionBundle-no-view', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const bundle = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(bundle.mode, 'no-view'); + assert.equal(bundle.preferences.freshExtensionPref, 'stored-extension-1'); + assert.equal(bundle.preferences.freshCommandPref, 'stored-command-1'); + } + }); + + const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => { + for (let i = 0; i < iterations; i++) { + const commands = runner + .discoverInstalledExtensionCommands() + .filter((command) => command.mode === 'menu-bar'); + assert.equal(commands.length, 1); + const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName); + assert.equal(bundle.mode, 'menu-bar'); + } + }); + + console.log(JSON.stringify({ iterations, measurements: [noView, menuBar] }, null, 2)); + } finally { + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/measure-extension-wrapper-cache.mjs b/scripts/measure-extension-wrapper-cache.mjs new file mode 100644 index 00000000..97114635 --- /dev/null +++ b/scripts/measure-extension-wrapper-cache.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node + +import { performance } from 'node:perf_hooks'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + clearCompiledExtensionWrapperCache, + EXTENSION_WRAPPER_ARGUMENTS, + getCompiledExtensionWrapper, + getCompiledExtensionWrapperCacheStats, +} = await importTs(path.join(root, 'src/renderer/src/utils/extension-wrapper-cache.ts')); + +const ITERATIONS = Number.parseInt(process.env.SUPERCMD_WRAPPER_MEASURE_ITERATIONS || '1000', 10); + +function patchSchemeDynamicImports(sourceCode) { + return String(sourceCode || '').replace( + /\bimport\(\s*(["'])(swift:[^"']+|rust:[^"']+)\1\s*\)/g, + (_match, quote, specifier) => `__scDynamicImport(${quote}${specifier}${quote})` + ); +} + +function createFixtureExtensionCode() { + const body = ` +Object.defineProperty(exports, "__esModule", { value: true }); +const React = require("react"); +let moduleScopedCounter = 0; +moduleScopedCounter += 1; +const pendingTimer = setTimeout(() => {}, 60000); +exports.default = function FixtureCommand() { + return { + reactVersion: React.version, + moduleScopedCounter, + pendingTimerType: typeof pendingTimer + }; +}; +`; + return body + '\n'.repeat(20) + '// filler '.repeat(250); +} + +function createRunEnv() { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const timers = new Set(); + const trackTimeout = (cb, ms, ...rest) => { + const id = setTimeout(cb, ms, ...rest); + timers.add(id); + return id; + }; + const trackClearTimeout = (id) => { + timers.delete(id); + clearTimeout(id); + }; + const fakeRequire = (name) => { + if (name === 'react') return { version: '18.2.0' }; + return {}; + }; + + return { + args: [ + moduleExports, + fakeRequire, + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalThis, + globalThis, + (fn, ...args) => trackTimeout(() => fn(...args), 0), + trackClearTimeout, + () => 0, + () => {}, + trackTimeout, + trackClearTimeout, + () => 0, + () => {}, + undefined, + async () => ({}), + ], + fakeModule, + cleanup: () => { + timers.forEach((id) => clearTimeout(id)); + timers.clear(); + }, + getTimerCount: () => timers.size, + }; +} + +function measureUncachedLoads(code, iterations) { + let wrapperCreationCount = 0; + let wrapperCreationMs = 0; + let executionMs = 0; + let leakedTimerPeak = 0; + + for (let i = 0; i < iterations; i++) { + const executableCode = patchSchemeDynamicImports(code); + + const compileStart = performance.now(); + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, executableCode); + wrapperCreationMs += performance.now() - compileStart; + wrapperCreationCount++; + + const env = createRunEnv(); + const executionStart = performance.now(); + wrapper(...env.args); + executionMs += performance.now() - executionStart; + leakedTimerPeak = Math.max(leakedTimerPeak, env.getTimerCount()); + env.cleanup(); + + if (typeof env.fakeModule.exports.default !== 'function') { + throw new Error('Fixture did not export a function'); + } + } + + return { + iterations, + wrapperCreationCount, + wrapperCreationMs, + executionMs, + totalLoadMs: wrapperCreationMs + executionMs, + leakedTimerPeak, + }; +} + +function measureCachedLoads(code, iterations) { + clearCompiledExtensionWrapperCache(); + + let cacheLookupMs = 0; + let executionMs = 0; + let leakedTimerPeak = 0; + + for (let i = 0; i < iterations; i++) { + const executableCode = patchSchemeDynamicImports(code); + + const cacheStart = performance.now(); + const { wrapper } = getCompiledExtensionWrapper({ + extensionIdentity: 'fixture-owner/fixture-extension/fixture-command', + code, + executableCode, + }); + cacheLookupMs += performance.now() - cacheStart; + + const env = createRunEnv(); + const executionStart = performance.now(); + wrapper(...env.args); + executionMs += performance.now() - executionStart; + leakedTimerPeak = Math.max(leakedTimerPeak, env.getTimerCount()); + env.cleanup(); + + if (typeof env.fakeModule.exports.default !== 'function') { + throw new Error('Fixture did not export a function'); + } + } + + const stats = getCompiledExtensionWrapperCacheStats(); + return { + iterations, + wrapperCreationCount: stats.wrapperCreationCount, + wrapperCreationMs: stats.wrapperCreationMs, + cacheHits: stats.hits, + cacheMisses: stats.misses, + cacheLookupMs, + executionMs, + totalLoadMs: cacheLookupMs + executionMs, + leakedTimerPeak, + }; +} + +const fixtureCode = createFixtureExtensionCode(); + +console.log(JSON.stringify({ + scenario: 'extension-wrapper-cache', + codeLength: fixtureCode.length, + uncached: measureUncachedLoads(fixtureCode, ITERATIONS), + cached: measureCachedLoads(fixtureCode, ITERATIONS), +}, null, 2)); diff --git a/scripts/measure-icon-resolution.mjs b/scripts/measure-icon-resolution.mjs new file mode 100644 index 00000000..4555ada0 --- /dev/null +++ b/scripts/measure-icon-resolution.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); +const outputFile = path.join(os.tmpdir(), `supercmd-icon-resolution-${process.pid}-${Date.now()}.mjs`); + +const exactInputs = [ + 'AddPerson', + 'Icon.ArrowLeftCircleFilled', + 'MagnifyingGlass', + 'Stopwatch', + 'Temperature', + 'Dot', + 'XMarkCircleFilled', + 'Folder', +]; + +const unknownAndFuzzyInputs = [ + 'TotallyMissingIconName', + 'HyperSpecificSparkleClockWidget', + 'SuperCmdTimerStopwatchShape', + 'UnmappedTerminalCommandLineIcon', + 'MysteryNetworkCloudBolt', + 'Noise___No_Such_Icon_999', +]; + +function exposeResolverPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-resolution-for-measurement', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source}\nexport const __measureResolvePhosphorIconFromRaycast = resolvePhosphorIconFromRaycast;\n`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function stubServerRenderingPlugin() { + return { + name: 'stub-server-rendering-for-measurement', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-measurement-stub', + namespace: 'measurement-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'measurement-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }; +} + +function measureCase(resolveIcon, { name, inputs, iterations }) { + const calls = inputs.length * iterations; + const start = performance.now(); + let resolved = 0; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const input of inputs) { + if (resolveIcon(input)?.icon) resolved += 1; + } + } + + const durationMs = performance.now() - start; + return { + name, + calls, + resolved, + durationMs: Number(durationMs.toFixed(3)), + callsPerMs: Number((calls / Math.max(durationMs, 0.001)).toFixed(3)), + }; +} + +async function main() { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [exposeResolverPlugin(), stubServerRenderingPlugin()], + }); + + const moduleUrl = `${pathToFileURL(outputFile).href}?t=${Date.now()}`; + const runtime = await import(moduleUrl); + const resolveIcon = runtime.__measureResolvePhosphorIconFromRaycast; + if (typeof resolveIcon !== 'function') { + throw new Error('Failed to expose resolvePhosphorIconFromRaycast for measurement.'); + } + + const results = [ + measureCase(resolveIcon, { + name: 'exact/repeated', + inputs: exactInputs, + iterations: 2500, + }), + measureCase(resolveIcon, { + name: 'unknown-fuzzy/repeated', + inputs: unknownAndFuzzyInputs, + iterations: 250, + }), + ]; + + console.log('Icon resolution measurement'); + for (const result of results) { + console.log( + `${result.name}: ${result.calls} calls, ${result.durationMs} ms, ${result.callsPerMs} calls/ms, resolved ${result.resolved}` + ); + } + console.log(JSON.stringify({ results }, null, 2)); +} + +try { + await main(); +} finally { + await fs.unlink(outputFile).catch(() => {}); +} diff --git a/scripts/measure-launcher-command-list-render.mjs b/scripts/measure-launcher-command-list-render.mjs new file mode 100644 index 00000000..5f90920a --- /dev/null +++ b/scripts/measure-launcher-command-list-render.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rowPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandRow.tsx'); +const listPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandList.tsx'); + +const rowCount = readNumberArg('--rows', 5000); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.launcher-command-list-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import LauncherCommandList from ${JSON.stringify(listPath)}; + +type CommandInfo = { + id: string; + title: string; + subtitle?: string; + keywords?: string[]; + iconDataUrl?: string; + iconEmoji?: string; + iconName?: string; + category: 'app' | 'settings' | 'system' | 'extension' | 'script'; + path?: string; + browserResultKind?: 'open-tab' | 'bookmark' | 'history' | 'search'; + browserFaviconUrl?: string; +}; + +type LauncherCommandSection = { + title: string; + items: CommandInfo[]; +}; + +type Metrics = { + rowRenderCount: number; +}; + +declare global { + // eslint-disable-next-line no-var + var __launcherCommandListMetrics: Metrics | undefined; +} + +const rowCount = ${rowCount}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; + +const TRANSLATIONS: Record = { + 'launcher.badges.application': 'Application', + 'launcher.badges.bookmark': 'Bookmark', + 'launcher.badges.extension': 'Extension', + 'launcher.badges.history': 'History', + 'launcher.badges.openTab': 'Open Tab', + 'launcher.badges.quickLink': 'Quick Link', + 'launcher.badges.script': 'Script', + 'launcher.badges.settings': 'System Settings', + 'launcher.categories.browser': 'Browser', + 'launcher.categories.files': 'Files', + 'launcher.categories.recent': 'Recent', + 'launcher.categories.search': 'Search', + 'launcher.sections.pinned': 'Pinned', + 'launcher.sections.results': 'Results', + 'launcher.sections.selectedText': 'Selected Text', + 'launcher.status.discoveringApps': 'Discovering apps...', + 'launcher.status.noMatchingResults': 'No matching results', + 'common.system': 'System', + 'read.title': 'Read', + 'settings.title': 'Settings', + 'whisper.title': 'Whisper', +}; + +function t(key: string): string { + return TRANSLATIONS[key] || key; +} + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function makeCommand(index: number, titlePrefix = 'Command'): CommandInfo { + const kind = index % 9; + const base = { + id: \`measure-command-\${index}\`, + title: \`\${titlePrefix} \${String(index).padStart(5, '0')}\`, + subtitle: index % 4 === 0 ? \`Workspace action \${index}\` : undefined, + keywords: ['measure', 'launcher', String(index)], + }; + if (kind === 0) { + return { ...base, category: 'app', path: \`/Applications/Measure \${index}.app\`, iconDataUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }; + } + if (kind === 1) { + return { ...base, category: 'system', id: \`system-measure-\${index}\` }; + } + if (kind === 2) { + return { ...base, category: 'extension', path: \`measure-extension/command-\${index}\` }; + } + if (kind === 3) { + return { ...base, category: 'script', iconEmoji: '>' }; + } + if (kind === 4) { + return { ...base, category: 'system', browserResultKind: 'history', browserFaviconUrl: 'https://example.com/favicon.ico' }; + } + if (kind === 5) { + return { ...base, category: 'system', browserResultKind: 'open-tab' }; + } + if (kind === 6) { + return { ...base, category: 'system', browserResultKind: 'bookmark' }; + } + if (kind === 7) { + return { ...base, category: 'system', id: \`quicklink-measure-\${index}\`, iconName: 'Link' }; + } + return { ...base, category: 'settings' }; +} + +function makeSections(commands: CommandInfo[], titles: string[]): LauncherCommandSection[] { + const perSection = Math.max(1, Math.ceil(commands.length / titles.length)); + return titles.map((title, sectionIndex) => ({ + title, + items: commands.slice(sectionIndex * perSection, (sectionIndex + 1) * perSection), + })).filter((section) => section.items.length > 0); +} + +function flatten(sections: LauncherCommandSection[]): CommandInfo[] { + return sections.flatMap((section) => section.items); +} + +const rootCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index)); +const queryCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index, 'Query Match')).reverse(); +const narrowedCommands = Array.from({ length: Math.max(1, Math.floor(rowCount * 0.6)) }, (_, index) => makeCommand(index * 2, 'Filtered Match')); + +const rootSections = makeSections(rootCommands, ['', 'Pinned', 'Recent', 'Results', 'Files']); +const querySections = makeSections(queryCommands, ['Results', 'Browser', 'Files', 'Search']); +const narrowedSections = makeSections(narrowedCommands, ['Results', 'Browser', 'Files']); + +const scenarios = [ + { name: 'root results initial', sections: rootSections, selectedIndex: 0 }, + { name: 'selection moves to middle', sections: rootSections, selectedIndex: Math.floor(rowCount / 2) }, + { name: 'selection moves to end', sections: rootSections, selectedIndex: rowCount - 1 }, + { name: 'query update same-size reshuffle', sections: querySections, selectedIndex: 0 }, + { name: 'query update narrowed-large', sections: narrowedSections, selectedIndex: Math.min(20, narrowedCommands.length - 1) }, +]; + +const noop = () => {}; + +function renderScenario(scenario: typeof scenarios[number]) { + const displayCommands = flatten(scenario.sections); + const listRef = { current: null }; + const itemRefs = { current: [] }; + globalThis.__launcherCommandListMetrics = { rowRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( + } + itemRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>} + isLoading={false} + isHidden={false} + displayCommands={displayCommands as any} + sections={scenario.sections as any} + calcResult={null} + calcOffset={0} + selectedIndex={scenario.selectedIndex} + commandAliases={{}} + commandHotkeys={{ + 'measure-command-2': 'Command+Shift+P', + 'measure-command-7': 'Control+Option+L', + 'quicklink-measure-16': 'Hyper+K', + }} + onCalculatorCopy={noop} + onCommandClick={noop} + onCommandContextMenu={noop} + t={t} + /> + ); + const durationMs = performance.now() - started; + return { + name: scenario.name, + durationMs, + rowRenderCount: globalThis.__launcherCommandListMetrics?.rowRenderCount || 0, + commandCount: displayCommands.length, + htmlLength: html.length, + }; +} + +for (let i = 0; i < warmups; i += 1) { + for (const scenario of scenarios) { + renderScenario(scenario); + } +} + +const results = scenarios.map((scenario) => { + const samples = Array.from({ length: iterations }, () => renderScenario(scenario)); + return { + name: scenario.name, + commandCount: samples[0]?.commandCount || 0, + rowRenderCount: samples[0]?.rowRenderCount || 0, + medianDurationMs: median(samples.map((sample) => sample.durationMs)), + minDurationMs: Math.min(...samples.map((sample) => sample.durationMs)), + maxDurationMs: Math.max(...samples.map((sample) => sample.durationMs)), + htmlLength: samples[0]?.htmlLength || 0, + }; +}); + +const totalRows = results.reduce((sum, result) => sum + result.rowRenderCount, 0); +const totalMedianMs = results.reduce((sum, result) => sum + result.medianDurationMs, 0); +const summary = { rowCount, iterations, results, totalRows, totalMedianMs }; + +if (!jsonOnly) { + console.log(\`LauncherCommandList render measurement (rows=\${rowCount}, iterations=\${iterations})\`); + for (const result of results) { + console.log([ + \`- \${result.name}\`, + \`commands=\${result.commandCount}\`, + \`rowRenders=\${result.rowRenderCount}\`, + \`median=\${result.medianDurationMs.toFixed(2)}ms\`, + \`min=\${result.minDurationMs.toFixed(2)}ms\`, + \`max=\${result.maxDurationMs.toFixed(2)}ms\`, + ].join(' | ')); + } + console.log(\`Total row renders per scenario sequence: \${totalRows}\`); + console.log(\`Total median render time per scenario sequence: \${totalMedianMs.toFixed(2)}ms\`); +} +console.log(JSON.stringify(summary, null, 2)); +`); + +const instrumentLauncherRowPlugin = { + name: 'instrument-launcher-command-row', + setup(build) { + build.onLoad({ filter: /LauncherCommandRow\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + if (path.resolve(args.path) === rowPath) { + const marker = '}) => {\n'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument LauncherCommandRow render counter.'); + } + source = source.replace(marker, `${marker} const __launcherMetrics = (globalThis.__launcherCommandListMetrics ||= { rowRenderCount: 0 });\n __launcherMetrics.rowRenderCount += 1;\n`); + } + return { contents: source, loader: 'tsx' }; + }); + }, +}; + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [instrumentLauncherRowPlugin], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/test-abortable-promise-hooks.mjs b/scripts/test-abortable-promise-hooks.mjs new file mode 100644 index 00000000..44393af2 --- /dev/null +++ b/scripts/test-abortable-promise-hooks.mjs @@ -0,0 +1,378 @@ +#!/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'); + +let activeHost = null; + +const fakeReact = { + useCallback: (...args) => activeHost.useCallback(...args), + useEffect: (...args) => activeHost.useEffect(...args), + useMemo: (...args) => activeHost.useMemo(...args), + useRef: (...args) => activeHost.useRef(...args), + useState: (...args) => activeHost.useState(...args), +}; + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request === 'react') return fakeReact; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + AbortController, + Array, + console, + Date, + Error, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + URL, + exports: module.exports, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +class HookHost { + constructor(renderHook) { + this.renderHook = renderHook; + this.hookIndex = 0; + this.hooks = []; + this.pendingEffects = []; + this.output = undefined; + this.isRendering = false; + this.isFlushingEffects = false; + this.needsRender = false; + this.unmounted = false; + } + + render() { + if (this.unmounted) return this.output; + this.hookIndex = 0; + this.pendingEffects = []; + this.isRendering = true; + const previousHost = activeHost; + activeHost = this; + try { + this.output = this.renderHook(); + } finally { + activeHost = previousHost; + this.isRendering = false; + } + this.flushEffects(); + return this.output; + } + + flushEffects() { + this.isFlushingEffects = true; + try { + for (const { index, effect } of this.pendingEffects) { + const record = this.hooks[index]; + if (record.cleanup) record.cleanup(); + const cleanup = effect(); + record.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + } finally { + this.isFlushingEffects = false; + } + + if (this.needsRender && !this.unmounted) { + this.needsRender = false; + this.render(); + } + } + + scheduleRender() { + if (this.unmounted) return; + if (this.isRendering || this.isFlushingEffects) { + this.needsRender = true; + return; + } + this.render(); + } + + useState(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { + state: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + const record = this.hooks[index]; + const next = typeof nextValue === 'function' ? nextValue(record.state) : nextValue; + if (Object.is(record.state, next)) return; + record.state = next; + this.scheduleRender(); + }; + return [this.hooks[index].state, setState]; + } + + useRef(initialValue) { + const index = this.hookIndex++; + if (!this.hooks[index]) { + this.hooks[index] = { current: initialValue }; + } + return this.hooks[index]; + } + + useEffect(effect, deps) { + const index = this.hookIndex++; + const record = this.hooks[index] || {}; + const changed = !record.deps || !depsEqual(record.deps, deps); + this.hooks[index] = { ...record, deps }; + if (changed) { + this.pendingEffects.push({ index, effect }); + } + } + + useCallback(callback, deps) { + return this.useMemo(() => callback, deps); + } + + useMemo(factory, deps) { + const index = this.hookIndex++; + const record = this.hooks[index]; + if (record && depsEqual(record.deps, deps)) return record.value; + const value = factory(); + this.hooks[index] = { value, deps }; + return value; + } + + unmount() { + this.unmounted = true; + for (const record of this.hooks) { + if (record?.cleanup) { + record.cleanup(); + record.cleanup = undefined; + } + } + } +} + +function depsEqual(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps || previousDeps.length !== nextDeps.length) return false; + return previousDeps.every((value, index) => Object.is(value, nextDeps[index])); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function currentController(abortable) { + const controller = abortable.current; + assert.ok(controller instanceof AbortController, 'abortable.current should be an AbortController for each run'); + return controller; +} + +const { usePromise } = loadTsModule('src/renderer/src/raycast-api/hooks/use-promise.ts'); +const { useCachedPromise } = loadTsModule('src/renderer/src/raycast-api/hooks/use-cached-promise.ts'); + +test('usePromise aborts the previous abortable run before revalidate starts another run', async () => { + const abortable = { current: null }; + const controllers = []; + const pending = []; + const fn = () => { + controllers.push(currentController(abortable)); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + assert.equal(controllers.length, 1); + + host.output.revalidate(); + await flushAsync(); + + assert.equal(controllers.length, 2); + assert.equal(controllers[0].signal.aborted, true); + assert.notEqual(controllers[1], controllers[0]); + assert.equal(controllers[1].signal.aborted, false); + + host.unmount(); +}); + +test('useCachedPromise aborts the previous abortable run before revalidate starts another run', async () => { + const abortable = { current: null }; + const controllers = []; + const pending = []; + const fn = () => { + controllers.push(currentController(abortable)); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + assert.equal(controllers.length, 1); + + host.output.revalidate(); + await flushAsync(); + + assert.equal(controllers.length, 2); + assert.equal(controllers[0].signal.aborted, true); + assert.notEqual(controllers[1], controllers[0]); + assert.equal(controllers[1].signal.aborted, false); + + host.unmount(); +}); + +test('usePromise aborts active abortable work on unmount and clears the abortable ref', async () => { + const abortable = { current: null }; + const controllers = []; + const fn = () => { + controllers.push(currentController(abortable)); + return deferred().promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + host.unmount(); + + assert.equal(controllers.length, 1); + assert.equal(controllers[0].signal.aborted, true); + assert.equal(abortable.current, null); +}); + +test('useCachedPromise aborts active abortable work on unmount and clears the abortable ref', async () => { + const abortable = { current: null }; + const controllers = []; + const fn = () => { + controllers.push(currentController(abortable)); + return deferred().promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable })); + + host.render(); + await flushAsync(); + host.unmount(); + + assert.equal(controllers.length, 1); + assert.equal(controllers[0].signal.aborted, true); + assert.equal(abortable.current, null); +}); + +test('usePromise keeps the latest abortable result when an older aborted run resolves later', async () => { + const abortable = { current: null }; + const pending = []; + const onDataCalls = []; + const fn = () => { + currentController(abortable); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { abortable, onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useCachedPromise keeps the latest abortable result when an older aborted run resolves later', async () => { + const abortable = { current: null }; + const pending = []; + const onDataCalls = []; + const fn = () => { + currentController(abortable); + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { abortable, onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); diff --git a/scripts/test-aerospace-workspace.mjs b/scripts/test-aerospace-workspace.mjs new file mode 100644 index 00000000..39f8598c --- /dev/null +++ b/scripts/test-aerospace-workspace.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { createAerospaceWorkspaceMover } = await importTs(path.join(root, 'src/main/aerospace-workspace.ts')); + +test('AeroSpace workspace mover', async (t) => { + await t.test('coalesces concurrent move requests into one follow-up run', async () => { + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(10); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + mover.requestMove(); + mover.requestMove(); + mover.requestMove(); + + await mover.whenIdle(); + + assert.deepEqual( + calls.map((args) => args[0]), + [ + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + 'list-workspaces', + 'list-windows', + 'move-node-to-workspace', + ], + ); + assert.deepEqual(mover.getState(), { available: true, inFlight: false, queued: false }); + }); + + await t.test('marks missing aerospace binary unavailable and skips later requests', async () => { + let calls = 0; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async () => { + calls += 1; + const error = new Error('spawn aerospace ENOENT'); + error.code = 'ENOENT'; + throw error; + }, + }); + + mover.requestMove(); + await mover.whenIdle(); + mover.requestMove(); + await delay(0); + + assert.equal(calls, 1); + assert.deepEqual(mover.getState(), { available: false, inFlight: false, queued: false }); + }); + + await t.test('does not block the event loop while slow commands are in flight', async () => { + const slowCommandDelayMs = 80; + const calls = []; + const mover = createAerospaceWorkspaceMover({ + platform: 'darwin', + runCommand: async (args) => { + calls.push(args); + await delay(slowCommandDelayMs); + if (args[0] === 'list-workspaces') return 'work\n'; + if (args[0] === 'list-windows') return '42 other\n'; + return ''; + }, + }); + + const startedAt = performance.now(); + let zeroDelayTimerFiredAfterMs = Number.POSITIVE_INFINITY; + const timerPromise = new Promise((resolve) => { + setTimeout(() => { + zeroDelayTimerFiredAfterMs = performance.now() - startedAt; + resolve(); + }, 0); + }); + + mover.requestMove(); + await timerPromise; + await mover.whenIdle(); + + assert.ok(calls.length >= 1, 'first command should start'); + assert.ok( + zeroDelayTimerFiredAfterMs < slowCommandDelayMs, + `zero-delay timer fired after ${zeroDelayTimerFiredAfterMs}ms`, + ); + }); +}); diff --git a/scripts/test-ai-chat-stream-batching.mjs b/scripts/test-ai-chat-stream-batching.mjs new file mode 100644 index 00000000..066a2c4b --- /dev/null +++ b/scripts/test-ai-chat-stream-batching.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const CHUNK_COUNT = 240; + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + vm.runInNewContext(transpiled.outputText, { + module, + exports: module.exports, + require, + console, + setTimeout, + clearTimeout, + }, { filename: resolvedPath }); + return module.exports; +} + +const { + AI_CHAT_STREAM_FLUSH_MS, + createAiChatStreamBuffer, +} = loadTsModule('src/renderer/src/utils/ai-chat-stream-buffer.ts'); + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index}\n`); +} + +function simulateUnbatchedStreaming(chunks) { + let content = ''; + let visibleUpdates = 0; + let markdownReparseChars = 0; + let layoutMeasurements = 0; + + const startedAt = performance.now(); + for (const chunk of chunks) { + content += chunk; + visibleUpdates += 1; + markdownReparseChars += content.length; + layoutMeasurements += 1; + } + + return { + content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements, + markdownReparseChars, + visibleUpdates, + }; +} + +function createManualScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + get size() { + return callbacks.size; + }, + schedule(callback) { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }, + cancel(handle) { + callbacks.delete(handle); + }, + runNext() { + const next = callbacks.entries().next(); + if (next.done) return false; + const [handle, callback] = next.value; + callbacks.delete(handle); + callback(); + return true; + }, + }; +} + +function createRenderCounters() { + return { + content: '', + layoutMeasurements: 0, + markdownReparseChars: 0, + visibleUpdates: 0, + onFlush(content) { + this.content = content; + this.visibleUpdates += 1; + this.layoutMeasurements += 1; + this.markdownReparseChars += content.length; + }, + }; +} + +function simulateBatchedBurstStreaming(chunks) { + const scheduler = createManualScheduler(); + const counters = createRenderCounters(); + const startedAt = performance.now(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => counters.onFlush(content), + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + for (const chunk of chunks) { + buffer.append(chunk); + } + assert.equal(scheduler.size, 1); + buffer.flushNow(); + assert.equal(scheduler.size, 0); + + return { + content: counters.content, + elapsedMs: performance.now() - startedAt, + layoutMeasurements: counters.layoutMeasurements, + markdownReparseChars: counters.markdownReparseChars, + visibleUpdates: counters.visibleUpdates, + }; +} + +function createConversationHarness() { + const scheduler = createManualScheduler(); + let messages = [ + { id: 'user-1', role: 'user', content: 'Question', createdAt: 1 }, + { id: 'assistant-1', role: 'assistant', content: '', createdAt: 2 }, + ]; + const persisted = []; + const counters = createRenderCounters(); + const buffer = createAiChatStreamBuffer({ + flushIntervalMs: AI_CHAT_STREAM_FLUSH_MS, + onFlush: (content) => { + counters.onFlush(content); + messages = messages.map((message) => ( + message.id === 'assistant-1' + ? { ...message, content } + : message + )); + }, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + return { + append(chunk) { + buffer.append(chunk); + }, + complete() { + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + fail(error) { + const currentContent = buffer.getContent(); + buffer.append(`${currentContent ? '\n\n' : ''}Error: ${error}`); + buffer.flushNow(); + persisted.push(messages.map((message) => ({ ...message }))); + buffer.reset(); + }, + get counters() { + return counters; + }, + get messages() { + return messages; + }, + get persisted() { + return persisted; + }, + get schedulerSize() { + return scheduler.size; + }, + flushScheduled() { + return scheduler.runNext(); + }, + }; +} + +function test(name, fn) { + fn(); + console.log(`PASS ${name}`); +} + +const chunks = makeChunks(); +const baseline = simulateUnbatchedStreaming(chunks); +const batched = simulateBatchedBurstStreaming(chunks); + +assert.equal(baseline.content, chunks.join('')); +assert.equal(baseline.visibleUpdates, CHUNK_COUNT); +assert.equal(baseline.layoutMeasurements, CHUNK_COUNT); +assert.equal(batched.content, baseline.content); +assert.ok(batched.visibleUpdates < baseline.visibleUpdates); +assert.ok(batched.markdownReparseChars < baseline.markdownReparseChars); + +test('scheduled flush exposes partial streaming content', () => { + const harness = createConversationHarness(); + harness.append('Hello'); + assert.equal(harness.schedulerSize, 1); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello'); + harness.append(' world'); + assert.equal(harness.flushScheduled(), true); + assert.equal(harness.messages[1].content, 'Hello world'); + assert.equal(harness.counters.visibleUpdates, 2); +}); + +test('completion forces final flush before persistence', () => { + const harness = createConversationHarness(); + chunks.forEach((chunk) => harness.append(chunk)); + assert.equal(harness.messages[1].content, ''); + harness.complete(); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, chunks.join('')); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +test('error forces authoritative content plus error before persistence', () => { + const harness = createConversationHarness(); + harness.append('partial'); + harness.append(' answer'); + harness.fail('network failed'); + assert.equal(harness.persisted.length, 1); + assert.equal(harness.persisted[0][1].content, 'partial answer\n\nError: network failed'); + assert.equal(harness.counters.visibleUpdates, 1); +}); + +console.log(JSON.stringify({ + mode: 'unbatched-baseline', + chunks: CHUNK_COUNT, + visibleUpdates: baseline.visibleUpdates, + layoutMeasurements: baseline.layoutMeasurements, + markdownReparseChars: baseline.markdownReparseChars, + elapsedMs: Number(baseline.elapsedMs.toFixed(3)), +}, null, 2)); + +console.log(JSON.stringify({ + mode: 'batched-burst', + chunks: CHUNK_COUNT, + flushWindowMs: AI_CHAT_STREAM_FLUSH_MS, + visibleUpdates: batched.visibleUpdates, + layoutMeasurements: batched.layoutMeasurements, + markdownReparseChars: batched.markdownReparseChars, + elapsedMs: Number(batched.elapsedMs.toFixed(3)), +}, null, 2)); diff --git a/scripts/test-ai-model-status-polling.mjs b/scripts/test-ai-model-status-polling.mjs new file mode 100644 index 00000000..e20149e2 --- /dev/null +++ b/scripts/test-ai-model-status-polling.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const FIVE_SECOND_TICKS = 5; + +const { + applyAiModelStatusPatch, + createEmptyAiModelStatusSnapshot, + fetchAiModelStatusPollPatch, +} = await importTs(path.resolve('src/renderer/src/settings/aiModelStatusPolling.ts')); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function makeWhisperCppStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'base', + path: '/models/ggml-base.bin', + bytesDownloaded: 1024, + totalBytes: 1024, + ...overrides, + }; +} + +function makeParakeetStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function makeQwen3Status(overrides = {}) { + return { + state: 'downloaded', + modelName: 'qwen3-asr', + path: '/models/qwen3', + progress: 1, + ...overrides, + }; +} + +function makeSnapshot(overrides = {}) { + return { + whisperCpp: makeWhisperCppStatus(overrides.whisperCpp), + parakeet: makeParakeetStatus(overrides.parakeet), + qwen3: makeQwen3Status(overrides.qwen3), + }; +} + +function createStaticFetchers(snapshot) { + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + + return { + calls, + fetchers: { + async whisperCpp() { + calls.whisperCpp += 1; + return clone(snapshot.whisperCpp); + }, + async parakeet() { + calls.parakeet += 1; + return clone(snapshot.parakeet); + }, + async qwen3() { + calls.qwen3 += 1; + return clone(snapshot.qwen3); + }, + }, + }; +} + +function createFixedPollingHarness(initialSnapshot) { + let current = initialSnapshot; + let stateUpdates = 0; + let profilerCommits = 0; + + function applyPatch(patch) { + const next = applyAiModelStatusPatch(current, patch); + if (next === current) return false; + + current = next; + stateUpdates += 1; + profilerCommits += 1; + return true; + } + + return { + async poll(fetchers) { + const patch = await fetchAiModelStatusPollPatch(fetchers); + applyPatch(patch); + }, + applyPatch, + get current() { + return current; + }, + get metrics() { + return { stateUpdates, profilerCommits }; + }, + }; +} + +async function simulateLegacyIndependentPolling(fetchers, ticks) { + let stateUpdates = 0; + let profilerCommits = 0; + + for (let tick = 0; tick < ticks; tick += 1) { + await fetchers.whisperCpp(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.parakeet(); + stateUpdates += 1; + profilerCommits += 1; + + await fetchers.qwen3(); + stateUpdates += 1; + profilerCommits += 1; + } + + return { stateUpdates, profilerCommits }; +} + +test('AI model status polling baseline performs three unchanged state writes per second', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + + const metrics = await simulateLegacyIndependentPolling(fetchers, FIVE_SECOND_TICKS); + + console.log( + `[ai-status baseline] ticks=${FIVE_SECOND_TICKS} stateUpdates=${metrics.stateUpdates} profilerCommits=${metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(metrics, { stateUpdates: 15, profilerCommits: 15 }); +}); + +test('coalesced AI model status polling skips commits when statuses are unchanged', async () => { + const snapshot = makeSnapshot(); + const { fetchers, calls } = createStaticFetchers(snapshot); + const harness = createFixedPollingHarness(clone(snapshot)); + + for (let tick = 0; tick < FIVE_SECOND_TICKS; tick += 1) { + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced unchanged] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 0, profilerCommits: 0 }); +}); + +test('coalesced AI model status polling commits once per tick when all statuses change', async () => { + let tickValue = 0; + const calls = { + whisperCpp: 0, + parakeet: 0, + qwen3: 0, + }; + const harness = createFixedPollingHarness(makeSnapshot({ + whisperCpp: { bytesDownloaded: 0, totalBytes: 100 }, + parakeet: { progress: 0 }, + qwen3: { progress: 0 }, + })); + const fetchers = { + async whisperCpp() { + calls.whisperCpp += 1; + return makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: tickValue, + totalBytes: 100, + }); + }, + async parakeet() { + calls.parakeet += 1; + return makeParakeetStatus({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + async qwen3() { + calls.qwen3 += 1; + return makeQwen3Status({ + state: 'downloading', + progress: tickValue / FIVE_SECOND_TICKS, + }); + }, + }; + + for (let tick = 1; tick <= FIVE_SECOND_TICKS; tick += 1) { + tickValue = tick; + await harness.poll(fetchers); + } + + console.log( + `[ai-status coalesced changing] ticks=${FIVE_SECOND_TICKS} stateUpdates=${harness.metrics.stateUpdates} profilerCommits=${harness.metrics.profilerCommits}` + ); + assert.deepEqual(calls, { whisperCpp: 5, parakeet: 5, qwen3: 5 }); + assert.deepEqual(harness.metrics, { stateUpdates: 5, profilerCommits: 5 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, FIVE_SECOND_TICKS); + assert.equal(harness.current.parakeet.progress, 1); + assert.equal(harness.current.qwen3.progress, 1); +}); + +test('download progress status patches still commit when progress fields change', () => { + const harness = createFixedPollingHarness(createEmptyAiModelStatusSnapshot()); + + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 10, + totalBytes: 100, + }), + }), false); + assert.equal(harness.applyPatch({ + whisperCpp: makeWhisperCppStatus({ + state: 'downloading', + bytesDownloaded: 25, + totalBytes: 100, + }), + }), true); + assert.equal(harness.applyPatch({ + parakeet: makeParakeetStatus({ state: 'downloading', progress: 0.25 }), + }), true); + assert.equal(harness.applyPatch({ + qwen3: makeQwen3Status({ state: 'downloading', progress: 0.5 }), + }), true); + + assert.deepEqual(harness.metrics, { stateUpdates: 4, profilerCommits: 4 }); + assert.equal(harness.current.whisperCpp.bytesDownloaded, 25); + assert.equal(harness.current.parakeet.progress, 0.25); + assert.equal(harness.current.qwen3.progress, 0.5); +}); + +test('coalesced poll starts all status requests before awaiting any one result', async () => { + const started = []; + const resolvers = []; + const fetchers = { + whisperCpp: () => new Promise((resolve) => { + started.push('whisperCpp'); + resolvers.push(() => resolve(makeWhisperCppStatus())); + }), + parakeet: () => new Promise((resolve) => { + started.push('parakeet'); + resolvers.push(() => resolve(makeParakeetStatus())); + }), + qwen3: () => new Promise((resolve) => { + started.push('qwen3'); + resolvers.push(() => resolve(makeQwen3Status())); + }), + }; + + const patchPromise = fetchAiModelStatusPollPatch(fetchers); + + assert.deepEqual(started, ['whisperCpp', 'parakeet', 'qwen3']); + for (const resolve of resolvers) resolve(); + assert.deepEqual(await patchPromise, makeSnapshot()); +}); diff --git a/scripts/test-auto-quit-manager.mjs b/scripts/test-auto-quit-manager.mjs new file mode 100644 index 00000000..a2127915 --- /dev/null +++ b/scripts/test-auto-quit-manager.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const { promisify } = require('util'); + +const autoQuitManagerPath = path.resolve('src/main/auto-quit-manager.ts'); +const metricsMode = process.argv.includes('--metrics'); + +function loadAutoQuitManager({ + frontmostBundleId = 'com.apple.finder', + recording = false, + musicPlaying = false, +} = {}) { + let now = 0; + let intervalCallback = null; + const counts = { + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + other: 0, + osascript: 0, + }; + const quits = []; + + function classifyAppleScript(args) { + const script = Array.isArray(args) ? args.join('\n') : ''; + if (script.includes('frontmost is true')) return 'frontmost'; + if (script.includes('AppleHDAEngineInput')) return 'recording'; + if (script.includes('player state is playing')) return 'music'; + if (script.includes('runningApplications()')) return 'quit'; + return 'other'; + } + + function execFile() { + throw new Error('execFile callback form is not supported by this test harness'); + } + + execFile[promisify.custom] = async (file, args) => { + if (file === '/usr/bin/osascript') { + counts.osascript += 1; + const kind = classifyAppleScript(args); + counts[kind] += 1; + if (kind === 'frontmost') return { stdout: `${frontmostBundleId}\n`, stderr: '' }; + if (kind === 'recording') return { stdout: recording ? '1\n' : '0\n', stderr: '' }; + if (kind === 'music') return { stdout: musicPlaying ? 'true\n' : 'false\n', stderr: '' }; + if (kind === 'quit') { + const script = args.at(-1) || ''; + const match = script.match(/if bid is "([^"]+)"/); + quits.push(match?.[1] || 'unknown'); + return { stdout: '', stderr: '' }; + } + } + return { stdout: '', stderr: '' }; + }; + + const source = fs.readFileSync(autoQuitManagerPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: autoQuitManagerPath, + }); + + const module = { exports: {} }; + const localRequire = (request) => { + if (request === 'child_process') return { execFile }; + return require(request); + }; + const testDate = { + now: () => now, + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date: testDate, + setInterval: (callback) => { + intervalCallback = callback; + return { callback }; + }, + clearInterval: (interval) => { + if (interval?.callback === intervalCallback) intervalCallback = null; + }, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: autoQuitManagerPath }); + + return { + counts, + quits, + module: module.exports, + advance(ms) { + now += ms; + }, + async tick() { + assert.equal(typeof intervalCallback, 'function', 'auto-quit interval should be running'); + await intervalCallback(); + }, + }; +} + +async function runNoTrackedAppDueScenario() { + const harness = loadAutoQuitManager(); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 60, + }, + ]); + + for (let i = 0; i < 3; i += 1) { + harness.advance(5000); + await harness.tick(); + } + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runOneNonMusicAppDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runNonMusicDueWithMusicTrackedButNotDueScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.apple.finder' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 60, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runDueAppIsFrontmostScenario() { + const harness = loadAutoQuitManager({ frontmostBundleId: 'com.example.editor' }); + harness.module.startAutoQuit([ + { + bundleId: 'com.example.editor', + appName: 'Example Editor', + appPath: '/Applications/Example Editor.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +async function runMusicAppDueAndPlayingScenario() { + const harness = loadAutoQuitManager({ + frontmostBundleId: 'com.apple.finder', + musicPlaying: true, + }); + harness.module.startAutoQuit([ + { + bundleId: 'com.spotify.client', + appName: 'Spotify', + appPath: '/Applications/Spotify.app', + timeoutSeconds: 5, + }, + ]); + + harness.advance(5000); + await harness.tick(); + + harness.module.stopAutoQuit(); + return { counts: harness.counts, quits: harness.quits }; +} + +function compactCounts({ counts, quits }) { + return { + osascript: counts.osascript, + frontmost: counts.frontmost, + recording: counts.recording, + music: counts.music, + quit: counts.quit, + quitBundleIds: quits, + }; +} + +async function printMetrics() { + const metrics = { + noTrackedAppDueOver3Ticks: compactCounts(await runNoTrackedAppDueScenario()), + oneNonMusicAppDue: compactCounts(await runOneNonMusicAppDueScenario()), + }; + console.log(JSON.stringify(metrics, null, 2)); +} + +const tests = []; +function test(name, fn) { + tests.push({ name, fn }); +} + +test('skips AppleScript probes while no tracked app is due', async () => { + const result = await runNoTrackedAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 0, + frontmost: 0, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('only probes frontmost, recording, and quit for a due non-music app', async () => { + const result = await runOneNonMusicAppDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not check music when only non-music candidates are due', async () => { + const result = await runNonMusicDueWithMusicTrackedButNotDueScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 0, + quit: 1, + quitBundleIds: ['com.example.editor'], + }); +}); + +test('does not run recording or music probes when the only due app is frontmost', async () => { + const result = await runDueAppIsFrontmostScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 1, + frontmost: 1, + recording: 0, + music: 0, + quit: 0, + quitBundleIds: [], + }); +}); + +test('checks music playback only when a due music app would otherwise quit', async () => { + const result = await runMusicAppDueAndPlayingScenario(); + assert.deepEqual(compactCounts(result), { + osascript: 3, + frontmost: 1, + recording: 1, + music: 1, + quit: 0, + quitBundleIds: [], + }); +}); + +if (metricsMode) { + await printMetrics(); +} else { + for (const { name, fn } of tests) { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + throw error; + } + } +} diff --git a/scripts/test-background-no-view-dedupe.mjs b/scripts/test-background-no-view-dedupe.mjs new file mode 100644 index 00000000..f2ef7296 --- /dev/null +++ b/scripts/test-background-no-view-dedupe.mjs @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import test from 'node:test'; +import vm from 'vm'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const { + enqueueBackgroundNoViewRun, + getBackgroundNoViewRunIdentity, + removeBackgroundNoViewRun, +} = loadTsModule('src/renderer/src/utils/background-no-view-runs.ts'); + +function runIdentity(bundle) { + return `${bundle.extensionName || bundle.extName || ''}/${bundle.commandName || bundle.cmdName || ''}`; +} + +function legacyQueueNoViewBundleRun(prev, bundle, launchType = 'background', reportStatus = false, now = Date.now) { + const runId = `${runIdentity(bundle)}/${now()}`; + return [...prev, { runId, bundle, launchType, reportStatus }]; +} + +function simulateRepeatedBackgroundTicks({ enqueue, ticks, intervalMs }) { + const bundle = { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extensionName: 'widgets', + commandName: 'refresh', + }; + let runs = []; + const tickTimes = Array.from({ length: ticks }, (_, index) => index * intervalMs); + for (const elapsedMs of tickTimes) { + runs = enqueue(runs, bundle, 'background', false, () => elapsedMs); + } + return { + runs, + ticks, + intervalMs, + elapsedMs: tickTimes.at(-1) ?? 0, + }; +} + +function simulateDedupedBackgroundTicks({ ticks, intervalMs }) { + const bundle = { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extName: 'widgets', + cmdName: 'refresh', + extensionName: 'widgets', + commandName: 'refresh', + }; + let runs = []; + let enqueued = 0; + const tickTimes = Array.from({ length: ticks }, (_, index) => index * intervalMs); + for (const elapsedMs of tickTimes) { + const result = enqueueBackgroundNoViewRun(runs, bundle, 'background', false, () => elapsedMs); + runs = result.runs; + if (result.enqueued) enqueued += 1; + } + return { + runs, + enqueued, + skipped: ticks - enqueued, + ticks, + intervalMs, + elapsedMs: tickTimes.at(-1) ?? 0, + }; +} + +function testBundle(overrides = {}) { + return { + code: 'export default async function Command() {}', + mode: 'no-view', + title: 'Refresh Widgets', + extName: 'widgets', + cmdName: 'refresh', + ...overrides, + }; +} + +test('baseline: legacy background no-view queue accepts overlapping duplicate ticks', () => { + const metrics = simulateRepeatedBackgroundTicks({ + enqueue: legacyQueueNoViewBundleRun, + ticks: 3, + intervalMs: 25, + }); + + console.log( + `[baseline] background no-view duplicate ticks: ticks=${metrics.ticks} intervalMs=${metrics.intervalMs} elapsedMs=${metrics.elapsedMs} queued=${metrics.runs.length}` + ); + + assert.equal(metrics.runs.length, 3); + assert.deepEqual(metrics.runs.map((run) => runIdentity(run.bundle)), [ + 'widgets/refresh', + 'widgets/refresh', + 'widgets/refresh', + ]); +}); + +test('background no-view queue dedupes overlapping ticks for the same extension command', () => { + const metrics = simulateDedupedBackgroundTicks({ + ticks: 3, + intervalMs: 25, + }); + + console.log( + `[after] background no-view duplicate ticks: ticks=${metrics.ticks} intervalMs=${metrics.intervalMs} elapsedMs=${metrics.elapsedMs} queued=${metrics.runs.length} skipped=${metrics.skipped}` + ); + + assert.equal(metrics.enqueued, 1); + assert.equal(metrics.skipped, 2); + assert.equal(metrics.runs.length, 1); + assert.equal(getBackgroundNoViewRunIdentity(metrics.runs[0].bundle), 'widgets/refresh'); +}); + +test('background no-view identity matches legacy and hydrated bundle fields', () => { + const legacyFields = testBundle(); + const hydratedFields = { + code: legacyFields.code, + mode: legacyFields.mode, + title: legacyFields.title, + extensionName: 'widgets', + commandName: 'refresh', + }; + + const first = enqueueBackgroundNoViewRun([], legacyFields, 'background', false, () => 0); + assert.equal(first.enqueued, true); + + const duplicate = enqueueBackgroundNoViewRun(first.runs, hydratedFields, 'background', false, () => 25); + assert.equal(duplicate.enqueued, false); + assert.equal(duplicate.runs.length, 1); +}); + +test('user-initiated no-view launches are not deduped', () => { + const bundle = testBundle(); + let runs = []; + for (const elapsedMs of [0, 25, 50]) { + const result = enqueueBackgroundNoViewRun(runs, bundle, 'userInitiated', false, () => elapsedMs); + assert.equal(result.enqueued, true); + runs = result.runs; + } + + assert.equal(runs.length, 3); + assert.deepEqual(Array.from(runs, (run) => run.launchType), [ + 'userInitiated', + 'userInitiated', + 'userInitiated', + ]); +}); + +test('background no-view launch skips while same command is already in flight', () => { + const bundle = testBundle({ + extensionName: 'widgets', + commandName: 'refresh', + }); + const userLaunch = enqueueBackgroundNoViewRun([], bundle, 'userInitiated', false, () => 0); + assert.equal(userLaunch.enqueued, true); + + const backgroundTick = enqueueBackgroundNoViewRun(userLaunch.runs, bundle, 'background', false, () => 25); + assert.equal(backgroundTick.enqueued, false); + assert.equal(backgroundTick.runs.length, 1); + + const explicitLaunch = enqueueBackgroundNoViewRun(backgroundTick.runs, bundle, 'userInitiated', false, () => 50); + assert.equal(explicitLaunch.enqueued, true); + assert.equal(explicitLaunch.runs.length, 2); +}); + +test('clearing a background no-view run allows the next background tick to enqueue', () => { + const bundle = testBundle({ + extensionName: 'widgets', + commandName: 'refresh', + }); + + for (const reason of ['success', 'error', 'unmount']) { + const first = enqueueBackgroundNoViewRun([], bundle, 'background', false, () => 0); + assert.equal(first.enqueued, true, reason); + + const overlapping = enqueueBackgroundNoViewRun(first.runs, bundle, 'background', false, () => 25); + assert.equal(overlapping.enqueued, false, reason); + assert.equal(overlapping.runs.length, 1, reason); + + const cleared = removeBackgroundNoViewRun(overlapping.runs, overlapping.runs[0].runId); + assert.equal(cleared.length, 0, reason); + + const afterClear = enqueueBackgroundNoViewRun(cleared, bundle, 'background', false, () => 50); + assert.equal(afterClear.enqueued, true, reason); + assert.equal(afterClear.runs.length, 1, reason); + } +}); diff --git a/scripts/test-background-refresh-timer-diff.mjs b/scripts/test-background-refresh-timer-diff.mjs new file mode 100644 index 00000000..691f3b20 --- /dev/null +++ b/scripts/test-background-refresh-timer-diff.mjs @@ -0,0 +1,230 @@ +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 { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const helperPath = path.join(rootDir, 'src/renderer/src/hooks/backgroundRefreshTimers.ts'); + +function loadHelperModule() { + const source = fs.readFileSync(helperPath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + fileName: helperPath, + }); + const module = { exports: {} }; + + vm.runInNewContext( + compiled.outputText, + { + exports: module.exports, + module, + require: (specifier) => { + throw new Error(`Unexpected runtime import while loading timer helper: ${specifier}`); + }, + }, + { filename: helperPath } + ); + + return module.exports; +} + +const { + getBackgroundRefreshTimerDescriptors, + reconcileBackgroundRefreshTimers, + clearBackgroundRefreshTimers, +} = loadHelperModule(); + +function parseIntervalToMs(interval) { + return { + '1m': 60_000, + '5m': 300_000, + '10m': 600_000, + }[interval] ?? null; +} + +function extensionCommand(overrides = {}) { + return { + id: 'extension-weather-current', + title: 'Current Weather', + category: 'extension', + path: 'weather/current', + mode: 'no-view', + interval: '1m', + ...overrides, + }; +} + +function inlineScriptCommand(overrides = {}) { + return { + id: 'script-stock-ticker', + title: 'Stock Ticker', + category: 'script', + path: '/Users/example/Scripts/stocks.sh', + mode: 'inline', + interval: '5m', + ...overrides, + }; +} + +function descriptors(commands) { + return getBackgroundRefreshTimerDescriptors(commands, parseIntervalToMs); +} + +function legacyReconcile(commands, activeTimers) { + const cleared = activeTimers.length; + activeTimers.length = 0; + + for (const descriptor of descriptors(commands)) { + activeTimers.push(descriptor.key); + } + + return { created: activeTimers.length, cleared }; +} + +function createHarness() { + const timers = new Map(); + const created = []; + const cleared = []; + let nextTimerId = 1; + + return { + timers, + created, + cleared, + reconcile(commands) { + return reconcileBackgroundRefreshTimers({ + timers, + descriptors: descriptors(commands), + createTimer(descriptor) { + const timerId = nextTimerId++; + created.push({ timerId, key: descriptor.key, commandId: descriptor.command.id }); + return timerId; + }, + clearTimer(timerId) { + cleared.push(timerId); + }, + }); + }, + clearAll() { + return clearBackgroundRefreshTimers(timers, (timerId) => { + cleared.push(timerId); + }); + }, + }; +} + +function activeTimerIds(harness) { + return Array.from(harness.timers.values()).map((entry) => entry.timerId); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +test('Background refresh timer diff', async (t) => { + await t.test('baseline legacy strategy clears and recreates unchanged background commands', () => { + const activeTimers = []; + const initialCommands = [ + extensionCommand(), + inlineScriptCommand(), + { id: 'app-terminal', title: 'Terminal', category: 'app' }, + ]; + const refreshedCommands = [ + extensionCommand({ subtitle: 'Updated metadata' }), + inlineScriptCommand({ subtitle: 'AAPL 210.00' }), + { id: 'app-terminal', title: 'Terminal', category: 'app', subtitle: 'Terminal.app' }, + ]; + + const first = legacyReconcile(initialCommands, activeTimers); + const second = legacyReconcile(refreshedCommands, activeTimers); + + console.log( + `[background-refresh baseline] unchanged update legacy created=${second.created} cleared=${second.cleared}` + ); + assert.deepEqual(first, { created: 2, cleared: 0 }); + assert.deepEqual(second, { created: 2, cleared: 2 }); + }); + + await t.test('unchanged background commands retain their timers across command-list updates', () => { + const harness = createHarness(); + const initial = harness.reconcile([ + extensionCommand(), + inlineScriptCommand(), + { id: 'app-terminal', title: 'Terminal', category: 'app' }, + ]); + const timerIdsBeforeUpdate = activeTimerIds(harness); + + const update = harness.reconcile([ + extensionCommand({ subtitle: 'Updated metadata' }), + inlineScriptCommand({ subtitle: 'AAPL 210.00' }), + { id: 'app-terminal', title: 'Terminal', category: 'app', subtitle: 'Terminal.app' }, + ]); + + console.log( + `[background-refresh keyed] unchanged update created=${update.created} retained=${update.retained} cleared=${update.cleared}` + ); + assert.deepEqual(plain(initial), { created: 2, retained: 0, cleared: 0 }); + assert.deepEqual(plain(update), { created: 0, retained: 2, cleared: 0 }); + assert.deepEqual(activeTimerIds(harness), timerIdsBeforeUpdate); + assert.equal(harness.created.length, 2); + assert.equal(harness.cleared.length, 0); + }); + + await t.test('added background commands create timers and removed commands clear timers', () => { + const harness = createHarness(); + const extension = extensionCommand(); + const script = inlineScriptCommand(); + + assert.deepEqual(plain(harness.reconcile([extension])), { created: 1, retained: 0, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([extension, script])), { created: 1, retained: 1, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([script])), { created: 0, retained: 1, cleared: 1 }); + assert.deepEqual(activeTimerIds(harness), [2]); + assert.deepEqual(harness.cleared, [1]); + }); + + await t.test('changed background command identity fields restart only the changed timer', () => { + const harness = createHarness(); + const extension = extensionCommand(); + const script = inlineScriptCommand(); + + assert.deepEqual(plain(harness.reconcile([extension, script])), { created: 2, retained: 0, cleared: 0 }); + assert.deepEqual(plain(harness.reconcile([extensionCommand({ interval: '5m' }), script])), { + created: 1, + retained: 1, + cleared: 1, + }); + assert.deepEqual(activeTimerIds(harness), [2, 3]); + assert.deepEqual(harness.cleared, [1]); + + assert.deepEqual(plain(harness.reconcile([extensionCommand({ interval: '5m', mode: 'menu-bar' }), script])), { + created: 1, + retained: 1, + cleared: 1, + }); + assert.deepEqual(activeTimerIds(harness), [2, 4]); + assert.deepEqual(harness.cleared, [1, 3]); + }); + + await t.test('unmount cleanup clears all remaining timers once', () => { + const harness = createHarness(); + + assert.deepEqual(plain(harness.reconcile([extensionCommand(), inlineScriptCommand()])), { + created: 2, + retained: 0, + cleared: 0, + }); + assert.equal(harness.clearAll(), 2); + assert.deepEqual(activeTimerIds(harness), []); + assert.deepEqual(harness.cleared, [1, 2]); + }); +}); diff --git a/scripts/test-browser-search-lag-fix.mjs b/scripts/test-browser-search-lag-fix.mjs index bff0d368..ee2f158b 100644 --- a/scripts/test-browser-search-lag-fix.mjs +++ b/scripts/test-browser-search-lag-fix.mjs @@ -3,6 +3,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const files = { history: fs.readFileSync('src/main/browser-search-history.ts', 'utf8'), @@ -24,9 +29,26 @@ function assertNotIncludes(source, needle) { } test('Browser search lag fix', async (t) => { - await t.test('main history module exports required functions', () => { - assertIncludes(files.history, 'getBrowserSearchRevision'); - assertIncludes(files.history, 'getBrowserSearchStats'); + // Baseline: this used to grep browser-search-history.ts for export names, + // which could not prove the module or its relative imports actually execute. + await t.test('main history module imports exported browser-search API', async () => { + const history = await importTs(path.join(root, 'src/main/browser-search-history.ts')); + + assert.equal(typeof history.getBrowserSearchRevision, 'function'); + assert.equal(typeof history.getBrowserSearchStats, 'function'); + assert.equal(typeof history.resolveInput, 'function'); + + const url = history.resolveInput('example.com'); + assert.equal(url?.type, 'url'); + assert.equal(url?.url, 'https://example.com/'); + assert.equal(url?.host, 'example.com'); + + const search = history.resolveInput('browser search lag'); + assert.equal(search?.type, 'search'); + assert.equal(search?.url, 'https://www.google.com/search?q=browser%20search%20lag'); + }); + + await t.test('profile bookmark import keeps dedupe state', () => { assertIncludes(files.history, 'seenBookmarkKeys'); assertIncludes(files.history, 'existingBookmarkByKey'); }); @@ -57,8 +79,12 @@ test('Browser search lag fix', async (t) => { assertIncludes(files.hook, 'refreshEntriesIfStale'); assertIncludes(files.hook, 'historyByTimeEntryIds'); assertIncludes(files.hook, 'bookmarksByBrowserOrderEntryIds'); + assertIncludes(files.hook, 'tokenPrefixEntryIds'); + assertIncludes(files.hook, 'tokenTrigramEntryIds'); + assertIncludes(files.hook, 'getIndexedEntryCandidateIds'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096'); + assertIncludes(files.hook, '__browserSearchTestAccess'); }); await t.test('local commands use refreshEntriesIfStale not refreshEntries', () => { diff --git a/scripts/test-browser-search-performance.mjs b/scripts/test-browser-search-performance.mjs new file mode 100644 index 00000000..8b2a345d --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const FIXED_NOW = Date.UTC(2026, 6, 3, 12, 0, 0); + +class FixedDate extends Date { + constructor(...args) { + super(...(args.length ? args : [FIXED_NOW])); + } + + static now() { + return FIXED_NOW; + } +} + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return /\.[cm]?[tj]sx?$/.test(nextPath) ? loadTsModule(nextPath) : require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Intl, + Date: FixedDate, + Math, + String, + Number, + Boolean, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function buildSyntheticBrowserData() { + const profiles = ['chrome:Default', 'chrome:Work', 'arc:Default', 'brave:Default']; + const topics = ['github', 'docs', 'linear', 'notion', 'calendar', 'supercmd', 'typescript', 'electron', 'raycast', 'browser']; + const entries = []; + const tabs = []; + + for (let index = 0; index < 20_000; index += 1) { + const topic = topics[index % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}${index % 700}.example.com`; + entries.push({ + id: `h-${index}`, + type: 'url', + query: `${topic} history page ${index}`, + url: `https://${host}/workspace/${index % 200}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 371_000, + useCount: (index % 23) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + }); + } + + for (let index = 0; index < 4_000; index += 1) { + const topic = topics[(index * 3) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-bookmark${index % 600}.example.com`; + entries.push({ + id: `b-${index}`, + type: 'bookmark', + query: `${topic} bookmark reference ${index}`, + url: `https://${host}/saved/${index % 300}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 997_000, + useCount: (index % 11) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + bookmarkFolder: `Folder ${index % 25}`, + bookmarkOrder: index, + }); + } + + const nicknameBookmark = { + id: 'b-nickname-github', + type: 'bookmark', + query: 'GitHub Pull Requests', + url: 'https://github.com/SuperCmdLabs/SuperCmd/pulls', + host: 'github.com', + lastUsedAt: FIXED_NOW, + useCount: 40, + source: 'chrome', + sourceProfileId: 'chrome:Default', + sourceProfileName: 'Default', + bookmarkFolder: 'Development', + bookmarkOrder: -1, + }; + entries.push(nicknameBookmark); + + for (let index = 0; index < 300; index += 1) { + const topic = topics[(index * 7) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-tab${index % 120}.example.com`; + tabs.push({ + id: `t-${index}`, + browserId: source, + browserName: source, + profileId: profile.split(':')[1], + profileSourceId: profile, + profileName: profile.split(':')[1], + windowId: String(index % 8), + windowOrdinal: index % 8, + tabId: String(index), + tabIndex: index % 40, + favIconUrl: '', + title: `${topic} active tab ${index}`, + url: `https://${host}/open/${index}`, + host, + active: index % 37 === 0, + windowLastFocusedAt: FIXED_NOW - (index % 120) * 60_000, + updatedAt: FIXED_NOW - index * 60_000, + }); + } + + const nicknames = [{ + source: nicknameBookmark.source, + sourceProfileId: nicknameBookmark.sourceProfileId, + url: nicknameBookmark.url, + nickname: 'gh', + }]; + + return { entries, tabs, nicknames }; +} + +function resultSignature(results) { + return results.map((result) => [ + result.id, + result.kind, + result.title, + result.url, + result.completion || '', + result.matchKind || '', + Boolean(result.nicknameMatch), + ]); +} + +function measureAverageMs(iterations, queries, fn) { + const byQuery = {}; + for (const query of queries) { + const start = performance.now(); + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const results = fn(query); + checksum += results.length + (results[0]?.id?.length || 0); + } + byQuery[query] = { + avgMs: (performance.now() - start) / iterations, + checksum, + }; + } + return byQuery; +} + +test('browser search indexed harness preserves full-scan result order', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; + + for (const query of queries) { + assert.deepEqual( + resultSignature(getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60)), + resultSignature(getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60)), + `ranked results should match the full scan for ${query}` + ); + assert.deepEqual( + resultSignature(getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true })), + resultSignature(getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true })), + `ordered results should match the full scan for ${query}` + ); + } +}); + +test('browser search indexed harness stays under generous query thresholds', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexStart = performance.now(); + const entryIndex = buildBrowserEntryIndex(entries); + const indexMs = performance.now() - indexStart; + const rankedFullScan = measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + ); + const orderedFullScan = measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedIndexed = measureAverageMs(15, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + ); + const orderedIndexed = measureAverageMs(15, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedMaxMs = Math.max(...Object.values(rankedIndexed).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedIndexed).map((item) => item.avgMs)); + + console.log(JSON.stringify({ + browserSearchPerf: { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMs.toFixed(2)), + rankedFullScanAvgMs: Object.fromEntries(Object.entries(rankedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedFullScanAvgMs: Object.fromEntries(Object.entries(orderedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + rankedIndexedAvgMs: Object.fromEntries(Object.entries(rankedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedIndexedAvgMs: Object.fromEntries(Object.entries(orderedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + }, + })); + + assert.ok(indexMs < 2_000, `index build should stay below 2000ms, got ${indexMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < 120, `ranked indexed search should stay below 120ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < 120, `ordered indexed search should stay below 120ms, got ${orderedMaxMs.toFixed(2)}ms`); +}); diff --git a/scripts/test-camera-capture-single-encode.mjs b/scripts/test-camera-capture-single-encode.mjs new file mode 100644 index 00000000..3e493ccd --- /dev/null +++ b/scripts/test-camera-capture-single-encode.mjs @@ -0,0 +1,130 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cameraExtensionPath = path.join(root, 'src/renderer/src/CameraExtension.tsx'); +const cameraCapturePath = path.join(root, 'src/renderer/src/camera-capture.ts'); + +const { + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} = await importTs(cameraCapturePath); + +function makeMockCanvas(blob = new Blob(['fake-png'], { type: 'image/png' })) { + const calls = { + toBlob: [], + toDataURL: [], + }; + + return { + calls, + toBlob(callback, type) { + calls.toBlob.push(type); + callback(blob); + }, + toDataURL(type) { + calls.toDataURL.push(type); + return 'data:image/png;base64,legacy-preview'; + }, + }; +} + +function makeMockUrlApi() { + const created = []; + const revoked = []; + + return { + created, + revoked, + api: { + createObjectURL(blob) { + const objectUrl = `blob:supercmd-test-${created.length + 1}`; + created.push({ objectUrl, blob }); + return objectUrl; + }, + revokeObjectURL(objectUrl) { + revoked.push(objectUrl); + }, + }, + }; +} + +test('Camera capture single-encode path', async (t) => { + await t.test('mock canvas catches the legacy duplicate encode baseline', async () => { + const canvas = makeMockCanvas(); + + canvas.toDataURL('image/png'); + await new Promise((resolve) => { + canvas.toBlob(resolve, 'image/png'); + }); + + assert.deepEqual(canvas.calls.toDataURL, ['image/png']); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + }); + + await t.test('production capture encoding calls toBlob once and never toDataURL', async () => { + const expectedBlob = new Blob(['fake-png'], { type: 'image/png' }); + const canvas = makeMockCanvas(expectedBlob); + + const blob = await encodeCanvasAsPngBlob(canvas); + + assert.equal(blob, expectedBlob); + assert.deepEqual(canvas.calls.toBlob, ['image/png']); + assert.deepEqual(canvas.calls.toDataURL, []); + }); + + await t.test('capture preview is shown from an object URL', () => { + const { api, created, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + const blob = new Blob(['preview'], { type: 'image/png' }); + + const preview = createCapturePreview(blob, manager); + + assert.deepEqual(preview, { url: 'blob:supercmd-test-1', visible: true }); + assert.equal(created.length, 1); + assert.equal(created[0].blob, blob); + assert.deepEqual(revoked, []); + }); + + await t.test('object URL is revoked when preview is cleared', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + assert.equal(manager.getCurrentUrl(), 'blob:supercmd-test-1'); + + manager.clear(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('object URL is revoked when preview manager is disposed on unmount', () => { + const { api, revoked } = makeMockUrlApi(); + const manager = createCapturePreviewUrlManager(api); + + createCapturePreview(new Blob(['preview'], { type: 'image/png' }), manager); + manager.dispose(); + + assert.equal(manager.getCurrentUrl(), null); + assert.deepEqual(revoked, ['blob:supercmd-test-1']); + }); + + await t.test('component wires the object URL preview to clear and unmount paths', () => { + const source = fs.readFileSync(cameraExtensionPath, 'utf8'); + + assert.ok(source.includes('const captureBlob = await encodeCanvasAsPngBlob(canvas);')); + assert.ok(source.includes("from './camera-capture';")); + assert.ok(!source.includes('.toDataURL(')); + assert.ok(source.includes('src={capturePreviewUrl}')); + assert.ok(source.includes('clearCapturePreview();')); + assert.ok(source.includes('capturePreviewUrlManagerRef.current?.dispose();')); + }); +}); diff --git a/scripts/test-clipboard-history-persistence.mjs b/scripts/test-clipboard-history-persistence.mjs new file mode 100644 index 00000000..c6076c41 --- /dev/null +++ b/scripts/test-clipboard-history-persistence.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; + +const clipboardManagerPath = 'src/main/clipboard-manager.ts'; +const mainPath = 'src/main/main.ts'; + +function makeTextItem(index) { + return { + id: `item-${index}`, + type: 'text', + content: `Clipboard text ${index}`, + preview: `Clipboard text ${index}`, + timestamp: Date.now() + index, + pinned: false, + }; +} + +function assertIncludes(source, needle) { + assert.ok(source.includes(needle), `Source should include: ${needle}`); +} + +function assertNotIncludes(source, needle) { + assert.ok(!source.includes(needle), `Source should not include: ${needle}`); +} + +function extractFunction(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `Missing function signature: ${signature}`); + + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `Missing function body: ${signature}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + + assert.fail(`Unterminated function body: ${signature}`); +} + +function measureCoalescedRapidAdditions(additions) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-clipboard-after-')); + const historyPath = path.join(tempDir, 'history.json'); + const tempPath = `${historyPath}.tmp`; + const history = []; + let serializeMs = 0; + let writeMs = 0; + + try { + for (let index = 0; index < additions; index += 1) { + history.unshift(makeTextItem(index)); + } + + const serializeStart = performance.now(); + const json = JSON.stringify(history, null, 2); + serializeMs += performance.now() - serializeStart; + + const writeStart = performance.now(); + fs.writeFileSync(tempPath, json); + fs.renameSync(tempPath, historyPath); + writeMs += performance.now() - writeStart; + + return { + additions, + writeCount: 1, + serializeMs, + writeMs, + finalBytes: fs.statSync(historyPath).size, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('clipboard history persistence coalesces rapid additions into async atomic writes', () => { + const source = fs.readFileSync(clipboardManagerPath, 'utf8'); + const syncHistoryWriteSites = (source.match(/fs\.writeFileSync\(historyPath/g) || []).length; + const metrics = measureCoalescedRapidAdditions(100); + + console.log( + `[clipboard-after] rapidAdditions=${metrics.additions} ` + + `coalescedWrites=${metrics.writeCount} ` + + `serializeMs=${metrics.serializeMs.toFixed(2)} ` + + `writeMs=${metrics.writeMs.toFixed(2)} ` + + `blockingMs=${(metrics.serializeMs + metrics.writeMs).toFixed(2)} ` + + `finalBytes=${metrics.finalBytes}` + ); + + assert.equal(syncHistoryWriteSites, 0); + assertIncludes(source, 'const HISTORY_SAVE_DEBOUNCE_MS = 250;'); + assertIncludes(source, 'let historySaveDirty = false;'); + assertIncludes(source, 'async function writeHistoryFileAtomic(serializedHistory: string): Promise'); + assertIncludes(source, "await fsp.writeFile(tempPath, serializedHistory, 'utf-8');"); + assertIncludes(source, 'await fsp.rename(tempPath, historyPath);'); + assertIncludes(source, 'while (historySaveDirty)'); + assertIncludes(source, 'historySaveTimer = setTimeout'); + assertIncludes(source, 'void flushClipboardHistoryWrites();'); + assert.equal(metrics.writeCount, 1); +}); + +test('clipboard history persistence flushes on stop, clear, delete, and app quit', () => { + const clipboardSource = fs.readFileSync(clipboardManagerPath, 'utf8'); + const mainSource = fs.readFileSync(mainPath, 'utf8'); + + const stopBlock = extractFunction(clipboardSource, 'export async function stopClipboardMonitor(): Promise'); + const clearBlock = extractFunction(clipboardSource, 'export async function clearClipboardHistory(): Promise'); + const deleteBlock = extractFunction(clipboardSource, 'export async function deleteClipboardItem(id: string): Promise'); + const beforeQuitBlock = extractFunction(mainSource, "app.on('before-quit', (event: any) =>"); + + assertIncludes(stopBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(clearBlock, 'saveHistory({ flush: true });'); + assertIncludes(clearBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(deleteBlock, 'saveHistory({ flush: true });'); + assertIncludes(deleteBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(beforeQuitBlock, 'hasPendingClipboardHistoryWrites()'); + assertIncludes(beforeQuitBlock, 'event.preventDefault();'); + assertIncludes(beforeQuitBlock, 'flushClipboardHistoryWrites()'); + assertIncludes(mainSource, 'await flushClipboardHistoryWrites();'); + assertIncludes(mainSource, "ipcMain.handle('clipboard-clear-history', async () =>"); + assertIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) =>"); + assertNotIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', (_event: any, id: string) =>"); +}); diff --git a/scripts/test-cursor-prompt-stream-batching.mjs b/scripts/test-cursor-prompt-stream-batching.mjs new file mode 100644 index 00000000..b9241522 --- /dev/null +++ b/scripts/test-cursor-prompt-stream-batching.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { importTs } from './lib/ts-import.mjs'; + +const CHUNK_COUNT = 500; +const { createCursorPromptResultBatcher } = await importTs( + path.resolve('src/renderer/src/hooks/cursorPromptResultBatcher.ts') +); + +function makeChunks(count) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +function simulateLegacyCursorPromptStream(chunks) { + let visibleResult = ''; + let visibleUpdateCount = 0; + + for (const chunk of chunks) { + visibleResult += chunk; + visibleUpdateCount += 1; + } + + return { visibleResult, visibleUpdateCount }; +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) { + callback(); + } + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createBatcherHarness() { + const scheduler = createManualFrameScheduler(); + const resultRef = { current: '' }; + let visibleResult = ''; + let visibleUpdateCount = 0; + const batcher = createCursorPromptResultBatcher({ + resultRef, + setVisibleResult(nextResult) { + visibleResult = nextResult; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + return { + batcher, + resultRef, + scheduler, + get visibleResult() { + return visibleResult; + }, + get visibleUpdateCount() { + return visibleUpdateCount; + }, + }; +} + +test('baseline cursor prompt streaming updates visible state once per chunk', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const metrics = simulateLegacyCursorPromptStream(chunks); + + assert.equal(metrics.visibleResult, expectedResult); + assert.equal(metrics.visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline visible result updates: ${metrics.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching coalesces many chunks into one visible update per frame', () => { + const chunks = makeChunks(CHUNK_COUNT); + const expectedResult = chunks.join(''); + const harness = createBatcherHarness(); + + for (const chunk of chunks) { + harness.batcher.appendChunk(chunk); + } + + assert.equal(harness.resultRef.current, expectedResult); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); + + assert.equal(harness.scheduler.flushFrames(), 1); + assert.equal(harness.visibleResult, expectedResult); + assert.equal(harness.visibleUpdateCount, 1); + console.log(`batched visible result updates: ${harness.visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('cursor prompt result batching flushes the final pending stream synchronously', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('final '); + harness.batcher.appendChunk('answer'); + harness.batcher.flush(); + + assert.equal(harness.resultRef.current, 'final answer'); + assert.equal(harness.visibleResult, 'final answer'); + assert.equal(harness.visibleUpdateCount, 1); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleUpdateCount, 1); +}); + +test('cursor prompt result batching keeps apply text current before the visible frame flush', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk(' rewrite'); + harness.batcher.appendChunk(' this '); + + const textReadByApply = String(harness.resultRef.current || '').trim(); + assert.equal(textReadByApply, 'rewrite this'); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); + assert.equal(harness.scheduler.pendingFrameCount(), 1); +}); + +test('cursor prompt result batching can cancel a pending repaint without changing authoritative text', () => { + const harness = createBatcherHarness(); + + harness.batcher.appendChunk('partial'); + harness.batcher.cancelPendingFlush(); + + assert.equal(harness.resultRef.current, 'partial'); + assert.equal(harness.scheduler.pendingFrameCount(), 0); + assert.equal(harness.scheduler.flushFrames(), 0); + assert.equal(harness.visibleResult, ''); + assert.equal(harness.visibleUpdateCount, 0); +}); diff --git a/scripts/test-emoji-caret-session.mjs b/scripts/test-emoji-caret-session.mjs new file mode 100644 index 00000000..a531fbeb --- /dev/null +++ b/scripts/test-emoji-caret-session.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +function canRunSwiftTests() { + if (process.platform !== 'darwin') return false; + try { + execFileSync('swiftc', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +if (!canRunSwiftTests()) { + console.log('[emoji-caret-session] skipped: Swift compiler is not available on this platform'); + process.exit(0); +} + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-emoji-caret-session-')); +const testSource = path.join(tempDir, 'main.swift'); +const testBinary = path.join(tempDir, 'emoji-caret-session-tests'); + +fs.writeFileSync(testSource, ` +import Foundation +@preconcurrency import ApplicationServices + +func makeSnapshot(pid: pid_t, bundleId: String = "com.example.Editor") -> AXCaretSessionSnapshot { + AXCaretSessionSnapshot( + context: AXCaretApplicationContext(pid: pid, bundleIdentifier: bundleId), + focusedElement: nil, + caret: AXCaretRect(x: 20, y: 40, w: 1, h: 18, tier: "unit") + ) +} + +func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fputs("failed: " + message + "\\n", stderr) + exit(1) + } +} + +var cache = EmojiCaretSessionCache() +expect(!cache.isActive, "new cache starts empty") + +cache.store(makeSnapshot(pid: 42)) +expect(cache.isActive, "store activates the cache") +switch cache.validate(eventTargetPID: 42) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "matching event PID reuses the snapshot") +default: + expect(false, "matching event PID reuses the snapshot") +} + +switch cache.validate(eventTargetPID: nil) { +case .valid(let snapshot): + expect(snapshot.context.pid == 42, "missing event PID does not force an AX requery") +default: + expect(false, "missing event PID does not force an AX requery") +} + +switch cache.validate(eventTargetPID: 43) { +case .invalidated: + break +default: + expect(false, "PID changes invalidate the cache") +} +expect(!cache.isActive, "PID invalidation clears the cache") + +cache.store(makeSnapshot(pid: 99)) +cache.invalidate() +expect(!cache.isActive, "failed rect/dismiss invalidation clears the cache") + +print("emoji caret session cache tests passed") +`); + +try { + execFileSync('swiftc', [ + '-o', testBinary, + 'src/native/ax-caret-query.swift', + 'src/native/emoji-caret-session-cache.swift', + testSource, + '-framework', 'AppKit', + '-framework', 'ApplicationServices', + ], { stdio: 'inherit' }); + const output = execFileSync(testBinary, { encoding: 'utf8' }).trim(); + assert.equal(output, 'emoji caret session cache tests passed'); + console.log(`[emoji-caret-session] ${output}`); +} finally { + fs.rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/scripts/test-emoji-grid-virtualization.mjs b/scripts/test-emoji-grid-virtualization.mjs new file mode 100644 index 00000000..a75cc3e5 --- /dev/null +++ b/scripts/test-emoji-grid-virtualization.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function importListHooks() { + const result = await build({ + entryPoints: [path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + }); + const code = result.outputFiles[0].text; + return import('data:text/javascript;base64,' + Buffer.from(code).toString('base64')); +} + +function makeEmojiItems(totalItems) { + return Array.from({ length: totalItems }, (_, index) => ({ + id: `emoji-${index}`, + order: index, + sectionTitle: index < totalItems / 2 ? 'Smileys' : 'Symbols', + props: { + title: `Emoji ${index}`, + icon: '😀', + }, + })); +} + +function countEmojiCells(rows) { + return rows.reduce((count, row) => count + (row.type === 'emoji-row' ? row.items.length : 0), 0); +} + +const hooks = await importListHooks(); + +test('Emoji grid virtualization', async (t) => { + const totalItems = 4096; + const items = makeEmojiItems(totalItems); + const groupedItems = hooks.groupListItems(items); + const gridRows = hooks.buildEmojiGridVirtualRows(groupedItems); + const rowMetrics = hooks.measureVirtualRows(gridRows); + + await t.test('renders only the visible emoji cells plus overscan', () => { + assert.equal(hooks.shouldUseEmojiGrid(items, false, () => true), true, 'fixture should use the emoji grid path'); + assert.equal(countEmojiCells(gridRows), totalItems, 'all items are represented by virtual rows'); + assert.ok(gridRows.length < totalItems, 'grid rows compact many cells into fixed rows'); + + const range = hooks.getVisibleVirtualRange(gridRows, rowMetrics, 0, 600); + const visibleCells = countEmojiCells(gridRows.slice(range.visibleStart, range.visibleEnd)); + + assert.equal(visibleCells, 112); + assert.ok(visibleCells < totalItems * 0.05, 'initial window renders less than 5% of the list'); + console.log(JSON.stringify({ + mode: 'after-test', + totalItems, + virtualRows: gridRows.length, + visibleCells, + visibleStart: range.visibleStart, + visibleEnd: range.visibleEnd, + })); + }); + + await t.test('maps selected indices to their grouped grid rows', () => { + const itemToRow = hooks.buildItemToVirtualRowMap(gridRows, totalItems); + + assert.equal(itemToRow[0], 1, 'first section starts after its header'); + assert.equal(itemToRow[7], 1, 'first row contains eight cells'); + assert.equal(itemToRow[8], 2, 'ninth cell starts the next grid row'); + assert.equal(itemToRow[2047], 256, 'last item in the first section stays in its final row'); + assert.equal(itemToRow[2048], 258, 'second section starts after the next header'); + + const selectedRow = itemToRow[3000]; + const selectedTop = rowMetrics.offsets[selectedRow]; + const selectedRange = hooks.getVisibleVirtualRange(gridRows, rowMetrics, selectedTop, 600); + const visibleCells = countEmojiCells(gridRows.slice(selectedRange.visibleStart, selectedRange.visibleEnd)); + + assert.ok(selectedRow >= selectedRange.visibleStart && selectedRow < selectedRange.visibleEnd, 'selected item row is visible'); + assert.ok(visibleCells < 200, 'selection scroll window stays bounded'); + }); +}); diff --git a/scripts/test-exec-command-timeout-cleanup.mjs b/scripts/test-exec-command-timeout-cleanup.mjs new file mode 100644 index 00000000..4fc98b82 --- /dev/null +++ b/scripts/test-exec-command-timeout-cleanup.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { runExecCommand } = await importTs(path.join(root, 'src/main/exec-command.ts')); + +function createTimerHarness() { + let nextId = 1; + const active = new Map(); + const cleared = []; + + return { + setTimeout(callback, ms) { + const id = nextId++; + active.set(id, { callback, ms }); + return id; + }, + clearTimeout(id) { + cleared.push(id); + active.delete(id); + }, + runNext() { + const next = active.entries().next(); + if (next.done) return false; + const [id, timer] = next.value; + active.delete(id); + timer.callback(); + return true; + }, + get activeCount() { + return active.size; + }, + get cleared() { + return cleared; + }, + }; +} + +function createFakeProc() { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + writes: [], + ended: false, + write(value) { + this.writes.push(value); + }, + end() { + this.ended = true; + }, + }; + proc.killCount = 0; + proc.kill = () => { + proc.killCount += 1; + }; + return proc; +} + +function createHarness() { + const timer = createTimerHarness(); + const spawned = []; + const dependencies = { + spawn(command, args, options) { + const proc = createFakeProc(); + spawned.push({ command, args, options, proc }); + return proc; + }, + existsSync() { + return true; + }, + env: { PATH: '/test/bin' }, + cwd: () => '/test/cwd', + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }; + + return { + timer, + spawned, + run(command = '/bin/test-command', args = ['--flag'], options) { + return runExecCommand(command, args, options, dependencies); + }, + }; +} + +test('exec-command timeout cleanup', async (t) => { + await t.test('clears timeout handle when process closes before timeout', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stdout.emit('data', Buffer.from('done')); + proc.stderr.emit('data', Buffer.from('warn')); + proc.emit('close', 0); + + assert.deepEqual(await promise, { stdout: 'done', stderr: 'warn', exitCode: 0 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); + + await t.test('clears timeout handle when process errors before timeout', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stderr.emit('data', Buffer.from('partial stderr')); + proc.emit('error', new Error('spawn failed')); + + assert.deepEqual(await promise, { stdout: '', stderr: 'spawn failed', exitCode: 1 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); + + await t.test('kills process and resolves with timeout result on timeout path', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.stdout.emit('data', Buffer.from('before-timeout')); + assert.equal(harness.timer.runNext(), true); + + assert.deepEqual(await promise, { + stdout: 'before-timeout', + stderr: 'Command timed out', + exitCode: 124, + }); + assert.equal(proc.killCount, 1); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + }); + + await t.test('settles once when close, error, and timeout race', async () => { + const harness = createHarness(); + const promise = harness.run(); + const { proc } = harness.spawned[0]; + + proc.emit('close', 7); + proc.emit('error', new Error('late error')); + assert.equal(harness.timer.runNext(), false); + + assert.deepEqual(await promise, { stdout: '', stderr: '', exitCode: 7 }); + assert.equal(harness.timer.activeCount, 0); + assert.equal(harness.timer.cleared.length, 1); + assert.equal(proc.killCount, 0); + }); +}); diff --git a/scripts/test-extension-bundle-cache.mjs b/scripts/test-extension-bundle-cache.mjs new file mode 100644 index 00000000..13679fdb --- /dev/null +++ b/scripts/test-extension-bundle-cache.mjs @@ -0,0 +1,112 @@ +#!/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 { createRequire } from 'node:module'; +import { + bundleExtensionRunner, + createCounters, + createFixture, +} from './measure-extension-bundle-cache.mjs'; + +const require = createRequire(import.meta.url); + +function updatedFixtureManifest() { + return { + name: 'bundle-cache-fixture', + title: 'Bundle Cache Fixture', + description: 'Fixture for extension bundle cache measurement', + owner: 'codex', + commands: [ + { + name: 'background', + title: 'Background Updated', + description: 'No-view command', + mode: 'no-view', + preferences: [ + { + name: 'freshCommandPref', + title: 'Fresh Command Pref', + type: 'textfield', + default: 'default-command', + }, + ], + }, + { + name: 'menu', + title: 'Menu', + description: 'Menu-bar command', + mode: 'menu-bar', + }, + ], + preferences: [ + { + name: 'freshExtensionPref', + title: 'Fresh Extension Pref', + type: 'textfield', + default: 'default-extension', + }, + ], + }; +} + +test('extension bundle cache hits and invalidates by file stats', async () => { + const fixture = createFixture(); + const bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + const { counters, restore } = createCounters(fixture.extDir); + + try { + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-1' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-1' }, + }; + + const first = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(first.title, 'Background'); + assert.match(first.code, /background/); + assert.equal(first.preferences.freshExtensionPref, 'stored-extension-1'); + assert.equal(first.preferences.freshCommandPref, 'stored-command-1'); + assert.equal(counters.packageJsonReads, 1); + assert.equal(counters.bundleReads, 1); + assert.equal(counters.jsonParseCalls, 1); + assert.equal(counters.readdirSync, 1); + + const afterFirst = { ...counters }; + globalThis.__extensionPreferenceValues = { + 'bundle-cache-fixture': { freshExtensionPref: 'stored-extension-2' }, + 'bundle-cache-fixture/background': { freshCommandPref: 'stored-command-2' }, + }; + + const second = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(second.preferences.freshExtensionPref, 'stored-extension-2'); + assert.equal(second.preferences.freshCommandPref, 'stored-command-2'); + assert.equal(counters.packageJsonReads, afterFirst.packageJsonReads); + assert.equal(counters.bundleReads, afterFirst.bundleReads); + assert.equal(counters.jsonParseCalls, afterFirst.jsonParseCalls); + assert.equal(counters.readdirSync, afterFirst.readdirSync); + + const bundlePath = path.join(fixture.extDir, '.sc-build', 'background.js'); + fs.writeFileSync(bundlePath, 'module.exports = { name: "background", version: 2 };\n'); + const beforeBundleInvalidation = { ...counters }; + const third = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.match(third.code, /version: 2/); + assert.equal(counters.bundleReads, beforeBundleInvalidation.bundleReads + 1); + assert.equal(counters.packageJsonReads, beforeBundleInvalidation.packageJsonReads); + + const manifestPath = path.join(fixture.extDir, 'package.json'); + fs.writeFileSync(manifestPath, JSON.stringify(updatedFixtureManifest(), null, 2)); + const beforeManifestInvalidation = { ...counters }; + const fourth = await runner.getExtensionBundle('bundle-cache-fixture', 'background'); + assert.equal(fourth.title, 'Background Updated'); + assert.equal(counters.packageJsonReads, beforeManifestInvalidation.packageJsonReads + 1); + assert.equal(counters.jsonParseCalls, beforeManifestInvalidation.jsonParseCalls + 1); + assert.equal(counters.bundleReads, beforeManifestInvalidation.bundleReads); + } finally { + restore(); + try { fs.unlinkSync(bundledRunner); } catch {} + try { fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); } catch {} + } +}); diff --git a/scripts/test-extension-sync-facade.mjs b/scripts/test-extension-sync-facade.mjs new file mode 100644 index 00000000..e1ed0132 --- /dev/null +++ b/scripts/test-extension-sync-facade.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const ROOT = process.cwd(); +const EXTENSION_VIEW_PATH = path.join(ROOT, 'src/renderer/src/ExtensionView.tsx'); +const SYNC_METHODS = ['execCommandSync', 'fileExistsSync', 'readFileSync', 'statSync']; + +function parseArgs() { + const args = new Set(process.argv.slice(2)); + const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) || '--mode=both'; + const mode = modeArg.slice('--mode='.length); + if (!['fallback', 'node', 'both'].includes(mode)) { + throw new Error(`Unsupported --mode value: ${mode}`); + } + return { + mode, + assertNodeDirect: !args.has('--report-only') || args.has('--assert-node-direct'), + }; +} + +function createLocalStorage() { + const store = new Map(); + return { + get length() { + return store.size; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + getItem(key) { + return store.has(String(key)) ? store.get(String(key)) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + }, + removeItem(key) { + store.delete(String(key)); + }, + clear() { + store.clear(); + }, + }; +} + +function createSyncIpcProbe() { + const entries = []; + const counts = Object.fromEntries(SYNC_METHODS.map((name) => [name, 0])); + const timeMs = Object.fromEntries(SYNC_METHODS.map((name) => [name, 0])); + + function record(name, fn) { + const started = performance.now(); + try { + return fn(); + } finally { + const elapsed = performance.now() - started; + counts[name] += 1; + timeMs[name] += elapsed; + entries.push({ name, elapsedMs: elapsed }); + } + } + + function snapshot() { + return { + counts: { ...counts }, + timeMs: { ...timeMs }, + totalCount: Object.values(counts).reduce((sum, value) => sum + value, 0), + totalTimeMs: Object.values(timeMs).reduce((sum, value) => sum + value, 0), + }; + } + + function delta(before) { + const after = snapshot(); + const deltaCounts = {}; + const deltaTimeMs = {}; + for (const name of SYNC_METHODS) { + deltaCounts[name] = after.counts[name] - before.counts[name]; + deltaTimeMs[name] = after.timeMs[name] - before.timeMs[name]; + } + return { + counts: deltaCounts, + timeMs: deltaTimeMs, + totalCount: Object.values(deltaCounts).reduce((sum, value) => sum + value, 0), + totalTimeMs: Object.values(deltaTimeMs).reduce((sum, value) => sum + value, 0), + }; + } + + return { entries, record, snapshot, delta }; +} + +function statPayload(filePath) { + const stat = fs.statSync(filePath); + return { + exists: true, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + size: stat.size, + mode: stat.mode, + uid: stat.uid, + gid: stat.gid, + dev: stat.dev, + ino: stat.ino, + nlink: stat.nlink, + atimeMs: stat.atimeMs, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + birthtimeMs: stat.birthtimeMs, + }; +} + +function createElectronFacade(probe) { + return { + execCommandSync(command, args = [], options = {}) { + return probe.record('execCommandSync', () => { + if (command === '/bin/ls' && args[0] === '-A1') { + return { + stdout: fs.readdirSync(args[1]).join('\n'), + stderr: '', + exitCode: 0, + }; + } + const childProcess = require('child_process'); + const result = childProcess.spawnSync(command, args, { + shell: options.shell ?? false, + env: { ...process.env, ...options.env }, + cwd: options.cwd || process.cwd(), + input: options.input, + encoding: 'utf8', + }); + return { + stdout: result.stdout || '', + stderr: result.stderr || '', + exitCode: typeof result.status === 'number' ? result.status : result.error ? 1 : 0, + }; + }); + }, + fileExistsSync(filePath) { + return probe.record('fileExistsSync', () => fs.existsSync(filePath)); + }, + readFileSync(filePath) { + return probe.record('readFileSync', () => { + try { + return { data: fs.readFileSync(filePath, 'utf8'), error: null }; + } catch (error) { + return { data: null, error: error instanceof Error ? error.message : String(error) }; + } + }); + }, + statSync(filePath) { + return probe.record('statSync', () => { + try { + return statPayload(filePath); + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } + }); + }, + }; +} + +function extractFsFacadeSource() { + const source = fs.readFileSync(EXTENSION_VIEW_PATH, 'utf8'); + const start = source.indexOf('const _bufferMarker'); + const end = source.indexOf('// ── path stub', start); + assert.notEqual(start, -1, 'Could not locate Buffer/fs facade start marker'); + assert.notEqual(end, -1, 'Could not locate fs facade end marker'); + return source.slice(start, end); +} + +function loadFsFacade({ nodeAvailable, electron, localStorage }) { + const source = ` + const USE_REAL_NODE_BUILTINS = ${nodeAvailable ? 'true' : 'false'}; + const _realNodeRequire = ${nodeAvailable ? '__REAL_NODE_REQUIRE' : 'undefined'}; + const _realNodeProcess = ${nodeAvailable ? '__REAL_NODE_PROCESS' : 'undefined'}; + const noop = () => {}; + const noopAsync = (..._args) => Promise.resolve(); + const noopCb = (...args) => { + const cb = args[args.length - 1]; + if (typeof cb === 'function') cb(null); + }; + ${extractFsFacadeSource()} + globalThis.__extensionSyncFacadeHarness = { fsStub, commandPathCache }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: EXTENSION_VIEW_PATH, + }); + const sandbox = { + __REAL_NODE_REQUIRE: nodeAvailable ? require : undefined, + __REAL_NODE_PROCESS: nodeAvailable ? process : undefined, + console, + localStorage, + window: { + electron, + __scNodeRequire: nodeAvailable ? require : undefined, + }, + performance, + TextEncoder, + TextDecoder, + URL, + Uint8Array, + ArrayBuffer, + DataView, + Blob: globalThis.Blob, + File: globalThis.File, + ReadableStream: globalThis.ReadableStream, + WritableStream: globalThis.WritableStream, + TransformStream: globalThis.TransformStream, + atob: (value) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value) => Buffer.from(value, 'binary').toString('base64'), + setTimeout, + clearTimeout, + queueMicrotask, + Promise, + Date, + Error, + Map, + Set, + Symbol, + Object, + Array, + String, + Number, + Boolean, + RegExp, + Math, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: EXTENSION_VIEW_PATH }); + return sandbox.__extensionSyncFacadeHarness.fsStub; +} + +function writeFixtureTree() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-extension-sync-facade-')); + const nestedDir = path.join(dir, 'nested'); + fs.mkdirSync(nestedDir); + fs.writeFileSync(path.join(dir, 'real.txt'), 'real text', 'utf8'); + fs.writeFileSync(path.join(dir, 'shadow.txt'), 'real shadow', 'utf8'); + fs.writeFileSync(path.join(nestedDir, 'child.txt'), 'child text', 'utf8'); + return dir; +} + +function runScenario(mode) { + const probe = createSyncIpcProbe(); + const localStorage = createLocalStorage(); + const electron = createElectronFacade(probe); + const tmpDir = writeFixtureTree(); + const fsStub = loadFsFacade({ + nodeAvailable: mode === 'node', + electron, + localStorage, + }); + + try { + const realFile = path.join(tmpDir, 'real.txt'); + const shadowFile = path.join(tmpDir, 'shadow.txt'); + const virtualFile = path.join(tmpDir, 'virtual.txt'); + + fsStub.writeFileSync(shadowFile, 'virtual shadow'); + fsStub.writeFileSync(virtualFile, 'virtual only'); + + const beforeVirtual = probe.snapshot(); + assert.equal(fsStub.readFileSync(shadowFile, 'utf8'), 'virtual shadow'); + assert.equal(fsStub.existsSync(shadowFile), true); + assert.equal(fsStub.statSync(shadowFile).size, 'virtual shadow'.length); + const virtualDelta = probe.delta(beforeVirtual); + assert.equal(virtualDelta.totalCount, 0, 'virtual/localStorage operations must not hit sync IPC'); + + const beforeReal = probe.snapshot(); + assert.equal(fsStub.existsSync(realFile), true); + assert.equal(fsStub.statSync(realFile).isFile(), true); + assert.equal(fsStub.readFileSync(realFile, 'utf8'), 'real text'); + assert.doesNotThrow(() => fsStub.accessSync(realFile)); + const entries = fsStub.readdirSync(tmpDir, { withFileTypes: true }); + assert.ok(entries.some((entry) => String(entry.name) === 'real.txt' && entry.isFile())); + assert.ok(entries.some((entry) => String(entry.name) === 'nested' && entry.isDirectory())); + assert.ok(entries.some((entry) => String(entry.name) === 'virtual.txt' && entry.isFile())); + const realDelta = probe.delta(beforeReal); + + return { + mode, + realPathOps: realDelta, + virtualPathOps: virtualDelta, + total: probe.snapshot(), + }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function formatCounts(result) { + return SYNC_METHODS + .map((name) => `${name}=${result.realPathOps.counts[name]}`) + .join(', '); +} + +const { mode, assertNodeDirect } = parseArgs(); +const modes = mode === 'both' ? ['fallback', 'node'] : [mode]; +const results = modes.map((entry) => runScenario(entry)); + +for (const result of results) { + const elapsed = result.realPathOps.totalTimeMs.toFixed(3); + console.log( + `[extension-sync-facade:${result.mode}] real path ops sync IPC calls: ${result.realPathOps.totalCount} (${formatCounts(result)}), sync IPC time: ${elapsed}ms` + ); + console.log( + `[extension-sync-facade:${result.mode}] virtual precedence sync IPC calls: ${result.virtualPathOps.totalCount}` + ); +} + +const fallbackResult = results.find((result) => result.mode === 'fallback'); +if (fallbackResult) { + assert.ok( + fallbackResult.realPathOps.totalCount > 0, + 'fallback mode should exercise the sync IPC path for real filesystem operations' + ); +} + +const nodeResult = results.find((result) => result.mode === 'node'); +if (assertNodeDirect && nodeResult) { + assert.equal( + nodeResult.realPathOps.totalCount, + 0, + 'node mode should avoid sync IPC for representative real filesystem operations' + ); +} diff --git a/scripts/test-extension-wrapper-cache.mjs b/scripts/test-extension-wrapper-cache.mjs new file mode 100644 index 00000000..e8a6bd7f --- /dev/null +++ b/scripts/test-extension-wrapper-cache.mjs @@ -0,0 +1,165 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + clearCompiledExtensionWrapperCache, + getCompiledExtensionWrapper, + getCompiledExtensionWrapperCacheStats, +} = await importTs(path.join(root, 'src/renderer/src/utils/extension-wrapper-cache.ts')); + +function createRunEnv() { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const timers = new Set(); + const trackTimeout = (cb, ms, ...rest) => { + const id = setTimeout(cb, ms, ...rest); + timers.add(id); + return id; + }; + const trackClearTimeout = (id) => { + timers.delete(id); + clearTimeout(id); + }; + + return { + fakeModule, + timers, + args: [ + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalThis, + globalThis, + (fn, ...args) => trackTimeout(() => fn(...args), 0), + trackClearTimeout, + () => 0, + () => {}, + trackTimeout, + trackClearTimeout, + () => 0, + () => {}, + undefined, + async () => ({}), + ], + cleanup: () => { + timers.forEach((id) => clearTimeout(id)); + timers.clear(); + }, + }; +} + +function runWrapper(wrapper) { + const env = createRunEnv(); + wrapper(...env.args); + return env; +} + +test('Extension wrapper cache', async (t) => { + await t.test('reuses the compiled wrapper for the same extension code', () => { + clearCompiledExtensionWrapperCache(); + const code = 'exports.default = function Command() { return "ok"; };'; + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + const second = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + + assert.equal(first.cacheHit, false); + assert.equal(second.cacheHit, true); + assert.strictEqual(second.wrapper, first.wrapper); + assert.equal(getCompiledExtensionWrapperCacheStats().wrapperCreationCount, 1); + }); + + await t.test('invalidates when code changes even if the length is unchanged', () => { + clearCompiledExtensionWrapperCache(); + const codeA = 'exports.default = function Command() { return "a"; };'; + const codeB = 'exports.default = function Command() { return "b"; };'; + assert.equal(codeA.length, codeB.length, 'fixture code must keep equal length'); + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code: codeA, + }); + const changed = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code: codeB, + }); + + assert.equal(first.cacheHit, false); + assert.equal(changed.cacheHit, false); + assert.notEqual(changed.cacheKey, first.cacheKey); + assert.notStrictEqual(changed.wrapper, first.wrapper); + + const envA = runWrapper(first.wrapper); + const envB = runWrapper(changed.wrapper); + try { + assert.equal(envA.fakeModule.exports.default(), 'a'); + assert.equal(envB.fakeModule.exports.default(), 'b'); + } finally { + envA.cleanup(); + envB.cleanup(); + } + }); + + await t.test('keeps module exports and timer handles fresh across cached runs', () => { + clearCompiledExtensionWrapperCache(); + const code = ` +let moduleScopedCounter = 0; +moduleScopedCounter += 1; +const timerId = setTimeout(() => {}, 60000); +exports.default = { + moduleScopedCounter, + timerId, + previousTouchedValue: exports.touched +}; +exports.touched = "mutated"; +`; + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + const second = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/command', + code, + }); + assert.equal(second.cacheHit, true); + assert.strictEqual(second.wrapper, first.wrapper); + + const envA = runWrapper(second.wrapper); + const envB = runWrapper(second.wrapper); + try { + assert.notStrictEqual(envA.fakeModule.exports, envB.fakeModule.exports); + assert.equal(envA.fakeModule.exports.default.moduleScopedCounter, 1); + assert.equal(envB.fakeModule.exports.default.moduleScopedCounter, 1); + assert.equal(envA.fakeModule.exports.default.previousTouchedValue, undefined); + assert.equal(envB.fakeModule.exports.default.previousTouchedValue, undefined); + assert.equal(envA.timers.size, 1); + assert.equal(envB.timers.size, 1); + assert.notStrictEqual( + envA.fakeModule.exports.default.timerId, + envB.fakeModule.exports.default.timerId + ); + } finally { + envA.cleanup(); + envB.cleanup(); + } + + assert.equal(envA.timers.size, 0); + assert.equal(envB.timers.size, 0); + }); +}); diff --git a/scripts/test-file-search-delete-batch.mjs b/scripts/test-file-search-delete-batch.mjs new file mode 100644 index 00000000..684d3001 --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + collapseNestedDeletedPaths, + indexEntry, + searchIndexedFiles, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, + setActiveIndex(snapshot, homeDir) { + activeIndex = snapshot; + configuredHomeDir = homeDir; + includeRoots = [homeDir]; + includeProtectedHomeRoots = true; + lastBuildStartedAt = 0; + lastIndexError = null; + }, +}; +`; + + 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: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +const api = loadFileSearchIndexInternals(); +const homeDir = '/tmp/supercmd-file-search-delete-tests'; + +function addEntry(snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function entryForPath(snapshot, filePath) { + const id = snapshot.pathToEntryId.get(filePath); + assert.notEqual(id, undefined, `${filePath} should be indexed`); + return snapshot.entries[id]; +} + +function isDeleted(snapshot, filePath) { + return Boolean(entryForPath(snapshot, filePath).deleted); +} + +test('file delete tombstones only the exact indexed file', () => { + const snapshot = api.makeSnapshot(); + const deletedFile = path.join(homeDir, 'notes', 'alpha.txt'); + const prefixSibling = path.join(homeDir, 'notes', 'alpha.txt.backup'); + const survivor = path.join(homeDir, 'notes', 'beta.txt'); + + addEntry(snapshot, path.dirname(deletedFile), true); + addEntry(snapshot, deletedFile); + addEntry(snapshot, prefixSibling); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedFile]); + + assert.equal(isDeleted(snapshot, deletedFile), true); + assert.equal(isDeleted(snapshot, prefixSibling), false); + assert.equal(isDeleted(snapshot, survivor), false); + + api.indexEntry(snapshot, { + path: deletedFile, + name: path.basename(deletedFile), + parentPath: path.dirname(deletedFile), + isDirectory: false, + }); + assert.equal(isDeleted(snapshot, deletedFile), false); +}); + +test('directory delete tombstones the directory and all indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedDir = path.join(homeDir, 'project'); + const childDir = path.join(deletedDir, 'src'); + const childFile = path.join(childDir, 'index.ts'); + const survivor = path.join(homeDir, 'other', 'index.ts'); + + addEntry(snapshot, deletedDir, true); + addEntry(snapshot, childDir, true); + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(survivor), true); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedDir]); + + assert.equal(isDeleted(snapshot, deletedDir), true); + assert.equal(isDeleted(snapshot, childDir), true); + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, survivor), false); +}); + +test('nested directory deletes collapse to the outermost deleted root', () => { + const deletedRoot = path.join(homeDir, 'nested-root'); + const nestedDir = path.join(deletedRoot, 'child'); + const nestedFile = path.join(nestedDir, 'deep.txt'); + + assert.deepEqual( + Array.from(api.collapseNestedDeletedPaths([nestedFile, nestedDir, deletedRoot])), + [deletedRoot] + ); + + const snapshot = api.makeSnapshot(); + const similarSibling = path.join(homeDir, 'nested-root-copy', 'deep.txt'); + addEntry(snapshot, deletedRoot, true); + addEntry(snapshot, nestedDir, true); + addEntry(snapshot, nestedFile); + addEntry(snapshot, path.dirname(similarSibling), true); + addEntry(snapshot, similarSibling); + + api.tombstoneDeletedPaths(snapshot, [nestedFile, nestedDir, deletedRoot]); + + assert.equal(isDeleted(snapshot, deletedRoot), true); + assert.equal(isDeleted(snapshot, nestedDir), true); + assert.equal(isDeleted(snapshot, nestedFile), true); + assert.equal(isDeleted(snapshot, similarSibling), false); +}); + +test('non-matching delete paths do not tombstone prefix-like neighbors', async () => { + const snapshot = api.makeSnapshot(); + const prefixLikeDelete = path.join(homeDir, 'docs'); + const survivorDir = path.join(homeDir, 'docs-old'); + const survivor = path.join(survivorDir, 'report.txt'); + const searchable = path.join(homeDir, 'searchable', 'needle-survivor.txt'); + + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + addEntry(snapshot, path.dirname(searchable), true); + addEntry(snapshot, searchable); + + api.tombstoneDeletedPaths(snapshot, [ + prefixLikeDelete, + path.join(homeDir, 'missing', 'child'), + ]); + + assert.equal(isDeleted(snapshot, survivor), false); + assert.equal(isDeleted(snapshot, searchable), false); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('needle-survivor', { limit: 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, searchable); +}); diff --git a/scripts/test-file-search-index.mjs b/scripts/test-file-search-index.mjs new file mode 100644 index 00000000..03dee3a3 --- /dev/null +++ b/scripts/test-file-search-index.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import vm from 'node:vm'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const SOURCE_PATH = path.resolve('src/main/file-search-index.ts'); +const BASELINE_MODE = process.env.SUPERCMD_FILE_INDEX_BASELINE === '1'; +const HEALTHY_INTERVAL_TICKS = 3; +const CLOCK_STEP_MS = 60_000; +const moduleCache = new Map(); + +function createFakeDate(clock) { + return class FakeDate extends Date { + constructor(...args) { + super(...(args.length > 0 ? args : [clock.now])); + } + + static now() { + return clock.now; + } + }; +} + +function transpileSource(source, fileName) { + return ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName, + }).outputText; +} + +function loadFileSearchIndexModule({ clock, logs }) { + const source = `${fs.readFileSync(SOURCE_PATH, 'utf8')} + +export const __fileSearchIndexTestInternals = { + configureForTest(homeDir: string) { + configuredHomeDir = path.resolve(homeDir); + includeRoots = resolveIncludeRoots(configuredHomeDir); + includeProtectedHomeRoots = false; + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + lastBuildStartedAt = 0; + }, + async applyWatchEventBatchForTest(paths: string[]) { + await applyWatchEventBatch(paths); + }, + setWatcherAvailableForTest(available: boolean) { + activeWatcher = available ? ({ close() {} } as fs.FSWatcher) : null; + watchedHomeDir = available ? configuredHomeDir : ''; + if (available && typeof lastWatcherError !== 'undefined') lastWatcherError = null; + }, + async waitForIdleForTest() { + while (rebuildPromise || indexing) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }, +}; +`; + + const module = { exports: {} }; + const localRequire = (request) => require(request); + const quietConsole = { + ...console, + log: (...args) => { + logs.push(args.map(String).join(' ')); + }, + warn: (...args) => { + logs.push(args.map(String).join(' ')); + }, + error: (...args) => { + logs.push(args.map(String).join(' ')); + }, + }; + const sandboxProcess = Object.create(process); + Object.defineProperty(sandboxProcess, 'platform', { value: 'linux' }); + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console: quietConsole, + URL, + Date: createFakeDate(clock), + Math, + String, + Number, + Boolean, + Set, + Map, + WeakMap, + Object, + Array, + RegExp, + Promise, + process: sandboxProcess, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + + vm.runInNewContext(transpileSource(source, SOURCE_PATH), sandbox, { filename: SOURCE_PATH }); + return module.exports; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Boolean, + Set, + Map, + WeakMap, + Object, + Array, + RegExp, + Promise, + process, + Buffer, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + vm.runInNewContext(transpileSource(source, resolvedPath), sandbox, { filename: resolvedPath }); + return module.exports; +} + +async function createFixtureTree(rootDir) { + const projectRoot = path.join(rootDir, 'Projects'); + await fs.promises.mkdir(projectRoot, { recursive: true }); + + for (let dirIndex = 0; dirIndex < 40; dirIndex += 1) { + const dir = path.join(projectRoot, `bucket-${String(dirIndex).padStart(2, '0')}`); + await fs.promises.mkdir(dir, { recursive: true }); + const writes = []; + for (let fileIndex = 0; fileIndex < 25; fileIndex += 1) { + writes.push( + fs.promises.writeFile( + path.join(dir, `alpha-file-${String(dirIndex).padStart(2, '0')}-${String(fileIndex).padStart(2, '0')}.txt`), + `fixture ${dirIndex} ${fileIndex}\n` + ) + ); + } + await Promise.all(writes); + } +} + +function rebuildLogCount(logs) { + return logs.filter((line) => line.includes('[FileIndex] Rebuilt')).length; +} + +async function measure(label, fn) { + const startedAt = performance.now(); + const value = await fn(); + return { + label, + value, + ms: Number((performance.now() - startedAt).toFixed(2)), + }; +} + +async function runRequestedRefresh(indexModule, reason, logs) { + const before = rebuildLogCount(logs); + const measurement = await measure(reason, async () => { + indexModule.requestFileSearchIndexRefresh(reason); + await indexModule.__fileSearchIndexTestInternals.waitForIdleForTest(); + }); + return { + rebuilds: rebuildLogCount(logs) - before, + ms: measurement.ms, + }; +} + +async function runWatcherScenario() { + const tempParent = await fs.promises.realpath(os.tmpdir()); + const tempHome = await fs.promises.mkdtemp(path.join(tempParent, 'supercmd-file-index-')); + const clock = { now: 1_800_000_000_000 }; + const logs = []; + const indexModule = loadFileSearchIndexModule({ clock, logs }); + const internals = indexModule.__fileSearchIndexTestInternals; + + try { + await createFixtureTree(tempHome); + internals.configureForTest(tempHome); + + const startupBefore = rebuildLogCount(logs); + const startup = await measure('startup', () => indexModule.rebuildFileSearchIndex('startup')); + const startupRebuilds = rebuildLogCount(logs) - startupBefore; + assert.equal(startupRebuilds, 1, 'startup should perform one full rebuild'); + + internals.setWatcherAvailableForTest(true); + + internals.setWatcherAvailableForTest(false); + const watcherErrorRecovery = await runRequestedRefresh(indexModule, 'watcher-error', logs); + internals.setWatcherAvailableForTest(true); + + const existingFile = path.join(tempHome, 'Projects', 'bucket-00', 'alpha-file-00-00.txt'); + await fs.promises.writeFile(existingFile, 'updated fixture\n'); + const updateBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([existingFile]); + const updatedResults = await indexModule.searchIndexedFiles('alpha file 00 00', { limit: 5 }); + assert.ok(updatedResults.some((result) => result.path === existingFile), 'updated file should remain searchable'); + const updateRebuilds = rebuildLogCount(logs) - updateBefore; + + const createdFile = path.join(tempHome, 'Projects', 'bucket-00', 'new-alpha-special.txt'); + await fs.promises.writeFile(createdFile, 'new fixture\n'); + const createBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([createdFile]); + const createdResults = await indexModule.searchIndexedFiles('new alpha special', { limit: 5 }); + assert.ok(createdResults.some((result) => result.path === createdFile), 'created file should be searchable'); + const createRebuilds = rebuildLogCount(logs) - createBefore; + + await fs.promises.unlink(createdFile); + const deleteBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([createdFile]); + const deletedResults = await indexModule.searchIndexedFiles('new alpha special', { limit: 5 }); + assert.equal(deletedResults.some((result) => result.path === createdFile), false, 'deleted file should be tombstoned'); + const deleteRebuilds = rebuildLogCount(logs) - deleteBefore; + + const addedDir = path.join(tempHome, 'Projects', 'fresh-folder'); + const nestedFile = path.join(addedDir, 'nested-special-note.md'); + await fs.promises.mkdir(addedDir, { recursive: true }); + await fs.promises.writeFile(nestedFile, 'nested fixture\n'); + const directoryBefore = rebuildLogCount(logs); + await internals.applyWatchEventBatchForTest([addedDir]); + const nestedResults = await indexModule.searchIndexedFiles('nested special note', { limit: 5 }); + assert.ok(nestedResults.some((result) => result.path === nestedFile), 'new directory contents should be indexed'); + const directoryRebuilds = rebuildLogCount(logs) - directoryBefore; + + const healthyIntervalRuns = []; + for (let tick = 0; tick < HEALTHY_INTERVAL_TICKS; tick += 1) { + clock.now += CLOCK_STEP_MS; + healthyIntervalRuns.push(await runRequestedRefresh(indexModule, 'interval', logs)); + } + + internals.setWatcherAvailableForTest(false); + clock.now += CLOCK_STEP_MS; + const fallbackInterval = await runRequestedRefresh(indexModule, 'interval', logs); + + return { + startupMs: startup.ms, + startupRebuilds, + watcherErrorRecoveryRebuilds: watcherErrorRecovery.rebuilds, + watcherErrorRecoveryMs: watcherErrorRecovery.ms, + watcherBatchRebuilds: updateRebuilds + createRebuilds + deleteRebuilds + directoryRebuilds, + healthyIntervalRebuilds: healthyIntervalRuns.reduce((sum, run) => sum + run.rebuilds, 0), + healthyIntervalMs: Number(healthyIntervalRuns.reduce((sum, run) => sum + run.ms, 0).toFixed(2)), + fallbackIntervalRebuilds: fallbackInterval.rebuilds, + fallbackIntervalMs: fallbackInterval.ms, + indexedEntryCount: indexModule.getFileSearchIndexStatus().indexedEntryCount, + }; + } finally { + indexModule.stopFileSearchIndexing(); + await fs.promises.rm(tempHome, { recursive: true, force: true }); + } +} + +function writeFile(filePath, contents = '') { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} + +function makeTempHome(label) { + const tmpRoot = fs.realpathSync(os.tmpdir()); + return fs.mkdtempSync(path.join(tmpRoot, `supercmd-file-search-${label}-`)); +} + +function removeTempHome(homeDir) { + try { + fs.rmSync(homeDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup for temp fixtures. + } +} + +async function withIndexedHome(homeDir, fn) { + moduleCache.delete(SOURCE_PATH); + const fileSearch = loadTsModule(SOURCE_PATH); + fileSearch.startFileSearchIndexing({ + homeDir, + refreshIntervalMs: 30_000, + includeProtectedHomeRoots: true, + }); + await fileSearch.rebuildFileSearchIndex('test'); + try { + await fn(fileSearch); + } finally { + fileSearch.stopFileSearchIndexing(); + } +} + +function resultPaths(results) { + return results.map((result) => result.path); +} + +function populateSyntheticTree(homeDir, targetEntries) { + const projectsDir = path.join(homeDir, 'Projects'); + const filesPerApp = 48; + const appCount = Math.max(1, Math.ceil(targetEntries / (filesPerApp + 2))); + + for (let appIndex = 0; appIndex < appCount; appIndex += 1) { + const appName = `app-${String(appIndex).padStart(4, '0')}`; + const srcDir = path.join(projectsDir, appName, 'src'); + const docsDir = path.join(projectsDir, appName, 'docs'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.mkdirSync(docsDir, { recursive: true }); + for (let fileIndex = 0; fileIndex < filesPerApp; fileIndex += 1) { + const bucket = fileIndex % 2 === 0 ? srcDir : docsDir; + const name = `module-${String(fileIndex).padStart(3, '0')}-${appName}.ts`; + fs.writeFileSync(path.join(bucket, name), ''); + } + } + + return { + appCount, + filesPerApp, + indexedEntryEstimate: appCount * (filesPerApp + 3) + 1, + }; +} + +async function runPathQueryPerformanceHarness() { + const targetEntries = Number(process.env.FILE_SEARCH_PERF_ENTRIES || 60000); + const iterations = Number(process.env.FILE_SEARCH_PERF_ITERATIONS || 24); + const homeDir = makeTempHome('perf'); + try { + const fixture = populateSyntheticTree(homeDir, targetEntries); + await withIndexedHome(homeDir, async ({ getFileSearchIndexStatus, searchIndexedFiles }) => { + const status = getFileSearchIndexStatus(); + const queryAppIds = [7, 42, 137, Math.floor(fixture.appCount / 2), fixture.appCount - 3] + .filter((value, index, values) => value >= 0 && value < fixture.appCount && values.indexOf(value) === index) + .map((value) => String(value).padStart(4, '0')); + const queries = queryAppIds.flatMap((id) => [ + path.join(homeDir, 'Projects', `app-${id}`, 'src'), + `~/Projects/app-${id}/src`, + `Projects/app-${id}/src`, + ]); + + const startedAt = performance.now(); + let totalResults = 0; + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (const query of queries) { + const results = await searchIndexedFiles(query, { limit: 1 }); + totalResults += results.length; + } + } + const elapsedMs = performance.now() - startedAt; + const queryCount = iterations * queries.length; + const metric = { + entries: status.indexedEntryCount, + estimatedEntries: fixture.indexedEntryEstimate, + queries: queryCount, + elapsedMs: Number(elapsedMs.toFixed(2)), + avgMsPerQuery: Number((elapsedMs / queryCount).toFixed(3)), + totalResults, + }; + console.log(`FILE_SEARCH_PERF ${JSON.stringify(metric)}`); + }); + } finally { + removeTempHome(homeDir); + } +} + +test('file search index uses watcher batches and avoids healthy interval rebuilds', async () => { + const metrics = await runWatcherScenario(); + console.log(`[FileIndexTest] ${JSON.stringify({ mode: BASELINE_MODE ? 'baseline' : 'assert', ...metrics })}`); + + assert.equal(metrics.watcherBatchRebuilds, 0, 'watcher-style updates should not trigger full rebuilds'); + assert.equal(metrics.watcherErrorRecoveryRebuilds, 1, 'watcher-error recovery should bypass the rebuild throttle'); + assert.equal(metrics.fallbackIntervalRebuilds, 1, 'interval should rebuild when watcher state is unavailable'); + + if (!BASELINE_MODE) { + assert.equal(metrics.healthyIntervalRebuilds, 0, 'healthy watcher interval ticks should use incremental state'); + } +}); + +test('path-like queries match absolute, tilde, and relative paths', async () => { + const homeDir = makeTempHome('correctness'); + try { + const srcDir = path.join(homeDir, 'Projects', 'app-042', 'src'); + const exactFile = path.join(srcDir, 'Report Final.txt'); + writeFile(exactFile, 'report'); + writeFile(path.join(srcDir, 'Report Notes.md'), 'notes'); + writeFile(path.join(homeDir, 'Projects', 'app-042', 'README.md'), 'readme'); + writeFile(path.join(homeDir, 'Archive', 'Reports', 'src', 'Q2 Plan.txt'), 'plan'); + + await withIndexedHome(homeDir, async ({ searchIndexedFiles }) => { + const absolute = await searchIndexedFiles(srcDir, { limit: 8 }); + assert.equal(absolute[0]?.path, srcDir); + assert.ok(resultPaths(absolute).includes(exactFile)); + + const tilde = await searchIndexedFiles('~/Projects/app-042/src', { limit: 8 }); + assert.equal(tilde[0]?.path, srcDir); + assert.ok(resultPaths(tilde).includes(exactFile)); + + const relative = await searchIndexedFiles('Projects/app-042/src', { limit: 8 }); + assert.equal(relative[0]?.path, srcDir); + assert.ok(resultPaths(relative).includes(exactFile)); + + const exact = await searchIndexedFiles('~/Projects/app-042/src/Report Final.txt', { limit: 4 }); + assert.equal(exact[0]?.path, exactFile); + assert.equal(exact[0]?.matchKind, 'path'); + }); + } finally { + removeTempHome(homeDir); + } +}); + +test('path-like fallback preserves mid-token slash matches', async () => { + const homeDir = makeTempHome('fallback'); + try { + const fallbackFile = path.join(homeDir, 'Archive', 'Reports', 'src', 'Q2 Plan.txt'); + writeFile(fallbackFile, 'plan'); + writeFile(path.join(homeDir, 'Archive', 'Exports', 'src', 'Other.txt'), 'other'); + + await withIndexedHome(homeDir, async ({ searchIndexedFiles }) => { + const midToken = await searchIndexedFiles('ports/src', { limit: 8 }); + assert.ok(resultPaths(midToken).includes(fallbackFile)); + + const trailingSlash = await searchIndexedFiles('ports/', { limit: 8 }); + assert.ok(resultPaths(trailingSlash).includes(fallbackFile)); + + const noMatch = await searchIndexedFiles('~/Archive/Missing/src', { limit: 8 }); + assert.equal(noMatch.length, 0); + }); + } finally { + removeTempHome(homeDir); + } +}); + +if (process.env.SUPERCMD_FILE_SEARCH_PERF === '1') { + test('path-like file search performance harness', async () => { + await runPathQueryPerformanceHarness(); + }); +} diff --git a/scripts/test-file-search-perf-harness.mjs b/scripts/test-file-search-perf-harness.mjs new file mode 100644 index 00000000..5ff1ddde --- /dev/null +++ b/scripts/test-file-search-perf-harness.mjs @@ -0,0 +1,52 @@ +#!/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 { runFileSearchPerfHarness } from './file-search-perf-harness.mjs'; + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +test('file search perf harness covers deterministic temp-home scenarios', async () => { + const summary = await runFileSearchPerfHarness({ + projects: 8, + modulesPerProject: 3, + filesPerModule: 6, + queryRuns: 2, + updateCount: 10, + deleteCount: 10, + limit: 8, + thresholds: { + initialIndexMs: 15_000, + normalQueryP95Ms: 2_000, + pathQueryP95Ms: 2_000, + watchUpdateBatchMs: 5_000, + deleteBatchMs: 5_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + assert.equal(summary.verification.homeDirectoryUsed, summary.fixture.homeDir); + assert.equal(summary.verification.homeIsTempDirectory, true); + assert.ok( + summary.fixture.homeDir.startsWith(await fs.realpath(os.tmpdir())), + 'harness must use an OS temp home' + ); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'temp fixture should be cleaned up'); + + assert.ok(summary.fixture.initialEntryCount >= summary.fixture.initialFileCount); + assert.ok(summary.metrics.initialIndexMs >= 0); + assert.ok(summary.metrics.normalQueries.totalResults > 0); + assert.ok(summary.metrics.pathLikeQueries.totalResults > 0); + assert.ok(summary.verification.postUpdateResultCount > 0); + assert.equal(summary.verification.deletedExactMatches, 0); +}); diff --git a/scripts/test-grid-virtualization.mjs b/scripts/test-grid-virtualization.mjs new file mode 100644 index 00000000..5740e850 --- /dev/null +++ b/scripts/test-grid-virtualization.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const helperPath = path.join(root, 'src/renderer/src/raycast-api/grid-runtime-virtualization.ts'); + +const ITEM_COUNT = 5000; +const VIEWPORT_HEIGHT = 640; +const CONTAINER_WIDTH = 800; +const DEFAULT_COLUMNS = 5; + +function makeLargeGridGroups() { + const firstItems = []; + const secondItems = []; + + for (let index = 0; index < ITEM_COUNT; index += 1) { + const item = { + item: { + id: `item-${index}`, + order: index, + props: { + id: `visible-${index}`, + title: `Item ${index}`, + subtitle: `Subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: index % 2 === 0 ? 'blue' : undefined } + : { value: `Icon.Circle.${index}`, tooltip: `Icon ${index}` }, + }, + section: index < ITEM_COUNT / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + }, + globalIdx: index, + }; + + if (index < ITEM_COUNT / 2) firstItems.push(item); + else secondItems.push(item); + } + + return [ + { key: 'section-a', title: 'Section A', section: { id: 'section-a', title: 'Section A' }, items: firstItems }, + { + key: 'section-b', + title: 'Section B', + section: { id: 'section-b', title: 'Section B', columns: 4, aspectRatio: '3/2', fit: 'fill', inset: 'lg' }, + items: secondItems, + }, + ]; +} + +function countEagerRenderedCells(groups) { + return groups.reduce((total, group) => total + group.items.length, 0); +} + +function simulateCellContentResolution(groups) { + let checksum = 0; + for (const group of groups) { + for (const { item } of group.items) { + const content = item.props.content; + if (typeof content?.source === 'string') checksum += content.source.length; + if (typeof content?.value === 'string') checksum += content.value.length; + if (typeof item.props.title === 'string') checksum += item.props.title.length; + } + } + return checksum; +} + +function countItemsInRows(rows) { + return rows.reduce((total, row) => total + (row.kind === 'items' ? row.items.length : 0), 0); +} + +function rowContainsIndex(rows, index) { + return rows.some((row) => row.kind === 'items' && row.items.some((entry) => entry.globalIdx === index)); +} + +function makeVirtualSectionRow(key, top, height) { + return { + kind: 'section', + key, + sectionKey: key, + title: key, + top, + height, + }; +} + +function getVisibleRowKeys(getVisibleVirtualRows, rows, options) { + return getVisibleVirtualRows(rows, options).map((row) => row.key); +} + +function makeInstrumentedRows(rowCount) { + const stats = { topReads: 0, heightReads: 0 }; + const rowHeight = 20; + const rowGap = 4; + const rows = Array.from({ length: rowCount }, (_, index) => { + const top = index * (rowHeight + rowGap); + + return { + kind: 'section', + key: `instrumented-${index}`, + sectionKey: 'instrumented', + get top() { + stats.topReads += 1; + return top; + }, + get height() { + stats.heightReads += 1; + return rowHeight; + }, + }; + }); + + return { rows, stats }; +} + +test('Grid virtualization keeps large grids to visible cells', async (t) => { + const groups = makeLargeGridGroups(); + const eagerStart = performance.now(); + const eagerCells = countEagerRenderedCells(groups); + const checksum = simulateCellContentResolution(groups); + const eagerDuration = performance.now() - eagerStart; + + console.log( + `[grid-perf] baseline eager cells=${eagerCells} contentResolutions=${ITEM_COUNT} durationMs=${eagerDuration.toFixed(3)} checksum=${checksum}`, + ); + + assert.equal(eagerCells, ITEM_COUNT, 'legacy grid renders every cell in a large grid'); + + if (!fs.existsSync(helperPath)) { + console.log('[grid-perf] virtualization helper not present yet; baseline captured before renderer edits'); + return; + } + + const { + buildVirtualGridLayout, + countVirtualizedRowItems, + getScrollTopForItemIndex, + getVisibleVirtualRows, + } = await importTs(helperPath); + + await t.test('renders only visible item cells plus overscan', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const visibleRows = getVisibleVirtualRows(layout.rows, { + scrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const visibleCells = countVirtualizedRowItems(visibleRows); + + console.log( + `[grid-perf] virtualized visibleCells=${visibleCells} totalCells=${layout.itemCount} renderedRows=${visibleRows.length} totalRows=${layout.rows.length} totalHeight=${layout.totalHeight.toFixed(1)}`, + ); + + assert.equal(layout.itemCount, ITEM_COUNT); + assert.equal(visibleCells, countItemsInRows(visibleRows)); + assert.ok(visibleCells > 0, 'initial viewport renders visible cells'); + assert.ok(visibleCells < 80, `expected a small visible window, got ${visibleCells}`); + }); + + await t.test('scrolls selected items into the virtual window', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const targetIndex = 4242; + const scrollTop = getScrollTopForItemIndex(layout, targetIndex, { + currentScrollTop: 0, + viewportHeight: VIEWPORT_HEIGHT, + }); + const selectedRows = getVisibleVirtualRows(layout.rows, { + scrollTop, + viewportHeight: VIEWPORT_HEIGHT, + }); + + assert.ok(rowContainsIndex(selectedRows, targetIndex), 'selected off-screen item is rendered after selection scroll'); + }); + + await t.test('preserves inclusive visible-row boundary semantics', () => { + const rows = [ + makeVirtualSectionRow('a', 0, 10), + makeVirtualSectionRow('b', 20, 10), + makeVirtualSectionRow('c', 40, 10), + ]; + + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 10, viewportHeight: 1, overscan: 0 }), + ['a'], + 'row whose bottom equals the visible start remains included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 15, viewportHeight: 5, overscan: 0 }), + ['b'], + 'row whose top equals the visible end remains included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 11, viewportHeight: 8, overscan: 0 }), + [], + 'rows outside the unexpanded viewport gap are excluded', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 11, viewportHeight: 8, overscan: 1 }), + ['a', 'b'], + 'overscan preserves the same inclusive start and end comparisons', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 45, viewportHeight: 1, overscan: 0 }), + ['c'], + 'partially visible rows remain included', + ); + assert.deepEqual( + getVisibleRowKeys(getVisibleVirtualRows, rows, { scrollTop: 51, viewportHeight: 1, overscan: 0 }), + [], + 'rows whose bottom is before the visible start are excluded', + ); + }); + + await t.test('uses sublinear row-bound lookup for large row sets', () => { + const { rows, stats } = makeInstrumentedRows(100000); + const rangeStart = performance.now(); + const visibleRows = getVisibleVirtualRows(rows, { + scrollTop: 1200000, + viewportHeight: VIEWPORT_HEIGHT, + overscan: 320, + }); + const duration = performance.now() - rangeStart; + const boundReads = stats.topReads + stats.heightReads; + + console.log( + `[grid-perf] visibleRange rows=${rows.length} renderedRows=${visibleRows.length} boundReads=${boundReads} durationMs=${duration.toFixed(3)}`, + ); + + assert.ok(visibleRows.length > 0, 'large range returns visible rows'); + assert.ok(visibleRows.length < 100, `expected a small visible window, got ${visibleRows.length}`); + assert.ok(boundReads < 512, `expected logarithmic bound reads, got ${boundReads}`); + }); + + await t.test('preserves section layout options', () => { + const layout = buildVirtualGridLayout(groups, { + defaultColumns: DEFAULT_COLUMNS, + containerWidth: CONTAINER_WIDTH, + }); + const sectionRow = layout.rows.find((row) => row.kind === 'items' && row.sectionKey === 'section-b'); + + assert.ok(sectionRow, 'section row exists'); + assert.equal(sectionRow.layout.columns, 4); + assert.equal(sectionRow.layout.fit, 'fill'); + assert.equal(sectionRow.layout.inset, 'lg'); + assert.notEqual(sectionRow.itemHeight, 160, 'aspect ratio creates a section-specific item row height'); + }); +}); diff --git a/scripts/test-icon-asset-cache.mjs b/scripts/test-icon-asset-cache.mjs new file mode 100644 index 00000000..2f49237c --- /dev/null +++ b/scripts/test-icon-asset-cache.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function createIconRuntimeHarness() { + const moduleCache = new Map(); + const existingPaths = new Set(); + let statCalls = 0; + + const windowStub = { + electron: { + statSync(filePath) { + statCalls += 1; + const exists = existingPaths.has(filePath); + return { + exists, + isDirectory: false, + isFile: exists, + size: exists ? 123 : 0, + }; + }, + }, + }; + + function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + } + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + window: windowStub, + document: { + documentElement: { + classList: { + contains: () => false, + }, + }, + }, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + config: loadTsModule('src/renderer/src/raycast-api/icon-runtime-config.ts'), + addExistingPath(filePath) { + existingPaths.add(filePath); + }, + get statCalls() { + return statCalls; + }, + }; +} + +test('icon asset existence cache', async (t) => { + await t.test('caches positive extension-relative asset checks by local path', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/icon.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + for (let i = 0; i < 5; i += 1) { + assert.equal(harness.assets.resolveIconSrc('icon.png', assetsPath), expectedUrl); + } + + assert.equal(harness.statCalls, 1); + }); + + await t.test('shares positive cache entries across sc-asset and absolute path resolution', () => { + const harness = createIconRuntimeHarness(); + const iconPath = '/tmp/supercmd-assets/shared icon.png'; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + assert.equal(harness.assets.resolveIconSrc(expectedUrl), expectedUrl); + assert.equal(harness.assets.resolveIconSrc(iconPath), expectedUrl); + + assert.equal(harness.statCalls, 1); + }); + + await t.test('does not cache misses so newly available assets resolve later', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/late.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), ''); + harness.addExistingPath(iconPath); + assert.equal(harness.assets.resolveIconSrc('late.png', assetsPath), expectedUrl); + + assert.equal(harness.statCalls, 2); + }); + + await t.test('uses configured assetsPath fallback without changing asset URL semantics', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-extension-assets'; + const iconPath = `${assetsPath}/nested/icon dark.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + harness.config.configureIconRuntime({ + getExtensionContext: () => ({ assetsPath }), + }); + + assert.equal(harness.assets.resolveIconSrc('nested/icon dark.png'), expectedUrl); + assert.equal(expectedUrl, 'sc-asset://ext-asset/tmp/supercmd-extension-assets/nested/icon%20dark.png'); + assert.equal(harness.statCalls, 1); + }); + + await t.test('repeated positive measurement avoids repeated statSync calls', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/measured.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + const iterations = 10_000; + harness.addExistingPath(iconPath); + + const start = performance.now(); + let result = ''; + for (let i = 0; i < iterations; i += 1) { + result = harness.assets.resolveIconSrc('measured.png', assetsPath); + } + const elapsedMs = performance.now() - start; + + assert.equal(result, expectedUrl); + assert.equal(harness.statCalls, 1); + console.log(`icon-asset-cache measurement: iterations=${iterations} statSync=${harness.statCalls} elapsedMs=${elapsedMs.toFixed(3)}`); + }); +}); diff --git a/scripts/test-icon-runtime-file-icon-cache.mjs b/scripts/test-icon-runtime-file-icon-cache.mjs new file mode 100644 index 00000000..1402be42 --- /dev/null +++ b/scripts/test-icon-runtime-file-icon-cache.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-render.tsx'); +let importNonce = 0; + +const REACT_EFFECT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: (effect) => { + effect(); + }, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const REACT_DOM_SERVER_STUB = ` +export function renderToStaticMarkup() { + return ""; +} +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const PHOSPHOR_STUB = ` +module.exports = {}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function installBrowserGlobals(electron) { + const document = { + documentElement: { + classList: { + contains: () => false, + }, + }, + body: { + appendChild: () => {}, + removeChild: () => {}, + }, + createElement: () => ({ + style: {}, + setAttribute: () => {}, + appendChild: () => {}, + remove: () => {}, + }), + }; + + globalThis.document = document; + globalThis.window = { + document, + electron, + matchMedia: () => ({ matches: true }), + getComputedStyle: () => ({ + color: 'rgb(255, 255, 255)', + getPropertyValue: () => '', + }), + }; +} + +function exposeIconRuntimeRenderHooksPlugin() { + const normalizedTarget = path.normalize(targetFile); + return { + name: 'expose-icon-runtime-render-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-render\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimeRender = { + FILE_ICON_CACHE_MAX_ENTRIES, + FileIcon, + fileIconCache, + inFlightFileIconRequests, + loadFileIconDataUrl, + readCachedFileIcon, + renderIcon, + clearFileIconCaches: () => { + fileIconCache.clear(); + inFlightFileIconRequests.clear(); + }, + cacheStats: () => ({ + cacheSize: fileIconCache.size, + inFlightSize: inFlightFileIconRequests.size, + keys: Array.from(fileIconCache.keys()), + limit: FILE_ICON_CACHE_MAX_ENTRIES, + }), +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }; +} + +function iconRuntimeStubPlugin() { + const stubs = new Map([ + ['@phosphor-icons/react', PHOSPHOR_STUB], + ['react', REACT_EFFECT_STUB], + ['react-dom/server', REACT_DOM_SERVER_STUB], + ['react/jsx-dev-runtime', JSX_RUNTIME_STUB], + ['react/jsx-runtime', JSX_RUNTIME_STUB], + ]); + + return { + name: 'icon-runtime-file-cache-stubs', + setup(buildApi) { + buildApi.onResolve({ filter: /.*/ }, (args) => { + if (!stubs.has(args.path)) return null; + return { path: args.path, namespace: 'icon-runtime-file-cache-stub' }; + }); + + buildApi.onLoad({ filter: /.*/, namespace: 'icon-runtime-file-cache-stub' }, (args) => ({ + contents: stubs.get(args.path) || '', + loader: 'js', + })); + }, + }; +} + +async function importIconRuntimeHarness(electron = {}) { + installBrowserGlobals(electron); + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + jsx: 'automatic', + logLevel: 'silent', + banner: { + js: 'var window = globalThis.window; var document = globalThis.document;', + }, + plugins: [iconRuntimeStubPlugin(), exposeIconRuntimeRenderHooksPlugin()], + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#icon-runtime-file-cache-${importNonce++}`, + ].join(''); + const module = await import(dataUrl); + const runtime = module.__testIconRuntimeRender; + assert.ok(runtime, 'expected icon runtime render test hooks to be exposed'); + runtime.clearFileIconCaches(); + return runtime; +} + +test('file icon runtime cache', async (t) => { + await t.test('coalesces 100 concurrently mounted identical file icons into one IPC call', async () => { + const filePath = '/tmp/supercmd-same-file.txt'; + const dataUrl = 'data:image/png;base64,same-file'; + const gate = deferred(); + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + getFileIconDataUrl(requestedPath, size) { + calls += 1; + assert.equal(requestedPath, filePath); + assert.equal(size, 20); + return gate.promise; + }, + statSync: () => ({ exists: true, isDirectory: false }), + }); + + for (let index = 0; index < 100; index += 1) { + runtime.FileIcon({ filePath, className: 'w-4 h-4' }); + } + + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().inFlightSize, 1); + + const pending = Array.from(runtime.inFlightFileIconRequests.values())[0]; + gate.resolve(dataUrl); + await pending; + + assert.equal(runtime.cacheStats().cacheSize, 1); + assert.equal(runtime.cacheStats().inFlightSize, 0); + assert.equal(runtime.readCachedFileIcon(filePath), dataUrl); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), dataUrl); + assert.equal(calls, 1, 'cached file icon should not issue another IPC call'); + }); + + await t.test('does not keep rejected IPC results as permanent negative cache entries', async () => { + const filePath = '/tmp/supercmd-late-success.txt'; + const recoveredDataUrl = 'data:image/png;base64,recovered'; + let calls = 0; + + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + if (calls === 1) throw new Error('temporary icon IPC failure'); + return recoveredDataUrl; + }, + }); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), null); + assert.equal(calls, 1); + assert.equal(runtime.cacheStats().cacheSize, 0); + assert.equal(runtime.cacheStats().inFlightSize, 0); + + assert.equal(await runtime.loadFileIconDataUrl(filePath), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(runtime.cacheStats().cacheSize, 1); + }); + + await t.test('caps unique file icon cache entries and evicts least recently used paths', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl(filePath) { + calls += 1; + return `data:image/png;base64,${Buffer.from(filePath).toString('base64')}`; + }, + }); + + const { limit } = runtime.cacheStats(); + assert.ok(limit < 5_000, 'test expects a bounded cache below the old 5k growth case'); + + for (let index = 0; index < limit; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + assert.equal(runtime.cacheStats().cacheSize, limit); + + const firstPath = '/tmp/supercmd-icon-0'; + const secondPath = '/tmp/supercmd-icon-1'; + const overflowPath = `/tmp/supercmd-icon-${limit}`; + assert.ok(runtime.readCachedFileIcon(firstPath), 'reading should refresh LRU order'); + + await runtime.loadFileIconDataUrl(overflowPath); + + const afterOverflow = runtime.cacheStats(); + assert.equal(afterOverflow.cacheSize, limit); + assert.equal(calls, limit + 1); + assert.ok(afterOverflow.keys.includes(firstPath), 'recently read path should survive overflow'); + assert.ok(!afterOverflow.keys.includes(secondPath), 'least recently used path should be evicted'); + assert.ok(afterOverflow.keys.includes(overflowPath), 'new path should be cached'); + + for (let index = limit + 1; index < 5_000; index += 1) { + await runtime.loadFileIconDataUrl(`/tmp/supercmd-icon-${index}`); + } + + assert.equal(runtime.cacheStats().cacheSize, limit); + }); + + await t.test('leaves non-file image icons on the existing render path without IPC', async () => { + let calls = 0; + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + calls += 1; + return 'data:image/png;base64,file-icon'; + }, + }); + + const remoteIcon = runtime.renderIcon('https://example.com/icon.png', 'w-5 h-5'); + + assert.equal(calls, 0); + assert.equal(remoteIcon?.props?.src, 'https://example.com/icon.png'); + assert.equal(remoteIcon?.props?.className, 'w-5 h-5'); + }); +}); diff --git a/scripts/test-icon-runtime-phosphor-cache.mjs b/scripts/test-icon-runtime-phosphor-cache.mjs new file mode 100644 index 00000000..ee5b2734 --- /dev/null +++ b/scripts/test-icon-runtime-phosphor-cache.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/renderer/src/raycast-api/icon-runtime-phosphor.tsx'); + +async function importIconRuntimeForTest() { + const outputFile = path.join(os.tmpdir(), `supercmd-icon-runtime-test-${process.pid}-${Date.now()}.mjs`); + const normalizedTarget = path.normalize(targetFile); + + try { + await build({ + entryPoints: [targetFile], + outfile: outputFile, + bundle: true, + format: 'esm', + platform: 'node', + target: 'es2022', + jsx: 'automatic', + logLevel: 'silent', + plugins: [ + { + name: 'expose-icon-runtime-test-hooks', + setup(buildApi) { + buildApi.onLoad({ filter: /icon-runtime-phosphor\.tsx$/ }, async (args) => { + if (path.normalize(args.path) !== normalizedTarget) return undefined; + const source = await fs.readFile(args.path, 'utf8'); + return { + contents: `${source} +export const __testIconRuntimePhosphor = { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches: () => { + phosphorIconResolutionCache.clear(); + phosphorNameResolutionCache.clear(); + raycastIconNameResolutionCache.clear(); + }, + caches: { + phosphorIconResolutionCache, + phosphorNameResolutionCache, + raycastIconNameResolutionCache, + }, +}; +`, + loader: 'tsx', + resolveDir: path.dirname(args.path), + }; + }); + }, + }, + { + name: 'stub-server-rendering-for-icon-runtime-tests', + setup(buildApi) { + buildApi.onResolve({ filter: /^react-dom\/server$/ }, () => ({ + path: 'react-dom-server-test-stub', + namespace: 'test-stub', + })); + buildApi.onLoad({ filter: /.*/, namespace: 'test-stub' }, () => ({ + contents: 'export function renderToStaticMarkup() { return ""; }', + loader: 'js', + })); + }, + }, + ], + }); + + const runtime = await import(`${pathToFileURL(outputFile).href}?t=${Date.now()}`); + return runtime.__testIconRuntimePhosphor; + } finally { + await fs.unlink(outputFile).catch(() => {}); + } +} + +test('Phosphor icon runtime resolution cache', async (t) => { + const iconRuntime = await importIconRuntimeForTest(); + const { + resolvePhosphorIconFromRaycast, + tryResolvePhosphorByName, + clearIconResolutionCaches, + caches, + } = iconRuntime; + + await t.test('resolves direct Raycast icon names without changing weight', () => { + clearIconResolutionCaches(); + + const expected = tryResolvePhosphorByName('MagnifyingGlass'); + const resolved = resolvePhosphorIconFromRaycast('MagnifyingGlass'); + + assert.ok(expected, 'expected Phosphor MagnifyingGlass export to exist'); + assert.equal(resolved?.icon, expected); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('preserves explicit Raycast aliases and filled weight compatibility', () => { + clearIconResolutionCaches(); + + const stopwatch = resolvePhosphorIconFromRaycast('Stopwatch'); + assert.equal(stopwatch?.icon, tryResolvePhosphorByName('Timer')); + assert.equal(stopwatch?.weight, 'regular'); + + const filled = resolvePhosphorIconFromRaycast('XMarkCircleFilled'); + assert.equal(filled?.icon, tryResolvePhosphorByName('XCircle')); + assert.equal(filled?.weight, 'fill'); + }); + + await t.test('keeps unknown icons on the existing fallback glyph', () => { + clearIconResolutionCaches(); + + const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); + const resolved = resolvePhosphorIconFromRaycast('QzxvPqmn999'); + + assert.ok(fallback, 'expected a Phosphor fallback glyph to exist'); + assert.equal(resolved?.icon, fallback); + assert.equal(resolved?.weight, 'regular'); + }); + + await t.test('caches successful and failed resolutions while allowing whole-cache clear', () => { + clearIconResolutionCaches(); + + const first = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + const second = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(second, first, 'repeated final resolution should return the cached result object'); + + assert.equal(tryResolvePhosphorByName('DefinitelyNotAPhosphorIcon'), undefined); + assert.equal( + caches.phosphorNameResolutionCache.get('DefinitelyNotAPhosphorIcon'), + null, + 'failed Phosphor export lookups are memoized as misses', + ); + + clearIconResolutionCaches(); + assert.equal(caches.phosphorIconResolutionCache.size, 0); + assert.equal(caches.phosphorNameResolutionCache.size, 0); + assert.equal(caches.raycastIconNameResolutionCache.size, 0); + + const afterClear = resolvePhosphorIconFromRaycast('TotallyMissingIconName'); + assert.equal(afterClear?.icon, first?.icon); + assert.equal(afterClear?.weight, first?.weight); + }); +}); diff --git a/scripts/test-inline-args-write-coalesce.mjs b/scripts/test-inline-args-write-coalesce.mjs new file mode 100644 index 00000000..b90ef672 --- /dev/null +++ b/scripts/test-inline-args-write-coalesce.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; + +const bundledPreferences = await build({ + entryPoints: ['src/renderer/src/utils/extension-preferences.ts'], + bundle: true, + write: false, + format: 'esm', + platform: 'browser', + logLevel: 'silent', +}); + +const bundledPreferencesUrl = + 'data:text/javascript;base64,' + Buffer.from(bundledPreferences.outputFiles[0].text).toString('base64'); + +let moduleCounter = 0; + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function copyPayload(value) { + return JSON.parse(JSON.stringify(value)); +} + +async function loadPreferencesWithMocks() { + const storage = new Map(); + const commandArgumentWrites = []; + + globalThis.localStorage = { + getItem: (key) => (storage.has(key) ? storage.get(key) : null), + setItem: (key, value) => { + storage.set(key, String(value)); + }, + removeItem: (key) => { + storage.delete(key); + }, + key: (index) => Array.from(storage.keys())[index] ?? null, + get length() { + return storage.size; + }, + }; + globalThis.window = { + electron: { + saveExtensionCommandArguments: async (args) => { + commandArgumentWrites.push(copyPayload(args)); + return {}; + }, + saveExtensionPreferences: async (args) => { + return {}; + }, + }, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + dispatchEvent: () => true, + }; + globalThis.CustomEvent = class CustomEvent { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } + }; + + moduleCounter += 1; + const preferences = await import(`${bundledPreferencesUrl}#test-${moduleCounter}`); + return { preferences, storage, commandArgumentWrites }; +} + +test('inline command argument settings mirror coalesces rapid writes', async () => { + const { preferences, storage, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('coalesce-extension', 'no-view-command'); + const values = ['s', 'se', 'sea', 'sear', 'searc', 'search', 'search ', 'search t', 'search te', 'search tex', 'search text']; + + for (const value of values) { + preferences.writeJsonObject( + key, + { query: value }, + { commandArgumentSettingsSync: 'debounced' } + ); + } + + assert.equal(JSON.parse(storage.get(key)).query, 'search text'); + assert.equal(commandArgumentWrites.length, 0); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'coalesce-extension', + cmdName: 'no-view-command', + values: { query: 'search text' }, + }); +}); + +test('launch flush writes pending inline command arguments immediately once', async () => { + const { preferences, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('flush-extension', 'no-view-command'); + + preferences.writeJsonObject( + key, + { query: 'stale' }, + { commandArgumentSettingsSync: 'debounced' } + ); + preferences.writeJsonObject( + key, + { query: 'fresh at launch' }, + { commandArgumentSettingsSync: 'debounced' } + ); + + await preferences.flushCommandArgumentSettingsSync(key); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'flush-extension', + cmdName: 'no-view-command', + values: { query: 'fresh at launch' }, + }); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + assert.equal(commandArgumentWrites.length, 1); +}); + +test('immediate command argument writes cancel pending debounced settings sync', async () => { + const { preferences, commandArgumentWrites } = await loadPreferencesWithMocks(); + const key = preferences.getCmdArgsKey('submit-extension', 'no-view-command'); + + preferences.writeJsonObject( + key, + { query: 'pending inline value' }, + { commandArgumentSettingsSync: 'debounced' } + ); + preferences.writeJsonObject(key, { query: 'submitted value' }); + + assert.equal(commandArgumentWrites.length, 1); + assert.deepEqual(commandArgumentWrites[0], { + extName: 'submit-extension', + cmdName: 'no-view-command', + values: { query: 'submitted value' }, + }); + + await wait(preferences.COMMAND_ARGUMENT_SETTINGS_SYNC_DEBOUNCE_MS + 50); + assert.equal(commandArgumentWrites.length, 1); +}); diff --git a/scripts/test-launcher-command-list-windowing.mjs b/scripts/test-launcher-command-list-windowing.mjs new file mode 100644 index 00000000..b34dc9d0 --- /dev/null +++ b/scripts/test-launcher-command-list-windowing.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function runMeasurement() { + const output = execFileSync( + process.execPath, + [ + 'scripts/measure-launcher-command-list-render.mjs', + '--rows=5000', + '--iterations=1', + '--warmups=0', + '--json', + ], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + } + ); + const jsonStart = output.lastIndexOf('{\n "rowCount"'); + assert.notEqual(jsonStart, -1, `Measurement output did not include JSON summary:\n${output}`); + return JSON.parse(output.slice(jsonStart)); +} + +test('launcher command list windows large result sets instead of rendering every row', () => { + const metrics = runMeasurement(); + const fullSizeScenarios = metrics.results.filter((result) => result.commandCount === 5000); + assert.equal(fullSizeScenarios.length, 4); + + for (const result of fullSizeScenarios) { + assert.ok( + result.rowRenderCount < 100, + `${result.name} rendered ${result.rowRenderCount} rows for ${result.commandCount} commands` + ); + } + + assert.ok( + metrics.totalRows < 300, + `Expected the full scenario sequence to render fewer than 300 rows, got ${metrics.totalRows}` + ); +}); diff --git a/scripts/test-list-registry-stable-order.mjs b/scripts/test-list-registry-stable-order.mjs new file mode 100644 index 00000000..8fa88142 --- /dev/null +++ b/scripts/test-list-registry-stable-order.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const LIST_RENDERERS_PATH = 'src/renderer/src/raycast-api/list-runtime-renderers.tsx'; +const ITEM_COUNT = 1000; +const SELECTION_MOVES = 25; + +function readListItemComponentSource() { + const source = fs.readFileSync(LIST_RENDERERS_PATH, 'utf8'); + const match = source.match(/function ListItemComponent[\s\S]*?\n }\n\n \(ListItemComponent/); + assert.ok(match, 'ListItemComponent source should be present'); + return match[0]; +} + +function getLayoutEffectDependencies(source) { + const match = source.match(/useLayoutEffect\(\(\) => \{[\s\S]*?\}, \[([^\]]*)\]\);/); + assert.ok(match, 'ListItemComponent should register through useLayoutEffect'); + return match[1] + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean); +} + +function analyzeListRegistration(source) { + const dependencies = getLayoutEffectDependencies(source); + const hasSelectedActionsContext = source.includes('useContext(SelectedItemActionsContext)'); + const hasVolatileRenderOrder = /const\s+renderOrder\s*=\s*\+\+itemOrderCounter\s*;/.test(source); + const effectDependsOnRenderOrder = dependencies.includes('renderOrder'); + const hasStableOrderRef = + /const\s+orderRef\s*=\s*useRef<\s*number\s*\|\s*null\s*>\(null\)/.test(source) && + /orderRef\.current\s*===\s*null/.test(source) && + /orderRef\.current\s*=\s*\+\+itemOrderCounter/.test(source); + + const layoutEffectRestartsOnSelectionRender = hasSelectedActionsContext && hasVolatileRenderOrder && effectDependsOnRenderOrder; + return { + dependencies, + hasSelectedActionsContext, + hasStableOrderRef, + hasVolatileRenderOrder, + effectDependsOnRenderOrder, + layoutEffectRestartsOnSelectionRender, + }; +} + +function measureSelectionChurn(analysis) { + const selectionDrivenItemRenders = ITEM_COUNT * SELECTION_MOVES; + const layoutEffectRestarts = analysis.layoutEffectRestartsOnSelectionRender ? selectionDrivenItemRenders : 0; + const registryDeletesOnSelection = layoutEffectRestarts; + const registrySetsOnSelection = layoutEffectRestarts; + return { + itemCount: ITEM_COUNT, + selectionMoves: SELECTION_MOVES, + orderMode: analysis.hasStableOrderRef ? 'stable-ref' : 'render-counter', + selectionDrivenItemRenders, + layoutEffectRestarts, + registryMutationsOnSelection: registryDeletesOnSelection + registrySetsOnSelection, + registryVersionUpdatesOnSelection: analysis.layoutEffectRestartsOnSelectionRender ? SELECTION_MOVES : 0, + }; +} + +function getMetrics() { + const source = readListItemComponentSource(); + const analysis = analyzeListRegistration(source); + return { analysis, metrics: measureSelectionChurn(analysis) }; +} + +if (process.argv.includes('--report')) { + const { analysis, metrics } = getMetrics(); + console.log(JSON.stringify({ analysis, metrics }, null, 2)); +} else { + test('List item registration order is stable across selection context renders', () => { + const { analysis, metrics } = getMetrics(); + assert.equal(analysis.hasSelectedActionsContext, true, 'List items still render selected item actions in their source context'); + assert.equal(analysis.hasStableOrderRef, true, 'List item render order should be stored in a ref like Grid items'); + assert.equal(analysis.hasVolatileRenderOrder, false, 'List items should not allocate a new order on every render'); + assert.equal(analysis.effectDependsOnRenderOrder, false, 'registration effect should not depend on render-time order'); + assert.equal(metrics.registryMutationsOnSelection, 0, 'selection changes should not unregister/register every list item'); + assert.equal(metrics.registryVersionUpdatesOnSelection, 0, 'selection changes should not publish list registry updates'); + }); +} diff --git a/scripts/test-menubar-payload-cache.mjs b/scripts/test-menubar-payload-cache.mjs new file mode 100644 index 00000000..a82931ab --- /dev/null +++ b/scripts/test-menubar-payload-cache.mjs @@ -0,0 +1,86 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + createMenuBarVisiblePayloadHashCache, + shouldSendMenuBarVisiblePayload, +} = await importTs(path.join(root, 'src/renderer/src/raycast-api/menubar-runtime-payload-cache.ts')); + +function makePayload(overrides = {}) { + return { + extId: 'demo/timer', + iconPath: undefined, + iconDataUrl: undefined, + iconEmoji: '*', + iconTemplate: undefined, + iconBitmapScale: undefined, + fallbackIconDataUrl: '', + title: 'Timer', + tooltip: 'Timer status', + items: [ + { + type: 'item', + id: '__mbi_1', + title: 'Start', + subtitle: 'Ready', + tooltip: 'Start timer', + disabled: false, + }, + ], + ...overrides, + }; +} + +test('MenuBarExtra visible payload cache', async (t) => { + await t.test('skips equivalent renderer sends while action maps stay current', () => { + const cache = createMenuBarVisiblePayloadHashCache(); + let updateSends = 0; + let actionMapUpdates = 0; + + for (let i = 0; i < 3; i += 1) { + const actions = new Map([['__mbi_1', () => i]]); + actionMapUpdates += actions.size; + if (shouldSendMenuBarVisiblePayload(cache, makePayload())) { + updateSends += 1; + } + } + + t.diagnostic(`equivalent registrations: before=3 sends, after=${updateSends} send`); + assert.equal(updateSends, 1, 'only the first equivalent visible payload crosses IPC'); + assert.equal(actionMapUpdates, 3, 'action maps still refresh for every registration pass'); + }); + + await t.test('sends again when visible tray or menu fields change', () => { + const cache = createMenuBarVisiblePayloadHashCache(); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload()), true, 'initial payload sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload()), false, 'equivalent payload is skipped'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ title: 'Timer Running' })), true, 'title change sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ iconEmoji: '>' })), true, 'icon change sends'); + assert.equal(shouldSendMenuBarVisiblePayload(cache, makePayload({ + items: [{ type: 'item', id: '__mbi_1', title: 'Pause', disabled: false }], + })), true, 'menu item change sends'); + }); + + await t.test('parent refreshes actions before skipping unchanged visible payloads', () => { + const parentSource = fs.readFileSync( + path.join(root, 'src/renderer/src/raycast-api/menubar-runtime-parent.tsx'), + 'utf8', + ); + const actionIndex = parentSource.indexOf('setMenuBarActions(extId'); + const cacheIndex = parentSource.indexOf('shouldSendMenuBarVisiblePayload('); + const updateIndex = parentSource.indexOf('updateMenuBar?.(payload)'); + + assert.ok(actionIndex >= 0, 'parent updates the action map'); + assert.ok(cacheIndex >= 0, 'parent checks the visible payload cache'); + assert.ok(updateIndex >= 0, 'parent sends the cached payload object'); + assert.ok(actionIndex < cacheIndex, 'actions update before unchanged payloads are skipped'); + assert.ok(cacheIndex < updateIndex, 'IPC send happens only after the cache check'); + }); +}); diff --git a/scripts/test-menubar-storage-remounts.mjs b/scripts/test-menubar-storage-remounts.mjs new file mode 100644 index 00000000..85cd259f --- /dev/null +++ b/scripts/test-menubar-storage-remounts.mjs @@ -0,0 +1,329 @@ +#!/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 { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function depsChanged(prevDeps, nextDeps) { + if (!prevDeps || !nextDeps || prevDeps.length !== nextDeps.length) return true; + return nextDeps.some((dep, index) => !Object.is(dep, prevDeps[index])); +} + +function createReactHookHarness() { + const hooks = []; + const effects = []; + let hookIndex = 0; + let pendingEffects = []; + + const react = { + useState(initialValue) { + const index = hookIndex++; + if (!hooks[index]) { + hooks[index] = { + value: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + const setState = (nextValue) => { + hooks[index].value = + typeof nextValue === 'function' ? nextValue(hooks[index].value) : nextValue; + }; + return [hooks[index].value, setState]; + }, + + useRef(initialValue) { + const index = hookIndex++; + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + + useCallback(callback, deps) { + const index = hookIndex++; + const slot = hooks[index]; + if (!slot || depsChanged(slot.deps, deps)) { + hooks[index] = { value: callback, deps }; + } + return hooks[index].value; + }, + + useEffect(effect, deps) { + const index = hookIndex++; + const slot = effects[index]; + if (!slot || depsChanged(slot.deps, deps)) { + pendingEffects.push({ index, effect, deps }); + } + }, + }; + + function flushEffects() { + const toRun = pendingEffects; + pendingEffects = []; + for (const next of toRun) { + const prev = effects[next.index]; + if (typeof prev?.cleanup === 'function') prev.cleanup(); + effects[next.index] = { + deps: next.deps, + cleanup: next.effect() || undefined, + }; + } + } + + return { + react, + render(callback) { + hookIndex = 0; + const result = callback(); + flushEffects(); + return result; + }, + cleanup() { + for (const effect of effects) { + if (typeof effect?.cleanup === 'function') effect.cleanup(); + } + }, + }; +} + +function installWindowHarness() { + const listeners = new Map(); + const removedMenuBars = []; + + const win = { + electron: { + removeMenuBar(extId) { + removedMenuBars.push(extId); + }, + onExtensionPreferencesUpdated() { + return () => {}; + }, + }, + addEventListener(type, listener) { + const next = listeners.get(type) || new Set(); + next.add(listener); + listeners.set(type, next); + }, + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); + }, + dispatchEvent(event) { + for (const listener of listeners.get(event.type) || []) { + listener(event); + } + return true; + }, + }; + + globalThis.window = win; + globalThis.CustomEvent = class TestCustomEvent { + constructor(type, init = {}) { + this.type = type; + this.detail = init.detail; + } + }; + + return { + removedMenuBars, + restore() { + delete globalThis.window; + delete globalThis.CustomEvent; + }, + }; +} + +function loadTsModule(filePath, stubs = {}) { + const resolvedPath = path.resolve(root, filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + const localRequire = (request) => { + if (request in stubs) return stubs[request]; + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return loadTsModule(path.relative(root, nextPath), stubs); + } + } + } + return require(request); + }; + + vm.runInNewContext(transpiled.outputText, { + module, + exports: module.exports, + require: localRequire, + console, + window: globalThis.window, + CustomEvent: globalThis.CustomEvent, + Date, + Math, + }, { filename: resolvedPath }); + + return module.exports; +} + +function makeBundle(extName, cmdName) { + return { + code: 'module.exports = function Command() { return null; }', + title: cmdName, + extName, + cmdName, + extensionName: extName, + commandName: cmdName, + }; +} + +function findEntry(entries, extName, cmdName) { + return entries.find((entry) => { + const entryExt = entry.bundle.extName || entry.bundle.extensionName; + const entryCmd = entry.bundle.cmdName || entry.bundle.commandName; + return entryExt === extName && entryCmd === cmdName; + }); +} + +function createMenuBarSubject() { + const windowHarness = installWindowHarness(); + const reactHarness = createReactHookHarness(); + const { useMenuBarExtensions } = loadTsModule('src/renderer/src/hooks/useMenuBarExtensions.ts', { + react: reactHarness.react, + }); + + let current = reactHarness.render(() => useMenuBarExtensions()); + + return { + get current() { + return current; + }, + rerender() { + current = reactHarness.render(() => useMenuBarExtensions()); + return current; + }, + cleanup() { + reactHarness.cleanup(); + windowHarness.restore(); + }, + }; +} + +test('menu-bar storage refresh behavior', async (t) => { + await t.test('emitted storage events keep extensionName compatibility and include command origin', () => { + const windowHarness = installWindowHarness(); + const events = []; + globalThis.window.addEventListener('sc-extension-storage-changed', (event) => { + events.push(event.detail); + }); + + const { configureStorageEvents, emitExtensionStorageChanged } = loadTsModule( + 'src/renderer/src/raycast-api/storage-events.ts' + ); + + configureStorageEvents({ + getExtensionContext: () => ({ + extensionName: 'Demo', + commandName: 'Menu', + commandMode: 'menu-bar', + }), + }); + emitExtensionStorageChanged(); + + assert.equal(events.length, 1); + assert.equal(events[0].extensionName, 'Demo'); + assert.equal(events[0].commandName, 'Menu'); + assert.equal(events[0].commandMode, 'menu-bar'); + windowHarness.restore(); + }); + + await t.test('self-originated menu-bar storage writes do not remount the writer', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Menu')); + subject.rerender(); + const before = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.ok(before, 'menu-bar command should be mounted'); + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { + extensionName: 'Demo', + commandName: 'Menu', + commandMode: 'menu-bar', + }, + }) + ); + subject.rerender(); + + const after = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.equal(after, before, 'the writer keeps its ExtensionView key'); + } finally { + subject.cleanup(); + } + }); + + await t.test('sibling menu-bar commands still remount for same-extension writes', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Writer')); + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Reader')); + subject.rerender(); + const writerBefore = findEntry(subject.current.menuBarExtensions, 'Demo', 'Writer')?.key; + const readerBefore = findEntry(subject.current.menuBarExtensions, 'Demo', 'Reader')?.key; + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { + extensionName: 'Demo', + commandName: 'Writer', + commandMode: 'menu-bar', + }, + }) + ); + subject.rerender(); + + const writerAfter = findEntry(subject.current.menuBarExtensions, 'Demo', 'Writer')?.key; + const readerAfter = findEntry(subject.current.menuBarExtensions, 'Demo', 'Reader')?.key; + assert.equal(writerAfter, writerBefore, 'writer is not remounted'); + assert.notEqual(readerAfter, readerBefore, 'sibling command is refreshed'); + } finally { + subject.cleanup(); + } + }); + + await t.test('external storage updates without origin metadata still remount every command', () => { + const subject = createMenuBarSubject(); + try { + subject.current.upsertMenuBarExtension(makeBundle('Demo', 'Menu')); + subject.rerender(); + const before = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + + globalThis.window.dispatchEvent( + new CustomEvent('sc-extension-storage-changed', { + detail: { extensionName: 'Demo' }, + }) + ); + subject.rerender(); + + const after = findEntry(subject.current.menuBarExtensions, 'Demo', 'Menu')?.key; + assert.notEqual(after, before, 'external update refreshes the mounted command'); + } finally { + subject.cleanup(); + } + }); +}); diff --git a/scripts/test-notes-async-saves.mjs b/scripts/test-notes-async-saves.mjs new file mode 100644 index 00000000..b39c481e --- /dev/null +++ b/scripts/test-notes-async-saves.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import Module, { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { transform } from 'esbuild'; + +const require = createRequire(import.meta.url); +const repoRoot = process.cwd(); +const notesStorePath = path.join(repoRoot, 'src/main/notes-store.ts'); + +function createMetrics() { + return { + notesJsonWriteCount: 0, + syncWriteFileCount: 0, + asyncWriteFileCount: 0, + syncWriteMs: 0, + asyncWriteMs: 0, + }; +} + +function resetMetrics(metrics) { + metrics.notesJsonWriteCount = 0; + metrics.syncWriteFileCount = 0; + metrics.asyncWriteFileCount = 0; + metrics.syncWriteMs = 0; + metrics.asyncWriteMs = 0; +} + +function isNotesJsonPath(filePath) { + const normalizedPath = String(filePath); + return ( + path.basename(path.dirname(normalizedPath)) === 'notes' && + (path.basename(normalizedPath) === 'notes.json' || path.basename(normalizedPath).startsWith('notes.json.')) + ); +} + +function blockForMs(durationMs) { + const end = performance.now() + durationMs; + while (performance.now() < end) { + // Deliberately burn a tiny amount of CPU to make sync write blocking visible. + } +} + +async function loadNotesStore({ writeDelayMs = 0 } = {}) { + const testRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'supercmd-notes-store-')); + const userData = path.join(testRoot, 'user-data'); + const compiledPath = path.join(testRoot, 'notes-store.cjs'); + + const source = fs.readFileSync(notesStorePath, 'utf8'); + const { code } = await transform(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', + }); + await fsp.writeFile(compiledPath, code, 'utf8'); + + const realFs = require('node:fs'); + const realFsPromises = require('node:fs/promises'); + const originalWriteFileSync = realFs.writeFileSync; + const originalFsPromisesWriteFile = realFs.promises.writeFile; + const originalStandaloneWriteFile = realFsPromises.writeFile; + const originalModuleLoad = Module._load; + const metrics = createMetrics(); + + function recordWrite(filePath, kind, elapsedMs) { + if (!isNotesJsonPath(filePath)) return; + metrics.notesJsonWriteCount += 1; + if (kind === 'sync') { + metrics.syncWriteFileCount += 1; + metrics.syncWriteMs += elapsedMs; + } else { + metrics.asyncWriteFileCount += 1; + metrics.asyncWriteMs += elapsedMs; + } + } + + realFs.writeFileSync = function patchedWriteFileSync(filePath, ...args) { + const isNotesWrite = isNotesJsonPath(filePath); + const startedAt = performance.now(); + if (isNotesWrite && writeDelayMs > 0) { + blockForMs(writeDelayMs); + } + try { + return originalWriteFileSync.apply(this, [filePath, ...args]); + } finally { + recordWrite(filePath, 'sync', performance.now() - startedAt); + } + }; + + async function patchedAsyncWriteFile(filePath, ...args) { + const startedAt = performance.now(); + try { + return await originalStandaloneWriteFile.apply(this, [filePath, ...args]); + } finally { + recordWrite(filePath, 'async', performance.now() - startedAt); + } + } + + realFs.promises.writeFile = patchedAsyncWriteFile; + + const fsPromisesStub = { + ...realFsPromises, + writeFile: patchedAsyncWriteFile, + }; + + Module._load = function patchedModuleLoad(request, parent, isMain) { + if (request === 'electron') { + return { + app: { + getPath(name) { + assert.equal(name, 'userData'); + return userData; + }, + }, + clipboard: { writeText() {} }, + dialog: { + async showSaveDialog() { + return { canceled: true }; + }, + async showOpenDialog() { + return { canceled: true, filePaths: [] }; + }, + }, + BrowserWindow: class BrowserWindow {}, + }; + } + if (request === 'fs/promises' || request === 'node:fs/promises') { + return fsPromisesStub; + } + return originalModuleLoad.apply(this, [request, parent, isMain]); + }; + + try { + const store = require(compiledPath); + return { + store, + metrics, + resetMetrics: () => resetMetrics(metrics), + getNotesFilePath: () => path.join(userData, 'notes', 'notes.json'), + async cleanup() { + Module._load = originalModuleLoad; + realFs.writeFileSync = originalWriteFileSync; + realFs.promises.writeFile = originalFsPromisesWriteFile; + realFsPromises.writeFile = originalStandaloneWriteFile; + delete require.cache[compiledPath]; + await fsp.rm(testRoot, { recursive: true, force: true }); + }, + }; + } catch (error) { + Module._load = originalModuleLoad; + realFs.writeFileSync = originalWriteFileSync; + realFs.promises.writeFile = originalFsPromisesWriteFile; + realFsPromises.writeFile = originalStandaloneWriteFile; + await fsp.rm(testRoot, { recursive: true, force: true }); + throw error; + } +} + +async function flushStore(store) { + assert.equal( + typeof store.flushNotesToDisk, + 'function', + 'notes store must export flushNotesToDisk() so app quit can persist queued saves' + ); + await store.flushNotesToDisk(); +} + +async function readPersistedNotes(notesFilePath) { + return JSON.parse(await fsp.readFile(notesFilePath, 'utf8')); +} + +test('Notes autosave persistence coalesces repeated updates off the synchronous path', async () => { + const harness = await loadNotesStore({ writeDelayMs: 1 }); + try { + harness.store.initNoteStore(); + const note = harness.store.createNote({ title: 'Autosave baseline', content: 'initial' }); + if (typeof harness.store.flushNotesToDisk === 'function') { + await harness.store.flushNotesToDisk(); + } + + harness.resetMetrics(); + const startedAt = performance.now(); + for (let index = 0; index < 20; index += 1) { + const updated = harness.store.updateNote(note.id, { + title: `Autosave ${index}`, + content: `body ${index}`, + }); + assert.equal(updated?.content, `body ${index}`); + } + const loopMs = performance.now() - startedAt; + + assert.equal(harness.store.getNoteById(note.id)?.content, 'body 19'); + if (typeof harness.store.flushNotesToDisk !== 'function') { + console.log( + `[Notes autosave baseline] repeatedUpdates=20 writes=${harness.metrics.notesJsonWriteCount} ` + + `syncWrites=${harness.metrics.syncWriteFileCount} asyncWrites=${harness.metrics.asyncWriteFileCount} ` + + `loopMs=${loopMs.toFixed(2)} syncWriteMs=${harness.metrics.syncWriteMs.toFixed(2)}` + ); + } + await flushStore(harness.store); + const persisted = await readPersistedNotes(harness.getNotesFilePath()); + assert.equal(persisted[0]?.content, 'body 19'); + + console.log( + `[Notes autosave metrics] repeatedUpdates=20 writes=${harness.metrics.notesJsonWriteCount} ` + + `syncWrites=${harness.metrics.syncWriteFileCount} asyncWrites=${harness.metrics.asyncWriteFileCount} ` + + `loopMs=${loopMs.toFixed(2)} syncWriteMs=${harness.metrics.syncWriteMs.toFixed(2)}` + ); + + assert.equal(harness.metrics.syncWriteFileCount, 0); + assert.ok( + harness.metrics.notesJsonWriteCount <= 1, + `expected repeated autosaves to coalesce to <= 1 physical write, got ${harness.metrics.notesJsonWriteCount}` + ); + } finally { + await harness.cleanup(); + } +}); + +test('Notes flush persists the latest queued state before shutdown', async () => { + const harness = await loadNotesStore(); + try { + harness.store.initNoteStore(); + const note = harness.store.createNote({ title: 'Flush baseline', content: 'initial' }); + await flushStore(harness.store); + + harness.resetMetrics(); + harness.store.updateNote(note.id, { content: 'queued one' }); + harness.store.updateNote(note.id, { content: 'queued two' }); + + assert.equal(harness.store.getNoteById(note.id)?.content, 'queued two'); + await flushStore(harness.store); + + const persisted = await readPersistedNotes(harness.getNotesFilePath()); + assert.equal(persisted[0]?.content, 'queued two'); + assert.equal(harness.metrics.syncWriteFileCount, 0); + assert.ok( + harness.metrics.notesJsonWriteCount <= 1, + `expected flush to persist the latest state once, got ${harness.metrics.notesJsonWriteCount} writes` + ); + } finally { + await harness.cleanup(); + } +}); diff --git a/scripts/test-oauth-callback-queue.mjs b/scripts/test-oauth-callback-queue.mjs new file mode 100644 index 00000000..5113408f --- /dev/null +++ b/scripts/test-oauth-callback-queue.mjs @@ -0,0 +1,141 @@ +#!/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)), '..'); +const bridgePath = path.join(root, 'src/renderer/src/raycast-api/oauth/oauth-bridge.ts'); +let importNonce = 0; + +async function importOAuthBridge() { + const result = await build({ + entryPoints: [bridgePath], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + }); + const code = result.outputFiles[0].text; + const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}#oauth-${importNonce++}`; + return import(dataUrl); +} + +async function loadBridge(t) { + const previousWindow = globalThis.window; + let callback = null; + + globalThis.window = { + electron: { + onOAuthCallback: (handler) => { + callback = handler; + return () => { + callback = null; + }; + }, + }, + }; + + t.after(() => { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); + + const bridge = await importOAuthBridge(); + bridge.ensureOAuthCallbackBridge(); + assert.equal(typeof callback, 'function', 'OAuth callback bridge should register a callback listener'); + + return { + bridge, + emit: (url) => callback(url), + }; +} + +function callbackUrl(state, code = state) { + const url = new URL('supercmd://oauth/callback'); + url.searchParams.set('state', state); + url.searchParams.set('code', code); + return url.toString(); +} + +test('OAuth callback queue caps retained unmatched callbacks', async (t) => { + const { bridge, emit } = await loadBridge(t); + const cap = bridge.OAUTH_CALLBACK_QUEUE_MAX_SIZE; + const total = 1000; + + assert.ok(cap < total, 'test fixture should exceed the configured callback queue cap'); + + for (let i = 0; i < total; i++) { + emit(callbackUrl(`queued-${i}`)); + } + + await assert.rejects( + bridge.waitForOAuthCallback('queued-0', 1), + /OAuth authorization timed out/, + 'old unmatched callbacks should be evicted once the cap is exceeded' + ); + + const firstRetained = total - cap; + const callback = await bridge.waitForOAuthCallback(`queued-${firstRetained}`, 1); + assert.equal(callback.state, `queued-${firstRetained}`); + assert.equal(callback.code, `queued-${firstRetained}`); +}); + +test('OAuth callback queue prunes entries older than the callback timeout window', async (t) => { + let now = 1_000_000; + t.mock.method(Date, 'now', () => now); + + const { bridge, emit } = await loadBridge(t); + emit(callbackUrl('stale')); + + now += bridge.OAUTH_CALLBACK_TIMEOUT_MS + 1; + emit(callbackUrl('fresh')); + + await assert.rejects( + bridge.waitForOAuthCallback('stale', 1), + /OAuth authorization timed out/, + 'callbacks outside the timeout window should be pruned' + ); + + const callback = await bridge.waitForOAuthCallback('fresh', 1); + assert.equal(callback.state, 'fresh'); + assert.equal(callback.code, 'fresh'); +}); + +test('OAuth callback waiter resolves when a matching state arrives', async (t) => { + const { bridge, emit } = await loadBridge(t); + const pending = bridge.waitForOAuthCallback('matching-state', 100); + + emit(callbackUrl('other-state')); + emit(callbackUrl('matching-state', 'matching-code')); + + const callback = await pending; + assert.equal(callback.state, 'matching-state'); + assert.equal(callback.code, 'matching-code'); +}); + +test('OAuth callback bridge ignores malformed and non-OAuth URLs safely', async (t) => { + const { bridge, emit } = await loadBridge(t); + emit(callbackUrl('keep')); + + for (let i = 0; i < bridge.OAUTH_CALLBACK_QUEUE_MAX_SIZE + 25; i++) { + emit(i % 2 === 0 ? `not a url ${i}` : `https://example.com/oauth/callback?state=ignored-${i}`); + } + + const callback = await bridge.waitForOAuthCallback('keep', 1); + assert.equal(callback.state, 'keep'); + assert.equal(callback.code, 'keep'); + + await assert.rejects( + bridge.waitForOAuthCallback('ignored-1', 1), + /OAuth authorization timed out/, + 'non-OAuth URLs should not be retained as callbacks' + ); +}); diff --git a/scripts/test-raycast-cache-size-index.mjs b/scripts/test-raycast-cache-size-index.mjs new file mode 100644 index 00000000..5272ba39 --- /dev/null +++ b/scripts/test-raycast-cache-size-index.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const CACHE_SOURCE_FILE = 'src/renderer/src/raycast-api/index.tsx'; +const ENTRY_COUNT = 120; +const ENTRY_SIZE = 1024; + +function extractCacheSource() { + const source = fs.readFileSync(CACHE_SOURCE_FILE, 'utf8'); + const start = source.indexOf('export namespace Cache'); + const end = source.indexOf('// =====================================================================\n// \u2500\u2500\u2500 AI', start); + assert.notEqual(start, -1, 'Cache namespace marker should exist'); + assert.notEqual(end, -1, 'AI section marker should exist after Cache'); + return source.slice(start, end); +} + +const transpiledCache = ts.transpileModule(extractCacheSource(), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: CACHE_SOURCE_FILE, +}); + +function createInstrumentedLocalStorage() { + const data = new Map(); + const stats = { + getItem: 0, + setItem: 0, + removeItem: 0, + key: 0, + length: 0, + }; + + return { + getItem(key) { + stats.getItem += 1; + const normalizedKey = String(key); + return data.has(normalizedKey) ? data.get(normalizedKey) : null; + }, + setItem(key, value) { + stats.setItem += 1; + data.set(String(key), String(value)); + }, + removeItem(key) { + stats.removeItem += 1; + data.delete(String(key)); + }, + key(index) { + stats.key += 1; + return Array.from(data.keys())[index] ?? null; + }, + get length() { + stats.length += 1; + return data.size; + }, + clear() { + data.clear(); + }, + resetStats() { + stats.getItem = 0; + stats.setItem = 0; + stats.removeItem = 0; + stats.key = 0; + stats.length = 0; + }, + snapshotStats() { + return { ...stats }; + }, + rawValue(key) { + return data.get(String(key)); + }, + }; +} + +function loadCache(storage, sandboxConsole = console) { + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + console: sandboxConsole, + localStorage: storage, + JSON, + Map, + Set, + }; + + vm.runInNewContext(transpiledCache.outputText, sandbox, { filename: CACHE_SOURCE_FILE }); + return module.exports.Cache; +} + +function measure(storage, name, fn) { + storage.resetStats(); + const startedAt = performance.now(); + const result = fn(); + const durationMs = performance.now() - startedAt; + return { + name, + durationMs: Number(durationMs.toFixed(3)), + ...storage.snapshotStats(), + ...result, + }; +} + +function payload(size = ENTRY_SIZE, char = 'x') { + return char.repeat(size); +} + +function cacheStorageKey(namespace) { + return `sc-cache-${namespace}`; +} + +function cacheItemStorageKey(namespace, key) { + return `${cacheStorageKey(namespace)}-item-${key}`; +} + +function readMetadata(storage, namespace) { + return JSON.parse(storage.rawValue(cacheStorageKey(namespace))); +} + +function runBenchmark() { + const setStorage = createInstrumentedLocalStorage(); + const SetCache = loadCache(setStorage); + const setCache = new SetCache({ namespace: 'bench-set', capacity: ENTRY_COUNT * ENTRY_SIZE * 10 }); + const setReport = measure(setStorage, 'set-fill', () => { + for (let index = 0; index < ENTRY_COUNT; index += 1) { + setCache.set(`key-${index}`, payload()); + } + return { entries: ENTRY_COUNT }; + }); + + const getReport = measure(setStorage, 'get-existing', () => { + for (let index = 0; index < ENTRY_COUNT; index += 1) { + assert.equal(setCache.get(`key-${index}`), payload()); + } + return { entries: ENTRY_COUNT }; + }); + + const evictionStorage = createInstrumentedLocalStorage(); + const EvictionCache = loadCache(evictionStorage); + const seedCache = new EvictionCache({ + namespace: 'bench-eviction', + capacity: ENTRY_COUNT * ENTRY_SIZE * 10, + }); + for (let index = 0; index < ENTRY_COUNT; index += 1) { + seedCache.set(`key-${index}`, payload()); + } + + const evictingCache = new EvictionCache({ + namespace: 'bench-eviction', + capacity: Math.floor(ENTRY_COUNT / 2) * ENTRY_SIZE, + }); + const evictionReport = measure(evictionStorage, 'set-with-eviction', () => { + evictingCache.set('new-key', payload()); + return { entries: ENTRY_COUNT, targetCapacityEntries: Math.floor(ENTRY_COUNT / 2) }; + }); + + return [setReport, getReport, evictionReport]; +} + +function printBenchmarkReport(reports) { + console.log('Raycast Cache localStorage benchmark'); + for (const report of reports) { + console.log( + [ + ` ${report.name}`, + `entries=${report.entries}`, + `durationMs=${report.durationMs}`, + `getItem=${report.getItem}`, + `setItem=${report.setItem}`, + `removeItem=${report.removeItem}`, + `key=${report.key}`, + `length=${report.length}`, + ].join(' ') + ); + } +} + +test('Cache stores, removes, clears, and notifies subscribers', () => { + const storage = createInstrumentedLocalStorage(); + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'behavior', capacity: 4096 }); + const notifications = []; + cache.subscribe((key, data) => notifications.push([key, data])); + + cache.set('alpha', 'one'); + assert.equal(cache.get('alpha'), 'one'); + assert.equal(cache.has('alpha'), true); + assert.equal(cache.remove('alpha'), true); + assert.equal(cache.remove('alpha'), false); + assert.equal(cache.has('alpha'), false); + + cache.set('beta', 'two'); + cache.set('gamma', 'three'); + assert.equal(cache.isEmpty, false); + cache.clear(); + assert.equal(cache.isEmpty, true); + assert.equal(cache.has('beta'), false); + assert.equal(cache.has('gamma'), false); + assert.deepEqual(notifications, [ + ['alpha', 'one'], + ['alpha', undefined], + ['beta', 'two'], + ['gamma', 'three'], + [undefined, undefined], + ]); +}); + +test('Cache eviction honors least-recently-used ordering', () => { + const storage = createInstrumentedLocalStorage(); + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'lru', capacity: 9 }); + + cache.set('a', '111'); + cache.set('b', '222'); + cache.set('c', '333'); + assert.equal(cache.get('a'), '111'); + cache.set('d', '444'); + + assert.equal(cache.has('a'), true); + assert.equal(cache.has('b'), false); + assert.equal(cache.has('c'), true); + assert.equal(cache.has('d'), true); +}); + +test('Cache migrates legacy LRU metadata into size metadata', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('legacy'), JSON.stringify({ lruOrder: ['alpha', 'beta'] })); + storage.setItem(cacheItemStorageKey('legacy', 'alpha'), '111'); + storage.setItem(cacheItemStorageKey('legacy', 'beta'), '2222'); + storage.resetStats(); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'legacy', capacity: 4096 }); + const metadata = readMetadata(storage, 'legacy'); + + assert.deepEqual(metadata.lruOrder, ['alpha', 'beta']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3, beta: 4 }); + assert.equal(metadata.totalSize, 7); + assert.equal(cache.get('alpha'), '111'); + assert.equal(cache.get('beta'), '2222'); +}); + +test('Cache recovers corrupt metadata by scanning cache item keys', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('corrupt'), '{not valid json'); + storage.setItem(cacheItemStorageKey('corrupt', 'alpha'), 'aaa'); + storage.setItem(cacheItemStorageKey('corrupt', 'beta'), 'bbbb'); + storage.resetStats(); + + const errors = []; + const Cache = loadCache(storage, { + ...console, + error: (...args) => errors.push(args), + }); + const cache = new Cache({ namespace: 'corrupt', capacity: 4096 }); + const metadata = readMetadata(storage, 'corrupt'); + + assert.equal(errors.length, 1); + assert.deepEqual(metadata.lruOrder, ['alpha', 'beta']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3, beta: 4 }); + assert.equal(metadata.totalSize, 7); + assert.equal(cache.get('alpha'), 'aaa'); + assert.equal(cache.get('beta'), 'bbbb'); +}); + +test('Cache recovers malformed LRU metadata by scanning cache item keys', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('malformed'), JSON.stringify({ lruOrder: [42], sizeByKey: {} })); + storage.setItem(cacheItemStorageKey('malformed', 'alpha'), 'aaa'); + storage.resetStats(); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'malformed', capacity: 4096 }); + const metadata = readMetadata(storage, 'malformed'); + + assert.deepEqual(metadata.lruOrder, ['alpha']); + assert.deepEqual(metadata.sizeByKey, { alpha: 3 }); + assert.equal(metadata.totalSize, 3); + assert.equal(cache.get('alpha'), 'aaa'); +}); + +test('Cache repairs stale size metadata for individual key operations', () => { + const storage = createInstrumentedLocalStorage(); + storage.setItem(cacheStorageKey('stale'), JSON.stringify({ + version: 2, + lruOrder: ['missing', 'external'], + sizeByKey: { missing: 10, external: 1 }, + totalSize: 11, + })); + storage.setItem(cacheItemStorageKey('stale', 'external'), 'actual'); + + const Cache = loadCache(storage); + const cache = new Cache({ namespace: 'stale', capacity: 4096 }); + + assert.equal(cache.has('missing'), false); + assert.equal(cache.has('external'), true); + assert.equal(cache.remove('external'), true); + assert.equal(cache.has('external'), false); + + const metadata = readMetadata(storage, 'stale'); + assert.deepEqual(metadata.lruOrder, []); + assert.deepEqual(metadata.sizeByKey, {}); + assert.equal(metadata.totalSize, 0); +}); + +test('Cache benchmark reports localStorage reads for set, get, and eviction', () => { + const reports = runBenchmark(); + printBenchmarkReport(reports); + + const byName = Object.fromEntries(reports.map((report) => [report.name, report])); + assert.equal(byName['set-fill'].getItem, 0); + assert.equal(byName['get-existing'].getItem, ENTRY_COUNT); + assert.equal(byName['set-with-eviction'].getItem, 0); + assert.ok(byName['set-with-eviction'].removeItem > 0, 'eviction scenario should remove LRU entries'); +}); diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..8911fab1 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') { + return { renderQuickLinkIconGlyph: () => null }; + } + if (request.startsWith('../icons/')) { + return { __esModule: true, default: makeComponent(path.basename(request)) }; + } + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); +const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); + +const { filterCommands, rankCommands } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 6000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 6); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 1); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +const WORDS = [ + 'Notes', + 'Clipboard', + 'Window', + 'Browser', + 'Settings', + 'Canvas', + 'Timer', + 'Schedule', + 'Project', + 'Deploy', + 'Debug', + 'GitHub', + 'Snippet', + 'Search', + 'Memory', + 'Automation', +]; + +function normalizeSearchText(value) { + return String(value || '') + .normalize('NFKD') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +function makeCommands(count) { + const commands = []; + const aliases = {}; + + for (let i = 0; i < count; i += 1) { + const primary = WORDS[i % WORDS.length]; + const secondary = WORDS[(i * 7 + 3) % WORDS.length]; + const group = i % 4 === 0 ? 'extension' : i % 4 === 1 ? 'script' : i % 4 === 2 ? 'app' : 'system'; + const command = { + id: `synthetic-command-${i}`, + title: `${primary} ${secondary} Command ${i}`, + subtitle: `${secondary} tools for Project Alpha workspace ${i % 97}`, + category: group, + path: group === 'extension' ? `extension-${i % 80}/command-${i}` : undefined, + keywords: [ + primary, + secondary, + `cmd ${i}`, + i % 9 === 0 ? 'project alpha' : 'workspace', + i % 11 === 0 ? 'gh github repo' : 'local action', + ], + alwaysOnTop: i % 997 === 0, + }; + commands.push(command); + + if (i % 5 === 0) aliases[command.id] = `${primary.toLowerCase()}-${i}`; + if (i % 37 === 0) aliases[command.id] = 'gh'; + } + + return { commands, aliases }; +} + +function rootCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.08 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.68 })), + ]; +} + +function legacyRankCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.7 })), + ]; +} + +function legacyRootCommandMatches(commands, query, aliases) { + const normalizedQuery = normalizeSearchText(query); + const ranked = commands + .map((command) => { + const alias = aliases[command.id] || ''; + const firstPass = scoreRootSearchFields(query, legacyRankCommandFields(command, alias)); + if (!firstPass.matched) return null; + return { + command, + score: firstPass.matchScore, + hasExactAliasMatch: Boolean(alias) && normalizeSearchText(alias) === normalizedQuery, + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } + if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.command.title.localeCompare(b.command.title); + }); + + return ranked + .map(({ command }) => { + const scored = scoreRootSearchFields(query, rootCommandFields(command, aliases[command.id] || '')); + assert.equal(scored.matched, true, `${command.id} lost its second-pass match for ${query}`); + return { + id: command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function optimizedRootCommandMatches(commands, query, aliases) { + return rankCommands(commands, query, aliases) + .map((entry) => { + if (typeof entry.matchKind === 'string' && typeof entry.matchScore === 'number') { + return { + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + }; + } + + const scored = scoreRootSearchFields(query, rootCommandFields(entry.command, aliases[entry.command.id] || '')); + assert.equal(scored.matched, true, `${entry.command.id} lost its optimized fallback match for ${query}`); + return { + id: entry.command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function signature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + .sort(); +} + +function assertSameRootCommandMatches(commands, aliases) { + for (const query of QUERIES) { + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + signature(legacyRootCommandMatches(commands, query, aliases)), + `root command matches changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +const { commands, aliases } = makeCommands(COMMAND_COUNT); + +assertSameRootCommandMatches(commands, aliases); + +const legacy = measure('legacy filter + two-pass command scoring', (query) => { + const filtered = filterCommands(commands, query, aliases); + const matches = legacyRootCommandMatches(commands, query, aliases); + return filtered.length + matches.length; +}); + +const optimized = measure('optimized command scoring path', (query) => { + const matches = optimizedRootCommandMatches(commands, query, aliases); + return matches.length; +}); + +const speedup = legacy.median > 0 ? legacy.median / optimized.median : 0; + +console.log('Root search perf harness'); +console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); +console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); +console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); +console.log(`speedup=${speedup.toFixed(2)}x`); diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..dcc3a0e4 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +async function withScriptCommandRunner(t, files, { instrumentFs = false } = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.equal(command.interpreter, '/usr/bin/env'); + assert.deepEqual(command.interpreterArgs, ['node', '--no-warnings']); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { + const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }, { instrumentFs: true }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/bash'); + assert.deepEqual(command.interpreterArgs, []); + + resetMetrics(); + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + assert.equal(metrics.readFileSyncBytes + metrics.readSyncBytes, 0); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, undefined); + assert.deepEqual(command.interpreterArgs, []); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + await t.test('discovers metadata in large scripts with bounded prefix reads', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner, metrics } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok( + metrics.readSyncBytes < 512 * 1024, + `expected a bounded prefix read, got ${metrics.readSyncBytes} bytes`, + ); + }); +}); diff --git a/scripts/test-speak-settings-listener.mjs b/scripts/test-speak-settings-listener.mjs new file mode 100644 index 00000000..d67c4011 --- /dev/null +++ b/scripts/test-speak-settings-listener.mjs @@ -0,0 +1,131 @@ +#!/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 hookSource = fs.readFileSync('src/renderer/src/hooks/useSpeakManager.ts', 'utf8'); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Promise, + String, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function makeRecorder(currentVoice = 'en-US-EricNeural') { + const calls = []; + const configuredModels = []; + const configuredEdgeVoices = []; + const appliedOptions = []; + + return { + calls, + configuredModels, + configuredEdgeVoices, + appliedOptions, + options: { + getCurrentVoice: () => currentVoice, + setConfiguredTtsModel: (value) => configuredModels.push(value), + setConfiguredEdgeTtsVoice: (value) => configuredEdgeVoices.push(value), + updateSpeakOptions: async (patch) => { + calls.push(patch); + currentVoice = patch.voice; + return { voice: patch.voice, rate: '+0%' }; + }, + setSpeakOptions: (next) => { + appliedOptions.push(next); + }, + }, + }; +} + +const { + applySpeakSettings, + buildElevenLabsSpeakModel, + parseElevenLabsSpeakModel, + resolveSpeakSettings, +} = loadTsModule('src/renderer/src/utils/speak-settings-sync.ts'); + +function assertJsonEqual(actual, expected) { + assert.equal(JSON.stringify(actual), JSON.stringify(expected)); +} + +test('speak settings sync uses initial load and settings-updated listener, not polling', () => { + assert.match(hookSource, /window\.electron\.getSettings\(\)\.then\(syncFromSettings\)/); + assert.match(hookSource, /window\.electron\.onSettingsUpdated\?\.\(\(settings\) =>/); + assert.match(hookSource, /cleanupSettings\?\.\(\)/); + assert.doesNotMatch(hookSource, /setInterval\(\s*syncFromSettings/); + assert.doesNotMatch(hookSource, /clearInterval\(/); +}); + +test('initial settings load applies configured Edge TTS voice once', async () => { + const recorder = makeRecorder('en-US-EricNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: 'edge-tts', + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assert.deepEqual(recorder.configuredModels, ['edge-tts']); + assert.deepEqual(recorder.configuredEdgeVoices, ['en-US-GuyNeural']); + assertJsonEqual(recorder.calls, [{ voice: 'en-US-GuyNeural', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'en-US-GuyNeural', rate: '+0%' }]); +}); + +test('live settings update switches to ElevenLabs voice without extra idle updates', async () => { + const recorder = makeRecorder('en-US-GuyNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assertJsonEqual(recorder.calls, [{ voice: 'AZnzlk1XvdvUeBnXmlld', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'AZnzlk1XvdvUeBnXmlld', rate: '+0%' }]); +}); + +test('ElevenLabs model parser and resolver preserve model and default voice behavior', () => { + assertJsonEqual(parseElevenLabsSpeakModel('elevenlabs-turbo-v2@pNInz6obpgDQGcFmaJgB'), { + model: 'elevenlabs-turbo-v2', + voiceId: 'pNInz6obpgDQGcFmaJgB', + }); + assert.equal(resolveSpeakSettings({ ai: { textToSpeechModel: 'elevenlabs-multilingual-v2' } }).targetVoice, '21m00Tcm4TlvDq8ikWAM'); +}); diff --git a/scripts/test-use-ai-stream-batching.mjs b/scripts/test-use-ai-stream-batching.mjs new file mode 100644 index 00000000..5d98bae0 --- /dev/null +++ b/scripts/test-use-ai-stream-batching.mjs @@ -0,0 +1,528 @@ +#!/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 { transform } from 'esbuild'; + +const CHUNK_COUNT = 600; + +let activeFrameScheduler = null; + +const testWindow = { + document: { + addEventListener() {}, + removeEventListener() {}, + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {}, remove() {} }), + body: { appendChild() {}, removeChild() {} }, + }, + navigator: { platform: 'test', userAgent: 'node' }, + localStorage: createTestStorage(), + sessionStorage: createTestStorage(), + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload() {} }, + requestAnimationFrame(callback) { + if (activeFrameScheduler) return activeFrameScheduler.requestFrame(callback); + return setTimeout(() => callback(Date.now()), 0); + }, + cancelAnimationFrame(handle) { + if (activeFrameScheduler) { + activeFrameScheduler.cancelFrame(handle); + return; + } + clearTimeout(handle); + }, +}; + +globalThis.window = testWindow; +globalThis.document = testWindow.document; +Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: testWindow.navigator, +}); +globalThis.requestAnimationFrame = (callback) => testWindow.requestAnimationFrame(callback); +globalThis.cancelAnimationFrame = (handle) => testWindow.cancelAnimationFrame(handle); + +const REACT_HOOK_IMPORT_STUB = ` +const runtime = () => { + const current = globalThis.__supercmdReactRuntime; + if (!current) throw new Error('React hook runtime is not installed'); + return current; +}; + +const useState = (initialValue) => runtime().useState(initialValue); +const useRef = (initialValue) => runtime().useRef(initialValue); +const useCallback = (callback, deps) => runtime().useCallback(callback, deps); +const useEffect = (effect, deps) => runtime().useEffect(effect, deps); +`; + +const { + createRaycastAIStreamBatcher, + useAI, +} = await importUseAiModule(path.resolve('src/renderer/src/raycast-api/hooks/use-ai.ts')); + +function createTestStorage() { + const values = new Map(); + return { + getItem(key) { + return values.has(String(key)) ? values.get(String(key)) : null; + }, + setItem(key, value) { + values.set(String(key), String(value)); + }, + removeItem(key) { + values.delete(String(key)); + }, + clear() { + values.clear(); + }, + }; +} + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +async function importUseAiModule(absPath) { + const source = fs.readFileSync(absPath, 'utf8'); + const stubbedSource = source.replace( + "import { useCallback, useEffect, useRef, useState } from 'react';", + REACT_HOOK_IMPORT_STUB + ); + + assert.notEqual(stubbedSource, source, 'expected use-ai.ts React hook import to be stubbed'); + + const { code } = await transform(stubbedSource, { + loader: 'ts', + format: 'esm', + target: 'es2020', + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + return import(dataUrl); +} + +function createManualFrameScheduler() { + let nextFrameId = 1; + const callbacks = new Map(); + + return { + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + }, + cancelFrame(id) { + callbacks.delete(id); + }, + flushFrames() { + const queuedCallbacks = Array.from(callbacks.values()); + callbacks.clear(); + for (const callback of queuedCallbacks) callback(Date.now()); + return queuedCallbacks.length; + }, + pendingFrameCount() { + return callbacks.size; + }, + }; +} + +function createFakeRaycastAI(chunks, options = {}) { + const fullText = options.fullText ?? chunks.join(''); + const requests = []; + + return { + requests, + ask(prompt, askOptions = {}) { + const dataHandlers = []; + const signal = askOptions.signal; + let settled = false; + let resolvePromise; + let rejectPromise; + + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + const request = { + prompt, + askOptions, + promise, + get settled() { + return settled; + }, + get signal() { + return signal; + }, + emitChunks(nextChunks = chunks) { + for (const chunk of nextChunks) { + if (signal?.aborted) return; + for (const handler of dataHandlers) handler(chunk); + } + }, + resolve(text = fullText) { + if (settled) return; + settled = true; + resolvePromise(text); + }, + reject(error) { + if (settled) return; + settled = true; + rejectPromise(error); + }, + complete(nextChunks = chunks, text = fullText) { + this.emitChunks(nextChunks); + if (!signal?.aborted) this.resolve(text); + }, + }; + + signal?.addEventListener('abort', () => { + request.reject(new Error('aborted')); + }, { once: true }); + + requests.push(request); + + const streamPromise = { + on(event, handler) { + if (event === 'data') dataHandlers.push(handler); + return streamPromise; + }, + then(onFulfilled, onRejected) { + return promise.then(onFulfilled, onRejected); + }, + catch(onRejected) { + return promise.catch(onRejected); + }, + finally(onFinally) { + return promise.finally(onFinally); + }, + }; + + if (options.autoComplete) { + queueMicrotask(() => request.complete()); + } + + return streamPromise; + }, + }; +} + +function depsChanged(previousDeps, nextDeps) { + if (!previousDeps || !nextDeps) return true; + if (previousDeps.length !== nextDeps.length) return true; + return previousDeps.some((value, index) => !Object.is(value, nextDeps[index])); +} + +function createHookRunner(renderHook, initialArgs) { + let args = initialArgs; + let hookIndex = 0; + let mounted = false; + let pendingRender = false; + let result; + let renderCount = 0; + let stateUpdateCount = 0; + let dataVisibleUpdateCount = 0; + let hasDataSnapshot = false; + let lastDataSnapshot; + const hooks = []; + + const runtime = { + useState(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) { + hooks[index] = { + value: typeof initialValue === 'function' ? initialValue() : initialValue, + }; + } + + const setState = (nextValueOrUpdater) => { + const currentValue = hooks[index].value; + const nextValue = typeof nextValueOrUpdater === 'function' + ? nextValueOrUpdater(currentValue) + : nextValueOrUpdater; + + if (Object.is(currentValue, nextValue)) return; + + hooks[index].value = nextValue; + stateUpdateCount += 1; + if (mounted) pendingRender = true; + }; + + return [hooks[index].value, setState]; + }, + useRef(initialValue) { + const index = hookIndex; + hookIndex += 1; + + if (!hooks[index]) hooks[index] = { current: initialValue }; + return hooks[index]; + }, + useCallback(callback, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + if (previous && !depsChanged(previous.deps, deps)) { + return previous.callback; + } + + hooks[index] = { callback, deps }; + return callback; + }, + useEffect(effect, deps) { + const index = hookIndex; + hookIndex += 1; + const previous = hooks[index]; + + hooks[index] = { + cleanup: previous?.cleanup, + deps, + effect, + pending: !previous || depsChanged(previous.deps, deps), + }; + }, + }; + + const render = () => { + hookIndex = 0; + globalThis.__supercmdReactRuntime = runtime; + result = renderHook(...args); + renderCount += 1; + + if (result && typeof result === 'object' && 'data' in result) { + if (hasDataSnapshot && result.data !== lastDataSnapshot) { + dataVisibleUpdateCount += 1; + } + hasDataSnapshot = true; + lastDataSnapshot = result.data; + } + + for (const hook of hooks) { + if (!hook?.pending) continue; + hook.pending = false; + if (typeof hook.cleanup === 'function') hook.cleanup(); + const cleanup = hook.effect(); + hook.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } + }; + + const flush = () => { + while (pendingRender) { + pendingRender = false; + render(); + } + }; + + return { + mount() { + mounted = true; + render(); + flush(); + return result; + }, + rerender(nextArgs = args) { + args = nextArgs; + pendingRender = true; + flush(); + return result; + }, + flush, + unmount() { + mounted = false; + for (const hook of hooks) { + if (typeof hook?.cleanup === 'function') { + hook.cleanup(); + hook.cleanup = undefined; + } + } + }, + get result() { + return result; + }, + get renderCount() { + return renderCount; + }, + get stateUpdateCount() { + return stateUpdateCount; + }, + get dataVisibleUpdateCount() { + return dataVisibleUpdateCount; + }, + }; +} + +async function drainMicrotasks(turns = 4) { + for (let index = 0; index < turns; index += 1) { + await Promise.resolve(); + } +} + +test('baseline fake Raycast useAI streaming updates visible state once per chunk', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + let visibleData = ''; + let visibleUpdateCount = 0; + + testWindow.__supercmdRaycastAI = ai; + const stream = testWindow.__supercmdRaycastAI.ask('baseline prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => { + visibleData += chunk; + visibleUpdateCount += 1; + }); + + ai.requests[0].complete(); + const finalData = await ai.requests[0].promise; + + assert.equal(finalData, expectedData); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, CHUNK_COUNT); + console.log(`baseline useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('Raycast useAI stream batcher coalesces many chunks into one visible frame update', () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const ai = createFakeRaycastAI(chunks); + const scheduler = createManualFrameScheduler(); + const dataRef = { current: '' }; + let visibleData = ''; + let visibleUpdateCount = 0; + const batcher = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData(nextData) { + visibleData = nextData; + visibleUpdateCount += 1; + }, + requestFrame: scheduler.requestFrame, + cancelFrame: scheduler.cancelFrame, + }); + + const stream = ai.ask('batched prompt', { + signal: new AbortController().signal, + }); + stream.on('data', (chunk) => batcher.appendChunk(chunk)); + ai.requests[0].emitChunks(); + + assert.equal(dataRef.current, expectedData); + assert.equal(visibleData, ''); + assert.equal(visibleUpdateCount, 0); + assert.equal(scheduler.pendingFrameCount(), 1); + + assert.equal(scheduler.flushFrames(), 1); + assert.equal(visibleData, expectedData); + assert.equal(visibleUpdateCount, 1); + assert.ok(visibleUpdateCount < CHUNK_COUNT / 10); + console.log(`batched useAI visible updates: ${visibleUpdateCount} for ${CHUNK_COUNT} chunks`); +}); + +test('useAI flushes complete final data and preserves final onData behavior', async () => { + const chunks = makeChunks(); + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks, { autoComplete: true }); + const onDataCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'final prompt', + { + stream: true, + onData: (data) => onDataCalls.push(data), + }, + ]); + + runner.mount(); + assert.equal(ai.requests.length, 1); + + await ai.requests[0].promise; + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error, undefined); + assert.deepEqual(onDataCalls, [expectedData]); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(runner.dataVisibleUpdateCount, 1); + assert.ok(runner.renderCount < CHUNK_COUNT / 10); + console.log(`hook useAI renders: ${runner.renderCount}; visible data updates: ${runner.dataVisibleUpdateCount} for ${CHUNK_COUNT} chunks`); + + activeFrameScheduler = null; +}); + +test('useAI flushes pending streamed data before surfacing an error', async () => { + const chunks = ['partial ', 'answer']; + const expectedData = chunks.join(''); + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + const onErrorCalls = []; + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, [ + 'error prompt', + { + stream: true, + onError: (error) => onErrorCalls.push(error), + }, + ]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + assert.equal(scheduler.pendingFrameCount(), 1); + + request.reject(new Error('network failed')); + await request.promise.catch(() => {}); + await drainMicrotasks(); + runner.flush(); + + assert.equal(runner.result.data, expectedData); + assert.equal(runner.result.isLoading, false); + assert.equal(runner.result.error?.message, 'network failed'); + assert.equal(onErrorCalls.length, 1); + assert.equal(onErrorCalls[0].message, 'network failed'); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + + activeFrameScheduler = null; +}); + +test('useAI abort cancels a pending stream flush', async () => { + const chunks = ['stale partial']; + const scheduler = createManualFrameScheduler(); + const ai = createFakeRaycastAI(chunks); + + activeFrameScheduler = scheduler; + testWindow.__supercmdRaycastAI = ai; + const runner = createHookRunner(useAI, ['abort prompt', { stream: true }]); + + runner.mount(); + const request = ai.requests[0]; + request.emitChunks(); + + assert.equal(runner.result.data, ''); + assert.equal(scheduler.pendingFrameCount(), 1); + + runner.unmount(); + await request.promise.catch(() => {}); + await drainMicrotasks(); + + assert.equal(request.signal.aborted, true); + assert.equal(scheduler.pendingFrameCount(), 0); + assert.equal(scheduler.flushFrames(), 0); + assert.equal(runner.result.data, ''); + + activeFrameScheduler = null; +}); diff --git a/scripts/test-use-frecency-sorting.mjs b/scripts/test-use-frecency-sorting.mjs new file mode 100644 index 00000000..1aa2657b --- /dev/null +++ b/scripts/test-use-frecency-sorting.mjs @@ -0,0 +1,244 @@ +#!/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'); + +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(); + }, + }; +} + +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({ initialNow = Date.UTC(2026, 0, 1, 12), localStorage = createLocalStorage() } = {}) { + const source = fs.readFileSync(hookPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: hookPath, + }); + + const dateController = createDateController(initialNow); + const reactMock = createReactMock(); + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require: (request) => { + if (request === 'react') return reactMock.react; + return require(request); + }, + console, + Date: dateController.Date, + JSON, + localStorage, + Math, + Object, + Promise, + String, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: hookPath }); + + return { + dateController, + localStorage, + render(data, options) { + reactMock.reset(); + return module.exports.useFrecencySorting(data, options); + }, + }; +} + +function ids(items) { + return Array.from(items, (item) => item.id); +} + +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-${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-${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-${namespace}`)), { + alpha: { count: 1, lastVisited: now - 4 * 60 * 60 * 1_000 }, + }); +}); diff --git a/scripts/test-with-cache-expiry.mjs b/scripts/test-with-cache-expiry.mjs new file mode 100644 index 00000000..41630993 --- /dev/null +++ b/scripts/test-with-cache-expiry.mjs @@ -0,0 +1,119 @@ +#!/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'); + +function loadPlatformRuntimeWithObservedMaps() { + const filePath = path.resolve('src/renderer/src/raycast-api/platform-runtime.ts'); + 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 observedMaps = []; + class ObservableMap extends Map { + constructor(...args) { + super(...args); + observedMaps.push(this); + } + } + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Error, + JSON, + Map: ObservableMap, + Promise, + window: {}, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return { exports: module.exports, observedMaps }; +} + +test('withCache sweeps expired unique keys when maxAge is configured', async () => { + const originalNow = Date.now; + const uniqueKeyCount = 1_000; + let now = 1_000; + let calls = 0; + + Date.now = () => now; + try { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + const cached = exports.withCache(async (key) => { + calls += 1; + return `value:${key}:${calls}`; + }, { maxAge: 10 }); + + for (let index = 0; index < uniqueKeyCount; index += 1) { + await cached(`expired-${index}`); + now += 11; + } + + await cached('latest'); + + const retainedSize = observedMaps[0]?.size; + console.log(`[withCache metric] retained entries after unique expired inserts: ${retainedSize}`); + assert.equal(retainedSize, 1); + } finally { + Date.now = originalNow; + } +}); + +test('withCache recomputes and replaces an expired hit', async () => { + const originalNow = Date.now; + let now = 1_000; + let calls = 0; + + Date.now = () => now; + try { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + const cached = exports.withCache(async (key) => { + calls += 1; + return { key, calls }; + }, { maxAge: 10 }); + + assert.deepEqual(await cached('same-key'), { key: 'same-key', calls: 1 }); + now += 11; + assert.deepEqual(await cached('same-key'), { key: 'same-key', calls: 2 }); + assert.equal(observedMaps[0]?.size, 1); + } finally { + Date.now = originalNow; + } +}); + +test('withCache retains non-expiring entries until clearCache is called', async () => { + const { exports, observedMaps } = loadPlatformRuntimeWithObservedMaps(); + let calls = 0; + const cached = exports.withCache(async (key) => { + calls += 1; + return `value:${key}:${calls}`; + }); + + assert.equal(await cached('a'), 'value:a:1'); + assert.equal(await cached('b'), 'value:b:2'); + assert.equal(await cached('a'), 'value:a:1'); + assert.equal(observedMaps[0]?.size, 2); + assert.equal(calls, 2); + + cached.clearCache(); + assert.equal(observedMaps[0]?.size, 0); +}); diff --git a/src/main/aerospace-workspace.ts b/src/main/aerospace-workspace.ts new file mode 100644 index 00000000..5aaf1a17 --- /dev/null +++ b/src/main/aerospace-workspace.ts @@ -0,0 +1,146 @@ +import { execFile } from 'child_process'; + +const DEFAULT_AEROSPACE_TIMEOUT_MS = 500; +const DEFAULT_SUPERCMD_BUNDLE_ID = 'com.supercmd.app'; + +export type AerospaceCommandRunner = (args: string[]) => Promise; + +export type AerospaceWorkspaceMoverOptions = { + platform?: NodeJS.Platform; + bundleId?: string; + timeoutMs?: number; + shouldRun?: () => boolean; + runCommand?: AerospaceCommandRunner; +}; + +export type AerospaceWorkspaceMoverState = { + available: boolean | null; + inFlight: boolean; + queued: boolean; +}; + +export type AerospaceWorkspaceMover = { + requestMove: () => void; + whenIdle: () => Promise; + getState: () => AerospaceWorkspaceMoverState; +}; + +function isAerospaceUnavailableError(error: unknown): boolean { + return (error as { code?: unknown } | null)?.code === 'ENOENT'; +} + +export function execAerospaceCommand(args: string[], timeoutMs = DEFAULT_AEROSPACE_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + execFile( + 'aerospace', + args, + { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + timeout: timeoutMs, + }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(String(stdout || '')); + }, + ); + }); +} + +export function createAerospaceWorkspaceMover(options: AerospaceWorkspaceMoverOptions = {}): AerospaceWorkspaceMover { + const platform = options.platform ?? process.platform; + const bundleId = options.bundleId ?? DEFAULT_SUPERCMD_BUNDLE_ID; + const timeoutMs = options.timeoutMs ?? DEFAULT_AEROSPACE_TIMEOUT_MS; + const runCommand = options.runCommand ?? ((args) => execAerospaceCommand(args, timeoutMs)); + + let available: boolean | null = null; + let inFlight = false; + let queued = false; + let inFlightPromise: Promise | null = null; + + function canAttemptMove(): boolean { + if (available === false || platform !== 'darwin') return false; + try { + return options.shouldRun ? options.shouldRun() : true; + } catch { + return false; + } + } + + async function reconcileOnce(): Promise { + if (!canAttemptMove()) return; + + try { + const focusedWs = String(await runCommand(['list-workspaces', '--focused'])).trim(); + if (!focusedWs) return; + available = true; + + const windowsRaw = String( + await runCommand([ + 'list-windows', + '--all', + '--app-bundle-id', + bundleId, + '--format', + '%{window-id} %{workspace}', + ]), + ).trim(); + if (!windowsRaw) return; + + for (const line of windowsRaw.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2) continue; + const [windowId, currentWs] = parts; + if (currentWs === focusedWs) continue; + await runCommand(['move-node-to-workspace', focusedWs, '--window-id', windowId]); + } + } catch (error) { + // ENOENT means the binary is unavailable; skip future attempts. + if (isAerospaceUnavailableError(error)) { + available = false; + } + // Other failures are transient (server not running, timeout, bad output). + } + } + + async function runQueuedReconciliations(): Promise { + try { + do { + queued = false; + await reconcileOnce(); + } while (queued && canAttemptMove()); + } finally { + inFlight = false; + inFlightPromise = null; + } + } + + function requestMove(): void { + if (!canAttemptMove()) return; + if (inFlight) { + queued = true; + return; + } + + inFlight = true; + inFlightPromise = runQueuedReconciliations(); + void inFlightPromise.catch(() => {}); + } + + function whenIdle(): Promise { + return inFlightPromise ?? Promise.resolve(); + } + + function getState(): AerospaceWorkspaceMoverState { + return { available, inFlight, queued }; + } + + return { + requestMove, + whenIdle, + getState, + }; +} diff --git a/src/main/auto-quit-manager.ts b/src/main/auto-quit-manager.ts index d79f98a3..76fbdc30 100644 --- a/src/main/auto-quit-manager.ts +++ b/src/main/auto-quit-manager.ts @@ -37,6 +37,8 @@ const MUSIC_BUNDLE_IDS = new Set([ let pollInterval: ReturnType | null = null; let lastFrontmostAt = new Map(); // bundleId → timestamp let autoQuitApps: AutoQuitAppEntry[] = []; +let trackedBundleIds = new Set(); +let trackedMusicBundleIds = new Set(); let checking = false; /** @@ -134,6 +136,61 @@ async function isMusicPlaying(): Promise { } } +function rebuildTrackedBundleIds(): void { + trackedBundleIds = new Set(); + trackedMusicBundleIds = new Set(); + for (const entry of autoQuitApps) { + trackedBundleIds.add(entry.bundleId); + if (MUSIC_BUNDLE_IDS.has(entry.bundleId)) { + trackedMusicBundleIds.add(entry.bundleId); + } + } +} + +function setAutoQuitApps(apps: AutoQuitAppEntry[]): void { + autoQuitApps = apps; + rebuildTrackedBundleIds(); +} + +function seedMissingLastFrontmostTimestamps(now: number): void { + for (const app of autoQuitApps) { + if (!lastFrontmostAt.has(app.bundleId)) { + lastFrontmostAt.set(app.bundleId, now); + } + } +} + +function getDueAutoQuitCandidates(now: number): AutoQuitAppEntry[] { + const dueCandidates: AutoQuitAppEntry[] = []; + + for (const entry of autoQuitApps) { + if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + + const lastActive = lastFrontmostAt.get(entry.bundleId); + if (lastActive === undefined) { + // First time seeing this app; record now as the inactivity baseline. + lastFrontmostAt.set(entry.bundleId, now); + continue; + } + + const inactiveMs = now - lastActive; + const timeoutMs = entry.timeoutSeconds * 1000; + if (inactiveMs >= timeoutMs) { + dueCandidates.push(entry); + } + } + + return dueCandidates; +} + +function pruneLastFrontmostEntries(frontmostBundleId: string | null): void { + for (const bundleId of lastFrontmostAt.keys()) { + if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { + lastFrontmostAt.delete(bundleId); + } + } +} + /** * Check all tracked apps and quit those that exceeded their timeout */ @@ -142,56 +199,48 @@ async function checkAndQuit(): Promise { if (autoQuitApps.length === 0) return; checking = true; try { - // Pause all auto-quit if system is recording - const recording = await isSystemRecording(); - if (recording) return; + const now = Date.now(); + const dueCandidates = getDueAutoQuitCandidates(now); + if (dueCandidates.length === 0) { + pruneLastFrontmostEntries(null); + return; + } const frontmostBundleId = await getFrontmostBundleId(); if (!frontmostBundleId) return; - // Check if music is playing (protect music apps) - const musicPlaying = await isMusicPlaying(); - - const now = Date.now(); - // Update frontmost timestamp lastFrontmostAt.set(frontmostBundleId, now); - // Check each auto-quit app - for (const entry of autoQuitApps) { - // Skip if this app is currently frontmost - if (entry.bundleId === frontmostBundleId) continue; + const nonFrontmostDueCandidates = dueCandidates.filter( + (entry) => entry.bundleId !== frontmostBundleId + ); + if (nonFrontmostDueCandidates.length === 0) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip protected apps - if (PROTECTED_BUNDLE_IDS.has(entry.bundleId)) continue; + // Pause auto-quit only when a non-frontmost tracked app is actually due. + const recording = await isSystemRecording(); + if (recording) { + pruneLastFrontmostEntries(frontmostBundleId); + return; + } - // Skip music apps if music is playing + const hasDueMusicApp = nonFrontmostDueCandidates.some((entry) => + trackedMusicBundleIds.has(entry.bundleId) + ); + const musicPlaying = hasDueMusicApp ? await isMusicPlaying() : false; + + for (const entry of nonFrontmostDueCandidates) { if (musicPlaying && MUSIC_BUNDLE_IDS.has(entry.bundleId)) continue; - const lastActive = lastFrontmostAt.get(entry.bundleId); - if (lastActive === undefined) { - // First time seeing this app — record now as baseline - lastFrontmostAt.set(entry.bundleId, now); - continue; - } - - const inactiveMs = now - lastActive; - const timeoutMs = entry.timeoutSeconds * 1000; - - if (inactiveMs >= timeoutMs) { - await quitApp(entry.bundleId); - // Remove from tracking so we don't try to quit again - lastFrontmostAt.delete(entry.bundleId); - } + await quitApp(entry.bundleId); + // Remove from tracking so we don't try to quit again + lastFrontmostAt.delete(entry.bundleId); } - // Prune lastFrontmostAt entries for apps no longer tracked or frontmost - const trackedBundleIds = new Set(autoQuitApps.map((e) => e.bundleId)); - for (const bundleId of lastFrontmostAt.keys()) { - if (!trackedBundleIds.has(bundleId) && bundleId !== frontmostBundleId) { - lastFrontmostAt.delete(bundleId); - } - } + pruneLastFrontmostEntries(frontmostBundleId); } finally { checking = false; } @@ -201,18 +250,13 @@ async function checkAndQuit(): Promise { * Start the auto-quit polling loop */ export function startAutoQuit(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; - if (pollInterval) return; // Already running + setAutoQuitApps(apps); if (apps.length === 0) return; // Initialize all tracked apps with current time - const now = Date.now(); - for (const app of apps) { - if (!lastFrontmostAt.has(app.bundleId)) { - lastFrontmostAt.set(app.bundleId, now); - } - } + seedMissingLastFrontmostTimestamps(Date.now()); + if (pollInterval) return; // Already running pollInterval = setInterval(checkAndQuit, 5000); } @@ -230,12 +274,14 @@ export function stopAutoQuit(): void { * Update the app list (restarts polling if needed) */ export function updateAutoQuitApps(apps: AutoQuitAppEntry[]): void { - autoQuitApps = apps; + setAutoQuitApps(apps); if (apps.length === 0) { stopAutoQuit(); lastFrontmostAt.clear(); } else if (!pollInterval) { startAutoQuit(apps); + } else { + seedMissingLastFrontmostTimestamps(Date.now()); } } @@ -251,6 +297,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { } else { autoQuitApps.push(entry); } + rebuildTrackedBundleIds(); // Set baseline timestamp lastFrontmostAt.set(entry.bundleId, Date.now()); if (!pollInterval) { @@ -262,7 +309,7 @@ export function addAutoQuitApp(entry: AutoQuitAppEntry): void { * Remove an app from auto-quit */ export function removeAutoQuitApp(bundleId: string): void { - autoQuitApps = autoQuitApps.filter(a => a.bundleId !== bundleId); + setAutoQuitApps(autoQuitApps.filter(a => a.bundleId !== bundleId)); lastFrontmostAt.delete(bundleId); if (autoQuitApps.length === 0) { stopAutoQuit(); diff --git a/src/main/clipboard-manager.ts b/src/main/clipboard-manager.ts index fa1453ff..97391be8 100644 --- a/src/main/clipboard-manager.ts +++ b/src/main/clipboard-manager.ts @@ -11,6 +11,7 @@ import { app, clipboard, nativeImage } from 'electron'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -87,6 +88,7 @@ export interface ClipboardItem { const MAX_ITEMS = 1000; const POLL_INTERVAL = 1000; // 1 second +const HISTORY_SAVE_DEBOUNCE_MS = 250; const MAX_TEXT_LENGTH = 100_000; // Don't store huge text items const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB max per image const INTERNAL_CLIPBOARD_PROBE_REGEX = /^__supercmd_[a-z0-9_]+_probe__\d+_[a-z0-9]+$/i; @@ -174,6 +176,11 @@ function ensurePinnedOrder(): void { // ─── Persistence ──────────────────────────────────────────────────── +let historySaveTimer: NodeJS.Timeout | null = null; +let historySaveDirty = false; +let historySaveFlushPromise: Promise | null = null; +let historyWriteInFlight: Promise | null = null; + function loadHistory(): void { try { const historyPath = getHistoryFilePath(); @@ -215,13 +222,96 @@ function loadHistory(): void { } } -function saveHistory(): void { +function getHistoryTempFilePath(historyPath: string): string { + return `${historyPath}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`; +} + +async function writeHistoryFileAtomic(serializedHistory: string): Promise { + const historyPath = getHistoryFilePath(); + const tempPath = getHistoryTempFilePath(historyPath); + try { - const historyPath = getHistoryFilePath(); - fs.writeFileSync(historyPath, JSON.stringify(clipboardHistory, null, 2)); + await fsp.mkdir(path.dirname(historyPath), { recursive: true }); + await fsp.writeFile(tempPath, serializedHistory, 'utf-8'); + await fsp.rename(tempPath, historyPath); } catch (e) { - console.error('Failed to save clipboard history:', e); + try { + await fsp.unlink(tempPath); + } catch {} + throw e; + } +} + +function clearHistorySaveTimer(): void { + if (historySaveTimer) { + clearTimeout(historySaveTimer); + historySaveTimer = null; + } +} + +async function drainHistorySaveQueue(): Promise { + clearHistorySaveTimer(); + + if (historyWriteInFlight) { + await historyWriteInFlight; + } + + while (historySaveDirty) { + clearHistorySaveTimer(); + historySaveDirty = false; + const serializedHistory = JSON.stringify(clipboardHistory, null, 2); + const writePromise = writeHistoryFileAtomic(serializedHistory); + historyWriteInFlight = writePromise; + try { + await writePromise; + } catch (e) { + historySaveDirty = true; + throw e; + } finally { + if (historyWriteInFlight === writePromise) { + historyWriteInFlight = null; + } + } + } +} + +export function hasPendingClipboardHistoryWrites(): boolean { + return Boolean(historySaveTimer || historySaveDirty || historyWriteInFlight || historySaveFlushPromise); +} + +export function flushClipboardHistoryWrites(): Promise { + clearHistorySaveTimer(); + + if (!hasPendingClipboardHistoryWrites()) { + return Promise.resolve(); } + + if (!historySaveFlushPromise) { + historySaveFlushPromise = drainHistorySaveQueue() + .catch((e) => { + console.error('Failed to save clipboard history:', e); + }) + .finally(() => { + historySaveFlushPromise = null; + }); + } + + return historySaveFlushPromise; +} + +function saveHistory(options: { flush?: boolean } = {}): void { + historySaveDirty = true; + + if (options.flush) { + void flushClipboardHistoryWrites(); + return; + } + + clearHistorySaveTimer(); + historySaveTimer = setTimeout(() => { + historySaveTimer = null; + void flushClipboardHistoryWrites(); + }, HISTORY_SAVE_DEBOUNCE_MS); } // ─── Clipboard Monitoring ─────────────────────────────────────────── @@ -888,11 +978,12 @@ export function startClipboardMonitor(): void { console.log('Clipboard monitor started'); } -export function stopClipboardMonitor(): void { +export async function stopClipboardMonitor(): Promise { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + await flushClipboardHistoryWrites(); console.log('Clipboard monitor stopped'); } @@ -929,13 +1020,13 @@ export function pruneClipboardHistoryOlderThan(retentionDays: number | null | un const removed = before - kept.length; if (removed > 0) { clipboardHistory = kept; - saveHistory(); + saveHistory({ flush: true }); console.log(`Pruned ${removed} clipboard item${removed === 1 ? '' : 's'} older than ${days} day${days === 1 ? '' : 's'}`); } return removed; } -export function clearClipboardHistory(): void { +export async function clearClipboardHistory(): Promise { // Delete all image files for (const item of clipboardHistory) { if (item.type === 'image' && fs.existsSync(item.content)) { @@ -946,11 +1037,12 @@ export function clearClipboardHistory(): void { } clipboardHistory = []; - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); console.log('Clipboard history cleared'); } -export function deleteClipboardItem(id: string): boolean { +export async function deleteClipboardItem(id: string): Promise { const index = clipboardHistory.findIndex((item) => item.id === id); if (index === -1) return false; @@ -964,7 +1056,8 @@ export function deleteClipboardItem(id: string): boolean { } clipboardHistory.splice(index, 1); - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); return true; } @@ -1132,7 +1225,7 @@ export function setClipboardMonitorEnabled(enabled: boolean): void { if (enabled && !pollInterval) { startClipboardMonitor(); } else if (!enabled && pollInterval) { - stopClipboardMonitor(); + void stopClipboardMonitor(); } } diff --git a/src/main/exec-command.ts b/src/main/exec-command.ts new file mode 100644 index 00000000..9475f9f5 --- /dev/null +++ b/src/main/exec-command.ts @@ -0,0 +1,157 @@ +import * as fs from 'fs'; +import { execFileSync as defaultExecFileSync, spawn as defaultSpawn } from 'child_process'; + +export type ExecCommandOptions = { + shell?: boolean | string; + input?: string; + env?: Record; + cwd?: string; +}; + +export type ExecCommandResult = { + stdout: string; + stderr: string; + exitCode: number; +}; + +type ExecCommandProcess = { + stdout?: { + on(event: 'data', listener: (data: Buffer) => void): void; + }; + stderr?: { + on(event: 'data', listener: (data: Buffer) => void): void; + }; + stdin?: { + write(input: string): void; + end(): void; + }; + on(event: 'close', listener: (code: number | null) => void): void; + on(event: 'error', listener: (err: Error) => void): void; + kill(): unknown; +}; + +type ExecFileSyncLike = ( + file: string, + args?: readonly string[], + options?: Record +) => string | Buffer; + +type ExecCommandDependencies = { + spawn?: (command: string, args: string[], options: Record) => ExecCommandProcess; + execFileSync?: ExecFileSyncLike; + existsSync?: (path: fs.PathLike) => boolean; + env?: NodeJS.ProcessEnv; + cwd?: () => string; + setTimeout?: typeof setTimeout; + clearTimeout?: typeof clearTimeout; +}; + +const EXEC_COMMAND_TIMEOUT_MS = 300000; + +function resolveExecutablePath(input: string, dependencies: Required>): string { + if (!input || typeof input !== 'string') return input; + if (!input.includes('/') && !input.includes('\\')) return input; + if (!input.startsWith('/')) return input; + if (dependencies.existsSync(input)) return input; + try { + const base = input.split('/').filter(Boolean).pop() || ''; + if (!base) return input; + const lookup = String( + dependencies.execFileSync( + '/bin/zsh', + ['-lc', `command -v -- ${JSON.stringify(base)} 2>/dev/null || true`], + { encoding: 'utf-8' } + ) + ).trim(); + if (lookup && dependencies.existsSync(lookup)) return lookup; + } catch {} + return input; +} + +export function runExecCommand( + command: string, + args: string[], + options?: ExecCommandOptions, + dependencies: ExecCommandDependencies = {} +): Promise { + const spawn = dependencies.spawn ?? defaultSpawn; + const execFileSync = dependencies.execFileSync ?? defaultExecFileSync; + const existsSync = dependencies.existsSync ?? fs.existsSync.bind(fs); + const env = dependencies.env ?? process.env; + const cwd = dependencies.cwd ?? process.cwd.bind(process); + const scheduleTimeout = dependencies.setTimeout ?? setTimeout; + const cancelTimeout = dependencies.clearTimeout ?? clearTimeout; + + return new Promise((resolve) => { + try { + const normalizedCommand = resolveExecutablePath(command, { execFileSync, existsSync }); + // Augment PATH so extensions can find brew, npm, nvm, etc. even when + // the app is launched from the Dock (where macOS strips the login PATH). + const extraPaths = [ + '/opt/homebrew/bin', '/opt/homebrew/sbin', + '/usr/local/bin', '/usr/local/sbin', + '/usr/bin', '/usr/sbin', '/bin', '/sbin', + ]; + const currentPath = (options?.env?.PATH ?? env.PATH ?? ''); + const augmentedPath = [ + ...extraPaths, + ...currentPath.split(':').filter(Boolean), + ].filter((v, i, a) => a.indexOf(v) === i).join(':'); + const spawnOptions: Record = { + shell: options?.shell ?? false, + env: { ...env, ...options?.env, PATH: augmentedPath }, + cwd: options?.cwd || cwd(), + }; + + const proc = options?.shell + ? spawn([normalizedCommand, ...args].join(' '), [], { ...spawnOptions, shell: true }) + : spawn(normalizedCommand, args, spawnOptions); + + let stdout = ''; + let stderr = ''; + let settled = false; + let timeout: ReturnType | null = null; + + const finish = (result: ExecCommandResult) => { + if (settled) return; + settled = true; + if (timeout) { + cancelTimeout(timeout); + timeout = null; + } + resolve(result); + }; + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + if (options?.input && proc.stdin) { + proc.stdin.write(options.input); + proc.stdin.end(); + } + + proc.on('close', (code: number | null) => { + finish({ stdout, stderr, exitCode: code ?? 0 }); + }); + + proc.on('error', (err: Error) => { + finish({ stdout, stderr: err.message, exitCode: 1 }); + }); + + // Timeout after 5 minutes - allows long-running commands (brew install, npm install, etc.) + timeout = scheduleTimeout(() => { + try { + proc.kill(); + } catch {} + finish({ stdout, stderr: stderr || 'Command timed out', exitCode: 124 }); + }, EXEC_COMMAND_TIMEOUT_MS); + } catch (e: any) { + resolve({ stdout: '', stderr: e?.message || 'Failed to execute command', exitCode: 1 }); + } + }); +} diff --git a/src/main/extension-registry.ts b/src/main/extension-registry.ts index 21cfc19a..43261b24 100644 --- a/src/main/extension-registry.ts +++ b/src/main/extension-registry.ts @@ -36,6 +36,13 @@ import { installDepsWithBun } from './bun-manager'; const execAsync = promisify(exec); +function invalidateExtensionRunnerCaches(): void { + try { + const runner = require('./extension-runner'); + runner.invalidateExtensionRunnerCaches?.(); + } catch {} +} + function shellQuoteSingle(value: string): string { return `'${String(value || '').replace(/'/g, `'\\''`)}'`; } @@ -1137,6 +1144,7 @@ async function installExtensionFromBundle( // Copy to extensions directory fs.cpSync(srcDir, installPath, { recursive: true }); + invalidateExtensionRunnerCaches(); // Cleanup backup if (backupPath && fs.existsSync(backupPath)) { @@ -1244,6 +1252,7 @@ async function installExtensionViaAPI(name: string): Promise { const { buildAllCommands } = require('./extension-runner'); const builtCount = await buildAllCommands(name); console.log(` Build: ${Date.now() - t1}ms. Extension "${name}" installed (${builtCount} commands) in ${Date.now() - t0}ms total`); + invalidateExtensionRunnerCaches(); } // Cleanup backup @@ -1355,6 +1364,7 @@ async function installExtensionViaGit(name: string): Promise { const { buildAllCommands } = require('./extension-runner'); const builtCount = await buildAllCommands(name); console.log(`Extension "${name}" installed (${builtCount} commands) at ${installPath}`); + invalidateExtensionRunnerCaches(); if (backupPath && fs.existsSync(backupPath)) { fs.rmSync(backupPath, { recursive: true, force: true }); } @@ -1535,6 +1545,7 @@ export async function uninstallExtension(name: string): Promise { try { fs.rmSync(installPath, { recursive: true, force: true }); + invalidateExtensionRunnerCaches(); console.log(`Extension "${name}" uninstalled.`); // Report uninstall to backend (fire-and-forget) diff --git a/src/main/extension-runner.ts b/src/main/extension-runner.ts index 034462e5..2a275382 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -111,6 +111,43 @@ interface InstalledExtensionSource { sourceRoot: string; } +interface FsPathSignature { + exists: boolean; + isFile: boolean; + isDirectory: boolean; + size: number; + mtimeMs: number; +} + +interface InstalledExtensionSourceSignature { + extName: string; + extPath: string; + sourceRoot: string; + extPathSignature: FsPathSignature; + packageJsonSignature: FsPathSignature; +} + +interface InstalledExtensionsSnapshot { + roots: string[]; + rootSignatures: FsPathSignature[]; + sources: InstalledExtensionSource[]; + sourceSignatures: InstalledExtensionSourceSignature[]; +} + +interface CachedManifest { + signature: FsPathSignature; + value: any; +} + +interface CachedTextFile { + signature: FsPathSignature; + value: string; +} + +let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null; +const _extensionManifestCache = new Map(); +const _extensionBundleCodeCache = new Map(); + function getManagedExtensionsDir(): string { const dir = path.join(app.getPath('userData'), 'extensions'); if (!fs.existsSync(dir)) { @@ -144,6 +181,91 @@ function normalizeExtensionName(name: string): string { return raw.replace(/^@/, '').replace(/[\\/]/g, '-'); } +function getPathSignature(filePath: string): FsPathSignature { + try { + const stat = fs.statSync(filePath); + return { + exists: true, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { + exists: false, + isFile: false, + isDirectory: false, + size: -1, + mtimeMs: -1, + }; + } +} + +function samePathSignature(a: FsPathSignature, b: FsPathSignature): boolean { + return ( + a.exists === b.exists && + a.isFile === b.isFile && + a.isDirectory === b.isDirectory && + a.size === b.size && + a.mtimeMs === b.mtimeMs + ); +} + +function sameRootSnapshot( + roots: string[], + rootSignatures: FsPathSignature[], + snapshot: InstalledExtensionsSnapshot +): boolean { + if (roots.length !== snapshot.roots.length) return false; + if (rootSignatures.length !== snapshot.rootSignatures.length) return false; + for (let i = 0; i < roots.length; i++) { + if (roots[i] !== snapshot.roots[i]) return false; + if (!samePathSignature(rootSignatures[i], snapshot.rootSignatures[i])) return false; + } + return true; +} + +function getInstalledExtensionSourceSignature(source: InstalledExtensionSource): InstalledExtensionSourceSignature { + return { + extName: source.extName, + extPath: source.extPath, + sourceRoot: source.sourceRoot, + extPathSignature: getPathSignature(source.extPath), + packageJsonSignature: getPathSignature(path.join(source.extPath, 'package.json')), + }; +} + +function sameInstalledExtensionSourceSignature( + a: InstalledExtensionSourceSignature, + b: InstalledExtensionSourceSignature +): boolean { + return ( + a.extName === b.extName && + a.extPath === b.extPath && + a.sourceRoot === b.sourceRoot && + samePathSignature(a.extPathSignature, b.extPathSignature) && + samePathSignature(a.packageJsonSignature, b.packageJsonSignature) + ); +} + +function getInstalledExtensionSourceSignatures( + sources: InstalledExtensionSource[] +): InstalledExtensionSourceSignature[] { + return sources.map((source) => getInstalledExtensionSourceSignature(source)); +} + +function sameInstalledExtensionSourceSignatures( + a: InstalledExtensionSourceSignature[], + b: InstalledExtensionSourceSignature[] +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!sameInstalledExtensionSourceSignature(a[i], b[i])) return false; + } + return true; +} + function getConfiguredExtensionRoots(): string[] { const settingsPaths = Array.isArray(loadSettings().customExtensionFolders) ? loadSettings().customExtensionFolders @@ -162,7 +284,7 @@ function getConfiguredExtensionRoots(): string[] { return [...unique]; } -function collectInstalledExtensions(): InstalledExtensionSource[] { +function scanInstalledExtensions(roots: string[]): InstalledExtensionSource[] { const results: InstalledExtensionSource[] = []; const seen = new Set(); @@ -183,7 +305,7 @@ function collectInstalledExtensions(): InstalledExtensionSource[] { results.push({ extName, extPath, sourceRoot }); }; - for (const sourceRoot of getConfiguredExtensionRoots()) { + for (const sourceRoot of roots) { if (!fs.existsSync(sourceRoot)) continue; const sourceRootPkg = path.join(sourceRoot, 'package.json'); @@ -206,6 +328,27 @@ function collectInstalledExtensions(): InstalledExtensionSource[] { return results; } +function collectInstalledExtensions(): InstalledExtensionSource[] { + const roots = getConfiguredExtensionRoots(); + const rootSignatures = roots.map((root) => getPathSignature(root)); + + if (_installedExtensionsSnapshot && sameRootSnapshot(roots, rootSignatures, _installedExtensionsSnapshot)) { + const currentSourceSignatures = getInstalledExtensionSourceSignatures(_installedExtensionsSnapshot.sources); + if (sameInstalledExtensionSourceSignatures(currentSourceSignatures, _installedExtensionsSnapshot.sourceSignatures)) { + return _installedExtensionsSnapshot.sources; + } + } + + const sources = scanInstalledExtensions(roots); + _installedExtensionsSnapshot = { + roots, + rootSignatures, + sources, + sourceSignatures: getInstalledExtensionSourceSignatures(sources), + }; + return sources; +} + function resolveInstalledExtensionPath(extName: string): string | null { const normalized = normalizeExtensionName(extName); if (!normalized) return null; @@ -213,11 +356,60 @@ function resolveInstalledExtensionPath(extName: string): string | null { return match?.extPath || null; } +function readExtensionManifest(extPath: string): any | null { + const pkgPath = path.join(extPath, 'package.json'); + const signature = getPathSignature(pkgPath); + if (!signature.exists || !signature.isFile) return null; + + const cached = _extensionManifestCache.get(pkgPath); + if (cached && samePathSignature(cached.signature, signature)) { + return cached.value; + } + + const value = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + _extensionManifestCache.set(pkgPath, { signature, value }); + return value; +} + +function readExtensionBundleCode(outFile: string): string { + const signature = getPathSignature(outFile); + if (!signature.exists || !signature.isFile) return ''; + + const cached = _extensionBundleCodeCache.get(outFile); + if (cached && samePathSignature(cached.signature, signature)) { + return cached.value; + } + + const value = fs.readFileSync(outFile, 'utf-8'); + _extensionBundleCodeCache.set(outFile, { signature, value }); + return value; +} + +function invalidateExtensionStaticCacheForPath(extPath: string): void { + const normalizedExtPath = path.resolve(extPath); + _installedExtensionsSnapshot = null; + _extensionManifestCache.delete(path.join(normalizedExtPath, 'package.json')); + + const buildDir = path.join(normalizedExtPath, '.sc-build') + path.sep; + for (const bundlePath of _extensionBundleCodeCache.keys()) { + if (bundlePath.startsWith(buildDir)) { + _extensionBundleCodeCache.delete(bundlePath); + } + } +} + +export function invalidateExtensionRunnerCaches(): void { + _installedExtensionsSnapshot = null; + _extensionManifestCache.clear(); + _extensionBundleCodeCache.clear(); + _extensionIconCache.clear(); +} + // ─── Icon extraction ──────────────────────────────────────────────── // Session-level cache: absolute icon path → stable data URL string. // Prevents re-reading and re-encoding the same icon file on every getCommands() call. -const _extensionIconCache = new Map(); +const _extensionIconCache = new Map(); function resizeIconWithSips(inputPath: string): Buffer | null { const tmp = path.join(os.tmpdir(), `sc-icon-${Date.now()}-${Math.random().toString(36).slice(2)}.png`); @@ -241,10 +433,13 @@ function getExtensionIconDataUrl( ]; for (const p of candidates) { - if (!fs.existsSync(p)) continue; + const signature = getPathSignature(p); + if (!signature.exists || !signature.isFile) continue; const cached = _extensionIconCache.get(p); - if (cached !== undefined) return cached; + if (cached !== undefined && samePathSignature(cached.signature, signature)) { + return cached.value; + } try { const ext = path.extname(p).toLowerCase(); @@ -260,7 +455,7 @@ function getExtensionIconDataUrl( result = `data:image/png;base64,${finalData.toString('base64')}`; } - _extensionIconCache.set(p, result); + _extensionIconCache.set(p, { signature, value: result }); return result; } catch {} } @@ -310,11 +505,11 @@ export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] { const results: ExtensionCommandInfo[] = []; for (const source of collectInstalledExtensions()) { const extPath = source.extPath; - const pkgPath = path.join(extPath, 'package.json'); const extName = source.extName; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) continue; if (!isManifestPlatformCompatible(pkg)) continue; const iconDataUrl = getExtensionIconDataUrl( extPath, @@ -375,11 +570,11 @@ export function getInstalledExtensionsSettingsSchema(): InstalledExtensionSettin const results: InstalledExtensionSettingsSchema[] = []; for (const source of collectInstalledExtensions()) { const extPath = source.extPath; - const pkgPath = path.join(extPath, 'package.json'); const extName = source.extName; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) continue; if (!isManifestPlatformCompatible(pkg)) continue; const iconDataUrl = getExtensionIconDataUrl(extPath, pkg.icon || 'icon.png'); const ownerRaw = pkg.owner || pkg.author || ''; @@ -660,11 +855,14 @@ export async function buildAllCommands(extName: string, extPathOverride?: string return 0; } + invalidateExtensionStaticCacheForPath(extPath); + let commands: any[]; let requiresNodeModules = false; let manifestExternal: string[] = []; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) return 0; if (!isManifestPlatformCompatible(pkg)) { console.warn(`Skipping build for incompatible extension ${extName}`); return 0; @@ -954,11 +1152,14 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom return false; } + invalidateExtensionStaticCacheForPath(extPath); + let cmd: any; let requiresNodeModules = false; let manifestExternal: string[] = []; try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) return false; if (!isManifestPlatformCompatible(pkg)) { console.error(`buildSingleCommand: platform not compatible for ${extName}`); return false; @@ -1172,8 +1373,7 @@ export async function getExtensionBundle( if (!fs.existsSync(outFile)) { let entryMissing = false; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); const cmd = (Array.isArray(pkg?.commands) ? pkg.commands : []).find((c: any) => c?.name === cmdName); if (cmd && !resolveEntryFile(extPath, cmd)) entryMissing = true; } catch {} @@ -1203,8 +1403,7 @@ export async function getExtensionBundle( if (!fs.existsSync(outFile)) { let diagnostic = ''; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); const commands = Array.isArray(pkg?.commands) ? pkg.commands : []; const cmd = commands.find((c: any) => c?.name === cmdName); const nodeModulesExists = fs.existsSync(path.join(extPath, 'node_modules')); @@ -1230,7 +1429,7 @@ export async function getExtensionBundle( } } - const code = fs.readFileSync(outFile, 'utf-8'); + const code = readExtensionBundleCode(outFile); if (!code) { const msg = `Pre-built bundle is empty: ${outFile}`; console.error(msg); @@ -1266,8 +1465,8 @@ export async function getExtensionBundle( }> = []; try { - const pkgPath = path.join(extPath, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const pkg = readExtensionManifest(extPath); + if (!pkg) return null; if (!isManifestPlatformCompatible(pkg)) { return null; } diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..7f5b0c20 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -41,6 +41,7 @@ type IndexedEntry = { parentPath: string; normalizedName: string; normalizedPath: string; + normalizedTildePath: string; compactName: string; tokens: string[]; pathTokens: string[]; @@ -63,6 +64,7 @@ const MAX_QUERY_RESULTS = 5_000; const MAX_FILE_METADATA_STAT_RESULTS = 240; const MIN_REBUILD_GAP_MS = 45_000; const DEFAULT_REFRESH_INTERVAL_MS = 8 * 60_000; +const SAFETY_REBUILD_INTERVAL_MS = 6 * 60 * 60_000; const WATCH_EVENT_DEBOUNCE_MS = 500; const MAX_SPOTLIGHT_CANDIDATES = 10_000; const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400; @@ -132,6 +134,7 @@ const PROTECTED_TOP_LEVEL_SET = new Set( FILE_SEARCH_INDEX_PROTECTED_HOME_TOP_LEVEL_DIRECTORIES.map((name) => name.toLowerCase()) ); const EXCLUDED_FILE_EXTENSIONS = new Set(['.tmp', '.temp', '.log', '.cache', '.crdownload', '.download']); +const PATH_CANDIDATE_TERM_REGEX = /[a-z0-9]/; let activeIndex: IndexSnapshot | null = null; let rebuildPromise: Promise | null = null; @@ -143,10 +146,12 @@ let includeProtectedHomeRoots = false; let indexing = false; let lastIndexError: string | null = null; let lastBuildStartedAt = 0; +let lastSuccessfulFullRebuildAt = 0; let activeWatcher: fs.FSWatcher | null = null; let pendingWatchEvents: Set = new Set(); let watchDebounceTimer: NodeJS.Timeout | null = null; let watchedHomeDir = ''; +let lastWatcherError: string | null = null; type DirectoryQueueEntry = { scanPath: string; @@ -263,12 +268,13 @@ function addPrefixIndexValue(prefixToEntryIds: Map, key: strin function indexEntry( snapshot: IndexSnapshot, - entry: Omit + entry: Omit ): void { const normalizedName = normalizeSearchText(entry.name); if (!normalizedName) return; const normalizedPath = normalizePathSearchText(entry.path); if (!normalizedPath) return; + const normalizedTildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); const existingId = snapshot.pathToEntryId.get(entry.path); if (existingId !== undefined) { @@ -292,6 +298,7 @@ function indexEntry( ...entry, normalizedName, normalizedPath, + normalizedTildePath, compactName, tokens, pathTokens, @@ -589,6 +596,62 @@ function resolveCandidateIds(snapshot: IndexSnapshot, terms: string[]): number[] return intersectCandidates(indexedLists); } +function getPathLikeBoundaryTerms(needle: string): string[] { + const normalizedNeedle = normalizePathSearchText(needle); + if (!normalizedNeedle) return []; + + const terms: string[] = []; + let termStart = -1; + + const addTerm = (endIndex: number) => { + if (termStart <= 0) return; + const previousChar = normalizedNeedle[termStart - 1] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(previousChar)) return; + const term = normalizedNeedle.slice(termStart, endIndex); + if (term.length >= 2) terms.push(term); + }; + + for (let index = 0; index <= normalizedNeedle.length; index += 1) { + const char = normalizedNeedle[index] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(char)) { + if (termStart < 0) termStart = index; + continue; + } + + if (termStart >= 0) { + addTerm(index); + termStart = -1; + } + } + + return [...new Set(terms)]; +} + +function resolvePathLikeCandidateIds( + snapshot: IndexSnapshot, + rawNeedle: string, + expandedNeedle: string, + trimmedQuery: string +): number[] | null { + const isHomeTildeQuery = trimmedQuery === '~' || trimmedQuery.startsWith('~/') || trimmedQuery.startsWith('~\\'); + const terms = getPathLikeBoundaryTerms(rawNeedle); + if (terms.length === 0 && isHomeTildeQuery) { + terms.push(...getPathLikeBoundaryTerms(expandedNeedle)); + } + if (terms.length === 0) return null; + + let smallestBucket: number[] | null = null; + for (const term of terms) { + const key = term.slice(0, Math.min(MAX_PREFIX_LENGTH, term.length)); + const matches = snapshot.prefixToEntryIds.get(key); + if (!matches || matches.length === 0) return []; + if (!smallestBucket || matches.length < smallestBucket.length) { + smallestBucket = matches; + } + } + return smallestBucket ? [...smallestBucket] : null; +} + function resolveHomeDir(inputHomeDir?: string): string { const candidate = String(inputHomeDir || '').trim(); if (candidate) return path.resolve(candidate); @@ -632,7 +695,8 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { if (rebuildPromise) return rebuildPromise; const now = Date.now(); - if (now - lastBuildStartedAt < MIN_REBUILD_GAP_MS) return; + const bypassRebuildThrottle = reason === 'startup' || reason === 'watcher-error'; + if (!bypassRebuildThrottle && now - lastBuildStartedAt < MIN_REBUILD_GAP_MS) return; lastBuildStartedAt = now; rebuildPromise = (async () => { @@ -640,6 +704,7 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { try { const snapshot = await buildIndexSnapshot(configuredHomeDir); activeIndex = snapshot; + lastSuccessfulFullRebuildAt = snapshot.builtAt; lastIndexError = null; if (reason) { console.log( @@ -660,9 +725,32 @@ export async function rebuildFileSearchIndex(reason = 'manual'): Promise { export function requestFileSearchIndexRefresh(reason = 'manual'): void { if (rebuildPromise) return; + if (reason === 'interval' && canUseIncrementalIntervalRefresh()) { + flushPendingWatchEventsNow(); + return; + } void rebuildFileSearchIndex(reason); } +function canUseIncrementalIntervalRefresh(): boolean { + if (!activeIndex) return false; + if (!isFileSearchWatcherHealthy()) return false; + if (!lastSuccessfulFullRebuildAt) return false; + return Date.now() - lastSuccessfulFullRebuildAt < SAFETY_REBUILD_INTERVAL_MS; +} + +function isFileSearchWatcherHealthy(): boolean { + return Boolean(activeWatcher && watchedHomeDir === configuredHomeDir && !lastWatcherError); +} + +function flushPendingWatchEventsNow(): void { + if (watchDebounceTimer) { + clearTimeout(watchDebounceTimer); + watchDebounceTimer = null; + } + flushWatchEvents(); +} + function isWatchablePath(absolutePath: string): boolean { if (!configuredHomeDir) return false; if (!isPathWithinRoot(absolutePath, configuredHomeDir)) return false; @@ -692,7 +780,7 @@ function startFileSearchWatcher(): void { if (!configuredHomeDir) return; try { - activeWatcher = fs.watch( + const watcher = fs.watch( configuredHomeDir, { recursive: true, persistent: false }, (_eventType, filename) => { @@ -705,13 +793,27 @@ function startFileSearchWatcher(): void { } } ); + activeWatcher = watcher; watchedHomeDir = configuredHomeDir; - activeWatcher.on('error', (error) => { + lastWatcherError = null; + watcher.on('error', (error) => { console.warn('[FileIndex] watcher error:', error); + lastWatcherError = error instanceof Error ? error.message : String(error || 'Unknown watcher error'); + try { + watcher.close(); + } catch { + // ignore + } + if (activeWatcher === watcher) { + activeWatcher = null; + watchedHomeDir = ''; + } + requestFileSearchIndexRefresh('watcher-error'); }); console.log(`[FileIndex] watcher started on ${configuredHomeDir}`); } catch (error) { console.warn('[FileIndex] failed to start watcher:', error); + lastWatcherError = error instanceof Error ? error.message : String(error || 'Unknown watcher error'); activeWatcher = null; watchedHomeDir = ''; } @@ -796,13 +898,75 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } +function hasDeletedPathAncestor(candidatePath: string, deletedPathSet: Set): boolean { + let currentPath = path.dirname(candidatePath); + while (currentPath && currentPath !== candidatePath) { + if (deletedPathSet.has(currentPath)) return true; + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; + } + return false; +} + +function normalizeDeletedPath(candidatePath: string): string | null { + const rawPath = String(candidatePath || ''); + if (!rawPath) return null; + return path.resolve(rawPath); +} + +function collapseNestedDeletedPaths(deletePaths: string[]): string[] { + const uniquePaths = new Set(); + for (const deletePath of deletePaths) { + const normalizedPath = normalizeDeletedPath(deletePath); + if (normalizedPath) uniquePaths.add(normalizedPath); + } + + const sortedPaths = [...uniquePaths].sort((a, b) => { + if (a.length !== b.length) return a.length - b.length; + return a.localeCompare(b); + }); + const collapsedPaths: string[] = []; + const collapsedPathSet = new Set(); + + for (const deletePath of sortedPaths) { + if (hasDeletedPathAncestor(deletePath, collapsedPathSet)) { + continue; + } + collapsedPaths.push(deletePath); + collapsedPathSet.add(deletePath); + } + + return collapsedPaths; +} + +function getDescendantPathPrefix(rootPath: string): string { + return rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; +} + function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + const directIds = new Set(); - for (const deletedPath of deletePaths) { + for (const deletedPath of collapsedDeletePaths) { const id = snapshot.pathToEntryId.get(deletedPath); if (id !== undefined) directIds.add(id); } - const prefixes = deletePaths.map((p) => p + path.sep); + + if (collapsedDeletePaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(collapsedDeletePaths[0]); + for (let i = 0; i < snapshot.entries.length; i += 1) { + const entry = snapshot.entries[i]; + if (entry.deleted) continue; + if (directIds.has(i) || entry.path.startsWith(descendantPrefix)) { + entry.deleted = true; + } + } + return; + } + + const deletedPathSet = new Set(collapsedDeletePaths); for (let i = 0; i < snapshot.entries.length; i += 1) { const entry = snapshot.entries[i]; @@ -811,11 +975,8 @@ function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): entry.deleted = true; continue; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { - entry.deleted = true; - break; - } + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + entry.deleted = true; } } } @@ -882,6 +1043,60 @@ export function stopFileSearchIndexing(): void { stopFileSearchWatcher(); } +function resetFileSearchIndexForPerfHarness(options?: { + homeDir?: string; + includeProtectedHomeRoots?: boolean; +}): void { + stopFileSearchIndexing(); + activeIndex = null; + rebuildPromise = null; + configuredHomeDir = ''; + includeRoots = []; + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + includeProtectedHomeRoots = Boolean(options?.includeProtectedHomeRoots); + indexing = false; + lastIndexError = null; + lastBuildStartedAt = 0; + pendingWatchEvents.clear(); + if (options?.homeDir) { + ensureConfigured(options.homeDir); + } +} + +async function rebuildFileSearchIndexForPerfHarness(options: { + homeDir: string; + includeProtectedHomeRoots?: boolean; +}): Promise { + resetFileSearchIndexForPerfHarness(options); + if (includeRoots.length === 0) return; + + indexing = true; + try { + activeIndex = await buildIndexSnapshot(configuredHomeDir); + lastIndexError = null; + } catch (error) { + lastIndexError = error instanceof Error ? error.message : String(error || 'Unknown indexing error'); + throw error; + } finally { + indexing = false; + rebuildPromise = null; + lastBuildStartedAt = 0; + } +} + +async function applyFileSearchWatchEventBatchForPerfHarness(paths: string[]): Promise { + if (!activeIndex) { + throw new Error('File search perf harness requires an active index before applying watch events.'); + } + await applyWatchEventBatch(paths.map((candidatePath) => path.resolve(candidatePath))); +} + +export const __fileSearchIndexPerfHarness = { + reset: resetFileSearchIndexForPerfHarness, + rebuild: rebuildFileSearchIndexForPerfHarness, + applyWatchEventBatch: applyFileSearchWatchEventBatchForPerfHarness, +}; + export async function searchIndexedFiles( rawQuery: string, options?: { limit?: number } @@ -907,13 +1122,16 @@ export async function searchIndexedFiles( const expandedNeedle = trimmedQuery.startsWith('~') && configuredHomeDir ? normalizePathSearchText(`${configuredHomeDir}${trimmedQuery.slice(1)}`) : rawNeedle; + const candidateIds = resolvePathLikeCandidateIds(snapshot, rawNeedle, expandedNeedle, trimmedQuery); + const candidateEntries = candidateIds === null + ? snapshot.entries + : candidateIds.map((entryId) => snapshot.entries[entryId]).filter(Boolean); const scored: Array<{ entry: IndexedEntry; score: number }> = []; - for (const entry of snapshot.entries) { + for (const entry of candidateEntries) { if (entry.deleted) continue; const pathIndex = entry.normalizedPath.indexOf(expandedNeedle); - const tildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); - const tildeIndex = tildePath.indexOf(rawNeedle); + const tildeIndex = entry.normalizedTildePath.indexOf(rawNeedle); const matchIndex = pathIndex >= 0 ? pathIndex : tildeIndex; if (matchIndex < 0) continue; @@ -1059,6 +1277,7 @@ export async function searchIndexedFiles( parentPath: path.dirname(candidatePath), normalizedName, normalizedPath: normalizePathSearchText(candidatePath), + normalizedTildePath: normalizePathSearchText(asTildePath(candidatePath, configuredHomeDir)), compactName: normalizedName.replace(/\s+/g, ''), tokens: tokenizeSearchText(candidateName), pathTokens: tokenizeSearchText(candidatePath), diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..a73f61a1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; +import { createAerospaceWorkspaceMover } from './aerospace-workspace'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; import { @@ -100,6 +101,8 @@ import { togglePinClipboardItem, moveClipboardPinnedItem, pruneClipboardHistoryOlderThan, + flushClipboardHistoryWrites, + hasPendingClipboardHistoryWrites, } from './clipboard-manager'; import { initSnippetStore, @@ -193,6 +196,8 @@ import { exportNoteToFile, exportNotesToFile, importNotesFromFile, + flushNotesToDisk, + hasPendingNotesSave, } from './notes-store'; import { initCanvasStore, @@ -218,6 +223,7 @@ import { importRaycastConfigFromFile, previewRaycastConfigImport, } from './raycast-config-import'; +import { runExecCommand, type ExecCommandOptions } from './exec-command'; import { initialize as initAptabase, trackEvent } from "@aptabase/electron/main"; @@ -3197,48 +3203,12 @@ function setLauncherOverlayTopmost(enabled: boolean): void { * This is fire-and-forget — failures are silently ignored so we never * block or delay the launcher for users who don't use AeroSpace. */ -let aerospaceAvailable: boolean | null = null; // null = not yet checked +const aerospaceWorkspaceMover = createAerospaceWorkspaceMover({ + shouldRun: () => !!mainWindow && !mainWindow.isDestroyed(), +}); + function moveWindowToCurrentAerospaceWorkspace(): void { - if (aerospaceAvailable === false || process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return; - try { - const { execFileSync } = require('child_process'); - // Quick check: is AeroSpace running? list-workspaces --focused - // exits 0 only when the server is up. - const focusedWs = String( - execFileSync('aerospace', ['list-workspaces', '--focused'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!focusedWs) return; - aerospaceAvailable = true; - - // Find our window(s) by bundle-id - const windowsRaw = String( - execFileSync('aerospace', ['list-windows', '--all', '--app-bundle-id', 'com.supercmd.app', '--format', '%{window-id} %{workspace}'], { - timeout: 500, - stdio: ['ignore', 'pipe', 'ignore'], - }) || '' - ).trim(); - if (!windowsRaw) return; - - for (const line of windowsRaw.split('\n')) { - const parts = line.trim().split(/\s+/); - if (parts.length < 2) continue; - const [windowId, currentWs] = parts; - if (currentWs === focusedWs) continue; // already on the right workspace - execFileSync('aerospace', ['move-node-to-workspace', focusedWs, '--window-id', windowId], { - timeout: 500, - stdio: 'ignore', - }); - } - } catch (err: any) { - // ENOENT = `aerospace` binary not found — will never appear, so skip future calls. - if (err?.code === 'ENOENT') { - aerospaceAvailable = false; - } - // Other errors (server not running, command failed) are transient — retry next time. - } + aerospaceWorkspaceMover.requestMove(); } function clearOAuthBlurHideSuppression(): void { @@ -10244,6 +10214,7 @@ function startEmojiTriggerMonitor(triggerPrefix = ':'): void { '-O', '-o', binaryPath, path.join(nativeDir, 'emoji-trigger-monitor.swift'), path.join(nativeDir, 'ax-caret-query.swift'), + path.join(nativeDir, 'emoji-caret-session-cache.swift'), '-framework', 'AppKit', '-framework', 'ApplicationServices', ]); @@ -13315,19 +13286,23 @@ async function restartAndInstallAppUpdate(): Promise { }); triggerTimer = setTimeout(() => { - try { - if (process.platform === 'darwin') { - try { - appUpdater.autoInstallOnAppQuit = true; - appUpdater.autoRunAppAfterInstall = true; - } catch {} - appUpdater.quitAndInstall(); - } else { - appUpdater.quitAndInstall(false, true); + void (async () => { + try { + await flushNotesToDisk(); + await flushClipboardHistoryWrites(); + if (process.platform === 'darwin') { + try { + appUpdater.autoInstallOnAppQuit = true; + appUpdater.autoRunAppAfterInstall = true; + } catch {} + appUpdater.quitAndInstall(); + } else { + appUpdater.quitAndInstall(false, true); + } + } catch (error: any) { + finish(false, error); } - } catch (error: any) { - finish(false, error); - } + })(); }, 40); timeoutTimer = setTimeout(() => { @@ -15384,91 +15359,9 @@ app.whenReady().then(async () => { _event: any, command: string, args: string[], - options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } + options?: ExecCommandOptions ) => { - const { spawn, execFile } = require('child_process'); - const fs = require('fs'); - - return new Promise((resolve) => { - try { - const resolveExecutablePath = (input: string): string => { - if (!input || typeof input !== 'string') return input; - if (!input.includes('/') && !input.includes('\\')) return input; - if (!input.startsWith('/')) return input; - if (fs.existsSync(input)) return input; - try { - const base = input.split('/').filter(Boolean).pop() || ''; - if (!base) return input; - const lookup = execFileSync('/bin/zsh', ['-lc', `command -v -- ${JSON.stringify(base)} 2>/dev/null || true`], { encoding: 'utf-8' }).trim(); - if (lookup && fs.existsSync(lookup)) return lookup; - } catch {} - return input; - }; - - const execFileSync = require('child_process').execFileSync; - const normalizedCommand = resolveExecutablePath(command); - // Augment PATH so extensions can find brew, npm, nvm, etc. even when - // the app is launched from the Dock (where macOS strips the login PATH). - const extraPaths = [ - '/opt/homebrew/bin', '/opt/homebrew/sbin', - '/usr/local/bin', '/usr/local/sbin', - '/usr/bin', '/usr/sbin', '/bin', '/sbin', - ]; - const currentPath = (options?.env?.PATH ?? process.env.PATH ?? ''); - const augmentedPath = [ - ...extraPaths, - ...currentPath.split(':').filter(Boolean), - ].filter((v, i, a) => a.indexOf(v) === i).join(':'); - const spawnOptions: any = { - shell: options?.shell ?? false, - env: { ...process.env, ...options?.env, PATH: augmentedPath }, - cwd: options?.cwd || process.cwd(), - }; - - let proc: any; - if (options?.shell) { - // When shell is true, join command and args - const fullCommand = [normalizedCommand, ...args].join(' '); - proc = spawn(fullCommand, [], { ...spawnOptions, shell: true }); - } else { - proc = spawn(normalizedCommand, args, spawnOptions); - } - - let stdout = ''; - let stderr = ''; - - proc.stdout?.on('data', (data: Buffer) => { - stdout += data.toString(); - }); - - proc.stderr?.on('data', (data: Buffer) => { - stderr += data.toString(); - }); - - if (options?.input && proc.stdin) { - proc.stdin.write(options.input); - proc.stdin.end(); - } - - proc.on('close', (code: number | null) => { - resolve({ stdout, stderr, exitCode: code ?? 0 }); - }); - - proc.on('error', (err: Error) => { - resolve({ stdout, stderr: err.message, exitCode: 1 }); - }); - - // Timeout after 5 minutes — allows long-running commands (brew install, npm install, etc.) - setTimeout(() => { - try { - proc.kill(); - } catch {} - resolve({ stdout, stderr: stderr || 'Command timed out', exitCode: 124 }); - }, 300000); - } catch (e: any) { - resolve({ stdout: '', stderr: e?.message || 'Failed to execute command', exitCode: 1 }); - } - }); + return runExecCommand(command, args, options); } ); @@ -16531,12 +16424,12 @@ return appURL's |path|() as text`, return searchClipboardHistory(query); }); - ipcMain.handle('clipboard-clear-history', () => { - clearClipboardHistory(); + ipcMain.handle('clipboard-clear-history', async () => { + await clearClipboardHistory(); }); - ipcMain.handle('clipboard-delete-item', (_event: any, id: string) => { - return deleteClipboardItem(id); + ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) => { + return await deleteClipboardItem(id); }); ipcMain.handle('clipboard-copy-item', (_event: any, id: string) => { @@ -19424,8 +19317,42 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', () => { +let pendingAsyncStorageQuitFlushComplete = false; +let pendingAsyncStorageQuitFlushInProgress = false; + +app.on('before-quit', (event: any) => { prepareWindowsForAppQuit(); + + const updateRestartInProgress = Boolean(appUpdaterRestartPromise) || appUpdaterStatusSnapshot.state === 'restarting'; + const shouldFlushNotes = hasPendingNotesSave(); + const shouldFlushClipboard = !updateRestartInProgress && hasPendingClipboardHistoryWrites(); + + if ( + pendingAsyncStorageQuitFlushComplete || + pendingAsyncStorageQuitFlushInProgress || + (!shouldFlushNotes && !shouldFlushClipboard) + ) { + return; + } + + pendingAsyncStorageQuitFlushInProgress = true; + event.preventDefault(); + Promise.all([ + shouldFlushNotes + ? flushNotesToDisk().catch((error) => { + console.error('[Notes] Failed to flush pending saves before quit:', error); + }) + : Promise.resolve(), + shouldFlushClipboard + ? flushClipboardHistoryWrites().catch((error) => { + console.error('Failed to flush clipboard history before quit:', error); + }) + : Promise.resolve(), + ]).finally(() => { + pendingAsyncStorageQuitFlushComplete = true; + pendingAsyncStorageQuitFlushInProgress = false; + app.quit(); + }); }); app.on('will-quit', () => { @@ -19451,7 +19378,7 @@ app.on('will-quit', () => { killParakeetServer(); killQwen3Server(); killAudioCapturer(); - stopClipboardMonitor(); + void stopClipboardMonitor(); stopSnippetExpander(); stopEmojiTriggerMonitor(); stopFileSearchIndexing(); diff --git a/src/main/notes-store.ts b/src/main/notes-store.ts index 39953508..f081167d 100644 --- a/src/main/notes-store.ts +++ b/src/main/notes-store.ts @@ -13,6 +13,7 @@ import { app, clipboard, dialog, BrowserWindow, SaveDialogOptions, OpenDialogOptions } from 'electron'; import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -46,6 +47,11 @@ export interface Note { // ─── Cache ────────────────────────────────────────────────────────── let notesCache: Note[] | null = null; +const NOTES_SAVE_DEBOUNCE_MS = 250; +let notesSaveTimer: ReturnType | null = null; +let notesSavePending = false; +let notesSaveDrainPromise: Promise | null = null; +let notesSaveTempCounter = 0; // ─── Paths ────────────────────────────────────────────────────────── @@ -88,15 +94,67 @@ function loadFromDisk(): Note[] { return []; } -function saveToDisk(): void { +async function writeNotesAtomically(data: string): Promise { + const filePath = getNotesFilePath(); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.${++notesSaveTempCounter}.tmp`; + try { - const filePath = getNotesFilePath(); - fs.writeFileSync(filePath, JSON.stringify(notesCache || [], null, 2), 'utf-8'); + await fsp.writeFile(tempPath, data, 'utf-8'); + await fsp.rename(tempPath, filePath); } catch (e) { - console.error('Failed to save notes to disk:', e); + fsp.unlink(tempPath).catch(() => {}); + throw e; + } +} + +function clearNotesSaveTimer(): void { + if (notesSaveTimer) { + clearTimeout(notesSaveTimer); + notesSaveTimer = null; } } +function drainNotesSaveQueue(): Promise { + if (notesSaveDrainPromise) return notesSaveDrainPromise; + + notesSaveDrainPromise = (async () => { + clearNotesSaveTimer(); + while (notesSavePending) { + notesSavePending = false; + const data = JSON.stringify(notesCache || [], null, 2); + try { + await writeNotesAtomically(data); + } catch (e) { + console.error('Failed to save notes to disk:', e); + } + clearNotesSaveTimer(); + } + })().finally(() => { + notesSaveDrainPromise = null; + }); + + return notesSaveDrainPromise; +} + +function saveToDisk(): void { + notesSavePending = true; + clearNotesSaveTimer(); + notesSaveTimer = setTimeout(() => { + notesSaveTimer = null; + void drainNotesSaveQueue(); + }, NOTES_SAVE_DEBOUNCE_MS); +} + +export function hasPendingNotesSave(): boolean { + return notesSavePending || notesSaveTimer !== null || notesSaveDrainPromise !== null; +} + +export async function flushNotesToDisk(): Promise { + clearNotesSaveTimer(); + if (!notesSavePending && !notesSaveDrainPromise) return; + await drainNotesSaveQueue(); +} + const VALID_THEMES: Set = new Set([ 'default', 'rose', 'orange', 'amber', 'emerald', 'cyan', 'blue', 'violet', 'fuchsia', 'slate', diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..cb1f5157 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -33,6 +33,8 @@ export interface ScriptCommandInfo { refreshTime?: string; interval?: string; currentDirectoryPath?: string; + interpreter?: string; + interpreterArgs?: string[]; needsConfirmation: boolean; arguments: ScriptArgumentDefinition[]; keywords: string[]; @@ -55,6 +57,9 @@ export interface ScriptExecutionResult { const CACHE_TTL_MS = 12_000; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; // 2MB const DEFAULT_TIMEOUT_MS = 60_000; +const SCRIPT_COMMAND_HEADER_MAX_LINES = 120; +const SCRIPT_COMMAND_HEADER_MAX_BYTES = 256 * 1024; +const SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES = 8 * 1024; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; @@ -302,24 +307,66 @@ function discoverScriptFiles(rootDir: string): string[] { return out; } -function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { - const scriptPath = path.resolve(filePath); - const scriptDir = path.dirname(scriptPath); +function countLineBreaks(buffer: Buffer, length: number): number { + let count = 0; + for (let i = 0; i < length; i += 1) { + if (buffer[i] === 10) count += 1; + } + return count; +} - let raw = ''; +function readScriptCommandHeader(filePath: string): string | null { + let fd: number | null = null; try { - raw = fs.readFileSync(scriptPath, 'utf-8'); + fd = fs.openSync(filePath, 'r'); + const chunks: Buffer[] = []; + let totalBytes = 0; + let lineBreaks = 0; + + while ( + totalBytes < SCRIPT_COMMAND_HEADER_MAX_BYTES && + lineBreaks < SCRIPT_COMMAND_HEADER_MAX_LINES + ) { + const bytesToRead = Math.min( + SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES, + SCRIPT_COMMAND_HEADER_MAX_BYTES - totalBytes + ); + if (bytesToRead <= 0) break; + + const chunk = Buffer.allocUnsafe(bytesToRead); + const bytesRead = fs.readSync(fd, chunk, 0, bytesToRead, totalBytes); + if (bytesRead <= 0) break; + + chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)); + totalBytes += bytesRead; + lineBreaks += countLineBreaks(chunk, bytesRead); + } + + return Buffer.concat(chunks, totalBytes).toString('utf8'); } catch { return null; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch {} + } } +} + +function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { + const scriptPath = path.resolve(filePath); + const scriptDir = path.dirname(scriptPath); + + const raw = readScriptCommandHeader(scriptPath); + if (raw === null) return null; if (!raw.trim()) return null; const lines = raw.split(/\r?\n/); + const shebang = shebangArgs(lines[0] || ''); const metadata: Record = {}; const argumentDefs = new Map(); - for (const line of lines.slice(0, 120)) { + for (const line of lines.slice(0, SCRIPT_COMMAND_HEADER_MAX_LINES)) { const match = line.match(/^\s*(#|\/\/|--)\s*@raycast\.([A-Za-z0-9]+)\s*(.*)$/); if (!match) continue; const key = String(match[2] || '').trim(); @@ -393,6 +440,8 @@ function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { refreshTime, interval: mode === 'inline' ? refreshTime : undefined, currentDirectoryPath, + interpreter: shebang[0], + interpreterArgs: shebang.slice(1), needsConfirmation, arguments: argumentsList, keywords: Array.from(new Set(keywords)), @@ -497,9 +546,7 @@ export async function executeScriptCommand( return { missingArguments: missing, command: cmd }; } - const source = fs.readFileSync(cmd.scriptPath, 'utf-8'); - const firstLine = source.split(/\r?\n/)[0] || ''; - const shebang = shebangArgs(firstLine); + fs.accessSync(cmd.scriptPath, fs.constants.R_OK); const env = { ...process.env, @@ -514,10 +561,10 @@ export async function executeScriptCommand( const cwd = cmd.currentDirectoryPath || cmd.scriptDir; const spawnCommand = - shebang.length > 0 ? shebang[0] : '/bin/bash'; + cmd.interpreter ? cmd.interpreter : '/bin/bash'; const spawnArgs = - shebang.length > 0 - ? [...shebang.slice(1), cmd.scriptPath, ...args] + cmd.interpreter + ? [...(cmd.interpreterArgs || []), cmd.scriptPath, ...args] : [cmd.scriptPath, ...args]; const run = await new Promise<{ diff --git a/src/native/audio-capturer.swift b/src/native/audio-capturer.swift index 752b6e3c..72ec9efc 100644 --- a/src/native/audio-capturer.swift +++ b/src/native/audio-capturer.swift @@ -279,6 +279,16 @@ class AudioCapturer { // MARK: Buffer processing private func processBuffer(_ buffer: AVAudioPCMBuffer) { + let now = Date() + let shouldUpdateMeter = now.timeIntervalSince(lastMeterAt) >= meterInterval + + guard isRecording || shouldUpdateMeter else { return } + + if !isRecording { + updateMeter(fromInputBuffer: buffer, now: now) + return + } + guard let converted = convertBuffer(buffer), converted.frameLength > 0, let samples = converted.floatChannelData?[0] @@ -290,20 +300,8 @@ class AudioCapturer { ringBuffer.append(UnsafeBufferPointer(start: samples, count: sampleCount)) } - // Compute meter - let now = Date() - if now.timeIntervalSince(lastMeterAt) >= meterInterval { - var sumSquares: Float = 0 - var peak: Float = 0 - for i in 0.. 0 else { return } + + let channelStride = buffer.format.isInterleaved ? Int(max(1, buffer.format.channelCount)) : 1 + + switch buffer.format.commonFormat { + case .pcmFormatFloat32: + guard let samples = buffer.floatChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, now: now) + case .pcmFormatFloat64: + guard let rawSamples = buffer.audioBufferList.pointee.mBuffers.mData else { return } + updateMeter( + samples: rawSamples.assumingMemoryBound(to: Double.self), + sampleCount: sampleCount, + stride: channelStride, + now: now + ) + case .pcmFormatInt16: + guard let samples = buffer.int16ChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, divisor: Float(Int16.max), now: now) + case .pcmFormatInt32: + guard let samples = buffer.int32ChannelData?[0] else { return } + updateMeter(samples: samples, sampleCount: sampleCount, stride: channelStride, divisor: Float(Int32.max), now: now) + case .otherFormat: + return + @unknown default: + return + } + } + + private func updateMeter( + samples: UnsafePointer, + sampleCount: Int, + stride: Int, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0.., + sampleCount: Int, + stride: Int, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0..( + samples: UnsafePointer, + sampleCount: Int, + stride: Int, + divisor: Float, + now: Date + ) { + var sumSquares: Float = 0 + var peak: Float = 0 + for i in 0.. AVAudioPCMBuffer? { diff --git a/src/native/ax-caret-query.swift b/src/native/ax-caret-query.swift index 2bfdabbf..48ad26c6 100644 --- a/src/native/ax-caret-query.swift +++ b/src/native/ax-caret-query.swift @@ -34,6 +34,17 @@ public struct AXCaretRect { public let tier: String } +public struct AXCaretApplicationContext { + public let pid: pid_t + public let bundleIdentifier: String +} + +public struct AXCaretSessionSnapshot { + public let context: AXCaretApplicationContext + public let focusedElement: AXUIElement? + public let caret: AXCaretRect +} + /// Three-way result so callers can distinguish the security-sensitive case /// from an ordinary AX gap. A plain `AXCaretRect?` cannot express this: /// both "secure field" and "no rect available" would be nil, and the caller @@ -50,6 +61,12 @@ public enum AXCaretResult { case noRect } +public enum AXCaretSessionResult { + case secureField + case snapshot(AXCaretSessionSnapshot) + case noRect(AXCaretApplicationContext?) +} + public enum AXCaretQuery { // PIDs we've already nudged. Avoid re-setting AX opt-in attributes every keystroke. nonisolated(unsafe) private static var nudgedPIDs: Set = [] @@ -66,11 +83,28 @@ public enum AXCaretQuery { /// Query the caret state for the frontmost app's focused text element. public static func current() -> AXCaretResult { + switch currentSession() { + case .secureField: + return .secureField + case .snapshot(let snapshot): + return .rect(snapshot.caret) + case .noRect: + return .noRect + } + } + + /// Query the caret state and return the process/focused element needed to + /// safely cache an active emoji search session. + public static func currentSession() -> AXCaretSessionResult { guard let frontApp = NSWorkspace.shared.frontmostApplication else { dbg("no frontmost app") - return .noRect + return .noRect(nil) } let pid = frontApp.processIdentifier + let context = AXCaretApplicationContext( + pid: pid, + bundleIdentifier: frontApp.bundleIdentifier ?? "" + ) let appElement = AXUIElementCreateApplication(pid) let justNudged = nudgeChromiumAX(appElement: appElement, pid: pid) @@ -93,9 +127,9 @@ public enum AXCaretQuery { } guard focusErr == .success, let focused = focusedRaw else { dbg("kAXFocusedUIElementAttribute failed: err=\(focusErr.rawValue)") - return .noRect + return .noRect(context) } - guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect } + guard CFGetTypeID(focused) == AXUIElementGetTypeID() else { return .noRect(context) } var element = focused as! AXUIElement let role = copyString(element, kAXRoleAttribute as CFString) ?? "" @@ -121,15 +155,27 @@ public enum AXCaretQuery { } if let r = caretRectViaTextMarker(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "textMarker") + )) } if let r = caretRectViaRange(element: element), isPlausible(r) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "boundsForRange") + )) } if let r = caretRectViaElementFrame(element: element) { - return .rect(AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame")) + return .snapshot(AXCaretSessionSnapshot( + context: context, + focusedElement: element, + caret: AXCaretRect(x: r.x, y: r.y, w: r.w, h: r.h, tier: "elementFrame") + )) } - return .noRect + return .noRect(context) } // MARK: - Descend focus tree to find the true text leaf @@ -161,9 +207,11 @@ public enum AXCaretQuery { } // BFS up to a small depth to avoid walking huge trees. var queue: [(el: AXUIElement, depth: Int)] = [(root, 0)] + var queueIndex = 0 let maxDepth = 6 - while let (el, depth) = queue.first { - queue.removeFirst() + while queueIndex < queue.count { + let (el, depth) = queue[queueIndex] + queueIndex += 1 if depth > 0 { let role = copyString(el, kAXRoleAttribute as CFString) ?? "" if TEXT_LEAF_ROLES.contains(role) && hasSelectedTextRange(el) { diff --git a/src/native/emoji-caret-session-cache.swift b/src/native/emoji-caret-session-cache.swift new file mode 100644 index 00000000..5612ef33 --- /dev/null +++ b/src/native/emoji-caret-session-cache.swift @@ -0,0 +1,35 @@ +import Foundation +@preconcurrency import ApplicationServices + +public enum EmojiCaretSessionValidation { + case empty + case valid(AXCaretSessionSnapshot) + case invalidated +} + +public struct EmojiCaretSessionCache { + private var snapshot: AXCaretSessionSnapshot? + + public init() {} + + public var isActive: Bool { + snapshot != nil + } + + public mutating func store(_ nextSnapshot: AXCaretSessionSnapshot) { + snapshot = nextSnapshot + } + + public mutating func invalidate() { + snapshot = nil + } + + public mutating func validate(eventTargetPID: pid_t?) -> EmojiCaretSessionValidation { + guard let current = snapshot else { return .empty } + if let targetPID = eventTargetPID, targetPID > 0, targetPID != current.context.pid { + snapshot = nil + return .invalidated + } + return .valid(current) + } +} diff --git a/src/native/emoji-trigger-monitor.swift b/src/native/emoji-trigger-monitor.swift index e2f27dd7..a2e47e84 100644 --- a/src/native/emoji-trigger-monitor.swift +++ b/src/native/emoji-trigger-monitor.swift @@ -21,6 +21,7 @@ var currentQuery = "" var interceptEnabled = false var prefixBuffer = "" // rolling window of recent chars, length ≤ triggerPrefixLen var eventTapRef: CFMachPort? +var caretSessionCache = EmojiCaretSessionCache() // MARK: - JSON output @@ -33,38 +34,68 @@ func emit(_ obj: [String: Any]) { // MARK: - Caret rect + secure-field guard -// Included on every `query` event so the host process can decide whether the -// frontmost app is on the user's exclusion list. Read here (rather than in the -// host) because lsappinfo lookups in the host are stale for keystroke-driven -// flows where the launcher window was never brought forward. -func currentFrontmostBundleId() -> String { - return NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "" +func resetTriggerState() { + triggerActive = false + currentQuery = "" + interceptEnabled = false + prefixBuffer = "" + caretSessionCache.invalidate() } -func emitQuery(_ query: String) { - let bundleId = currentFrontmostBundleId() - switch AXCaretQuery.current() { +func dismissTrigger(emitDismiss: Bool = true) { + resetTriggerState() + if emitDismiss { emit(["type": "dismiss"]) } +} + +func eventTargetPID(from event: CGEvent) -> pid_t? { + let rawPID = event.getIntegerValueField(.eventTargetUnixProcessID) + return rawPID > 0 ? pid_t(rawPID) : nil +} + +func sessionValidationPID(from event: CGEvent) -> pid_t? { + if let targetPID = eventTargetPID(from: event) { return targetPID } + guard caretSessionCache.isActive else { return nil } + return NSWorkspace.shared.frontmostApplication?.processIdentifier +} + +func emitQueryPayload(_ query: String, bundleId: String, caret: AXCaretRect?) { + var payload: [String: Any] = [ + "type": "query", + "value": query, + "prefixLen": triggerPrefixLen, + "bundleId": bundleId, + ] + if let caret { + payload["caret"] = ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier] + } + emit(payload) +} + +func emitQuery(_ query: String, eventTargetPID: pid_t?) { + switch caretSessionCache.validate(eventTargetPID: eventTargetPID) { + case .valid(let snapshot): + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + return + case .invalidated: + dismissTrigger() + return + case .empty: + break + } + + switch AXCaretQuery.currentSession() { case .secureField: - triggerActive = false - currentQuery = "" - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) - case .rect(let caret): - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - "caret": ["x": caret.x, "y": caret.y, "w": caret.w, "h": caret.h, "tier": caret.tier], - ]) - case .noRect: - emit([ - "type": "query", - "value": query, - "prefixLen": triggerPrefixLen, - "bundleId": bundleId, - ]) + dismissTrigger() + case .snapshot(let snapshot): + if let targetPID = eventTargetPID, targetPID > 0, targetPID != snapshot.context.pid { + dismissTrigger() + return + } + caretSessionCache.store(snapshot) + emitQueryPayload(query, bundleId: snapshot.context.bundleIdentifier, caret: snapshot.caret) + case .noRect(let context): + caretSessionCache.invalidate() + emitQueryPayload(query, bundleId: context?.bundleIdentifier ?? "", caret: nil) } } @@ -131,9 +162,7 @@ func run() { // a partial prefix that could accidentally fire on the next keystroke. if hasCmd || hasCtrl { if triggerActive { - triggerActive = false - currentQuery = "" - emit(["type": "dismiss"]) + dismissTrigger() } prefixBuffer = "" // clear regardless of triggerActive (fix A) return Unmanaged.passUnretained(event) @@ -146,10 +175,7 @@ func run() { // otherwise silently extend the query with extended chars (ü, ©, etc.) // on non-US layouts, which is unintuitive (fix B). if hasAlt && triggerActive { - triggerActive = false - currentQuery = "" - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() return Unmanaged.passUnretained(event) } @@ -159,7 +185,7 @@ func run() { switch keyCode { case 36: emit(["type": "nav", "key": "enter"]); return nil case 53: - triggerActive = false; currentQuery = ""; prefixBuffer = "" + resetTriggerState() emit(["type": "nav", "key": "escape"]) return nil case 48: emit(["type": "nav", "key": "tab"]); return nil @@ -175,13 +201,10 @@ func run() { if triggerActive { if currentQuery.isEmpty { // Backspaced through the entire query back into the prefix — dismiss. - triggerActive = false - interceptEnabled = false - prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { currentQuery.removeLast() - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Update prefix buffer for non-trigger backspace. @@ -194,8 +217,7 @@ func run() { let chars = extractTypedChars(from: event) if chars.isEmpty { if triggerActive { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } return Unmanaged.passUnretained(event) } @@ -206,15 +228,13 @@ func run() { if isEmojiQueryChar(char) { currentQuery.append(char) if currentQuery.count > 30 { - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } else { - emitQuery(currentQuery) + emitQuery(currentQuery, eventTargetPID: sessionValidationPID(from: event)) } } else { // Non-query char (space, punctuation, …) → dismiss trigger. - triggerActive = false; currentQuery = ""; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() // Feed this char into the prefix buffer in case it starts the // next trigger (e.g. when trigger prefix contains this char). feedPrefixBuffer(char) @@ -268,8 +288,7 @@ func run() { interceptEnabled = json["enabled"] as? Bool ?? false case "dismiss": if triggerActive { - triggerActive = false; currentQuery = ""; interceptEnabled = false; prefixBuffer = "" - emit(["type": "dismiss"]) + dismissTrigger() } default: break } diff --git a/src/native/get-selected-text.swift b/src/native/get-selected-text.swift index 7e4f4112..3b86892b 100644 --- a/src/native/get-selected-text.swift +++ b/src/native/get-selected-text.swift @@ -163,12 +163,14 @@ private func enqueueChildren(of element: AXUIElement, depth: Int, into queue: in private func findSelectedText(from roots: [AXUIElement]) -> String? { var queue = roots.map { ($0, 0) } + var queueIndex = 0 var inspected = 0 let maxDepth = 8 let maxElements = 240 - while let (element, depth) = queue.first { - queue.removeFirst() + while queueIndex < queue.count { + let (element, depth) = queue[queueIndex] + queueIndex += 1 inspected += 1 if inspected > maxElements { break } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a49482ba..dc1f1ec8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -111,6 +111,7 @@ import { MAX_INLINE_QUICK_LINK_ARGUMENTS, getQuickLinkIdFromCommandId, } from './utils/launcher-misc'; +import { enqueueBackgroundNoViewRun } from './utils/background-no-view-runs'; import { WEB_SEARCH_ROOT_BANG_PREFIX, buildBangSearchUrl, @@ -310,8 +311,9 @@ const App: React.FC = () => { launchType: 'userInitiated' | 'background' = 'userInitiated', reportStatus = false ) => { - const runId = `${bundle.extensionName || bundle.extName}/${bundle.commandName || bundle.cmdName}/${Date.now()}`; - setBackgroundNoViewRuns((prev) => [...prev, { runId, bundle, launchType, reportStatus }]); + setBackgroundNoViewRuns((prev) => + enqueueBackgroundNoViewRun(prev, bundle, launchType, reportStatus).runs + ); }, [setBackgroundNoViewRuns]); const onExitAiMode = useCallback(() => { @@ -1076,7 +1078,7 @@ const App: React.FC = () => { const pinToggleForCommand = useCallback( async (command: CommandInfo) => { - console.log('[PIN-TOGGLE] called for command:', command?.id, command?.name); + console.log('[PIN-TOGGLE] called for command:', command?.id, command?.title); const currentPinned = pinnedCommandsRef.current; const exists = currentPinned.includes(command.id); console.log('[PIN-TOGGLE] currentPinned:', currentPinned, 'exists:', exists); diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..118ce3dd 100644 --- a/src/renderer/src/CameraExtension.tsx +++ b/src/renderer/src/CameraExtension.tsx @@ -1,6 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, Camera, Image, RefreshCw, RotateCcw, Settings, Video, X } from 'lucide-react'; import ExtensionActionFooter from './components/ExtensionActionFooter'; +import { + type CapturePreviewUrlManager, + createCapturePreview, + createCapturePreviewUrlManager, + encodeCanvasAsPngBlob, +} from './camera-capture'; interface CameraExtensionProps { onClose: () => void; @@ -78,7 +84,7 @@ const CameraExtension: React.FC = ({ onClose }) => { const [stream, setStream] = useState(null); const [showActions, setShowActions] = useState(false); const [selectedActionIndex, setSelectedActionIndex] = useState(0); - const [capturePreviewDataUrl, setCapturePreviewDataUrl] = useState(null); + const [capturePreviewUrl, setCapturePreviewUrl] = useState(null); const [capturePreviewVisible, setCapturePreviewVisible] = useState(false); const [captureStatus, setCaptureStatus] = useState(null); const [flashVisible, setFlashVisible] = useState(false); @@ -95,6 +101,11 @@ const CameraExtension: React.FC = ({ onClose }) => { const startRequestIdRef = useRef(0); const unmountedRef = useRef(false); const streamRef = useRef(null); + const capturePreviewUrlManagerRef = useRef(null); + + if (capturePreviewUrlManagerRef.current === null) { + capturePreviewUrlManagerRef.current = createCapturePreviewUrlManager(); + } const clearTransientUi = useCallback(() => { if (captureNoticeTimerRef.current != null) { @@ -115,6 +126,45 @@ const CameraExtension: React.FC = ({ onClose }) => { } }, []); + const clearCapturePreview = useCallback(() => { + capturePreviewUrlManagerRef.current?.clear(); + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + }, []); + + const showCapturePreview = useCallback( + (blob: Blob) => { + const previewUrlManager = capturePreviewUrlManagerRef.current; + if (!previewUrlManager) return; + + if (capturePreviewFadeTimerRef.current != null) { + window.clearTimeout(capturePreviewFadeTimerRef.current); + } + if (capturePreviewClearTimerRef.current != null) { + window.clearTimeout(capturePreviewClearTimerRef.current); + } + + const preview = createCapturePreview(blob, previewUrlManager); + if (!preview.url) { + setCapturePreviewUrl(null); + setCapturePreviewVisible(false); + return; + } + + setCapturePreviewUrl(preview.url); + setCapturePreviewVisible(preview.visible); + capturePreviewFadeTimerRef.current = window.setTimeout(() => { + setCapturePreviewVisible(false); + capturePreviewFadeTimerRef.current = null; + }, 5000); + capturePreviewClearTimerRef.current = window.setTimeout(() => { + clearCapturePreview(); + capturePreviewClearTimerRef.current = null; + }, 5300); + }, + [clearCapturePreview] + ); + const refocusCameraRoot = useCallback(() => { window.requestAnimationFrame(() => { rootRef.current?.focus(); @@ -284,22 +334,6 @@ const CameraExtension: React.FC = ({ onClose }) => { context.drawImage(video, 0, 0, width, height); } - setCapturePreviewDataUrl(canvas.toDataURL('image/png')); - setCapturePreviewVisible(true); - if (capturePreviewFadeTimerRef.current != null) { - window.clearTimeout(capturePreviewFadeTimerRef.current); - } - if (capturePreviewClearTimerRef.current != null) { - window.clearTimeout(capturePreviewClearTimerRef.current); - } - capturePreviewFadeTimerRef.current = window.setTimeout(() => { - setCapturePreviewVisible(false); - capturePreviewFadeTimerRef.current = null; - }, 5000); - capturePreviewClearTimerRef.current = window.setTimeout(() => { - setCapturePreviewDataUrl(null); - setCapturePreviewClearTimerRef.current = null; - }, 5300); setFlashVisible(true); if (flashTimerRef.current != null) { window.clearTimeout(flashTimerRef.current); @@ -316,9 +350,10 @@ const CameraExtension: React.FC = ({ onClose }) => { const timestamp = `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())}_${two(now.getHours())}-${two(now.getMinutes())}-${two(now.getSeconds())}`; const savePath = `${saveDir}/supercmd-capture-${timestamp}.png`; - let captureBlob: Blob | null = await new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), 'image/png'); - }); + const captureBlob = await encodeCanvasAsPngBlob(canvas); + if (captureBlob && !unmountedRef.current) { + showCapturePreview(captureBlob); + } let savedToDisk = false; if (captureBlob) { @@ -349,7 +384,7 @@ const CameraExtension: React.FC = ({ onClose }) => { showCaptureStatus({ kind: 'neutral', text: 'Failed to copy picture to clipboard.' }, 3000); } refocusCameraRoot(); - }, [isHorizontallyFlipped, refocusCameraRoot, showCaptureStatus, stream]); + }, [isHorizontallyFlipped, refocusCameraRoot, showCapturePreview, showCaptureStatus, stream]); const openSystemCameraSettings = useCallback(async () => { try { @@ -363,6 +398,7 @@ const CameraExtension: React.FC = ({ onClose }) => { unmountedRef.current = true; startRequestIdRef.current += 1; clearTransientUi(); + capturePreviewUrlManagerRef.current?.dispose(); const currentStream = streamRef.current; streamRef.current = null; stopMediaStream(currentStream); @@ -586,14 +622,14 @@ const CameraExtension: React.FC = ({ onClose }) => { {selectedCameraLabel} - {capturePreviewDataUrl ? ( + {capturePreviewUrl ? (
Latest capture(); +type SyncCommandResult = { stdout: string; stderr: string; exitCode: number }; +type RealFsStatPayload = { + exists: boolean; + isDirectory: boolean; + isFile: boolean; + size: number; + mode?: number; + uid?: number; + gid?: number; + dev?: number; + ino?: number; + nlink?: number; + atimeMs?: number; + mtimeMs?: number; + ctimeMs?: number; + birthtimeMs?: number; +}; + +let realNodeFsModule: any | undefined; +let realNodeChildProcessModule: any | undefined; + +function getRealNodeRequire(): ((id: string) => any) | null { + if (!USE_REAL_NODE_BUILTINS) return null; + if (typeof _realNodeRequire === 'function') return _realNodeRequire; + if (typeof window !== 'undefined' && typeof (window as any).__scNodeRequire === 'function') { + return (window as any).__scNodeRequire; + } + return null; +} + +function getRealNodeBuiltin(name: string): any | null { + const realRequire = getRealNodeRequire(); + if (!realRequire) return null; + try { + return realRequire(name); + } catch { + return null; + } +} + +function getRealNodeFsModule(): any | null { + if (realNodeFsModule !== undefined) return realNodeFsModule; + const mod = getRealNodeBuiltin('fs'); + if (mod) realNodeFsModule = mod; + return mod || null; +} + +function getRealNodeChildProcessModule(): any | null { + if (realNodeChildProcessModule !== undefined) return realNodeChildProcessModule; + const mod = getRealNodeBuiltin('child_process'); + if (mod) realNodeChildProcessModule = mod; + return mod || null; +} + +function formatSyncOutput(value: any): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + try { + return BufferPolyfill.from(toUint8Array(value)).toString(); + } catch { + return String(value ?? ''); + } +} + +function buildSyncCommandEnv(options?: { env?: Record }): Record { + const baseEnv = { ...((_realNodeProcess?.env || {}) as Record) }; + const extraPaths = [ + '/opt/homebrew/bin', '/opt/homebrew/sbin', + '/usr/local/bin', '/usr/local/sbin', + '/usr/bin', '/usr/sbin', '/bin', '/sbin', + ]; + const currentPath = (options?.env?.PATH ?? baseEnv.PATH ?? ''); + const augmentedPath = [ + ...extraPaths, + ...String(currentPath).split(':').filter(Boolean), + ].filter((value, index, all) => all.indexOf(value) === index).join(':'); + return { + ...baseEnv, + ...(options?.env || {}), + PATH: augmentedPath, + }; +} + +function runNodeCommandSync( + command: string, + args: string[], + options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } +): SyncCommandResult | null { + const childProcess = getRealNodeChildProcessModule(); + if (!childProcess || typeof childProcess.spawnSync !== 'function') return null; + try { + const spawnOptions: any = { + shell: options?.shell ?? false, + env: buildSyncCommandEnv(options), + cwd: options?.cwd || _realNodeProcess?.cwd?.() || undefined, + input: options?.input, + encoding: 'utf-8', + timeout: 60_000, + }; + const result = options?.shell + ? childProcess.spawnSync([command, ...(args || [])].join(' '), [], { ...spawnOptions, shell: true }) + : childProcess.spawnSync(command, args || [], spawnOptions); + return { + stdout: formatSyncOutput(result?.stdout), + stderr: formatSyncOutput(result?.stderr || result?.error?.message), + exitCode: typeof result?.status === 'number' ? result.status : result?.error ? 1 : 0, + }; + } catch (error: any) { + return { + stdout: '', + stderr: error?.message || 'Failed to execute command', + exitCode: 1, + }; + } +} + +function runExtensionCommandSync( + command: string, + args: string[], + options?: { shell?: boolean | string; input?: string; env?: Record; cwd?: string } +): SyncCommandResult { + const nodeResult = runNodeCommandSync(command, args, options); + if (nodeResult) return nodeResult; + try { + return (window as any).electron?.execCommandSync?.(command, args, options) || { stdout: '', stderr: '', exitCode: 0 }; + } catch (error: any) { + return { stdout: '', stderr: error?.message || 'Failed to execute command', exitCode: 1 }; + } +} + +function realFileExistsSync(path: string): boolean { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.existsSync === 'function') { + try { + return Boolean(fs.existsSync(path)); + } catch { + return false; + } + } + try { + return (window as any).electron?.fileExistsSync?.(path) ?? false; + } catch { + return false; + } +} + +function readRealFileSyncText(path: string): { data: string | null; error: string | null } { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.readFileSync === 'function') { + try { + return { data: String(fs.readFileSync(path, 'utf-8')), error: null }; + } catch (error: any) { + return { data: null, error: error?.message || String(error) }; + } + } + try { + return (window as any).electron?.readFileSync?.(path) || { data: null, error: 'readFileSync unavailable' }; + } catch (error: any) { + return { data: null, error: error?.message || String(error) }; + } +} + +function toRealFsStatPayload(stat: any): RealFsStatPayload { + return { + exists: true, + isDirectory: typeof stat?.isDirectory === 'function' ? stat.isDirectory() : Boolean(stat?.isDirectory), + isFile: typeof stat?.isFile === 'function' ? stat.isFile() : Boolean(stat?.isFile), + size: Number(stat?.size) || 0, + mode: Number(stat?.mode) || undefined, + uid: Number(stat?.uid) || undefined, + gid: Number(stat?.gid) || undefined, + dev: Number(stat?.dev) || undefined, + ino: Number(stat?.ino) || undefined, + nlink: Number(stat?.nlink) || undefined, + atimeMs: Number(stat?.atimeMs) || undefined, + mtimeMs: Number(stat?.mtimeMs) || undefined, + ctimeMs: Number(stat?.ctimeMs) || undefined, + birthtimeMs: Number(stat?.birthtimeMs) || undefined, + }; +} + +function realStatSync(path: string): RealFsStatPayload { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.statSync === 'function') { + try { + return toRealFsStatPayload(fs.statSync(path)); + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } + } + try { + return (window as any).electron?.statSync?.(path) || { exists: false, isDirectory: false, isFile: false, size: 0 }; + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } +} + function isBareCommandPath(p: string): boolean { if (!p) return false; if (p.includes('/') || p.includes('\\')) return false; @@ -540,7 +738,7 @@ function resolveCommandOnPath(command: string): string | null { if (!isBareCommandPath(command)) return null; if (commandPathCache.has(command)) return commandPathCache.get(command) || null; try { - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( '/bin/zsh', ['-lc', `command -v -- ${JSON.stringify(command)} 2>/dev/null || true`], {} @@ -553,7 +751,7 @@ function resolveCommandOnPath(command: string): string | null { const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']; for (const dir of commonDirs) { const candidate = `${dir}/${command}`; - if ((window as any).electron?.fileExistsSync?.(candidate)) { + if (realFileExistsSync(candidate)) { commandPathCache.set(command, candidate); return candidate; } @@ -572,7 +770,7 @@ function resolveExecutablePath(input: any): string { if (raw.startsWith('/')) { try { - const exists = (window as any).electron?.fileExistsSync?.(raw); + const exists = realFileExistsSync(raw); if (exists) return raw; const base = raw.split('/').filter(Boolean).pop() || ''; if (base) { @@ -705,8 +903,16 @@ function collectVirtualDirectoryEntries(dirPath: string): Map String(entry || '')).filter((entry: string) => entry.length > 0); + } catch { + return []; + } + } try { - const result = (window as any).electron?.execCommandSync?.('/bin/ls', ['-A1', dirPath], {}); + const result = runExtensionCommandSync('/bin/ls', ['-A1', dirPath], {}); if (!result || result.exitCode !== 0) return []; return String(result.stdout || '') .split(/\r?\n/) @@ -729,7 +935,7 @@ async function getRealDirectoryEntriesAsync(dirPath: string): Promise function getEntryKindFromRealStat(path: string): FsEntryKind { try { - const stat = (window as any).electron?.statSync?.(path); + const stat = realStatSync(path); if (!stat?.exists) return 'unknown'; if (stat.isDirectory) return 'directory'; if (stat.isFile) return 'file'; @@ -773,7 +979,7 @@ function combineDirectoryEntries( } function assertReadableDirectory(path: string, hasEntries: boolean): void { - const stat = (window as any).electron?.statSync?.(path); + const stat = realStatSync(path); if (stat?.exists && !stat.isDirectory) { throw createFsError('ENOTDIR', 'scandir', path); } @@ -819,7 +1025,7 @@ const fsStub: Record = { if (getStoredText(path) !== null) return true; // Fall back to real file system via sync IPC try { - return (window as any).electron?.fileExistsSync?.(path) ?? false; + return realFileExistsSync(path); } catch { return false; } @@ -834,7 +1040,7 @@ const fsStub: Record = { } // Fall back to real file system via sync IPC (for reading extension assets etc.) try { - const result = (window as any).electron?.readFileSync?.(path); + const result = readRealFileSyncText(path); if (result && result.data !== null) { if (opts?.encoding || typeof opts === 'string') return result.data; return BufferPolyfill.from(result.data); @@ -876,7 +1082,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} return fsStatResult(false); @@ -886,7 +1092,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} return fsStatResult(false); @@ -915,7 +1121,7 @@ const fsStub: Record = { const path = resolveFsLookupPath(p); if (getStoredText(path) !== null) return; try { - if ((window as any).electron?.fileExistsSync?.(path)) return; + if (realFileExistsSync(path)) return; } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1007,7 +1213,7 @@ const fsStub: Record = { cb(null, content); } else { // Fall back to real file system - const exists = Boolean((window as any).electron?.fileExistsSync?.(path)); + const exists = realFileExistsSync(path); ((window as any).electron?.readFile?.(path) as Promise) ?.then((data: string) => { if (exists) { @@ -1049,7 +1255,7 @@ const fsStub: Record = { if (getStoredText(path) !== null) cb(null); else { try { - if ((window as any).electron?.fileExistsSync?.(path)) { cb(null); return; } + if (realFileExistsSync(path)) { cb(null); return; } } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1067,7 +1273,7 @@ const fsStub: Record = { return; } try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) { cb(null, fsStatResult(true, result.isDirectory, Number(result.size) || 0, result)); return; @@ -1085,7 +1291,7 @@ const fsStub: Record = { return; } try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) { cb(null, fsStatResult(true, result.isDirectory, Number(result.size) || 0, result)); return; @@ -1131,7 +1337,7 @@ const fsStub: Record = { } // Fall back to real file system try { - const exists = Boolean((window as any).electron?.fileExistsSync?.(path)); + const exists = realFileExistsSync(path); const data = await (window as any).electron?.readFile?.(path); if (exists) { if (opts?.encoding || typeof opts === 'string') return data; @@ -1167,7 +1373,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} throw createFsError('ENOENT', 'stat', path); @@ -1177,7 +1383,7 @@ const fsStub: Record = { const content = getStoredText(path); if (content !== null) return fsStatResult(true, false, content.length); try { - const result = (window as any).electron?.statSync?.(path); + const result = realStatSync(path); if (result?.exists) return fsStatResult(true, result.isDirectory, Number(result.size) || 0, result); } catch {} throw createFsError('ENOENT', 'lstat', path); @@ -1187,7 +1393,7 @@ const fsStub: Record = { const path = resolveFsLookupPath(p); if (getStoredText(path) !== null) return; try { - if ((window as any).electron?.fileExistsSync?.(path)) return; + if (realFileExistsSync(path)) return; } catch {} const err: any = new Error(`ENOENT: no such file or directory, access '${path}'`); err.code = 'ENOENT'; @@ -1874,7 +2080,7 @@ const childProcessStub = { }, execSync: (command: string) => { const normalizedCommand = rewriteShellCommandForMissingBinary(command); - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( '/bin/zsh', ['-lc', normalizedCommand], { shell: false } @@ -1982,11 +2188,11 @@ const childProcessStub = { options = args[1]; } - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( file, execArgs, { shell: false, env: options?.env, cwd: options?.cwd, input: options?.input } - ) || { stdout: '', stderr: '', exitCode: 0 }; + ); if (result.exitCode !== 0) { const stderrOrMsg = String(result?.stderr || ''); @@ -2203,11 +2409,11 @@ const childProcessStub = { }, spawnSync: (command: string, spawnArgs?: string[], options?: any) => { const resolvedCommand = resolveExecutablePath(command); - const result = (window as any).electron?.execCommandSync?.( + const result = runExtensionCommandSync( resolvedCommand, Array.isArray(spawnArgs) ? spawnArgs : [], { shell: options?.shell ?? false, env: options?.env, cwd: options?.cwd, input: options?.input } - ) || { stdout: '', stderr: '', exitCode: 0 }; + ); const stdoutBuf = BufferPolyfill.from(result.stdout || ''); const stderrBuf = BufferPolyfill.from(result.stderr || ''); @@ -3037,6 +3243,11 @@ function isNodeBuiltinRequest(name: string): boolean { return false; } +function shouldUseSuperCmdBuiltinFacade(name: string): boolean { + const normalized = name.startsWith('node:') ? name.slice(5) : name; + return normalized === 'fs' || normalized === 'fs/promises' || normalized === 'child_process'; +} + // Capture real Node globals once, then remove them from globalThis so // extensions can't reach around fakeRequire. Runs at module load, before // any extension code executes. @@ -3571,7 +3782,8 @@ end tell`.trim(); function loadExtensionExport( code: string, extensionPath?: string, - timerRegistry?: TimerRegistry + timerRegistry?: TimerRegistry, + extensionIdentity = extensionPath || 'unknown-extension' ): Function | null { const patchSchemeDynamicImports = (sourceCode: string): string => { // Extension bundles may emit dynamic imports for native Raycast bridges @@ -3747,6 +3959,10 @@ function loadExtensionExport( // Prefer real Node (via the preload bridge) when the hosting window // has Node enabled. Falls back to the stub if the module isn't a // recognised built-in, or if real require throws. + if (shouldUseSuperCmdBuiltinFacade(name)) { + const facade = nodeBuiltinStubs[name] || nodeBuiltinStubs[`node:${name}`]; + if (facade) return facade; + } const realModule = tryRealNodeRequire(name); if (realModule !== undefined) { return realModule; @@ -3962,28 +4178,12 @@ function loadExtensionExport( // anyway because requests are routed through the main process). Code // that genuinely needs navigator can still reach it via // `globalThis.navigator` or `window.navigator`. - const fn = new Function( - 'exports', - 'require', - 'module', - '__filename', - '__dirname', - 'process', - 'Buffer', - 'global', - 'globalThis', - 'setImmediate', - 'clearImmediate', - 'setInterval', - 'clearInterval', - 'setTimeout', - 'clearTimeout', - 'requestAnimationFrame', - 'cancelAnimationFrame', - 'navigator', - '__scDynamicImport', - executableCode - ); + const { wrapper: fn, cacheHit } = getCompiledExtensionWrapper({ + extensionIdentity, + code, + executableCode, + }); + console.debug(`[loadExtensionExport] Wrapper cache ${cacheHit ? 'hit' : 'miss'} for ${extensionIdentity}`); fn( moduleExports, @@ -4239,6 +4439,11 @@ const ExtensionView: React.FC = ({ mode, ]); + const extensionWrapperIdentity = useMemo( + () => [owner, extensionName, commandName, extensionPath].map((part) => String(part || '')).join('\0'), + [owner, extensionName, commandName, extensionPath] + ); + // Set extension context before loading (so getPreferenceValues etc. work) useEffect(() => { setExtensionContext(extensionCtx); @@ -4266,9 +4471,9 @@ const ExtensionView: React.FC = ({ // Load under the extension's scoped context so other async extension work // cannot leak a different context into this bundle. return withExtensionContext(extensionCtx, () => - loadExtensionExport(code, extensionPath, timerRegistryRef.current) + loadExtensionExport(code, extensionPath, timerRegistryRef.current, extensionWrapperIdentity) ); - }, [code, buildError, extensionCtx, extensionPath]); + }, [code, buildError, extensionCtx, extensionPath, extensionWrapperIdentity]); // Is this a no-view command? Trust the mode from package.json. // NOTE: 'menu-bar' commands ARE React components (they use hooks), diff --git a/src/renderer/src/QuickLinkManager.tsx b/src/renderer/src/QuickLinkManager.tsx index 2f749757..deaea4c2 100644 --- a/src/renderer/src/QuickLinkManager.tsx +++ b/src/renderer/src/QuickLinkManager.tsx @@ -1286,7 +1286,7 @@ const QuickLinkManager: React.FC = ({ onClose, initialVie const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineDynamicInputRefs = useRef>([]); + const inlineDynamicInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/SnippetManager.tsx b/src/renderer/src/SnippetManager.tsx index 3e2e8c65..e5ef51dd 100644 --- a/src/renderer/src/SnippetManager.tsx +++ b/src/renderer/src/SnippetManager.tsx @@ -432,7 +432,7 @@ const SnippetManager: React.FC = ({ onClose, initialView }) const inputRef = useRef(null); const inlineArgumentLaneRef = useRef(null); const inlineArgumentClusterRef = useRef(null); - const inlineArgumentInputRefs = useRef>([]); + const inlineArgumentInputRefs = useRef>([]); const firstDynamicInputRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); diff --git a/src/renderer/src/camera-capture.ts b/src/renderer/src/camera-capture.ts new file mode 100644 index 00000000..7be85a95 --- /dev/null +++ b/src/renderer/src/camera-capture.ts @@ -0,0 +1,73 @@ +export interface CapturePreviewUrlApi { + createObjectURL: (blob: Blob) => string; + revokeObjectURL: (objectUrl: string) => void; +} + +export interface CapturePreviewUrlManager { + getCurrentUrl: () => string | null; + show: (blob: Blob) => string | null; + clear: () => void; + dispose: () => void; +} + +function getCapturePreviewUrlApi(): CapturePreviewUrlApi | null { + if ( + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return null; + } + return URL; +} + +export function createCapturePreviewUrlManager( + urlApi: CapturePreviewUrlApi | null = getCapturePreviewUrlApi() +): CapturePreviewUrlManager { + let currentUrl: string | null = null; + + const clear = () => { + const urlToRevoke = currentUrl; + currentUrl = null; + if (!urlToRevoke || !urlApi) return; + try { + urlApi.revokeObjectURL(urlToRevoke); + } catch {} + }; + + return { + getCurrentUrl: () => currentUrl, + show: (blob: Blob) => { + let nextUrl: string | null = null; + if (urlApi) { + try { + nextUrl = urlApi.createObjectURL(blob); + } catch { + nextUrl = null; + } + } + clear(); + currentUrl = nextUrl; + return currentUrl; + }, + clear, + dispose: clear, + }; +} + +export function createCapturePreview( + blob: Blob, + previewUrlManager: CapturePreviewUrlManager +): { url: string | null; visible: boolean } { + const url = previewUrlManager.show(blob); + return { + url, + visible: Boolean(url), + }; +} + +export function encodeCanvasAsPngBlob(canvas: Pick): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), 'image/png'); + }); +} diff --git a/src/renderer/src/components/HiddenExtensionRunners.tsx b/src/renderer/src/components/HiddenExtensionRunners.tsx index 6f21f494..db83a2c5 100644 --- a/src/renderer/src/components/HiddenExtensionRunners.tsx +++ b/src/renderer/src/components/HiddenExtensionRunners.tsx @@ -1,7 +1,8 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo, useCallback, useEffect, useMemo } from 'react'; import ExtensionView from '../ExtensionView'; import type { BackgroundNoViewRun, MenuBarEntry } from '../hooks/useMenuBarExtensions'; import { NOOP_ON_CLOSE } from '../utils/launcher-misc'; +import { removeBackgroundNoViewRun } from '../utils/background-no-view-runs'; type HiddenExtensionRunnersProps = { menuBarExtensions: MenuBarEntry[]; @@ -9,11 +10,55 @@ type HiddenExtensionRunnersProps = { setBackgroundNoViewRuns: React.Dispatch>; }; +type BackgroundNoViewRunnerProps = { + run: BackgroundNoViewRun; + onRunFinished: (runId: string) => void; +}; + +const BackgroundNoViewRunner: React.FC = ({ run, onRunFinished }) => { + const closeRun = useCallback(() => { + onRunFinished(run.runId); + }, [onRunFinished, run.runId]); + + return ( + + ); +}; + const HiddenExtensionRunners: React.FC = ({ menuBarExtensions, backgroundNoViewRuns, setBackgroundNoViewRuns, }) => { + const onBackgroundNoViewRunFinished = useCallback((runId: string) => { + setBackgroundNoViewRuns((prev) => removeBackgroundNoViewRun(prev, runId)); + }, [setBackgroundNoViewRuns]); + + useEffect(() => { + return () => { + setBackgroundNoViewRuns((prev) => (prev.length === 0 ? prev : [])); + }; + }, [setBackgroundNoViewRuns]); + const menuBarRunner = useMemo(() => { if (menuBarExtensions.length === 0) return null; return ( @@ -49,33 +94,15 @@ const HiddenExtensionRunners: React.FC = ({ return (
{backgroundNoViewRuns.map((run) => ( - { - setBackgroundNoViewRuns((prev) => prev.filter((item) => item.runId !== run.runId)); - }} + run={run} + onRunFinished={onBackgroundNoViewRunFinished} /> ))}
); - }, [backgroundNoViewRuns, setBackgroundNoViewRuns]); + }, [backgroundNoViewRuns, onBackgroundNoViewRunFinished]); return ( <> diff --git a/src/renderer/src/components/LauncherCommandList.tsx b/src/renderer/src/components/LauncherCommandList.tsx index d6873475..9a1e1921 100644 --- a/src/renderer/src/components/LauncherCommandList.tsx +++ b/src/renderer/src/components/LauncherCommandList.tsx @@ -9,6 +9,176 @@ export type LauncherCommandSection = { items: CommandInfo[]; }; +const COMMAND_ROW_HEIGHT = 38; +const SECTION_HEADER_HEIGHT = 26; +const CALCULATOR_CARD_HEIGHT = 124; +const DEFAULT_VIEWPORT_HEIGHT = 420; +const VIRTUALIZATION_THRESHOLD = 120; +const VIRTUALIZATION_OVERSCAN_PX = COMMAND_ROW_HEIGHT * 6; + +type LauncherVirtualEntry = + | { + kind: 'calculator'; + key: string; + top: number; + height: number; + absoluteIndex: number; + } + | { + kind: 'section'; + key: string; + title: string; + top: number; + height: number; + } + | { + kind: 'command'; + key: string; + command: CommandInfo; + flatIndex: number; + absoluteIndex: number; + top: number; + height: number; + }; + +type LauncherVirtualList = { + entries: LauncherVirtualEntry[]; + totalHeight: number; +}; + +function buildVirtualEntries( + sections: LauncherCommandSection[], + calcResult: CalcResult | null, + calcOffset: number +): LauncherVirtualList { + const entries: LauncherVirtualEntry[] = []; + let top = 0; + let flatIndexCursor = 0; + + if (calcResult) { + entries.push({ + kind: 'calculator', + key: 'calculator', + top, + height: CALCULATOR_CARD_HEIGHT, + absoluteIndex: 0, + }); + top += CALCULATOR_CARD_HEIGHT; + } + + sections.forEach((section, sectionIndex) => { + const sectionStartIndex = flatIndexCursor; + if (section.title) { + entries.push({ + kind: 'section', + key: `section-${sectionIndex}-${sectionStartIndex}-${section.title}`, + title: section.title, + top, + height: SECTION_HEADER_HEIGHT, + }); + top += SECTION_HEADER_HEIGHT; + } + + section.items.forEach((command, itemIndex) => { + const flatIndex = sectionStartIndex + itemIndex; + entries.push({ + kind: 'command', + key: command.id, + command, + flatIndex, + absoluteIndex: flatIndex + calcOffset, + top, + height: COMMAND_ROW_HEIGHT, + }); + top += COMMAND_ROW_HEIGHT; + }); + flatIndexCursor += section.items.length; + }); + + return { entries, totalHeight: top }; +} + +function findEntryIndexAtOffset(entries: LauncherVirtualEntry[], offset: number): number { + let low = 0; + let high = entries.length - 1; + let match = entries.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const entry = entries[mid]; + if (entry.top + entry.height >= offset) { + match = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.max(0, Math.min(match, entries.length)); +} + +function getEntryRangeForOffsets( + entries: LauncherVirtualEntry[], + startOffset: number, + endOffset: number +): { start: number; end: number } { + if (entries.length === 0) return { start: 0, end: 0 }; + const start = findEntryIndexAtOffset(entries, startOffset); + let end = start; + while (end < entries.length && entries[end].top <= endOffset) { + end += 1; + } + return { start, end }; +} + +function mergeEntryRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> { + const normalized = ranges + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + + normalized.forEach((range) => { + const last = merged[merged.length - 1]; + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end); + return; + } + merged.push({ ...range }); + }); + + return merged; +} + +function getVirtualizedEntries( + entries: LauncherVirtualEntry[], + scrollTop: number, + viewportHeight: number, + selectedIndex: number +): LauncherVirtualEntry[] { + const viewportStart = Math.max(0, scrollTop - VIRTUALIZATION_OVERSCAN_PX); + const viewportEnd = scrollTop + viewportHeight + VIRTUALIZATION_OVERSCAN_PX; + const ranges = [getEntryRangeForOffsets(entries, viewportStart, viewportEnd)]; + const selectedEntry = entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex); + + if (selectedEntry && (selectedEntry.top < viewportStart || selectedEntry.top + selectedEntry.height > viewportEnd)) { + ranges.push( + getEntryRangeForOffsets( + entries, + Math.max(0, selectedEntry.top - VIRTUALIZATION_OVERSCAN_PX), + selectedEntry.top + selectedEntry.height + VIRTUALIZATION_OVERSCAN_PX + ) + ); + } + + return mergeEntryRanges(ranges).flatMap((range) => entries.slice(range.start, range.end)); +} + +function useLatestValue(value: T): React.MutableRefObject { + const ref = React.useRef(value); + ref.current = value; + return ref; +} + type LauncherCommandListProps = { listRef: React.RefObject; itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; @@ -47,75 +217,180 @@ const LauncherCommandList: React.FC = ({ onCommandClick, onCommandContextMenu, t, -}) => ( -
- {isLoading ? ( -
-

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

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

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

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

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

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

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

+
+ ) : shouldVirtualize ? ( +
+ {visibleEntries.map((entry) => renderEntry(entry, true))} +
+ ) : ( +
+ {visibleEntries.map((entry) => renderEntry(entry, false))} +
+ )} +
+ ); +}; export default LauncherCommandList; diff --git a/src/renderer/src/components/LauncherCommandRow.tsx b/src/renderer/src/components/LauncherCommandRow.tsx index 877b8247..62ada67a 100644 --- a/src/renderer/src/components/LauncherCommandRow.tsx +++ b/src/renderer/src/components/LauncherCommandRow.tsx @@ -12,30 +12,63 @@ import { type LauncherCommandRowProps = { command: CommandInfo; flatIndex: number; + absoluteIndex: number; selected: boolean; - itemRef: (el: HTMLDivElement | null) => void; + registerItemRef: (absoluteIndex: number, el: HTMLDivElement | null) => void; commandAlias: string; commandHotkey: string; - onClick: (event: React.MouseEvent) => void; - onContextMenu: (event: React.MouseEvent) => void; + onCommandClick: ( + command: CommandInfo, + selectedIndex: number, + event?: React.MouseEvent + ) => void | Promise; + onCommandContextMenu: ( + event: React.MouseEvent, + command: CommandInfo, + selectedIndex: number + ) => void; t: (key: string, params?: Record) => string; }; -const LauncherCommandRow: React.FC = ({ +const LauncherCommandRowComponent: React.FC = ({ command, flatIndex, + absoluteIndex, selected, - itemRef, + registerItemRef, commandAlias, commandHotkey, - onClick, - onContextMenu, + onCommandClick, + onCommandContextMenu, t, }) => { - const accessoryLabel = getCommandAccessoryLabel(command); - const typeBadgeLabel = getCommandTypeBadgeLabel(command, t); - const fallbackCategory = getCategoryLabel(command.category, t); - const hotkeyParts = commandHotkey ? getShortcutDisplayParts(commandHotkey) : []; + const accessoryLabel = React.useMemo(() => getCommandAccessoryLabel(command), [command]); + const typeBadgeLabel = React.useMemo(() => getCommandTypeBadgeLabel(command, t), [command, t]); + const fallbackCategory = React.useMemo(() => getCategoryLabel(command.category, t), [command.category, t]); + const hotkeyParts = React.useMemo( + () => (commandHotkey ? getShortcutDisplayParts(commandHotkey) : []), + [commandHotkey] + ); + const displayTitle = React.useMemo(() => getCommandDisplayTitle(command, t), [command, t]); + const commandIcon = React.useMemo(() => renderCommandIcon(command), [command]); + const itemRef = React.useCallback( + (el: HTMLDivElement | null) => { + registerItemRef(absoluteIndex, el); + }, + [absoluteIndex, registerItemRef] + ); + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + void onCommandClick(command, absoluteIndex, event); + }, + [absoluteIndex, command, onCommandClick] + ); + const handleContextMenu = React.useCallback( + (event: React.MouseEvent) => { + onCommandContextMenu(event, command, absoluteIndex); + }, + [absoluteIndex, command, onCommandContextMenu] + ); return (
= ({ className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ selected ? 'selected' : '' }`} - onClick={onClick} - onContextMenu={onContextMenu} + onClick={handleClick} + onContextMenu={handleContextMenu} >
- {renderCommandIcon(command)} + {commandIcon}
- {getCommandDisplayTitle(command, t)} + {displayTitle}
{accessoryLabel ? (
@@ -95,4 +128,6 @@ const LauncherCommandRow: React.FC = ({ ); }; +const LauncherCommandRow = React.memo(LauncherCommandRowComponent); + export default LauncherCommandRow; diff --git a/src/renderer/src/hooks/backgroundRefreshTimers.ts b/src/renderer/src/hooks/backgroundRefreshTimers.ts new file mode 100644 index 00000000..e067fc35 --- /dev/null +++ b/src/renderer/src/hooks/backgroundRefreshTimers.ts @@ -0,0 +1,136 @@ +import type { CommandInfo } from '../../types/electron'; + +export type BackgroundRefreshTimerKind = 'extension' | 'inline-script'; + +export interface BackgroundRefreshTimerDescriptor { + key: string; + kind: BackgroundRefreshTimerKind; + command: CommandInfo; + intervalMs: number; + extensionCommand?: { extName: string; cmdName: string }; +} + +export interface BackgroundRefreshTimerEntry { + timerId: TTimerId; +} + +export interface BackgroundRefreshTimerReconcileResult { + created: number; + retained: number; + cleared: number; +} + +export interface BackgroundRefreshTimerReconcileOptions { + timers: Map>; + descriptors: BackgroundRefreshTimerDescriptor[]; + createTimer: (descriptor: BackgroundRefreshTimerDescriptor) => TTimerId; + clearTimer: (timerId: TTimerId) => void; +} + +export function parseExtensionCommandPath(pathValue: string): { extName: string; cmdName: string } | null { + const rawPath = String(pathValue || '').trim(); + const separatorIndex = rawPath.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex >= rawPath.length - 1) return null; + + const extName = rawPath.slice(0, separatorIndex).trim(); + const cmdName = rawPath.slice(separatorIndex + 1).trim(); + if (!extName || !cmdName) return null; + + return { extName, cmdName }; +} + +export function getBackgroundRefreshTimerKey(cmd: CommandInfo): string | null { + if (cmd.category === 'extension' && typeof cmd.interval === 'string' && cmd.path) { + const identity = parseExtensionCommandPath(cmd.path); + if (!identity) return null; + + return [ + 'extension', + cmd.id, + `${identity.extName}/${identity.cmdName}`, + cmd.path.trim(), + cmd.mode || '', + cmd.interval, + ].join('\u0000'); + } + + if (cmd.category === 'script' && cmd.mode === 'inline' && typeof cmd.interval === 'string') { + return ['inline-script', cmd.id, (cmd.path || '').trim(), cmd.mode, cmd.interval].join('\u0000'); + } + + return null; +} + +export function getBackgroundRefreshTimerDescriptors( + commands: CommandInfo[], + parseIntervalToMs: (interval: string) => number | null +): BackgroundRefreshTimerDescriptor[] { + const descriptors: BackgroundRefreshTimerDescriptor[] = []; + const seenKeys = new Set(); + + for (const cmd of commands) { + const key = getBackgroundRefreshTimerKey(cmd); + if (!key || seenKeys.has(key) || typeof cmd.interval !== 'string') continue; + + const intervalMs = parseIntervalToMs(cmd.interval); + if (!intervalMs) continue; + + descriptors.push({ + key, + kind: cmd.category === 'extension' ? 'extension' : 'inline-script', + command: cmd, + intervalMs, + extensionCommand: cmd.category === 'extension' ? parseExtensionCommandPath(cmd.path || '') || undefined : undefined, + }); + seenKeys.add(key); + } + + return descriptors; +} + +export function reconcileBackgroundRefreshTimers({ + timers, + descriptors, + createTimer, + clearTimer, +}: BackgroundRefreshTimerReconcileOptions): BackgroundRefreshTimerReconcileResult { + const nextKeys = new Set(descriptors.map((descriptor) => descriptor.key)); + let created = 0; + let retained = 0; + let cleared = 0; + + for (const [key, entry] of Array.from(timers.entries())) { + if (!nextKeys.has(key)) { + clearTimer(entry.timerId); + timers.delete(key); + cleared += 1; + } + } + + for (const descriptor of descriptors) { + if (timers.has(descriptor.key)) { + retained += 1; + continue; + } + + timers.set(descriptor.key, { timerId: createTimer(descriptor) }); + created += 1; + } + + return { created, retained, cleared }; +} + +export function clearBackgroundRefreshTimers( + timers: Map>, + clearTimer: (timerId: TTimerId) => void +): number { + let cleared = 0; + + for (const entry of timers.values()) { + clearTimer(entry.timerId); + cleared += 1; + } + + timers.clear(); + return cleared; +} diff --git a/src/renderer/src/hooks/cursorPromptResultBatcher.ts b/src/renderer/src/hooks/cursorPromptResultBatcher.ts new file mode 100644 index 00000000..0cf68fe3 --- /dev/null +++ b/src/renderer/src/hooks/cursorPromptResultBatcher.ts @@ -0,0 +1,106 @@ +export const CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS = 32; + +export interface CursorPromptResultRef { + current: string; +} + +export interface CursorPromptResultBatcherOptions { + resultRef: CursorPromptResultRef; + setVisibleResult: (value: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface CursorPromptResultBatcher { + appendChunk: (chunk: string) => void; + flush: () => void; + reset: (nextResult?: string) => void; + cancelPendingFlush: () => void; + dispose: () => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createCursorPromptResultBatcher({ + resultRef, + setVisibleResult, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS, +}: CursorPromptResultBatcherOptions): CursorPromptResultBatcher { + let visibleResult = resultRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleResult = () => { + const nextResult = resultRef.current; + if (nextResult === visibleResult) return; + visibleResult = nextResult; + setVisibleResult(nextResult); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleResult(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + resultRef.current += chunk; + scheduleFlush(); + }, + flush() { + cancelPendingFlush(); + publishVisibleResult(); + }, + reset(nextResult = '') { + cancelPendingFlush(); + resultRef.current = nextResult; + visibleResult = nextResult; + setVisibleResult(nextResult); + }, + cancelPendingFlush, + dispose: cancelPendingFlush, + }; +} diff --git a/src/renderer/src/hooks/useAiChat.ts b/src/renderer/src/hooks/useAiChat.ts index fea32c94..a4bf7b7f 100644 --- a/src/renderer/src/hooks/useAiChat.ts +++ b/src/renderer/src/hooks/useAiChat.ts @@ -20,6 +20,7 @@ import type { AiChatMessage as AiMessage, AiChatSnapshot, } from '../../types/electron'; +import { createAiChatStreamBuffer } from '../utils/ai-chat-stream-buffer'; export type { AiConversation, AiMessage }; @@ -36,7 +37,7 @@ export interface UseAiChatReturn { setAiQuery: (value: string) => void; aiInputRef: React.RefObject; aiResponseRef: React.RefObject; - setAiAvailable: (value: boolean) => void; + setAiAvailable: React.Dispatch>; conversations: AiConversation[]; activeConversationId: string | null; startAiChat: (searchQuery: string) => void; @@ -80,9 +81,65 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const streamingMessageIdRef = useRef(null); const activeConversationIdRef = useRef(null); const messagesRef = useRef([]); + const streamBufferRef = useRef | null>(null); const aiInputRef = useRef(null); const aiResponseRef = useRef(null); + const setMessagesSnapshot = useCallback((nextMessages: AiMessage[]) => { + messagesRef.current = nextMessages; + setMessages(nextMessages); + }, []); + + const updateMessagesSnapshot = useCallback((updater: (current: AiMessage[]) => AiMessage[]) => { + const current = messagesRef.current; + const next = updater(current); + if (next === current) return current; + messagesRef.current = next; + setMessages(next); + return next; + }, []); + + const applyStreamingContent = useCallback( + (messageId: string, content: string) => { + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.content === content) return message; + changed = true; + return { ...message, content }; + }); + return changed ? next : current; + }); + }, + [updateMessagesSnapshot] + ); + + if (streamBufferRef.current === null) { + streamBufferRef.current = createAiChatStreamBuffer({ + onFlush: (content) => { + const messageId = streamingMessageIdRef.current; + if (messageId) { + applyStreamingContent(messageId, content); + } + }, + }); + } + + const flushStreamingBuffer = useCallback(() => { + streamBufferRef.current?.flushNow(); + }, []); + + const resetStreamingBuffer = useCallback((content = '') => { + streamBufferRef.current?.reset(content); + }, []); + + useEffect(() => { + return () => { + streamBufferRef.current?.cancel(); + }; + }, []); + useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); @@ -104,7 +161,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const nextActive = nextConversations.find((conversation) => conversation.id === activeId); if (nextActive) { - setMessages(nextActive.messages); + setMessagesSnapshot(nextActive.messages); return; } @@ -112,7 +169,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC activeConversationIdRef.current = null; setActiveConversationId(null); } - }, []); + }, [setMessagesSnapshot]); const refreshSnapshot = useCallback(() => { void window.electron.getAiChatSnapshot() @@ -146,37 +203,29 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const appendToStreamingMessage = (chunk: string) => { const msgId = streamingMessageIdRef.current; if (!msgId) return; - setMessages((prev) => - prev.map((message) => ( - message.id === msgId - ? { ...message, content: message.content + chunk } - : message - )) - ); + streamBufferRef.current?.append(chunk); }; const finalizeConversation = () => { const conversationId = activeConversationIdRef.current; if (!conversationId) return; - setMessages((current) => { - const existing = conversations.find((conversation) => conversation.id === conversationId); - const updatedConversation: AiConversation = { - id: conversationId, - title: - existing?.title && existing.title !== 'New Chat' - ? existing.title - : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), - messages: current, - createdAt: existing?.createdAt ?? Date.now(), - updatedAt: Date.now(), - source: existing?.source || 'local', - ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), - ...(existing?.metadata ? { metadata: existing.metadata } : {}), - }; - persistConversation(updatedConversation); - return current; - }); + const current = messagesRef.current; + const existing = conversations.find((conversation) => conversation.id === conversationId); + const updatedConversation: AiConversation = { + id: conversationId, + title: + existing?.title && existing.title !== 'New Chat' + ? existing.title + : makeTitle(current.find((message) => message.role === 'user')?.content || 'New Chat'), + messages: current, + createdAt: existing?.createdAt ?? Date.now(), + updatedAt: Date.now(), + source: existing?.source || 'local', + ...(existing?.sourceConversationId ? { sourceConversationId: existing.sourceConversationId } : {}), + ...(existing?.metadata ? { metadata: existing.metadata } : {}), + }; + persistConversation(updatedConversation); }; const handleChunk = (data: { requestId: string; chunk: string }) => { @@ -187,10 +236,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC const handleDone = (data: { requestId: string }) => { if (data.requestId === aiRequestIdRef.current) { + flushStreamingBuffer(); aiStreamingRef.current = false; setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -199,20 +250,14 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiStreamingRef.current = false; const msgId = streamingMessageIdRef.current; if (msgId) { - setMessages((prev) => - prev.map((message) => - message.id === msgId - ? { - ...message, - content: message.content + (message.content ? '\n\n' : '') + `Error: ${data.error}`, - } - : message - ) - ); + const currentContent = streamBufferRef.current?.getContent() ?? ''; + streamBufferRef.current?.append(`${currentContent ? '\n\n' : ''}Error: ${data.error}`); + flushStreamingBuffer(); } setAiStreaming(false); - streamingMessageIdRef.current = null; finalizeConversation(); + streamingMessageIdRef.current = null; + resetStreamingBuffer(); } }; @@ -227,7 +272,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC removeDone?.(); removeError?.(); }; - }, [hasBeenActivated, conversations, persistConversation]); + }, [hasBeenActivated, conversations, flushStreamingBuffer, persistConversation, resetStreamingBuffer]); useEffect(() => { if (aiResponseRef.current) { @@ -283,16 +328,16 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC content: '', createdAt: Date.now(), }; + resetStreamingBuffer(); streamingMessageIdRef.current = assistantMessage.id; - setMessages((prev) => { - const next = [...prev, userMessage, assistantMessage]; - sendChatTurn([...prev, userMessage]); - return next; - }); + const currentMessages = messagesRef.current; + const nextMessages = [...currentMessages, userMessage, assistantMessage]; + setMessagesSnapshot(nextMessages); + sendChatTurn([...currentMessages, userMessage]); setAiQuery(''); }, - [aiAvailable, persistConversation, sendChatTurn] + [aiAvailable, persistConversation, resetStreamingBuffer, sendChatTurn, setMessagesSnapshot] ); const startAiChat = useCallback( @@ -301,7 +346,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setHasBeenActivated(true); activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiMode(true); const trimmed = searchQuery.trim(); if (trimmed) { @@ -310,10 +356,11 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiQuery(''); } }, - [aiAvailable, setAiMode, sendMessage] + [aiAvailable, resetStreamingBuffer, setAiMode, sendMessage, setMessagesSnapshot] ); const stopStreaming = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -321,13 +368,21 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiStreaming(false); const messageId = streamingMessageIdRef.current; if (messageId) { - setMessages((prev) => - prev.map((message) => (message.id === messageId ? { ...message, cancelled: true } : message)) - ); + updateMessagesSnapshot((current) => { + let changed = false; + const next = current.map((message) => { + if (message.id !== messageId) return message; + if (message.cancelled) return message; + changed = true; + return { ...message, cancelled: true }; + }); + return changed ? next : current; + }); } streamingMessageIdRef.current = null; aiRequestIdRef.current = null; - }, []); + resetStreamingBuffer(); + }, [flushStreamingBuffer, resetStreamingBuffer, updateMessagesSnapshot]); const newChat = useCallback(() => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -338,11 +393,12 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); setAiStreaming(false); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const selectConversation = useCallback((id: string) => { if (aiRequestIdRef.current && aiStreamingRef.current) { @@ -351,6 +407,7 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC aiRequestIdRef.current = null; aiStreamingRef.current = false; streamingMessageIdRef.current = null; + resetStreamingBuffer(); setAiStreaming(false); setConversations((current) => { @@ -358,13 +415,13 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (conversation) { activeConversationIdRef.current = id; setActiveConversationId(id); - setMessages(conversation.messages); + setMessagesSnapshot(conversation.messages); } return current; }); setAiQuery(''); setTimeout(() => aiInputRef.current?.focus(), 0); - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const deleteConversation = useCallback((id: string) => { setConversations((prev) => prev.filter((conversation) => conversation.id !== id)); @@ -372,7 +429,8 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC if (activeConversationIdRef.current === id) { activeConversationIdRef.current = null; setActiveConversationId(null); - setMessages([]); + resetStreamingBuffer(); + setMessagesSnapshot([]); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -381,9 +439,10 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC streamingMessageIdRef.current = null; setAiStreaming(false); } - }, []); + }, [resetStreamingBuffer, setMessagesSnapshot]); const exitAiMode = useCallback(() => { + flushStreamingBuffer(); if (aiRequestIdRef.current && aiStreamingRef.current) { window.electron.aiCancel(aiRequestIdRef.current); } @@ -393,8 +452,9 @@ export function useAiChat({ onExitAiMode, setAiMode }: UseAiChatOptions): UseAiC setAiMode(false); setAiStreaming(false); setAiQuery(''); + resetStreamingBuffer(); onExitAiMode?.(); - }, [setAiMode, onExitAiMode]); + }, [flushStreamingBuffer, resetStreamingBuffer, setAiMode, onExitAiMode]); useEffect(() => { if (messages.length === 0 && !aiQuery && !aiStreaming) return; diff --git a/src/renderer/src/hooks/useBackgroundRefresh.ts b/src/renderer/src/hooks/useBackgroundRefresh.ts index 0c19005d..013a2903 100644 --- a/src/renderer/src/hooks/useBackgroundRefresh.ts +++ b/src/renderer/src/hooks/useBackgroundRefresh.ts @@ -7,13 +7,21 @@ * - Inline script commands (category: "script", mode: "inline"): runs the script and * calls fetchCommands() to refresh the subtitle shown in the launcher * - * All timers are cleared and re-registered whenever the `commands` list changes. - * This ensures newly installed or removed commands are handled correctly. + * Timers are keyed by stable command identity, path, mode, and interval so + * unchanged background commands keep their existing intervals across command + * list refreshes. Removed commands are cleared, and changed commands restart. */ -import { useRef, useEffect } from 'react'; +import { useCallback, useRef, useEffect } from 'react'; import type { CommandInfo } from '../../types/electron'; import { parseIntervalToMs } from '../utils/command-helpers'; +import { + clearBackgroundRefreshTimers, + getBackgroundRefreshTimerDescriptors, + reconcileBackgroundRefreshTimers, + type BackgroundRefreshTimerEntry, + type BackgroundRefreshTimerDescriptor, +} from './backgroundRefreshTimers'; import { readJsonObject, getScriptCmdArgsKey, @@ -36,41 +44,20 @@ export interface UseBackgroundRefreshOptions { } export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommandActive }: UseBackgroundRefreshOptions): void { - const intervalTimerIdsRef = useRef([]); + const intervalTimersRef = useRef>>(new Map()); + const fetchCommandsRef = useRef(fetchCommands); + fetchCommandsRef.current = fetchCommands; + const isMenuBarCommandActiveRef = useRef(isMenuBarCommandActive); isMenuBarCommandActiveRef.current = isMenuBarCommandActive; - const parseExtensionCommandPath = (pathValue: string): { extName: string; cmdName: string } | null => { - const rawPath = String(pathValue || '').trim(); - const separatorIndex = rawPath.indexOf('/'); - if (separatorIndex <= 0 || separatorIndex >= rawPath.length - 1) return null; - - const extName = rawPath.slice(0, separatorIndex).trim(); - const cmdName = rawPath.slice(separatorIndex + 1).trim(); - if (!extName || !cmdName) return null; - - return { extName, cmdName }; - }; - - useEffect(() => { - for (const timerId of intervalTimerIdsRef.current) { - window.clearInterval(timerId); - } - intervalTimerIdsRef.current = []; - - const extensionCommands = commands.filter( - (cmd) => cmd.category === 'extension' && typeof cmd.interval === 'string' && cmd.path - ); + const createTimer = useCallback((descriptor: BackgroundRefreshTimerDescriptor): number => { + const cmd = descriptor.command; - for (const cmd of extensionCommands) { - const ms = parseIntervalToMs(cmd.interval); - if (!ms) continue; + if (descriptor.kind === 'extension') { + const { extName, cmdName } = descriptor.extensionCommand!; - const identity = parseExtensionCommandPath(cmd.path || ''); - if (!identity) continue; - const { extName, cmdName } = identity; - - const timerId = window.setInterval(async () => { + return window.setInterval(async () => { try { const result = await window.electron.runExtension(extName, cmdName); if (!result || !result.code) return; @@ -110,47 +97,41 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand } catch (error) { console.error('[BackgroundRefresh] Failed to run command:', cmd.id, error); } - }, ms); - - intervalTimerIdsRef.current.push(timerId); + }, descriptor.intervalMs); } - const inlineScriptCommands = commands.filter( - (cmd) => - cmd.category === 'script' && - cmd.mode === 'inline' && - typeof cmd.interval === 'string' - ); - for (const cmd of inlineScriptCommands) { - const ms = parseIntervalToMs(cmd.interval); - if (!ms) continue; - - const timerId = window.setInterval(async () => { - try { - const storedArgs = readJsonObject(getScriptCmdArgsKey(cmd.id)); - const missingArgs = getMissingRequiredScriptArguments(cmd, storedArgs); - if (missingArgs.length > 0) return; - const result = await window.electron.runScriptCommand({ - commandId: cmd.id, - arguments: storedArgs, - background: true, - }); - if (result?.mode === 'inline') { - await fetchCommands(); - } - } catch (error) { - console.error('[BackgroundRefresh] Failed to run script command:', cmd.id, error); + return window.setInterval(async () => { + try { + const storedArgs = readJsonObject(getScriptCmdArgsKey(cmd.id)); + const missingArgs = getMissingRequiredScriptArguments(cmd, storedArgs); + if (missingArgs.length > 0) return; + const result = await window.electron.runScriptCommand({ + commandId: cmd.id, + arguments: storedArgs, + background: true, + }); + if (result?.mode === 'inline') { + await fetchCommandsRef.current(); } - }, ms); + } catch (error) { + console.error('[BackgroundRefresh] Failed to run script command:', cmd.id, error); + } + }, descriptor.intervalMs); + }, []); - intervalTimerIdsRef.current.push(timerId); - } + useEffect(() => { + const descriptors = getBackgroundRefreshTimerDescriptors(commands, parseIntervalToMs); + reconcileBackgroundRefreshTimers({ + timers: intervalTimersRef.current, + descriptors, + createTimer, + clearTimer: (timerId) => window.clearInterval(timerId), + }); + }, [commands, createTimer]); + useEffect(() => { return () => { - for (const timerId of intervalTimerIdsRef.current) { - window.clearInterval(timerId); - } - intervalTimerIdsRef.current = []; + clearBackgroundRefreshTimers(intervalTimersRef.current, (timerId) => window.clearInterval(timerId)); }; - }, [commands, fetchCommands]); + }, []); } diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index e7f3bf2a..0ba44d51 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -354,6 +354,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult const getBookmarkResults = useCallback((rawInput: string, limit = MAX_SCOPED_BOOKMARK_RESULTS): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('bookmark', decorateBrowserResults(getBrowserEntryCandidates('bookmark', rawInput, entriesRef.current, { + entryIndex: index, preserveBookmarkOrder: !rawInput.trim(), limit, nicknames: nicknamesRef.current, @@ -369,6 +370,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult ): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('history', decorateBrowserResults(getBrowserEntryCandidates('history', rawInput, entriesRef.current, { + entryIndex: index, preserveHistoryChronology: true, includeHistoryTimestamp: true, showHistoryProfileContext: showProfileContext, @@ -667,41 +669,73 @@ type BrowserEntrySearchIndex = { searchFields: TokenSearchField[]; }; +type BrowserEntryKindIndex = { + tokenPrefixEntryIds: Map; + tokenTrigramEntryIds: Map; +}; + type BrowserEntryIndex = { historyByTimeEntryIds: number[]; bookmarksByBrowserOrderEntryIds: number[]; + entrySearchIndexes: Array; + entriesByKind: { + history: BrowserEntryKindIndex; + bookmark: BrowserEntryKindIndex; + }; + bookmarkEntryIdsByNicknameKey: Map; profileCountsByKind: { history: Map; bookmark: Map; }; }; +const EMPTY_BROWSER_ENTRY_KIND_INDEX: BrowserEntryKindIndex = { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), +}; + const EMPTY_BROWSER_ENTRY_INDEX: BrowserEntryIndex = { historyByTimeEntryIds: [], bookmarksByBrowserOrderEntryIds: [], + entrySearchIndexes: [], + entriesByKind: { + history: EMPTY_BROWSER_ENTRY_KIND_INDEX, + bookmark: EMPTY_BROWSER_ENTRY_KIND_INDEX, + }, + bookmarkEntryIdsByNicknameKey: new Map(), profileCountsByKind: { history: new Map(), bookmark: new Map() }, }; const BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128; const BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096; +const BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH = 2; const BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX = 2_000; const browserEntrySearchIndexCache = new Map(); function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryIndex { const historyByTimeEntryIds: number[] = []; const bookmarksByBrowserOrderEntryIds: number[] = []; + const entrySearchIndexes: Array = new Array(entries.length); + const historyIndex = createBrowserEntryKindIndex(); + const bookmarkIndex = createBrowserEntryKindIndex(); + const bookmarkEntryIdsByNicknameKey = new Map(); const historyProfileCounts = new Map(); const bookmarkProfileCounts = new Map(); entries.forEach((entry, entryId) => { if (entry.type !== 'url' && entry.type !== 'bookmark') return; + const searchIndex = createBrowserEntrySearchIndex(entry); + entrySearchIndexes[entryId] = searchIndex; if (entry.type === 'url') { historyByTimeEntryIds.push(entryId); + addBrowserEntryToKindIndex(historyIndex, entryId, searchIndex); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); historyProfileCounts.set(key, (historyProfileCounts.get(key) || 0) + 1); } } else { bookmarksByBrowserOrderEntryIds.push(entryId); + addBrowserEntryToKindIndex(bookmarkIndex, entryId, searchIndex); + addBookmarkEntryToNicknameIndex(bookmarkEntryIdsByNicknameKey, entryId, entry); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); bookmarkProfileCounts.set(key, (bookmarkProfileCounts.get(key) || 0) + 1); @@ -713,6 +747,12 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde return { historyByTimeEntryIds, bookmarksByBrowserOrderEntryIds, + entrySearchIndexes, + entriesByKind: { + history: historyIndex, + bookmark: bookmarkIndex, + }, + bookmarkEntryIdsByNicknameKey, profileCountsByKind: { history: historyProfileCounts, bookmark: bookmarkProfileCounts, @@ -720,6 +760,77 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde }; } +function createBrowserEntryKindIndex(): BrowserEntryKindIndex { + return { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), + }; +} + +function addBrowserEntryToKindIndex( + kindIndex: BrowserEntryKindIndex, + entryId: number, + searchIndex: BrowserEntrySearchIndex +): void { + const tokens = getEntryIndexTokens(searchIndex); + const prefixes = new Set(); + const trigrams = new Set(); + for (const token of tokens) { + prefixes.add(token.slice(0, BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH)); + if (token.length >= 3) { + for (let index = 0; index <= token.length - 3; index += 1) { + trigrams.add(token.slice(index, index + 3)); + } + } + } + for (const prefix of prefixes) pushEntryId(kindIndex.tokenPrefixEntryIds, prefix, entryId); + for (const trigram of trigrams) pushEntryId(kindIndex.tokenTrigramEntryIds, trigram, entryId); +} + +function getEntryIndexTokens(searchIndex: BrowserEntrySearchIndex): string[] { + const seen = new Set(); + const tokens: string[] = []; + for (const field of searchIndex.searchFields) { + const value = field.value || ''; + if (!value) continue; + for (const token of value.split(' ')) { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH || seen.has(token)) continue; + seen.add(token); + tokens.push(token); + } + } + return tokens; +} + +function pushEntryId(index: Map, key: string, entryId: number): void { + const existing = index.get(key); + if (existing) { + existing.push(entryId); + return; + } + index.set(key, [entryId]); +} + +function addBookmarkEntryToNicknameIndex( + index: Map, + entryId: number, + entry: BrowserSearchEntry +): void { + const url = normalizeNicknameUrl(entry.url); + if (!url) return; + const source = String(entry.source || ''); + const profileId = String(entry.sourceProfileId || ''); + const fullProfileId = getEntryProfileKey(entry); + pushEntryId(index, getBookmarkNicknameLookupKey(source, profileId, url), entryId); + if (fullProfileId !== profileId) { + pushEntryId(index, getBookmarkNicknameLookupKey(source, fullProfileId, url), entryId); + } +} + +function getBookmarkNicknameLookupKey(source: string, sourceProfileId: string, url: string): string { + return [source, sourceProfileId, url].join('\0'); +} + function compareHistoryEntriesByTime(a: BrowserSearchEntry, b: BrowserSearchEntry): number { const aTime = Number.isFinite(Number(a?.lastUsedAt)) ? Number(a.lastUsedAt) : 0; const bTime = Number.isFinite(Number(b?.lastUsedAt)) ? Number(b.lastUsedAt) : 0; @@ -868,14 +979,15 @@ function buildBrowserCandidates( options: { limitPerKind?: number } = {} ): Record { const openTabs = getOpenTabCandidates(input, tabs); - void entryIndex; return { 'open-tab': openTabs, bookmark: getBrowserEntryCandidates('bookmark', input, entries, { + entryIndex, nicknames, limit: options.limitPerKind, }), history: getBrowserEntryCandidates('history', input, entries, { + entryIndex, limit: options.limitPerKind, }), }; @@ -886,6 +998,7 @@ function getBrowserEntryCandidates( input: string, entries: BrowserSearchEntry[], options: { + entryIndex?: BrowserEntryIndex | null; preserveBookmarkOrder?: boolean; preserveHistoryChronology?: boolean; includeHistoryTimestamp?: boolean; @@ -912,7 +1025,6 @@ function getBrowserEntryCandidates( if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -948,11 +1060,22 @@ function getBrowserEntryCandidates( } return results; } - const candidateEntries = entries; - for (const entry of candidateEntries) { + const indexedEntryIds = getIndexedEntryCandidateIds(kind, queryTokens, options.entryIndex || null); + const nicknameEntryIds = indexedEntryIds !== null + ? getBookmarkNicknameCandidateIds(kind, trimmed, options.nicknames || [], options.entryIndex || null) + : null; + const candidateEntryIds = indexedEntryIds && nicknameEntryIds + ? unionSortedEntryIds(indexedEntryIds, nicknameEntryIds) + : indexedEntryIds; + const scanEntryIds = candidateEntryIds || null; + const scanLength = scanEntryIds ? scanEntryIds.length : entries.length; + for (let scanIndex = 0; scanIndex < scanLength; scanIndex += 1) { + const entryId = scanEntryIds ? scanEntryIds[scanIndex] : scanIndex; + const entry = entries[entryId]; + if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); + const index = getBrowserEntrySearchIndexForEntry(entry, entryId, options.entryIndex || null); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -1035,6 +1158,155 @@ function getBrowserEntryCandidates( return options.limit && options.limit > 0 ? sorted.slice(0, options.limit) : sorted; } +function getIndexedEntryCandidateIds( + kind: 'bookmark' | 'history', + queryTokens: string[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (!entryIndex || queryTokens.length === 0) return null; + const kindIndex = entryIndex.entriesByKind[kind]; + if (!kindIndex) return null; + + const tokenLists: number[][] = []; + for (const token of queryTokens) { + const tokenEntryIds = getIndexedEntryIdsForToken(kindIndex, token); + if (!tokenEntryIds) return null; + if (tokenEntryIds.length === 0) return []; + tokenLists.push(tokenEntryIds); + } + + tokenLists.sort((a, b) => a.length - b.length); + let candidateIds = tokenLists[0] || []; + for (let index = 1; index < tokenLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, tokenLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getBookmarkNicknameCandidateIds( + kind: 'bookmark' | 'history', + input: string, + nicknames: BrowserSearchNicknameSetting[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (kind !== 'bookmark' || !entryIndex || nicknames.length === 0 || /\s/.test(input)) return null; + const parsed = parseNicknameQuery(input); + const normalizedToken = normalizeNicknameToken(parsed.firstToken); + if (!normalizedToken) return null; + let candidateIds: number[] = []; + for (const item of nicknames) { + const nickname = normalizeNicknameToken(item.nickname); + if (!nickname || !nickname.startsWith(normalizedToken)) continue; + const key = getBookmarkNicknameLookupKey( + String(item.source || ''), + String(item.sourceProfileId || ''), + normalizeNicknameUrl(item.url) + ); + const entryIds = entryIndex.bookmarkEntryIdsByNicknameKey.get(key); + if (entryIds?.length) candidateIds = unionSortedEntryIds(candidateIds, entryIds); + } + return candidateIds; +} + +function getIndexedEntryIdsForToken( + kindIndex: BrowserEntryKindIndex, + token: string +): number[] | null { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) return null; + if (token.length === BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) { + return kindIndex.tokenPrefixEntryIds.get(token) || []; + } + const trigrams = getTokenTrigrams(token); + if (trigrams.length === 0) return null; + const gramLists: number[][] = []; + for (const trigram of trigrams) { + const ids = kindIndex.tokenTrigramEntryIds.get(trigram) || []; + if (ids.length === 0) return []; + gramLists.push(ids); + } + gramLists.sort((a, b) => a.length - b.length); + let candidateIds = gramLists[0] || []; + for (let index = 1; index < gramLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, gramLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getTokenTrigrams(token: string): string[] { + if (token.length < 3) return []; + const seen = new Set(); + const trigrams: string[] = []; + for (let index = 0; index <= token.length - 3; index += 1) { + const trigram = token.slice(index, index + 3); + if (seen.has(trigram)) continue; + seen.add(trigram); + trigrams.push(trigram); + } + return trigrams; +} + +function intersectSortedEntryIds(a: number[], b: number[]): number[] { + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + aIndex += 1; + } else { + bIndex += 1; + } + } + return out; +} + +function unionSortedEntryIds(a: number[], b: number[]): number[] { + if (a.length === 0) return b; + if (b.length === 0) return a; + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + out.push(aValue); + aIndex += 1; + } else { + out.push(bValue); + bIndex += 1; + } + } + while (aIndex < a.length) { + out.push(a[aIndex]); + aIndex += 1; + } + while (bIndex < b.length) { + out.push(b[bIndex]); + bIndex += 1; + } + return out; +} + +function getBrowserEntrySearchIndexForEntry( + entry: BrowserSearchEntry, + entryId: number, + entryIndex: BrowserEntryIndex | null +): BrowserEntrySearchIndex { + return entryIndex?.entrySearchIndexes[entryId] || getBrowserEntrySearchIndex(entry); +} + function compareBookmarksByBrowserOrder(a: BrowserSearchResult, b: BrowserSearchResult): number { const aOrder = Number.isFinite(Number(a.bookmarkOrder)) ? Number(a.bookmarkOrder) : Number.MAX_SAFE_INTEGER; const bOrder = Number.isFinite(Number(b.bookmarkOrder)) ? Number(b.bookmarkOrder) : Number.MAX_SAFE_INTEGER; @@ -1338,6 +1610,16 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear browserEntrySearchIndexCache.set(cacheKey, cached); return cached.index; } + const index = createBrowserEntrySearchIndex(entry); + browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); + if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { + const oldestKey = browserEntrySearchIndexCache.keys().next().value; + if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); + } + return index; +} + +function createBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySearchIndex { const searchFields: TokenSearchField[] = [ { value: normalizeForTokenSearch(entry.query), weight: 1.15 }, { value: normalizeForTokenSearch(entry.url, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), weight: 1 }, @@ -1351,11 +1633,6 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear normalizedUrl: normalizeUrlForCompletion(entry.url || entry.host, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), searchFields, }; - browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); - if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { - const oldestKey = browserEntrySearchIndexCache.keys().next().value; - if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); - } return index; } @@ -1572,3 +1849,9 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + getOrderedBrowserResults, + getRankedBrowserResults, +}; diff --git a/src/renderer/src/hooks/useCursorPrompt.ts b/src/renderer/src/hooks/useCursorPrompt.ts index a601bf1e..27e0647b 100644 --- a/src/renderer/src/hooks/useCursorPrompt.ts +++ b/src/renderer/src/hooks/useCursorPrompt.ts @@ -16,6 +16,10 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import { NO_AI_MODEL_ERROR } from '../utils/constants'; +import { + createCursorPromptResultBatcher, + type CursorPromptResultBatcher, +} from './cursorPromptResultBatcher'; // ─── Interfaces ────────────────────────────────────────────────────── @@ -53,7 +57,7 @@ export function useCursorPrompt({ }: UseCursorPromptOptions): UseCursorPromptReturn { const [cursorPromptText, setCursorPromptText] = useState(''); const [cursorPromptStatus, setCursorPromptStatus] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle'); - const [cursorPromptResult, setCursorPromptResult] = useState(''); + const [cursorPromptResult, setCursorPromptResultState] = useState(''); const [cursorPromptError, setCursorPromptError] = useState(''); const [cursorPromptSourceText, setCursorPromptSourceText] = useState(''); @@ -61,6 +65,18 @@ export function useCursorPrompt({ const cursorPromptResultRef = useRef(''); const cursorPromptSourceTextRef = useRef(''); const cursorPromptInputRef = useRef(null); + const cursorPromptResultBatcherRef = useRef(null); + + if (!cursorPromptResultBatcherRef.current) { + cursorPromptResultBatcherRef.current = createCursorPromptResultBatcher({ + resultRef: cursorPromptResultRef, + setVisibleResult: setCursorPromptResultState, + }); + } + + const setCursorPromptResult = useCallback((value: string) => { + cursorPromptResultBatcherRef.current?.reset(value); + }, []); // ── Apply result to the editor ────────────────────────────────── @@ -89,18 +105,19 @@ export function useCursorPrompt({ useEffect(() => { const handleChunk = (data: { requestId: string; chunk: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { - cursorPromptResultRef.current += data.chunk; - setCursorPromptResult((prev) => prev + data.chunk); + cursorPromptResultBatcherRef.current?.appendChunk(data.chunk); } }; const handleDone = (data: { requestId: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; void applyCursorPromptResultToEditor(); } }; const handleError = (data: { requestId: string; error: string }) => { if (data.requestId === cursorPromptRequestIdRef.current) { + cursorPromptResultBatcherRef.current?.flush(); cursorPromptRequestIdRef.current = null; setCursorPromptStatus('error'); setCursorPromptError(data.error || 'Failed to process this prompt.'); @@ -118,6 +135,12 @@ export function useCursorPrompt({ }; }, [applyCursorPromptResultToEditor]); + useEffect(() => { + return () => { + cursorPromptResultBatcherRef.current?.dispose(); + }; + }, []); + // ── Focus cursor prompt input when shown ──────────────────────── useEffect(() => { @@ -203,7 +226,7 @@ export function useCursorPrompt({ `Instruction: ${instruction}`, ].join('\n'); await window.electron.aiAsk(requestId, compositePrompt); - }, [cursorPromptStatus, cursorPromptText, setAiAvailable]); + }, [cursorPromptStatus, cursorPromptText, setAiAvailable, setCursorPromptResult]); const closeCursorPrompt = useCallback(async () => { if (cursorPromptRequestIdRef.current) { @@ -212,6 +235,7 @@ export function useCursorPrompt({ } catch {} cursorPromptRequestIdRef.current = null; } + cursorPromptResultBatcherRef.current?.cancelPendingFlush(); setShowCursorPrompt(false); window.electron.hideWindow(); }, [setShowCursorPrompt]); @@ -223,7 +247,8 @@ export function useCursorPrompt({ setCursorPromptError(''); setCursorPromptSourceText(''); cursorPromptRequestIdRef.current = null; - }, []); + cursorPromptSourceTextRef.current = ''; + }, [setCursorPromptResult]); return { cursorPromptText, diff --git a/src/renderer/src/hooks/useLauncherCommandExecution.ts b/src/renderer/src/hooks/useLauncherCommandExecution.ts index 9fbf3ebd..18563a44 100644 --- a/src/renderer/src/hooks/useLauncherCommandExecution.ts +++ b/src/renderer/src/hooks/useLauncherCommandExecution.ts @@ -3,6 +3,7 @@ import type React from 'react'; import type { CommandInfo, ExtensionBundle, QuickLinkDynamicField } from '../../types/electron'; import { LAST_EXT_KEY } from '../utils/constants'; import { + flushCommandArgumentSettingsSync, getCmdArgsKey, getScriptCmdArgsKey, hydrateExtensionBundlePreferences, @@ -266,10 +267,13 @@ export function useLauncherCommandExecution( }; if (Object.keys(inlineArguments).length > 0 && command.mode === 'no-view') { + const commandArgsKey = getCmdArgsKey(extName, cmdName); writeJsonObject( - getCmdArgsKey(extName, cmdName), - { ...((hydratedWithInlineArguments as any).launchArguments || {}) } + commandArgsKey, + { ...((hydratedWithInlineArguments as any).launchArguments || {}) }, + { commandArgumentSettingsSync: 'debounced' } ); + await flushCommandArgumentSettingsSync(commandArgsKey); } if (shouldOpenCommandSetup(hydratedWithInlineArguments)) { diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..ae4c8d88 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -187,7 +187,7 @@ function getFileDepthPenalty(result: IndexedFileSearchResult): number { type BrowserLauncherProfile = { id?: string; - browserId?: BrowserSearchSource | string; + browserId?: BrowserSearchSource; displayName: string; detectedName?: string; profileId: string; @@ -339,9 +339,13 @@ export function useLauncherCommandModel({ const calcResult = syncCalcResult ?? asyncCalcResult; const calcOffset = calcResult ? 1 : 0; const contextualCommands = commands; + const hasSearchQuery = searchQuery.trim().length > 0; + const shouldComputeLegacyCommandList = !hasSearchQuery || Boolean(calcResult); const filteredCommands = useMemo( - () => filterCommands(contextualCommands, searchQuery, commandAliases), - [contextualCommands, searchQuery, commandAliases] + () => shouldComputeLegacyCommandList + ? filterCommands(contextualCommands, searchQuery, commandAliases) + : contextualCommands, + [contextualCommands, searchQuery, commandAliases, shouldComputeLegacyCommandList] ); // When calculator is showing but no commands match, show unfiltered list below. @@ -355,7 +359,6 @@ export function useLauncherCommandModel({ () => new Set(['system-add-to-memory', 'system-cursor-prompt', 'system-emoji-picker']), [] ); - const hasSearchQuery = searchQuery.trim().length > 0; const visibleSourceCommands = useMemo( () => sourceCommands .filter((cmd) => !hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) @@ -527,15 +530,7 @@ export function useLauncherCommandModel({ (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) ); return rankCommands(searchableCommands, searchQuery, commandAliases) - .map(({ command }) => { - const alias = commandAliases[command.id] || ''; - const scored = scoreRootSearchFields(searchQuery, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), - ]); - if (!scored.matched) return null; + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; return scoreRootSearchCandidate({ @@ -551,8 +546,8 @@ export function useLauncherCommandModel({ label: command.title, description: command.subtitle, pathOrUrl: command.path, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + matchKind, + matchScore, sourceQualityBoost: command.alwaysOnTop ? 80 : 0, freshnessBoost: 0, pathLocationBoost: 0, @@ -857,41 +852,6 @@ export function useLauncherCommandModel({ queryFileSectionCommands, } = rootSearchSectionAssembly; - // TEMP DIAGNOSTIC (SC-RANK2): pinpoint whether the internal>browser comparator - // is actually running, and whether Search Notes / Create Note are candidates. - // Remove once the override bug is confirmed fixed. - useEffect(() => { - if (!hasSearchQuery) return; - try { - const idOf = (c: any) => String(c?.command?.id || c?.stableKey || ''); - const sn = commandCandidates.find((c) => idOf(c).includes('search-notes')); - const cn = commandCandidates.find((c) => idOf(c).includes('create-note')); - const internalCount = rootRankedCandidates.filter((c) => !c.isOrganicBrowserResult).length; - const browserCount = rootRankedCandidates.filter((c) => c.isOrganicBrowserResult).length; - const firstBrowserIdx = rootRankedCandidates.findIndex((c) => c.isOrganicBrowserResult); - const snIdx = rootRankedCandidates.findIndex((c) => idOf(c).includes('search-notes')); - const cnIdx = rootRankedCandidates.findIndex((c) => idOf(c).includes('create-note')); - const describe = (c: any, idx: number) => c - ? { score: Math.round(c.finalScore), matchKind: c.matchKind, organic: c.isOrganicBrowserResult, rankIdx: idx } - : 'NOT_A_CANDIDATE'; - (window as any).electron?.whisperDebugLog?.('SC-RANK2', `q="${searchQuery}"`, { - cmdCands: commandCandidates.length, - browserCands: browserCandidates.length, - rootTotal: rootRankedCandidates.length, - internalCount, - browserCount, - // If the comparator works, firstBrowserIdx === internalCount (all internal first). - firstBrowserIdx, - comparatorWorking: firstBrowserIdx === -1 || firstBrowserIdx >= internalCount, - searchNotes: describe(sn, snIdx), - createNote: describe(cn, cnIdx), - RESULTS: queryResultCommands.map((c) => c.title), - }); - } catch (e) { - (window as any).electron?.whisperDebugLog?.('SC-RANK2', 'ERR', String(e)); - } - }, [hasSearchQuery, searchQuery, rootRankedCandidates, queryResultCommands, commandCandidates, browserCandidates]); - const displayCommands = useMemo(() => { if (rootBangState.mode === 'selecting') return rootSearchSectionAssembly.displayCommands; if (rootBangState.mode === 'active') return rootSearchSectionAssembly.displayCommands; diff --git a/src/renderer/src/hooks/useLauncherInlineArguments.ts b/src/renderer/src/hooks/useLauncherInlineArguments.ts index 8894bbff..35da8f03 100644 --- a/src/renderer/src/hooks/useLauncherInlineArguments.ts +++ b/src/renderer/src/hooks/useLauncherInlineArguments.ts @@ -272,7 +272,11 @@ export function useLauncherInlineArguments({ [argumentName]: value, }; if (extensionIdentity && command.mode === 'no-view') { - writeJsonObject(getCmdArgsKey(extensionIdentity.extName, extensionIdentity.cmdName), nextCommandValues); + writeJsonObject( + getCmdArgsKey(extensionIdentity.extName, extensionIdentity.cmdName), + nextCommandValues, + { commandArgumentSettingsSync: 'debounced' } + ); } return { ...prev, diff --git a/src/renderer/src/hooks/useLauncherKeyboardControls.ts b/src/renderer/src/hooks/useLauncherKeyboardControls.ts index a385562f..e965d9f1 100644 --- a/src/renderer/src/hooks/useLauncherKeyboardControls.ts +++ b/src/renderer/src/hooks/useLauncherKeyboardControls.ts @@ -91,6 +91,7 @@ export type UseLauncherKeyboardControlsOptions = { kind?: CommandInfo['browserResultKind']; url?: string; sourceProfileId?: string; + openInSourceProfile?: boolean; windowId?: string | number; tabId?: string | number; } diff --git a/src/renderer/src/hooks/useMenuBarExtensions.ts b/src/renderer/src/hooks/useMenuBarExtensions.ts index cf912a32..a638c8ba 100644 --- a/src/renderer/src/hooks/useMenuBarExtensions.ts +++ b/src/renderer/src/hooks/useMenuBarExtensions.ts @@ -19,20 +19,16 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import type { ExtensionBundle } from '../../types/electron'; +import type { ExtensionStorageChangedDetail } from '../raycast-api/storage-events'; +import type { BackgroundNoViewRun } from '../utils/background-no-view-runs'; + +export type { BackgroundNoViewRun } from '../utils/background-no-view-runs'; export interface MenuBarEntry { key: string; bundle: ExtensionBundle; } -export interface BackgroundNoViewRun { - runId: string; - bundle: ExtensionBundle; - launchType: 'userInitiated' | 'background'; - /** When true, execution status is mirrored to the system status-bar badge. */ - reportStatus?: boolean; -} - export interface UseMenuBarExtensionsReturn { menuBarExtensions: MenuBarEntry[]; backgroundNoViewRuns: BackgroundNoViewRun[]; @@ -46,7 +42,13 @@ export interface UseMenuBarExtensionsReturn { hideMenuBarExtension: (bundle: Partial) => void; hideMenuBarExtensionsForExtension: (extensionName: string) => void; upsertMenuBarExtension: (bundle: ExtensionBundle, options?: { remount?: boolean }) => void; - remountMenuBarExtensionsForExtension: (extensionName: string) => void; + remountMenuBarExtensionsForExtension: ( + extensionName: string, + options?: { + sourceCommandName?: string; + sourceCommandMode?: string; + } + ) => void; } export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { @@ -133,25 +135,35 @@ export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { }); }, [getMenuBarIdentity]); - const remountMenuBarExtensionsForExtension = useCallback((extensionName: string) => { + const remountMenuBarExtensionsForExtension = useCallback(( + extensionName: string, + options?: { + sourceCommandName?: string; + sourceCommandMode?: string; + } + ) => { const normalized = (extensionName || '').trim(); if (!normalized) return; const now = Date.now(); const lastTs = menuBarRemountTimestampsRef.current[normalized] || 0; if (now - lastTs < 200) return; - menuBarRemountTimestampsRef.current[normalized] = now; + const sourceCommandName = (options?.sourceCommandName || '').trim(); + const sourceCommandMode = (options?.sourceCommandMode || '').trim(); + const skipSourceCommand = sourceCommandMode === 'menu-bar' && Boolean(sourceCommandName); setMenuBarExtensions((prev) => { let changed = false; const next = prev.map((entry) => { const entryExt = (entry.bundle.extName || entry.bundle.extensionName || '').trim(); if (!entryExt || entryExt !== normalized) return entry; + const cmdName = (entry.bundle.cmdName || entry.bundle.commandName || '').trim(); + if (skipSourceCommand && cmdName === sourceCommandName) return entry; changed = true; - const cmdName = entry.bundle.cmdName || entry.bundle.commandName || ''; return { key: `${normalized}:${cmdName}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, bundle: entry.bundle, }; }); + if (changed) menuBarRemountTimestampsRef.current[normalized] = now; return changed ? next : prev; }); }, []); @@ -163,10 +175,13 @@ export function useMenuBarExtensions(): UseMenuBarExtensionsReturn { // This matches Raycast behavior where menu-bar commands observe state changes quickly. useEffect(() => { const onStorageChanged = (event: Event) => { - const custom = event as CustomEvent<{ extensionName?: string }>; + const custom = event as CustomEvent>; const extensionName = (custom.detail?.extensionName || '').trim(); if (!extensionName) return; - remountMenuBarExtensionsForExtension(extensionName); + remountMenuBarExtensionsForExtension(extensionName, { + sourceCommandName: custom.detail?.commandName, + sourceCommandMode: custom.detail?.commandMode, + }); }; window.addEventListener('sc-extension-storage-changed', onStorageChanged as EventListener); return () => { diff --git a/src/renderer/src/hooks/useSpeakManager.ts b/src/renderer/src/hooks/useSpeakManager.ts index 1e3f8cb3..3dfb070a 100644 --- a/src/renderer/src/hooks/useSpeakManager.ts +++ b/src/renderer/src/hooks/useSpeakManager.ts @@ -10,8 +10,8 @@ * - handleSpeakVoiceChange / handleSpeakRateChange: persist user selections to settings * - Opens a detached portal window for the speak overlay via useDetachedPortalWindow * - * Polls speak status from the main process while the overlay is visible, and syncs - * the configured voice from settings each time the overlay opens. + * Listens for speak status updates from the main process, and syncs + * the configured voice from initial settings plus live settings updates. */ import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; @@ -23,9 +23,19 @@ import { getCachedElevenLabsVoices, setCachedElevenLabsVoices, } from '../utils/voice-cache'; +import { + applySpeakSettings, + buildElevenLabsSpeakModel, + DEFAULT_EDGE_TTS_VOICE, + DEFAULT_ELEVENLABS_VOICE_ID, + DEFAULT_TTS_MODEL, + parseElevenLabsSpeakModel, + type SpeakOptions, + type SpeakSettingsSnapshot, +} from '../utils/speak-settings-sync'; const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ - { id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel' }, + { id: DEFAULT_ELEVENLABS_VOICE_ID, label: 'Rachel' }, { id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi' }, { id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella' }, { id: 'ErXwobaYiN019PkySvjV', label: 'Antoni' }, @@ -36,23 +46,6 @@ const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ { id: 'yoZ06aMxZJJ28mfd3POQ', label: 'Sam' }, ]; -const DEFAULT_ELEVENLABS_VOICE_ID = ELEVENLABS_VOICES[0].id; - -function parseElevenLabsSpeakModel(raw: string): { model: string; voiceId: string } { - const value = String(raw || '').trim(); - const explicitVoice = /@([A-Za-z0-9]{8,})$/.exec(value)?.[1]; - const modelOnly = explicitVoice ? value.replace(/@[A-Za-z0-9]{8,}$/, '') : value; - const model = modelOnly.startsWith('elevenlabs-') ? modelOnly : 'elevenlabs-multilingual-v2'; - const voiceId = explicitVoice || DEFAULT_ELEVENLABS_VOICE_ID; - return { model, voiceId }; -} - -function buildElevenLabsSpeakModel(model: string, voiceId: string): string { - const normalizedModel = String(model || '').trim() || 'elevenlabs-multilingual-v2'; - const normalizedVoice = String(voiceId || '').trim() || DEFAULT_ELEVENLABS_VOICE_ID; - return `${normalizedModel}@${normalizedVoice}`; -} - // ─── Types ─────────────────────────────────────────────────────────── export interface SpeakStatus { @@ -71,7 +64,7 @@ export interface UseSpeakManagerOptions { export interface UseSpeakManagerReturn { speakStatus: SpeakStatus; - speakOptions: { voice: string; rate: string }; + speakOptions: SpeakOptions; edgeTtsVoices: EdgeTtsVoice[]; configuredEdgeTtsVoice: string; configuredTtsModel: string; @@ -98,18 +91,24 @@ export function useSpeakManager({ index: 0, total: 0, }); - const [speakOptions, setSpeakOptions] = useState<{ voice: string; rate: string }>({ - voice: 'en-US-EricNeural', + const [speakOptions, setSpeakOptions] = useState({ + voice: DEFAULT_EDGE_TTS_VOICE, rate: '+0%', }); const [edgeTtsVoices, setEdgeTtsVoices] = useState([]); const [elevenLabsVoices, setElevenLabsVoices] = useState([]); - const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState('en-US-EricNeural'); - const [configuredTtsModel, setConfiguredTtsModel] = useState('edge-tts'); + const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState(DEFAULT_EDGE_TTS_VOICE); + const [configuredTtsModel, setConfiguredTtsModel] = useState(DEFAULT_TTS_MODEL); + const speakOptionsVoiceRef = useRef(speakOptions.voice); const speakSessionShownRef = useRef(false); const pauseToggleInFlightRef = useRef(false); + const applySpeakOptions = useCallback((next: SpeakOptions) => { + speakOptionsVoiceRef.current = next.voice; + setSpeakOptions(next); + }, []); + // ── Portal ───────────────────────────────────────────────────────── const speakPortalTarget = useDetachedPortalWindow(showSpeak, { @@ -135,7 +134,7 @@ export function useSpeakManager({ useEffect(() => { let disposed = false; window.electron.speakGetOptions().then((options) => { - if (!disposed && options) setSpeakOptions(options); + if (!disposed && options) applySpeakOptions(options); }).catch(() => {}); window.electron.speakGetStatus().then((status) => { if (!disposed && status) setSpeakStatus(status); @@ -147,7 +146,7 @@ export function useSpeakManager({ disposed = true; disposeSpeak(); }; - }, []); + }, [applySpeakOptions]); // Edge TTS voice list fetch useEffect(() => { @@ -205,49 +204,30 @@ export function useSpeakManager({ }; }, [configuredTtsModel]); - // Sync configured voice from settings whenever they change + // Sync configured voice from initial settings and live settings updates useEffect(() => { let disposed = false; - const syncFromSettings = async () => { - try { - const settings = await window.electron.getSettings(); - if (disposed) return; - - const ttsModel = String(settings.ai?.textToSpeechModel || 'edge-tts'); - const edgeVoice = String(settings.ai?.edgeTtsVoice || 'en-US-EricNeural'); - - setConfiguredTtsModel(ttsModel); - setConfiguredEdgeTtsVoice(edgeVoice); - - // Also sync speakOptions to match settings - const usingElevenLabs = ttsModel.startsWith('elevenlabs-'); - const targetVoice = usingElevenLabs - ? parseElevenLabsSpeakModel(ttsModel).voiceId - : edgeVoice; - - if (targetVoice && targetVoice !== speakOptions.voice) { - const next = await window.electron.speakUpdateOptions({ - voice: targetVoice, - restartCurrent: false, - }); - setSpeakOptions(next); - } - } catch { - // Ignore errors - } + const syncFromSettings = (settings: SpeakSettingsSnapshot | null | undefined) => { + void applySpeakSettings(settings, { + getCurrentVoice: () => speakOptionsVoiceRef.current, + setConfiguredTtsModel, + setConfiguredEdgeTtsVoice, + updateSpeakOptions: (patch) => window.electron.speakUpdateOptions(patch), + setSpeakOptions: applySpeakOptions, + isDisposed: () => disposed, + }).catch(() => {}); }; - - // Initial sync - syncFromSettings(); - - // Poll for settings changes every 2 seconds while widget is relevant - const interval = setInterval(syncFromSettings, 2000); - + + window.electron.getSettings().then(syncFromSettings).catch(() => {}); + const cleanupSettings = window.electron.onSettingsUpdated?.((settings) => { + syncFromSettings(settings); + }); + return () => { disposed = true; - clearInterval(interval); + cleanupSettings?.(); }; - }, []); + }, [applySpeakOptions]); // Auto-sync configured voice when speak view opens useEffect(() => { @@ -266,9 +246,9 @@ export function useSpeakManager({ voice: targetVoice, restartCurrent: true, }).then((next) => { - setSpeakOptions(next); + applySpeakOptions(next); }).catch(() => {}); - }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice]); + }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice, applySpeakOptions]); // ── Memos ────────────────────────────────────────────────────────── @@ -317,7 +297,7 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} return; } @@ -333,17 +313,17 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} - }, [configuredTtsModel]); + }, [configuredTtsModel, applySpeakOptions]); const handleSpeakRateChange = useCallback(async (rate: string) => { const next = await window.electron.speakUpdateOptions({ rate, restartCurrent: true, }); - setSpeakOptions(next); - }, []); + applySpeakOptions(next); + }, [applySpeakOptions]); const handleSpeakTogglePause = useCallback(async () => { if (pauseToggleInFlightRef.current) return; diff --git a/src/renderer/src/i18n/locales/it.json b/src/renderer/src/i18n/locales/it.json index 75ae34be..82538b88 100644 --- a/src/renderer/src/i18n/locales/it.json +++ b/src/renderer/src/i18n/locales/it.json @@ -375,6 +375,12 @@ "elevenlabsWarning": "STT di ElevenLabs selezionato. {action} la chiave API di ElevenLabs in Chiavi API.", "elevenlabsReady": "STT di ElevenLabs selezionato. La trascrizione cloud userà la tua chiave ElevenLabs.", "recognitionLanguage": "Lingua di riconoscimento", + "vocabulary": { + "label": "Vocabolario personalizzato", + "placeholder": "Kotlin, Electron, Raycast, SuperCmd…", + "hint": "Facoltativo. Elenca parole poco comuni, nomi o termini tecnici (separati da virgole o da nuove righe) per orientare il riconoscimento verso l'ortografia corretta.", + "tokenWarning": "Circa {count} token. Whisper usa all'incirca solo i primi 224: le parole oltre quel limite vengono ignorate." + }, "providerInfo": { "parakeet": "Trascrizione offline sul dispositivo con Parakeet TDT v3. Viene eseguita su Apple Neural Engine. Richiede un riscaldamento del modello al primo utilizzo. Scarica il modello qui sotto prima di usare la dettatura.", "qwen3": "Trascrizione offline sul dispositivo con Qwen3 ASR. Supporta oltre 30 lingue, richiede macOS 15+ e si riscalda al primo utilizzo.", @@ -611,6 +617,18 @@ "openedExisting": "Cartella degli script personalizzati aperta.", "failed": "Impossibile aprire la cartella degli script personalizzati." }, + "appSearchScope": { + "title": "Ambito di ricerca delle applicazioni", + "description": "Cartelle in cui SuperCmd cerca le applicazioni installate da mostrare nel launcher.", + "add": "Aggiungi cartella", + "remove": "Rimuovi", + "empty": "Nessuna cartella aggiunta. Verranno usate le cartelle applicazioni di sistema predefinite.", + "added": "Cartella aggiunta all'ambito di ricerca.", + "removed": "Cartella rimossa dall'ambito di ricerca.", + "duplicate": "Cartella già presente nell'elenco.", + "failed": "Impossibile aggiornare l'ambito di ricerca.", + "restartHint": "Riavvia SuperCmd affinché le modifiche alle cartelle abbiano effetto." + }, "emojiPicker": { "enabled": "Abilita il selettore emoji", "enabledDesc": "Mostra suggerimenti emoji quando si digita un prefisso trigger.", diff --git a/src/renderer/src/i18n/runtime.ts b/src/renderer/src/i18n/runtime.ts index edf181c9..e64e92dd 100644 --- a/src/renderer/src/i18n/runtime.ts +++ b/src/renderer/src/i18n/runtime.ts @@ -12,7 +12,9 @@ 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'; diff --git a/src/renderer/src/raycast-api/action-runtime-overlay.tsx b/src/renderer/src/raycast-api/action-runtime-overlay.tsx index 02896d10..e623136c 100644 --- a/src/renderer/src/raycast-api/action-runtime-overlay.tsx +++ b/src/renderer/src/raycast-api/action-runtime-overlay.tsx @@ -112,7 +112,8 @@ export function createActionOverlayRuntime(deps: OverlayDeps) { } if (source && typeof source === 'object') { - const variants = [source.light, source.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + const sourceVariants = source as Record; + const variants = [sourceVariants.light, sourceVariants.dark].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); if (variants.length > 0) { const assetLikeVariants = variants.filter((value) => hasImageExtension(value)); if (assetLikeVariants.length === 0) return true; diff --git a/src/renderer/src/raycast-api/context-scope-runtime.ts b/src/renderer/src/raycast-api/context-scope-runtime.ts index 440b0bc1..410737cc 100644 --- a/src/renderer/src/raycast-api/context-scope-runtime.ts +++ b/src/renderer/src/raycast-api/context-scope-runtime.ts @@ -89,7 +89,7 @@ export function withExtensionContext(ctx: ExtensionContextSnapshot | undefine try { const value = fn(); if (value && typeof (value as any).then === 'function') { - return (value as Promise).finally(restore) as T; + return (value as unknown as Promise).finally(restore) as T; } restore(); return value; diff --git a/src/renderer/src/raycast-api/detail-runtime.tsx b/src/renderer/src/raycast-api/detail-runtime.tsx index 88d513ff..0566bb26 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -90,7 +90,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { for (const node of allChildren) { if (React.isValidElement(node)) { - const typeRecord = node.type as Record | null; + const typeRecord = node.type as unknown as Record | null; if (typeRecord?.[DETAIL_METADATA_RUNTIME_MARKER] === true) { metadataNodes.push(node); continue; @@ -290,7 +290,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { ); const MetadataComponent = ({ children }: { children?: React.ReactNode }) =>
{children}
; - (MetadataComponent as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; + (MetadataComponent as unknown as Record)[DETAIL_METADATA_RUNTIME_MARKER] = true; MetadataComponent.displayName = 'Detail.Metadata'; const Metadata = Object.assign( diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..b402c061 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -7,6 +7,14 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import type { GridItemRegistration, GridRegistryAPI } from './grid-runtime-items'; +export interface GridItemGroup { + key: string; + title?: string; + subtitle?: string; + section?: GridItemRegistration['section']; + items: { item: GridItemRegistration; globalIdx: number }[]; +} + export function useGridRegistry() { const registryRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); @@ -22,7 +30,8 @@ export function useGridRegistry() { .map((entry) => { const actionType = entry.props.actions?.type as any; const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return `${entry.id}:${entry.props.title || ''}:${entry.sectionTitle || ''}:${actionName}`; + const section = entry.section; + return `${entry.id}:${entry.props.title || ''}:${section?.id || ''}:${section?.title || ''}:${section?.columns || ''}:${section?.aspectRatio || ''}:${section?.fit || ''}:${section?.inset || ''}:${actionName}`; }) .join('|'); if (snapshot !== lastSnapshotRef.current) { @@ -38,7 +47,7 @@ export function useGridRegistry() { const existing = registryRef.current.get(id); if (existing) { existing.props = data.props; - existing.sectionTitle = data.sectionTitle; + existing.section = data.section; existing.order = data.order; } else { registryRef.current.set(id, { id, ...data }); @@ -63,14 +72,22 @@ export function useGridRegistry() { } export function groupGridItems(filteredItems: GridItemRegistration[]) { - const groups: { title?: string; items: { item: GridItemRegistration; globalIdx: number }[] }[] = []; - let currentSection: string | undefined | null = null; + const groups: GridItemGroup[] = []; + let currentSectionKey: string | undefined | null = null; let globalIndex = 0; for (const item of filteredItems) { - if (item.sectionTitle !== currentSection || groups.length === 0) { - currentSection = item.sectionTitle; - groups.push({ title: item.sectionTitle, items: [] }); + const section = item.section; + const sectionKey = section?.id || section?.title || '__default_grid_section'; + if (sectionKey !== currentSectionKey || groups.length === 0) { + currentSectionKey = sectionKey; + groups.push({ + key: sectionKey, + title: section?.title, + subtitle: section?.subtitle, + section, + items: [], + }); } groups[groups.length - 1].items.push({ item, globalIdx: globalIndex++ }); } diff --git a/src/renderer/src/raycast-api/grid-runtime-items.tsx b/src/renderer/src/raycast-api/grid-runtime-items.tsx index d06d0269..8f36d6e2 100644 --- a/src/renderer/src/raycast-api/grid-runtime-items.tsx +++ b/src/renderer/src/raycast-api/grid-runtime-items.tsx @@ -4,10 +4,20 @@ * Contains grid item registration contexts and row/cell renderers. */ -import React, { createContext, useContext, useLayoutEffect, useRef } from 'react'; +import React, { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'react'; import { resolveTintColor } from './icon-runtime-assets'; import { renderIcon } from './icon-runtime-render'; +export interface GridSectionRegistration { + id: string; + title?: string; + subtitle?: string; + columns?: number; + aspectRatio?: string; + fit?: string; + inset?: string; +} + export interface GridItemRegistration { id: string; props: { @@ -20,7 +30,7 @@ export interface GridItemRegistration { accessory?: any; quickLook?: { name?: string; path: string }; }; - sectionTitle?: string; + section?: GridSectionRegistration; order: number; } @@ -31,33 +41,53 @@ export interface GridRegistryAPI { export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) { let gridItemOrderCounter = 0; + let gridSectionOrderCounter = 0; const GridRegistryContext = createContext({ set: () => {}, delete: () => {}, }); - const GridSectionTitleContext = createContext(undefined); + const GridSectionContext = createContext(undefined); function GridItemComponent(props: any) { const registry = useContext(GridRegistryContext); - const sectionTitle = useContext(GridSectionTitleContext); + const section = useContext(GridSectionContext); const stableId = useRef(props.id || `__gi_${++gridItemOrderCounter}`).current; const orderRef = useRef(null); if (orderRef.current === null) orderRef.current = ++gridItemOrderCounter; useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); + registry.set(stableId, { props, section, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, sectionTitle, stableId]); + }, [props, registry, section, stableId]); return null; } - function GridSectionComponent({ children, title }: { children?: React.ReactNode; title?: string }) { - return {children}; + function GridSectionComponent({ children, title, subtitle, columns, aspectRatio, fit, inset }: any) { + const stableId = useRef(`__gs_${++gridSectionOrderCounter}`).current; + const section = useMemo( + () => ({ id: stableId, title, subtitle, columns, aspectRatio, fit, inset }), + [aspectRatio, columns, fit, inset, stableId, subtitle, title], + ); + + return {children}; } - function GridItemRenderer({ title, subtitle, content, isSelected, dataIdx, onSelect, onActivate, onContextAction }: any) { + function GridItemRenderer({ + title, + subtitle, + content, + accessory, + isSelected, + dataIdx, + itemHeight, + fit, + inset, + onSelect, + onActivate, + onContextAction, + }: any) { const isImageLikeSourceString = (value: string): boolean => { const source = String(value || '').trim(); if (!source) return false; @@ -158,6 +188,17 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) const swatchColor = getGridColor(content); const renderableContent = swatchColor ? null : toRenderableContent(content); + const accessoryIcon = accessory?.icon ? toRenderableContent(accessory.icon) : null; + const accessoryTitle = typeof accessory?.tooltip === 'string' ? accessory.tooltip : undefined; + const contentFitClass = fit === 'fill' ? 'w-full h-full object-cover' : 'w-full h-full object-contain'; + const insetClass = + inset === 'zero' + ? 'p-0' + : inset === 'md' + ? 'p-3' + : inset === 'lg' + ? 'p-5' + : 'p-1.5'; return (
string) : 'border-[var(--launcher-card-border)] bg-[var(--launcher-card-bg)] hover:bg-[var(--launcher-card-hover-bg)]' }`} style={{ - height: '160px', + height: `${itemHeight || 160}px`, boxShadow: isSelected ? '0 0 0 2px rgba(var(--on-surface-rgb), 0.24), inset 0 0 0 1px rgba(var(--on-surface-rgb), 0.16)' : undefined, @@ -181,12 +222,12 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string) onMouseMove={onSelect} onContextMenu={onContextAction} > -
+
{swatchColor ? (
) : renderableContent ? (
- {renderIcon(renderableContent, 'w-full h-full object-contain')} + {renderIcon(renderableContent, contentFitClass)}
) : (
@@ -194,9 +235,14 @@ export function createGridItemsRuntime(resolveIconSrc: (src: string) => string)
)}
- {title && ( + {(title || subtitle || accessoryIcon) && (
-

{title}

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

{title}

} {subtitle &&

{subtitle}

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

{t('common.loading')}

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

{t('common.noResults')}

) : ( - groupedItems.map((group, groupIndex) => ( -
- {group.title &&
{group.title}
} -
- {group.items.map(({ item, globalIdx }) => ( - setSelectedIdx(globalIdx)} - onActivate={() => { - setSelectedIdx(globalIdx); - inputRef.current?.focus(); - }} - onContextAction={(event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setSelectedIdx(globalIdx); - setShowActions(true); - }} - /> - ))} -
-
- )) +
+ {visibleRows.map((row) => ( + row.kind === 'section' ? ( +
+ {row.title} + {row.subtitle && ( + {row.subtitle} + )} +
+ ) : ( +
+ {row.items.map(({ item, globalIdx }) => ( + setSelectedIdx(globalIdx)} + onActivate={() => { + setSelectedIdx(globalIdx); + inputRef.current?.focus(); + }} + onContextAction={(event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedIdx(globalIdx); + setShowActions(true); + }} + /> + ))} +
+ ) + ))} +
)}
@@ -300,7 +408,7 @@ export function createGridRuntime(deps: GridRuntimeDeps) { ); } - const GridInset = { Small: 'small', Medium: 'medium', Large: 'large' } as const; + const GridInset = { Zero: 'zero', Small: 'sm', Medium: 'md', Large: 'lg' } as const; const GridItemSize = { Small: 'small', Medium: 'medium', Large: 'large' } as const; const GridFit = { Contain: 'contain', Fill: 'fill' } as const; diff --git a/src/renderer/src/raycast-api/hooks/use-ai.ts b/src/renderer/src/raycast-api/hooks/use-ai.ts index 1bf07c76..fd8a60ab 100644 --- a/src/renderer/src/raycast-api/hooks/use-ai.ts +++ b/src/renderer/src/raycast-api/hooks/use-ai.ts @@ -7,6 +7,113 @@ import { useCallback, useEffect, useRef, useState } from 'react'; type AICreativity = 'none' | 'low' | 'medium' | 'high' | 'maximum' | number; +export const RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS = 32; + +interface RaycastAIStreamDataRef { + current: string; +} + +export interface RaycastAIStreamBatcherOptions { + dataRef: RaycastAIStreamDataRef; + setVisibleData: (data: string) => void; + requestFrame?: (callback: () => void) => number; + cancelFrame?: (handle: number) => void; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (handle: ReturnType) => void; + flushDelayMs?: number; +} + +export interface RaycastAIStreamBatcher { + appendChunk: (chunk: string) => void; + cancelPendingFlush: () => void; + flush: () => void; + reset: (data?: string) => void; +} + +function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + return undefined; + } + return (callback) => window.requestAnimationFrame(() => callback()); +} + +function createDefaultCancelFrame(): ((handle: number) => void) | undefined { + if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') { + return undefined; + } + return (handle) => window.cancelAnimationFrame(handle); +} + +export function createRaycastAIStreamBatcher({ + dataRef, + setVisibleData, + requestFrame = createDefaultRequestFrame(), + cancelFrame = createDefaultCancelFrame(), + setTimer = globalThis.setTimeout.bind(globalThis), + clearTimer = globalThis.clearTimeout.bind(globalThis), + flushDelayMs = RAYCAST_AI_STREAM_FLUSH_INTERVAL_MS, +}: RaycastAIStreamBatcherOptions): RaycastAIStreamBatcher { + let visibleData = dataRef.current; + let pendingFrame: number | null = null; + let pendingTimer: ReturnType | null = null; + + const publishVisibleData = () => { + const nextData = dataRef.current; + if (nextData === visibleData) return; + visibleData = nextData; + setVisibleData(nextData); + }; + + const cancelPendingFlush = () => { + if (pendingFrame !== null) { + cancelFrame?.(pendingFrame); + pendingFrame = null; + } + if (pendingTimer !== null) { + clearTimer(pendingTimer); + pendingTimer = null; + } + }; + + const flush = () => { + cancelPendingFlush(); + publishVisibleData(); + }; + + const runScheduledFlush = () => { + pendingFrame = null; + pendingTimer = null; + publishVisibleData(); + }; + + const scheduleFlush = () => { + if (pendingFrame !== null || pendingTimer !== null) return; + + if (requestFrame) { + pendingFrame = requestFrame(runScheduledFlush); + return; + } + + pendingTimer = setTimer(runScheduledFlush, flushDelayMs); + }; + + return { + appendChunk(chunk: string) { + if (!chunk) return; + dataRef.current += chunk; + scheduleFlush(); + }, + cancelPendingFlush, + flush, + reset(data = '') { + cancelPendingFlush(); + dataRef.current = data; + visibleData = data; + setVisibleData(data); + }, + }; +} + export function useAI( prompt: string, options?: { @@ -24,17 +131,27 @@ export function useAI( const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(undefined); const abortRef = useRef(null); + const dataRef = useRef(''); + const streamBatcherRef = useRef(null); const promptRef = useRef(prompt); const optionsRef = useRef(options); promptRef.current = prompt; optionsRef.current = options; + if (!streamBatcherRef.current) { + streamBatcherRef.current = createRaycastAIStreamBatcher({ + dataRef, + setVisibleData: setData, + }); + } + const shouldExecute = options?.execute !== false; const stream = options?.stream !== false; const run = useCallback(() => { if (!promptRef.current) return; const opts = optionsRef.current; + const streamBatcher = streamBatcherRef.current; abortRef.current?.abort(); const controller = new AbortController(); @@ -42,7 +159,7 @@ export function useAI( setIsLoading(true); setError(undefined); - setData(''); + streamBatcher?.reset(''); opts?.onWillExecute?.([promptRef.current]); @@ -64,20 +181,22 @@ export function useAI( if (stream) { sp.on('data', (chunk: string) => { if (!controller.signal.aborted) { - setData((prev) => prev + chunk); + streamBatcher?.appendChunk(chunk); } }); } sp.then((fullText: string) => { if (!controller.signal.aborted) { - if (!stream) setData(fullText); + dataRef.current = fullText; + streamBatcher?.flush(); setIsLoading(false); opts?.onData?.(fullText); } }).catch((err: any) => { if (!controller.signal.aborted) { const e = err instanceof Error ? err : new Error(err?.message || 'AI request failed'); + streamBatcher?.flush(); setError(e); setIsLoading(false); opts?.onError?.(e); @@ -91,6 +210,7 @@ export function useAI( } return () => { abortRef.current?.abort(); + streamBatcherRef.current?.cancelPendingFlush(); }; }, [shouldExecute, run]); diff --git a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts index 7d1f4c4e..bec30deb 100644 --- a/src/renderer/src/raycast-api/hooks/use-cached-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-cached-promise.ts @@ -47,13 +47,8 @@ export function useCachedPromise( const [isPaginated, setIsPaginated] = useState(false); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - + const abortControllerRef = useRef(null); + const abortableRefRef = useRef | undefined>(undefined); const fnRef = useRef(fn); const argsRef = useRef(args || []); const optionsRef = useRef(options); @@ -63,30 +58,68 @@ export function useCachedPromise( optionsRef.current = options; runtimeCtxRef.current = snapshotExtensionContext(); + const clearAbortController = useCallback((controller: AbortController) => { + const abortableRef = abortableRefRef.current; + if (abortableRef?.current === controller) { + abortableRef.current = null; + } + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + if (abortableRefRef.current === abortableRef) { + abortableRefRef.current = undefined; + } + } + }, []); + + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + clearAbortController(controller); + }, [clearAbortController]); + + const prepareAbortController = useCallback((abortable?: React.MutableRefObject) => { + abortCurrentRun(); + if (!abortable) return null; + + const controller = new AbortController(); + abortControllerRef.current = controller; + abortableRefRef.current = abortable; + abortable.current = controller; + return controller; + }, [abortCurrentRun]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const fetchPage = useCallback(async (pageNum: number, currentCursor?: string) => { const opts = optionsRef.current; if (opts?.execute === false || !mountedRef.current) return; + const runController = prepareAbortController(opts?.abortable); + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => !runController || abortControllerRef.current === runController; + setIsLoading(true); setError(undefined); - if (opts?.abortable) { - const controller = new AbortController(); - opts.abortable.current = controller; - } - - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onWillExecute?.(argsRef.current); }); try { - const outerResult = withExtensionContext(runtimeCtxRef.current, () => fnRef.current(...argsRef.current)); + const outerResult = withExtensionContext(runCtx, () => fnRef.current(...argsRef.current)); if (typeof outerResult === 'function') { setIsPaginated(true); const paginationOptions = { page: pageNum, cursor: currentCursor, lastItem: undefined }; - const innerResult = await withExtensionContext(runtimeCtxRef.current, () => outerResult(paginationOptions)); - if (!mountedRef.current) return; + const innerResult = await withExtensionContext(runCtx, () => outerResult(paginationOptions)); + if (!mountedRef.current || !isCurrentRun()) return; if (innerResult && typeof innerResult === 'object' && 'data' in innerResult) { const { data: pageData, hasMore: more, cursor: nextCursor } = innerResult; @@ -96,14 +129,14 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; }); } - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.((innerResult as any).data); }); } else { @@ -112,7 +145,7 @@ export function useCachedPromise( } } else { const result = await outerResult; - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; if (result && typeof result === 'object' && 'data' in result && 'hasMore' in result) { setIsPaginated(true); @@ -123,35 +156,40 @@ export function useCachedPromise( if (pageNum === 0) { setAccumulatedData(Array.isArray(pageData) ? pageData : []); } else { - setAccumulatedData((prev) => { + setAccumulatedData((prev: unknown) => { const prevArr = Array.isArray(prev) ? prev : []; const newArr = Array.isArray(pageData) ? pageData : []; return [...prevArr, ...newArr]; }); } - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.(pageData as T); }); } else { setAccumulatedData(result as any); setHasMore(false); - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onData?.(result as T); }); } } } catch (err) { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); - withExtensionContext(runtimeCtxRef.current, () => { + withExtensionContext(runCtx, () => { opts?.onError?.(e); }); } finally { - if (mountedRef.current) setIsLoading(false); + if (mountedRef.current && isCurrentRun()) { + setIsLoading(false); + } + if (runController) { + clearAbortController(runController); + } } - }, []); + }, [prepareAbortController, clearAbortController]); const argsKey = JSON.stringify(args || []); useEffect(() => { 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..d2fc7895 100644 --- a/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts +++ b/src/renderer/src/raycast-api/hooks/use-frecency-sorting.ts @@ -10,8 +10,13 @@ interface FrecencyEntry { 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; } @@ -30,7 +35,7 @@ export function useFrecencySorting( } { const ns = options?.namespace || 'default'; const storageKey = `sc-frecency-${ns}`; - const getKey = options?.key || ((item: any) => item?.id ?? String(item)); + const getKey = options?.key || getDefaultFrecencyKey; const [frecencyMap, setFrecencyMap] = useState>(() => { try { @@ -60,13 +65,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/hooks/use-local-storage.ts b/src/renderer/src/raycast-api/hooks/use-local-storage.ts index 98370518..e3ca8900 100644 --- a/src/renderer/src/raycast-api/hooks/use-local-storage.ts +++ b/src/renderer/src/raycast-api/hooks/use-local-storage.ts @@ -5,6 +5,7 @@ import { useCallback, useState } from 'react'; import { emitExtensionStorageChanged } from '../storage-events'; +import { getCurrentScopedExtensionContext } from '../context-scope-runtime'; import { getScopedLocalStorageKeys, readScopedJsonState } from './storage-scope'; export function useLocalStorage( @@ -17,6 +18,10 @@ export function useLocalStorage( isLoading: boolean; } { const { scopedKey, legacyKeys } = getScopedLocalStorageKeys(key); + const currentContext = getCurrentScopedExtensionContext(); + const storageEventExtensionName = currentContext.extensionName; + const storageEventCommandName = currentContext.commandName; + const storageEventCommandMode = currentContext.commandMode; const [value, setValueState] = useState(() => { return readScopedJsonState(scopedKey, legacyKeys, initialValue); }); @@ -29,8 +34,12 @@ export function useLocalStorage( } catch { // best-effort } - emitExtensionStorageChanged(); - }, [scopedKey]); + emitExtensionStorageChanged({ + extensionName: storageEventExtensionName, + commandName: storageEventCommandName, + commandMode: storageEventCommandMode, + }); + }, [scopedKey, storageEventCommandMode, storageEventCommandName, storageEventExtensionName]); const removeValue = useCallback(async () => { setValueState(undefined); @@ -42,8 +51,12 @@ export function useLocalStorage( } catch { // best-effort } - emitExtensionStorageChanged(); - }, [legacyKeys, scopedKey]); + emitExtensionStorageChanged({ + extensionName: storageEventExtensionName, + commandName: storageEventCommandName, + commandMode: storageEventCommandMode, + }); + }, [legacyKeys, scopedKey, storageEventCommandMode, storageEventCommandName, storageEventExtensionName]); return { value, setValue, removeValue, isLoading }; } diff --git a/src/renderer/src/raycast-api/hooks/use-promise.ts b/src/renderer/src/raycast-api/hooks/use-promise.ts index 5127e284..0f480eb2 100644 --- a/src/renderer/src/raycast-api/hooks/use-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-promise.ts @@ -49,51 +49,98 @@ export function usePromise( const [error, setError] = useState(undefined); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + const abortControllerRef = useRef(null); + const abortableRefRef = useRef | undefined>(undefined); const stableArgs = useStableArgs(args || []); const fnRef = useRef(fn); const argsRef = useRef(stableArgs); + const optionsRef = useRef(options); const runtimeCtxRef = useRef(snapshotExtensionContext()); fnRef.current = fn; argsRef.current = stableArgs; + optionsRef.current = options; runtimeCtxRef.current = snapshotExtensionContext(); + const clearAbortController = useCallback((controller: AbortController) => { + const abortableRef = abortableRefRef.current; + if (abortableRef?.current === controller) { + abortableRef.current = null; + } + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + if (abortableRefRef.current === abortableRef) { + abortableRefRef.current = undefined; + } + } + }, []); + + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + clearAbortController(controller); + }, [clearAbortController]); + + const prepareAbortController = useCallback((abortable?: React.MutableRefObject) => { + abortCurrentRun(); + if (!abortable) return null; + + const controller = new AbortController(); + abortControllerRef.current = controller; + abortableRefRef.current = abortable; + abortable.current = controller; + return controller; + }, [abortCurrentRun]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const execute = useCallback(() => { - if (options?.execute === false || !mountedRef.current) return; + const opts = optionsRef.current; + if (opts?.execute === false || !mountedRef.current) return; + + const runController = prepareAbortController(opts?.abortable); + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => !runController || abortControllerRef.current === runController; setIsLoading(true); setError(undefined); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onWillExecute?.(argsRef.current); + withExtensionContext(runCtx, () => { + opts?.onWillExecute?.(argsRef.current); }); Promise.resolve() - .then(() => withExtensionContext(runtimeCtxRef.current, () => fnRef.current(...argsRef.current))) + .then(() => withExtensionContext(runCtx, () => fnRef.current(...argsRef.current))) .then((result) => { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; setData(result); setIsLoading(false); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onData?.(result); + withExtensionContext(runCtx, () => { + opts?.onData?.(result); }); }) .catch((err) => { - if (!mountedRef.current) return; + if (!mountedRef.current || !isCurrentRun()) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); setIsLoading(false); - withExtensionContext(runtimeCtxRef.current, () => { - options?.onError?.(e); + withExtensionContext(runCtx, () => { + opts?.onError?.(e); }); + }) + .finally(() => { + if (runController) { + clearAbortController(runController); + } }); - }, [options?.execute]); + }, [options?.execute, prepareAbortController, clearAbortController]); useEffect(() => { execute(); diff --git a/src/renderer/src/raycast-api/icon-runtime-assets.tsx b/src/renderer/src/raycast-api/icon-runtime-assets.tsx index 05731627..cd3bf941 100644 --- a/src/renderer/src/raycast-api/icon-runtime-assets.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-assets.tsx @@ -6,6 +6,9 @@ import React from 'react'; import { getIconRuntimeContext } from './icon-runtime-config'; +const LOCAL_PATH_EXISTS_CACHE_MAX = 4096; +const positiveLocalPathExistsCache = new Set(); + type RgbColor = { r: number; g: number; @@ -41,9 +44,18 @@ export function toScAssetUrl(filePath: string): string { function localPathExists(filePath: string): boolean { if (!filePath) return false; + if (positiveLocalPathExistsCache.has(filePath)) return true; + try { const stat = (window as any).electron?.statSync?.(filePath); - return Boolean(stat?.exists); + const exists = Boolean(stat?.exists); + if (exists) { + if (positiveLocalPathExistsCache.size >= LOCAL_PATH_EXISTS_CACHE_MAX) { + positiveLocalPathExistsCache.clear(); + } + positiveLocalPathExistsCache.add(filePath); + } + return exists; } catch { return false; } diff --git a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx index 2716a240..8922668b 100644 --- a/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-phosphor.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import * as Phosphor from '../../../../node_modules/@phosphor-icons/react/dist/index.es.js'; +import * as Phosphor from '@phosphor-icons/react'; import { RAYCAST_ICON_NAMES, RAYCAST_ICON_VALUE_TO_NAME, type RaycastIconName } from './raycast-icon-enum'; type PhosphorIconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; @@ -16,9 +16,27 @@ type PhosphorIconComponent = React.ComponentType<{ style?: React.CSSProperties; weight?: PhosphorIconWeight; }>; +type PhosphorIconResolution = { icon: PhosphorIconComponent; weight: PhosphorIconWeight }; type PhosphorExportValue = unknown; +const ICON_RESOLUTION_CACHE_LIMIT = 2048; +const raycastIconNameResolutionCache = new Map(); +const phosphorNameResolutionCache = new Map(); +const phosphorIconResolutionCache = new Map(); + +function setBoundedCacheEntry(cache: Map, key: K, value: V) { + if (!cache.has(key) && cache.size >= ICON_RESOLUTION_CACHE_LIMIT) { + cache.clear(); + } + cache.set(key, value); +} + +function cachePhosphorIconResolution(input: string, result: PhosphorIconResolution | undefined): PhosphorIconResolution | undefined { + setBoundedCacheEntry(phosphorIconResolutionCache, input, result || null); + return result; +} + function normalizeIconName(name: string): string { return String(name || '') .trim() @@ -76,29 +94,61 @@ if (RAYCAST_ICON_VALUE_TO_NAME instanceof Map) { function resolveRaycastIconName(input: string): RaycastIconName | undefined { const rawInput = String(input || '').trim(); + const cached = raycastIconNameResolutionCache.get(rawInput); + if (cached !== undefined || raycastIconNameResolutionCache.has(rawInput)) { + return cached || undefined; + } + const normalized = normalizeIconName(input); - if (!rawInput && !normalized) return undefined; - if (raycastIconNameSet.has(rawInput)) return rawInput as RaycastIconName; - return raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + let resolved: RaycastIconName | undefined; + if (rawInput || normalized) { + resolved = raycastIconNameSet.has(rawInput) + ? rawInput as RaycastIconName + : raycastIconValueToNameMap.get(rawInput) || raycastIconValueToNameMap.get(normalized); + } + + setBoundedCacheEntry(raycastIconNameResolutionCache, rawInput, resolved || null); + return resolved; } function tryResolvePhosphorByName(name: string): PhosphorIconComponent | undefined { - if (!name) return undefined; + const cacheKey = String(name || ''); + const cached = phosphorNameResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorNameResolutionCache.has(cacheKey)) { + return cached || undefined; + } + + if (!name) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } const direct = (Phosphor as Record)[name]; - if (isRenderablePhosphorComponent(direct)) return direct as PhosphorIconComponent; + if (isRenderablePhosphorComponent(direct)) { + const resolved = direct as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } const normalizedTarget = normalizeIconName(name); - if (!normalizedTarget) return undefined; + if (!normalizedTarget) { + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); + return undefined; + } // Some bundling modes can make namespace entries non-enumerable for Object.entries. // getOwnPropertyNames is more robust for resolving the export keys. for (const key of Object.getOwnPropertyNames(Phosphor)) { if (normalizeIconName(key) !== normalizedTarget) continue; const candidate = (Phosphor as Record)[key]; - if (isRenderablePhosphorComponent(candidate)) return candidate as PhosphorIconComponent; + if (isRenderablePhosphorComponent(candidate)) { + const resolved = candidate as PhosphorIconComponent; + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, resolved); + return resolved; + } } + setBoundedCacheEntry(phosphorNameResolutionCache, cacheKey, null); return undefined; } @@ -203,7 +253,13 @@ function bestFuzzyPhosphorCandidate(input: string): string | undefined { return bestName || undefined; } -function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComponent; weight: PhosphorIconWeight } | undefined { +function resolvePhosphorIconFromRaycast(input: string): PhosphorIconResolution | undefined { + const cacheKey = String(input || ''); + const cached = phosphorIconResolutionCache.get(cacheKey); + if (cached !== undefined || phosphorIconResolutionCache.has(cacheKey)) { + return cached || undefined; + } + const resolvedRaycastName = resolveRaycastIconName(input); const iconName = (resolvedRaycastName || input || '').replace(/^Icon\./, ''); const normalized = normalizeIconName(iconName); @@ -214,7 +270,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (normalized === 'dot' || normalizedBase === 'dot') { const dotIcon = tryResolvePhosphorByName('Circle'); if (dotIcon) { - return { icon: dotIcon, weight: 'fill' }; + return cachePhosphorIconResolution(cacheKey, { icon: dotIcon, weight: 'fill' }); } } @@ -233,7 +289,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of directCandidates) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -244,7 +300,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of explicitAliases) { const resolved = tryResolvePhosphorByName(candidate); if (resolved) { - return { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: resolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -296,7 +352,7 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp for (const candidate of candidates) { const icon = tryResolvePhosphorByName(candidate); if (icon) { - return { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } @@ -304,13 +360,13 @@ function resolvePhosphorIconFromRaycast(input: string): { icon: PhosphorIconComp if (fuzzyCandidate) { const fuzzyResolved = tryResolvePhosphorByName(fuzzyCandidate); if (fuzzyResolved) { - return { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + return cachePhosphorIconResolution(cacheKey, { icon: fuzzyResolved, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } } const fallback = tryResolvePhosphorByName('Question') || tryResolvePhosphorByName('Circle'); - if (!fallback) return undefined; - return { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }; + if (!fallback) return cachePhosphorIconResolution(cacheKey, undefined); + return cachePhosphorIconResolution(cacheKey, { icon: fallback, weight: shouldUseFillWeight ? 'fill' : 'regular' }); } export function renderPhosphorIcon(input: string, className: string, tint?: string): React.ReactNode { diff --git a/src/renderer/src/raycast-api/icon-runtime-render.tsx b/src/renderer/src/raycast-api/icon-runtime-render.tsx index 5a4d77ce..88469b02 100644 --- a/src/renderer/src/raycast-api/icon-runtime-render.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-render.tsx @@ -7,30 +7,81 @@ import React, { useEffect, useState } from 'react'; import { isRaycastIconName, renderPhosphorIcon } from './icon-runtime-phosphor'; import { isEmojiOrSymbol, renderTintedAssetIcon, resolveIconSrc, resolveTintColor } from './icon-runtime-assets'; +const FILE_ICON_CACHE_MAX_ENTRIES = 1024; const fileIconCache = new Map(); +const inFlightFileIconRequests = new Map>(); + +function peekCachedFileIcon(filePath: string): string | null | undefined { + if (!fileIconCache.has(filePath)) return undefined; + return fileIconCache.get(filePath) ?? null; +} + +function readCachedFileIcon(filePath: string): string | null | undefined { + const cached = peekCachedFileIcon(filePath); + if (cached === undefined) return undefined; + fileIconCache.delete(filePath); + fileIconCache.set(filePath, cached); + return cached; +} + +function writeCachedFileIcon(filePath: string, src: string | null) { + if (fileIconCache.has(filePath)) fileIconCache.delete(filePath); + fileIconCache.set(filePath, src); + + while (fileIconCache.size > FILE_ICON_CACHE_MAX_ENTRIES) { + const oldestKey = fileIconCache.keys().next().value; + if (oldestKey === undefined) break; + fileIconCache.delete(oldestKey); + } +} + +function loadFileIconDataUrl(filePath: string): Promise { + const cached = readCachedFileIcon(filePath); + if (cached !== undefined) return Promise.resolve(cached); + + const pending = inFlightFileIconRequests.get(filePath); + if (pending) return pending; + + const getFileIconDataUrl = typeof window !== 'undefined' + ? (window as any).electron?.getFileIconDataUrl + : undefined; + if (typeof getFileIconDataUrl !== 'function') return Promise.resolve(null); + + let request: Promise; + try { + request = Promise.resolve(getFileIconDataUrl(filePath, 20)) + .then((iconSrc: string | null | undefined) => { + const normalized = iconSrc || null; + writeCachedFileIcon(filePath, normalized); + return normalized; + }) + .catch(() => null) + .finally(() => { + inFlightFileIconRequests.delete(filePath); + }); + } catch { + return Promise.resolve(null); + } + + inFlightFileIconRequests.set(filePath, request); + return request; +} function FileIcon({ filePath, className }: { filePath: string; className: string }) { - const [src, setSrc] = useState(() => fileIconCache.get(filePath) ?? null); + const [src, setSrc] = useState(() => peekCachedFileIcon(filePath) ?? null); useEffect(() => { let cancelled = false; - const cached = fileIconCache.get(filePath); + const cached = readCachedFileIcon(filePath); if (cached !== undefined) { setSrc(cached); return; } - (window as any).electron?.getFileIconDataUrl?.(filePath, 20) - .then((iconSrc: string | null) => { - if (cancelled) return; - fileIconCache.set(filePath, iconSrc || null); - setSrc(iconSrc || null); - }) - .catch(() => { - if (cancelled) return; - fileIconCache.set(filePath, null); - setSrc(null); - }); + loadFileIconDataUrl(filePath).then((iconSrc) => { + if (cancelled) return; + setSrc(iconSrc); + }); return () => { cancelled = true; diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 95940389..24da47c9 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -434,8 +434,10 @@ export enum ToastStyle { Failure = 'failure', } +type ToastStyleInput = ToastStyle | 'animated' | 'success' | 'failure'; + export class Toast { - static Style = ToastStyle; + static Style: typeof ToastStyle = ToastStyle; private static _activeToast: Toast | null = null; private _title = ''; @@ -483,7 +485,7 @@ export class Toast { return this._style; } - public set style(value: ToastStyle | Toast.Style | string) { + public set style(value: ToastStyleInput | Toast.Style | string) { this._style = this.normalizeStyle(value); this.refresh(); } @@ -506,7 +508,7 @@ export class Toast { this.refresh(); } - private normalizeStyle(value: ToastStyle | Toast.Style | string | undefined): ToastStyle { + private normalizeStyle(value: ToastStyleInput | Toast.Style | string | undefined): ToastStyle { if (value === ToastStyle.Animated || value === Toast.Style.Animated || value === 'animated') { return ToastStyle.Animated; } @@ -894,16 +896,12 @@ export class Toast { // Toast namespace for types (merged with class) export namespace Toast { - export enum Style { - Animated = 'animated', - Success = 'success', - Failure = 'failure', - } + export type Style = ToastStyle; export interface Options { title: string; message?: string; - style?: ToastStyle | Toast.Style; + style?: ToastStyleInput | Toast.Style; primaryAction?: Alert.ActionOptions; secondaryAction?: Alert.ActionOptions; } @@ -924,9 +922,9 @@ function shouldSuppressBenignGitMissingPathToast(options: Toast.Options): boolea } export async function showToast(options: Toast.Options): Promise; -export async function showToast(style: ToastStyle | Toast.Style, title: string, message?: string): Promise; +export async function showToast(style: ToastStyleInput | Toast.Style, title: string, message?: string): Promise; export async function showToast( - optionsOrStyle: Toast.Options | ToastStyle | Toast.Style, + optionsOrStyle: Toast.Options | ToastStyleInput | Toast.Style, title?: string, message?: string ): Promise { @@ -1387,11 +1385,22 @@ export namespace Cache { export type Subscription = () => void; } +const CACHE_METADATA_VERSION = 2; + +interface CacheStorageMetadata { + version?: number; + lruOrder?: unknown; + sizeByKey?: unknown; + totalSize?: unknown; +} + export class Cache { private storageKey: string; private capacity: number; private subscribers: Set = new Set(); private lruOrder: string[] = []; // Track access order for LRU + private sizeByKey: Map = new Map(); + private totalSize = 0; constructor(options: Cache.Options = {}) { this.capacity = options.capacity ?? 10 * 1024 * 1024; // 10MB default @@ -1403,21 +1412,146 @@ export class Cache { } private loadFromStorage(): void { + let metadata: CacheStorageMetadata | null = null; + let hadStoredMetadata = false; + try { const stored = localStorage.getItem(this.storageKey); + hadStoredMetadata = stored !== null; if (stored) { - const parsed = JSON.parse(stored); - this.lruOrder = parsed.lruOrder || []; + metadata = JSON.parse(stored); } } catch (e) { console.error('Failed to load cache from storage:', e); + this.recoverFromStorage(undefined, true); + return; + } + + if (!metadata || typeof metadata !== 'object') { + this.recoverFromStorage(undefined, hadStoredMetadata); + return; + } + + const lruOrder = this.parseLruOrder(metadata.lruOrder); + if (!lruOrder) { + this.recoverFromStorage(undefined, true); + return; + } + + const storedSizes = this.parseStoredSizes(metadata.sizeByKey); + if (!storedSizes) { + this.recoverFromStorage(lruOrder, true); + return; + } + + let totalSize = 0; + const nextSizes = new Map(); + let hasAllSizes = true; + + for (const key of lruOrder) { + const size = storedSizes.get(key); + if (size === undefined) { + hasAllSizes = false; + break; + } + nextSizes.set(key, size); + totalSize += size; + } + + if (!hasAllSizes) { + this.recoverFromStorage(lruOrder, true); + return; + } + + this.lruOrder = lruOrder; + this.sizeByKey = nextSizes; + this.totalSize = totalSize; + + if ( + metadata.version !== CACHE_METADATA_VERSION || + metadata.totalSize !== totalSize || + storedSizes.size !== lruOrder.length + ) { + this.saveToStorage(); + } + } + + private parseLruOrder(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + + const seen = new Set(); + const order: string[] = []; + for (const key of value) { + if (typeof key !== 'string') return null; + if (seen.has(key)) continue; + seen.add(key); + order.push(key); + } + return order; + } + + private parseStoredSizes(value: unknown): Map | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const sizes = new Map(); + for (const [key, size] of Object.entries(value as Record)) { + if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) return null; + sizes.set(key, size); + } + return sizes; + } + + private recoverFromStorage(preferredOrder?: string[], shouldSave = false): void { + this.lruOrder = []; + this.sizeByKey.clear(); + this.totalSize = 0; + + const recovered = new Set(); + const recoverKey = (key: string): void => { + if (recovered.has(key)) return; + recovered.add(key); + + const value = localStorage.getItem(this.getItemKey(key)); + if (value === null) return; + + this.lruOrder.push(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + }; + + if (preferredOrder) { + for (const key of preferredOrder) recoverKey(key); + } else { + const itemPrefix = this.getItemPrefix(); + const storageKeys: string[] = []; + for (let index = 0; index < localStorage.length; index += 1) { + const storageKey = localStorage.key(index); + if (storageKey?.startsWith(itemPrefix)) storageKeys.push(storageKey); + } + + for (const storageKey of storageKeys) { + recoverKey(storageKey.slice(itemPrefix.length)); + } + } + + if (shouldSave || this.lruOrder.length > 0) { + this.saveToStorage(); } } private saveToStorage(): void { try { + const sizeByKey: Record = {}; + for (const key of this.lruOrder) { + const size = this.sizeByKey.get(key); + if (size !== undefined) sizeByKey[key] = size; + } + const data = { + version: CACHE_METADATA_VERSION, lruOrder: this.lruOrder, + sizeByKey, + totalSize: this.totalSize, }; localStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (e) { @@ -1425,19 +1559,16 @@ export class Cache { } } + private getItemPrefix(): string { + return `${this.storageKey}-item-`; + } + private getItemKey(key: string): string { - return `${this.storageKey}-item-${key}`; + return `${this.getItemPrefix()}${key}`; } private getCurrentSize(): number { - let total = 0; - for (const key of this.lruOrder) { - const value = localStorage.getItem(this.getItemKey(key)); - if (value) { - total += value.length; - } - } - return total; + return this.totalSize; } private evictLRU(): void { @@ -1445,6 +1576,9 @@ export class Cache { const oldestKey = this.lruOrder.shift(); if (oldestKey) { localStorage.removeItem(this.getItemKey(oldestKey)); + const size = this.sizeByKey.get(oldestKey) ?? 0; + this.sizeByKey.delete(oldestKey); + this.totalSize = Math.max(0, this.totalSize - size); } } @@ -1458,6 +1592,29 @@ export class Cache { this.lruOrder.push(key); } + private removeTrackedKey(key: string): boolean { + const index = this.lruOrder.indexOf(key); + const trackedSize = this.sizeByKey.get(key); + const existed = index !== -1 || trackedSize !== undefined; + + if (index !== -1) { + this.lruOrder.splice(index, 1); + } + if (trackedSize !== undefined) { + this.sizeByKey.delete(key); + this.totalSize = Math.max(0, this.totalSize - trackedSize); + } + + return existed; + } + + private trackItem(key: string, dataSize: number): void { + this.removeTrackedKey(key); + this.sizeByKey.set(key, dataSize); + this.totalSize += dataSize; + this.updateLRU(key); + } + private notifySubscribers(key: string | undefined, data: string | undefined): void { for (const subscriber of this.subscribers) { try { @@ -1471,10 +1628,18 @@ export class Cache { get(key: string): string | undefined { const value = localStorage.getItem(this.getItemKey(key)); if (value !== null) { + if (this.sizeByKey.get(key) !== value.length) { + this.removeTrackedKey(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + } this.updateLRU(key); this.saveToStorage(); return value; } + if (this.removeTrackedKey(key)) { + this.saveToStorage(); + } return undefined; } @@ -1483,15 +1648,14 @@ export class Cache { const dataSize = data.length; // Check if adding this item would exceed capacity - let currentSize = this.getCurrentSize(); - while (currentSize + dataSize > this.capacity && this.lruOrder.length > 0) { + this.removeTrackedKey(key); + while (this.getCurrentSize() + dataSize > this.capacity && this.lruOrder.length > 0) { this.evictLRU(); - currentSize = this.getCurrentSize(); } // Store the item localStorage.setItem(itemKey, data); - this.updateLRU(key); + this.trackItem(key, dataSize); this.saveToStorage(); // Notify subscribers @@ -1500,14 +1664,11 @@ export class Cache { remove(key: string): boolean { const itemKey = this.getItemKey(key); - const existed = localStorage.getItem(itemKey) !== null; + const existedInStorage = localStorage.getItem(itemKey) !== null; + const existed = this.removeTrackedKey(key) || existedInStorage; if (existed) { localStorage.removeItem(itemKey); - const index = this.lruOrder.indexOf(key); - if (index !== -1) { - this.lruOrder.splice(index, 1); - } this.saveToStorage(); this.notifySubscribers(key, undefined); } @@ -1516,7 +1677,22 @@ export class Cache { } has(key: string): boolean { - return localStorage.getItem(this.getItemKey(key)) !== null; + const value = localStorage.getItem(this.getItemKey(key)); + if (value !== null) { + if (this.sizeByKey.get(key) !== value.length) { + this.removeTrackedKey(key); + this.sizeByKey.set(key, value.length); + this.totalSize += value.length; + this.updateLRU(key); + this.saveToStorage(); + } + return true; + } + + if (this.removeTrackedKey(key)) { + this.saveToStorage(); + } + return false; } get isEmpty(): boolean { @@ -1531,6 +1707,8 @@ export class Cache { localStorage.removeItem(this.getItemKey(key)); } this.lruOrder = []; + this.sizeByKey.clear(); + this.totalSize = 0; this.saveToStorage(); // Notify subscribers diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..82948ff6 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -7,6 +7,35 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { ItemRegistration, ListRegistryAPI } from './list-runtime-types'; +export const LIST_ROW_HEIGHT = 36; +export const LIST_HEADER_HEIGHT = 24; +export const LIST_OVERSCAN = 8; +export const EMOJI_GRID_COLUMNS = 8; +export const EMOJI_GRID_CELL_HEIGHT = 96; +export const EMOJI_GRID_ROW_GAP = 8; +export const EMOJI_GRID_ROW_HEIGHT = EMOJI_GRID_CELL_HEIGHT + EMOJI_GRID_ROW_GAP; +export const EMOJI_GRID_HEADER_HEIGHT = 28; + +export type ListItemGroup = { + title?: string; + items: { item: ItemRegistration; globalIdx: number }[]; +}; + +export type ListVirtualRow = + | { type: 'header'; title: string; key: string; height: number } + | { type: 'item'; item: ItemRegistration; globalIdx: number; key: string; height: number }; + +export type EmojiGridVirtualRow = + | { type: 'header'; title: string; count: number; key: string; height: number } + | { type: 'emoji-row'; items: ListItemGroup['items']; key: string; height: number }; + +export type VirtualRow = ListVirtualRow | EmojiGridVirtualRow; + +export type VirtualRowMetrics = { + offsets: number[]; + totalHeight: number; +}; + function getReactTypeName(type: any): string { return String(type?.displayName || type?.name || type || ''); } @@ -155,8 +184,8 @@ export function shouldUseEmojiGrid(filteredItems: ItemRegistration[], isShowingD return emojiIcons / Math.max(1, iconsWithValue) >= 0.95; } -export function groupListItems(filteredItems: ItemRegistration[]) { - const groups: { title?: string; items: { item: ItemRegistration; globalIdx: number }[] }[] = []; +export function groupListItems(filteredItems: ItemRegistration[]): ListItemGroup[] { + const groups: ListItemGroup[] = []; let currentSection: string | undefined | null = null; let globalIndex = 0; @@ -170,3 +199,105 @@ export function groupListItems(filteredItems: ItemRegistration[]) { return groups; } + +export function buildListVirtualRows(groupedItems: ListItemGroup[]): ListVirtualRow[] { + const rows: ListVirtualRow[] = []; + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ type: 'header', title: group.title, key: `__h_${groupIndex}`, height: LIST_HEADER_HEIGHT }); + } + for (const entry of group.items) { + rows.push({ + type: 'item', + item: entry.item, + globalIdx: entry.globalIdx, + key: entry.item.id, + height: LIST_ROW_HEIGHT, + }); + } + } + return rows; +} + +export function buildEmojiGridVirtualRows(groupedItems: ListItemGroup[], columns = EMOJI_GRID_COLUMNS): EmojiGridVirtualRow[] { + const rows: EmojiGridVirtualRow[] = []; + const safeColumns = Math.max(1, Math.floor(columns)); + + for (let groupIndex = 0; groupIndex < groupedItems.length; groupIndex += 1) { + const group = groupedItems[groupIndex]; + if (group.title) { + rows.push({ + type: 'header', + title: group.title, + count: group.items.length, + key: `__eg_h_${groupIndex}`, + height: EMOJI_GRID_HEADER_HEIGHT, + }); + } + + for (let start = 0; start < group.items.length; start += safeColumns) { + rows.push({ + type: 'emoji-row', + items: group.items.slice(start, start + safeColumns), + key: `__eg_r_${groupIndex}_${start}`, + height: EMOJI_GRID_ROW_HEIGHT, + }); + } + } + + return rows; +} + +export function measureVirtualRows(rows: Array<{ height: number }>): VirtualRowMetrics { + const offsets: number[] = new Array(rows.length); + let totalHeight = 0; + for (let index = 0; index < rows.length; index += 1) { + offsets[index] = totalHeight; + totalHeight += rows[index].height; + } + return { offsets, totalHeight }; +} + +export function getVisibleVirtualRange( + rows: Array<{ height: number }>, + rowMetrics: VirtualRowMetrics, + scrollTop: number, + containerHeight: number, + overscan = LIST_OVERSCAN, +) { + if (rows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; + + const top = scrollTop; + const bottom = scrollTop + (containerHeight || 600); + let lo = 0; + let hi = rows.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (rowMetrics.offsets[mid] + rows[mid].height <= top) lo = mid + 1; + else hi = mid; + } + + const visibleStart = Math.max(0, lo - overscan); + let visibleEnd = lo; + while (visibleEnd < rows.length && rowMetrics.offsets[visibleEnd] < bottom) visibleEnd += 1; + visibleEnd = Math.min(rows.length, visibleEnd + overscan); + return { visibleStart, visibleEnd }; +} + +export function buildItemToVirtualRowMap(rows: VirtualRow[], itemCount: number): number[] { + const map: number[] = new Array(itemCount); + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (row.type === 'item') { + map[row.globalIdx] = rowIndex; + } else if (row.type === 'emoji-row') { + for (const entry of row.items) { + map[entry.globalIdx] = rowIndex; + } + } + } + + return map; +} diff --git a/src/renderer/src/raycast-api/list-runtime-renderers.tsx b/src/renderer/src/raycast-api/list-runtime-renderers.tsx index 44d20a55..59df5e0e 100644 --- a/src/renderer/src/raycast-api/list-runtime-renderers.tsx +++ b/src/renderer/src/raycast-api/list-runtime-renderers.tsx @@ -19,7 +19,7 @@ interface ListRendererDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (tintColor?: string) => string | undefined; resolveReadableTintColor: (tintColor?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; } export function createListRenderers(deps: ListRendererDeps) { @@ -53,13 +53,14 @@ export function createListRenderers(deps: ListRendererDeps) { const registry = useContext(ListRegistryContext); const sectionTitle = useContext(ListSectionTitleContext); const stableId = useRef(props.id || `__li_${++itemOrderCounter}`).current; - const renderOrder = ++itemOrderCounter; + const orderRef = useRef(null); + if (orderRef.current === null) orderRef.current = ++itemOrderCounter; const selCtx = useContext(SelectedItemActionsContext); useLayoutEffect(() => { - registry.set(stableId, { props, sectionTitle, order: renderOrder }); + registry.set(stableId, { props, sectionTitle, order: orderRef.current! }); return () => registry.delete(stableId); - }, [props, registry, renderOrder, sectionTitle, stableId]); + }, [props, registry, sectionTitle, stableId]); // When this item is selected, render its actions within the extension's // context tree so that per-item React contexts (e.g. VaultItemContext) @@ -102,7 +103,7 @@ export function createListRenderers(deps: ListRendererDeps) { {icon &&
{renderIcon(icon, iconClassName, assetsPath)}
}
{primaryText}
{secondaryText && {secondaryText}} - {accessories?.map((accessory, index) => { + {accessories?.map((accessory: NonNullable[number], index: number) => { const accessoryText = typeof accessory?.text === 'string' ? accessory.text : typeof accessory?.text === 'object' ? accessory.text?.value || '' : ''; const accessoryTextColorRaw = typeof accessory?.text === 'object' ? accessory.text?.color : undefined; const tagText = typeof accessory?.tag === 'string' ? accessory.tag : typeof accessory?.tag === 'object' ? accessory.tag?.value || '' : ''; diff --git a/src/renderer/src/raycast-api/list-runtime.tsx b/src/renderer/src/raycast-api/list-runtime.tsx index d7b665bd..9d3c1a95 100644 --- a/src/renderer/src/raycast-api/list-runtime.tsx +++ b/src/renderer/src/raycast-api/list-runtime.tsx @@ -10,7 +10,19 @@ import type { ExtractedAction } from './action-runtime'; import { useI18n } from '../i18n'; import { transliterateForSearch } from '../utils/transliterate'; import { createListDetailRuntime } from './list-runtime-detail'; -import { groupListItems, shouldUseEmojiGrid, useListRegistry } from './list-runtime-hooks'; +import { + buildEmojiGridVirtualRows, + buildItemToVirtualRowMap, + buildListVirtualRows, + EMOJI_GRID_CELL_HEIGHT, + EMOJI_GRID_COLUMNS, + EMOJI_GRID_ROW_GAP, + getVisibleVirtualRange, + groupListItems, + measureVirtualRows, + shouldUseEmojiGrid, + useListRegistry, +} from './list-runtime-hooks'; import { createListRenderers } from './list-runtime-renderers'; import { EmptyViewRegistryContext, @@ -34,7 +46,7 @@ interface ListRuntimeDeps { renderIcon: (icon: any, className?: string, assetsPath?: string) => React.ReactNode; resolveTintColor: (value?: string) => string | undefined; resolveReadableTintColor: (value?: string, options?: { minContrast?: number }) => string | undefined; - addHexAlpha: (hex: string, alphaHex?: string) => string | null; + addHexAlpha: (hex: string, alphaHex: string) => string | undefined; getExtensionContext: () => { assetsPath: string; extensionDisplayName?: string; @@ -188,8 +200,8 @@ export function createListRuntime(deps: ListRuntimeDeps) { if (event.key === 'ArrowRight' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.min(value + 1, filteredItems.length - 1)); else if (event.key === 'ArrowLeft' && shouldUseEmojiGridValue) setSelectedIdx((value) => Math.max(value - 1, 0)); - else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? 8 : 1), filteredItems.length - 1)); - else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? 8 : 1), 0)); + else if (event.key === 'ArrowDown') setSelectedIdx((value) => Math.min(value + (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), filteredItems.length - 1)); + else if (event.key === 'ArrowUp') setSelectedIdx((value) => Math.max(value - (shouldUseEmojiGridValue ? EMOJI_GRID_COLUMNS : 1), 0)); else if (event.key === 'Enter' && !event.repeat) primaryAction?.execute(); else return; @@ -255,39 +267,13 @@ export function createListRuntime(deps: ListRuntimeDeps) { const groupedItems = useMemo(() => groupListItems(filteredItems), [filteredItems]); - // ─── Viewport virtualization for the linear (non-grid) list ───── - // Brew-style extensions ship ~5k items; rendering all of them as DOM - // nodes is the bottleneck. We render only the slice in view (plus a - // small buffer) and pad the scroll container with spacer divs so the - // scrollbar still represents the full list. - const ROW_HEIGHT = 36; - const HEADER_HEIGHT = 24; - const OVERSCAN = 8; - - const flatRows = useMemo(() => { - const rows: Array< - | { type: 'header'; title: string; key: string } - | { type: 'item'; item: typeof filteredItems[number]; globalIdx: number; key: string } - > = []; - for (let g = 0; g < groupedItems.length; g += 1) { - const group = groupedItems[g]; - if (group.title) rows.push({ type: 'header', title: group.title, key: `__h_${g}` }); - for (const entry of group.items) { - rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); - } - } - return rows; - }, [groupedItems]); - - const rowMetrics = useMemo(() => { - const offsets: number[] = new Array(flatRows.length); - let cum = 0; - for (let i = 0; i < flatRows.length; i += 1) { - offsets[i] = cum; - cum += flatRows[i].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - } - return { offsets, totalHeight: cum }; - }, [flatRows]); + // ─── Viewport virtualization for list rows and emoji grid rows ───── + // Emoji-heavy extensions can ship thousands of cells. Both layouts render + // only the rows in view plus a buffer and use spacers to preserve scroll. + const listRows = useMemo(() => buildListVirtualRows(groupedItems), [groupedItems]); + const emojiGridRows = useMemo(() => buildEmojiGridVirtualRows(groupedItems), [groupedItems]); + const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows; + const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]); const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); @@ -320,45 +306,23 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; }, []); - const { visibleStart, visibleEnd } = useMemo(() => { - if (flatRows.length === 0) return { visibleStart: 0, visibleEnd: 0 }; - const top = scrollTop; - const bottom = scrollTop + (containerHeight || 600); - let lo = 0; - let hi = flatRows.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const rowH = flatRows[mid].type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; - if (rowMetrics.offsets[mid] + rowH <= top) lo = mid + 1; - else hi = mid; - } - const start = Math.max(0, lo - OVERSCAN); - let end = lo; - while (end < flatRows.length && rowMetrics.offsets[end] < bottom) end += 1; - end = Math.min(flatRows.length, end + OVERSCAN); - return { visibleStart: start, visibleEnd: end }; - }, [flatRows, rowMetrics, scrollTop, containerHeight]); - - // Map from filteredItems index → flat row index for scroll-into-view. + const { visibleStart, visibleEnd } = useMemo( + () => getVisibleVirtualRange(virtualRows, rowMetrics, scrollTop, containerHeight), + [containerHeight, rowMetrics, scrollTop, virtualRows], + ); + + // Map from filteredItems index → active virtual row index for scroll-into-view. const itemIdxToRowIdx = useMemo(() => { - const map: number[] = new Array(filteredItems.length); - let itemSeen = -1; - for (let i = 0; i < flatRows.length; i += 1) { - if (flatRows[i].type === 'item') { - itemSeen += 1; - map[itemSeen] = i; - } - } - return map; - }, [flatRows, filteredItems.length]); + return buildItemToVirtualRowMap(virtualRows, filteredItems.length); + }, [filteredItems.length, virtualRows]); // Stable refs so the scroll-into-view effect only fires when the user - // moves selection — not when upstream re-renders give flatRows/rowMetrics/ - // itemIdxToRowIdx fresh identities. Without this, scrolling the wheel + // moves selection — not when upstream re-renders give virtualRows/ + // rowMetrics/itemIdxToRowIdx fresh identities. Without this, scrolling the wheel // triggers any unrelated re-render → effect re-runs → snaps back to // selectedIdx. - const flatRowsRef = useRef(flatRows); - flatRowsRef.current = flatRows; + const virtualRowsRef = useRef(virtualRows); + virtualRowsRef.current = virtualRows; const rowMetricsRef = useRef(rowMetrics); rowMetricsRef.current = rowMetrics; const itemIdxToRowIdxRef = useRef(itemIdxToRowIdx); @@ -371,7 +335,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { if (rowIdx == null) return; const top = rowMetricsRef.current.offsets[rowIdx]; if (top == null) return; - const rowH = flatRowsRef.current[rowIdx]?.type === 'header' ? HEADER_HEIGHT : ROW_HEIGHT; + const rowH = virtualRowsRef.current[rowIdx]?.height || 0; const visTop = el.scrollTop; const visBottom = visTop + el.clientHeight; // 'auto' (instant) — smooth scrolling queues animations that interrupt @@ -390,16 +354,22 @@ export function createListRuntime(deps: ListRuntimeDeps) { const detailElement = useMemo(() => { if (!rawDetail || !React.isValidElement(rawDetail)) return rawDetail; if (rawDetail.type !== React.Fragment) return rawDetail; - const children = React.Children.toArray(rawDetail.props.children); + const rawDetailElement = rawDetail as React.ReactElement<{ children?: React.ReactNode }>; + const children = React.Children.toArray(rawDetailElement.props.children); let mergedMarkdown: string | undefined; let mergedMetadata: React.ReactElement | undefined; let mergedIsLoading: boolean | undefined; for (const child of children) { if (!React.isValidElement(child)) continue; if ((child.type as any) !== ListItemDetail) continue; - if (child.props.markdown !== undefined) mergedMarkdown = child.props.markdown; - if (child.props.metadata !== undefined) mergedMetadata = child.props.metadata; - if (child.props.isLoading !== undefined) mergedIsLoading = child.props.isLoading; + const detailProps = child.props as { + markdown?: string; + metadata?: React.ReactElement; + isLoading?: boolean; + }; + if (detailProps.markdown !== undefined) mergedMarkdown = detailProps.markdown; + if (detailProps.metadata !== undefined) mergedMetadata = detailProps.metadata; + if (detailProps.isLoading !== undefined) mergedIsLoading = detailProps.isLoading; } if (mergedMarkdown === undefined && mergedMetadata === undefined) return rawDetail; return React.createElement(ListItemDetail, { @@ -416,40 +386,72 @@ export function createListRuntime(deps: ListRuntimeDeps) { ) : filteredItems.length === 0 ? ( emptyViewProps ? :

{t('common.noResults')}

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