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..ca875c96 --- /dev/null +++ b/.github/workflows/project-checks.yml @@ -0,0 +1,62 @@ +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: Run perf regression budgets + run: npm run test:perf:ci + + - name: Check i18n + run: npm run check:i18n + + - name: Build main process + run: npm run build:main + + - name: Typecheck renderer + run: npm run typecheck:renderer + + - name: Build renderer + run: npm run build:renderer diff --git a/package-lock.json b/package-lock.json index cbe18d8a..6d7d0d23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.25-PMH", + "version": "1.0.26", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -14,7 +14,6 @@ "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -39,6 +38,9 @@ "typescript": "^5.3.3", "vite": "^5.0.11", "wait-on": "^7.2.0" + }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" } }, "node_modules/@alloc/quick-lru": { @@ -3420,6 +3422,7 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", + "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -4631,6 +4634,7 @@ "resolved": "https://registry.npmjs.org/electron-liquid-glass/-/electron-liquid-glass-1.1.1.tgz", "integrity": "sha512-AfPxcu6RJYxlsW2sjgjDEON0rVOjhh5QYM6ur5hsfILQmfY5ewHCWJZJZoDsQxhjeW1DAhbRbvB79IvgW4ansA==", "license": "MIT", + "optional": true, "os": [ "darwin" ], @@ -4650,6 +4654,7 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", "license": "MIT", + "optional": true, "engines": { "node": "^18 || ^20 || >= 21" } @@ -5144,7 +5149,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/filelist": { "version": "1.0.4", diff --git a/package.json b/package.json index f262a8c1..549183d7 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,18 @@ "start:electron": "wait-on dist/main/main.js && cross-env NODE_ENV=development SUPERCMD_OPEN_DEVTOOLS_ON_STARTUP=1 electron .", "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", + "typecheck:renderer": "tsc -p tsconfig.renderer.json --noEmit --pretty false", "build:renderer": "vite build", + "measure:launcher-command-list": "node scripts/measure-launcher-command-list-render.mjs", + "measure:renderer-bundle": "node scripts/measure-renderer-bundle.mjs", + "measure:canvas-asset-event-loop": "node scripts/measure-canvas-asset-event-loop.mjs", "check:i18n": "node scripts/check-i18n.mjs", + "perf:file-search": "node scripts/file-search-perf-harness.mjs", + "test:perf:ci": "cross-env SUPERCMD_PERF_CI=1 SUPERCMD_PERF_REPORT=1 node --test scripts/test-browser-search-performance.mjs scripts/test-file-search-perf-harness.mjs && cross-env SUPERCMD_ROOT_SEARCH_PERF_JSON=1 SUPERCMD_ROOT_SEARCH_PERF_COMMANDS=5000 SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS=3 SUPERCMD_ROOT_SEARCH_PERF_WARMUPS=1 SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS=900 SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS=350 SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS=500 SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN=1.05 node scripts/test-root-search-perf.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", - "postinstall": "node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", + "postinstall": "node scripts/ensure-macos-runtime-deps.mjs && node scripts/ensure-cross-arch-esbuild.mjs && electron-builder install-app-deps", "start": "electron .", "package": "cross-env NODE_ENV=production npm run build && electron-builder", "package:unsigned": "cross-env NODE_ENV=production CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && electron-builder --mac dmg -c.mac.identity=null -c.mac.notarize=false" @@ -42,12 +48,9 @@ }, "dependencies": { "@aptabase/electron": "^0.3.1", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", "@phosphor-icons/react": "^2.1.10", "@raycast/api": "^1.104.5", "electron-builder-notarize": "^1.5.2", - "electron-liquid-glass": "^1.1.1", "electron-updater": "^6.7.3", "esbuild": "^0.19.12", "lucide-react": "^0.312.0", @@ -57,6 +60,9 @@ "react-dom": "^18.2.0", "transliteration": "^2.6.1" }, + "optionalDependencies": { + "electron-liquid-glass": "^1.1.1" + }, "build": { "appId": "com.supercmd.app", "productName": "SuperCmd", 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.. `command-${index * 13}`), + enabledCommands: Array.from({ length: Math.floor(commandCount / 60) }, (_, index) => `command-${index * 17}`), + ai: { + enabled: true, + readEnabled: true, + whisperEnabled: true, + llmEnabled: true, + }, + }; +} + +const visibility = { + isAIDisabled(settings) { + return settings?.ai?.enabled === false; + }, + isAIDependentSystemCommand(commandId) { + return commandId === 'system-cursor-prompt'; + }, + isAISectionDisabledForCommand(commandId, settings) { + if (commandId === 'system-supercmd-speak') return settings?.ai?.readEnabled === false; + return false; + }, +}; + +function buildLegacyPayload(commands, settings) { + const disabled = new Set(settings.disabledCommands || []); + const enabled = new Set(settings.enabledCommands || []); + const aiDisabled = visibility.isAIDisabled(settings); + return commands.filter((command) => { + const commandId = String(command?.id || ''); + if (aiDisabled && visibility.isAIDependentSystemCommand(commandId)) return false; + if (visibility.isAISectionDisabledForCommand(commandId, settings)) return false; + if (disabled.has(command.id)) return false; + if (command?.disabledByDefault && !enabled.has(command.id)) return false; + return true; + }); +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + const sum = values.reduce((total, value) => total + value, 0); + return { + minMs: Number(sorted[0].toFixed(3)), + meanMs: Number((sum / Math.max(1, values.length)).toFixed(3)), + medianMs: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + maxMs: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +function measure(fn) { + const durations = []; + let payload = []; + for (let index = 0; index < iterations; index += 1) { + const startedAt = performance.now(); + payload = fn(); + durations.push(performance.now() - startedAt); + } + return { + payload, + timings: stats(durations), + }; +} + +const commands = makeCommands(); +const settings = makeSettings(); +const cache = createLauncherCommandPayloadCache(); + +const legacy = measure(() => buildLegacyPayload(commands, settings)); +const cached = measure(() => cache.build(commands, settings, { + ...visibility, + updateBannerSignature: 'none', +})); +const changedSettingsStart = performance.now(); +const changedSettingsPayload = cache.build(commands, { + ...settings, + disabledCommands: [...settings.disabledCommands, 'command-42'], +}, { + ...visibility, + updateBannerSignature: 'none', +}); +const changedSettingsMs = performance.now() - changedSettingsStart; + +const report = { + commandCount, + iconBytes, + iterations, + payloadBytes: estimateLauncherCommandPayloadBytes(cached.payload), + payloadCommands: cached.payload.length, + legacyFilterMs: legacy.timings, + cachedPayloadMs: cached.timings, + changedSettingsRebuildMs: Number(changedSettingsMs.toFixed(3)), + changedSettingsPayloadCommands: changedSettingsPayload.length, +}; + +console.log(`COMMAND_IPC_PAYLOAD ${JSON.stringify(report)}`); diff --git a/scripts/bench-script-command-discovery.mjs b/scripts/bench-script-command-discovery.mjs new file mode 100644 index 00000000..c07216c8 --- /dev/null +++ b/scripts/bench-script-command-discovery.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const fileCount = Number(process.env.SUPERCMD_BENCH_SCRIPT_COUNT || 40); +const bodyBytesPerFile = Number(process.env.SUPERCMD_BENCH_SCRIPT_BODY_BYTES || 1024 * 1024); +const iconBytesPerFile = Number(process.env.SUPERCMD_BENCH_SCRIPT_ICON_BYTES || 0); + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; +} + +function makeLargeScript(title, bodyBytes, icon = '⚡') { + const header = `#!/bin/bash +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title ${title} +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.packageName Benchmark +# @raycast.icon ${icon} +# @raycast.description Large script command fixture +# @raycast.needsConfirmation false + +exit 0 +`; + const line = `# ${'x'.repeat(253)}\n`; + const repeatCount = Math.max(0, Math.ceil(bodyBytes / Buffer.byteLength(line))); + return header + line.repeat(repeatCount); +} + +function snapshot(metrics) { + return { + readFileSyncCalls: metrics.readFileSyncCalls, + readFileSyncBytes: metrics.readFileSyncBytes, + readSyncCalls: metrics.readSyncCalls, + readSyncBytes: metrics.readSyncBytes, + openSyncCalls: metrics.openSyncCalls, + totalBytes: metrics.readFileSyncBytes + metrics.readSyncBytes, + }; +} + +function printMetric(label, durationMs, metricSnapshot) { + console.log(`${label}: ${durationMs.toFixed(1)}ms`); + console.log(` readFileSync: ${metricSnapshot.readFileSyncCalls} calls, ${formatBytes(metricSnapshot.readFileSyncBytes)}`); + console.log(` readSync: ${metricSnapshot.readSyncCalls} calls, ${formatBytes(metricSnapshot.readSyncBytes)}`); + console.log(` total runner bytes read: ${formatBytes(metricSnapshot.totalBytes)}`); +} + +async function withFakeDateNow(now, callback) { + const previousDateNow = Date.now; + Date.now = () => now; + try { + return await callback(); + } finally { + Date.now = previousDateNow; + } +} + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-bench-')); +const scriptsDir = path.join(tempRoot, 'script-commands'); +const userDataDir = path.join(tempRoot, 'user-data'); +fs.mkdirSync(scriptsDir, { recursive: true }); +fs.mkdirSync(userDataDir, { recursive: true }); + +try { + const iconsDir = path.join(scriptsDir, '.icons'); + if (iconBytesPerFile > 0) { + fs.mkdirSync(iconsDir, { recursive: true }); + } + + for (let i = 0; i < fileCount; i += 1) { + const scriptPath = path.join(scriptsDir, `large-command-${String(i + 1).padStart(3, '0')}.sh`); + let icon = '⚡'; + if (iconBytesPerFile > 0) { + icon = `.icons/icon-${String(i + 1).padStart(3, '0')}.png`; + fs.writeFileSync(path.join(scriptsDir, icon), Buffer.alloc(iconBytesPerFile, i % 251)); + } + fs.writeFileSync(scriptPath, makeLargeScript(`Large Command ${i + 1}`, bodyBytesPerFile, icon), { + mode: 0o755, + }); + } + + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + const { module: runner, metrics, resetMetrics } = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs: true, + }); + + resetMetrics(); + const discoveryStart = performance.now(); + const commands = runner.discoverScriptCommands(); + const discoveryMs = performance.now() - discoveryStart; + const discoveryMetrics = snapshot(metrics); + + if (commands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands, discovered ${commands.length}`); + } + + resetMetrics(); + const executeStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const executeMs = performance.now() - executeStart; + const executeMetrics = snapshot(metrics); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedDiscoveryStart = performance.now(); + const rediscoveredCommands = runner.discoverScriptCommands(); + const invalidatedDiscoveryMs = performance.now() - invalidatedDiscoveryStart; + const invalidatedDiscoveryMetrics = snapshot(metrics); + + if (rediscoveredCommands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands after invalidation, discovered ${rediscoveredCommands.length}`); + } + + resetMetrics(); + const ttlRefreshStart = performance.now(); + const ttlRefreshCommands = await withFakeDateNow(Date.now() + 20_000, () => runner.discoverScriptCommands()); + const ttlRefreshMs = performance.now() - ttlRefreshStart; + const ttlRefreshMetrics = snapshot(metrics); + + if (ttlRefreshCommands.length !== fileCount) { + throw new Error(`Expected ${fileCount} commands after TTL refresh, discovered ${ttlRefreshCommands.length}`); + } + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const invalidatedExecuteStart = performance.now(); + await runner.executeScriptCommand(commands[0].id); + const invalidatedExecuteMs = performance.now() - invalidatedExecuteStart; + const invalidatedExecuteMetrics = snapshot(metrics); + + console.log('Script command discovery benchmark'); + console.log(`files: ${fileCount}`); + console.log(`body bytes per file: ${formatBytes(bodyBytesPerFile)}`); + console.log(`icon bytes per file: ${iconBytesPerFile > 0 ? formatBytes(iconBytesPerFile) : 'emoji'}`); + printMetric('discovery', discoveryMs, discoveryMetrics); + printMetric('cached execution shebang lookup', executeMs, executeMetrics); + printMetric('invalidated discovery', invalidatedDiscoveryMs, invalidatedDiscoveryMetrics); + printMetric('ttl-expired unchanged discovery', ttlRefreshMs, ttlRefreshMetrics); + printMetric('invalidated execution lookup', invalidatedExecuteMs, invalidatedExecuteMetrics); +} finally { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/benchmark-file-search-delete-batch.mjs b/scripts/benchmark-file-search-delete-batch.mjs new file mode 100644 index 00000000..946f8bec --- /dev/null +++ b/scripts/benchmark-file-search-delete-batch.mjs @@ -0,0 +1,170 @@ +#!/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 { performance } from 'node:perf_hooks'; +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 = { + indexEntry, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, +}; +`; + + 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; +} + +function addEntry(api, snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function buildDirectoryScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench'; + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'deleted-root'); + const survivorRoot = path.join(homeDir, 'survivors'); + const deletePaths = [deletedRoot]; + + addEntry(api, snapshot, deletedRoot, true); + for (let group = 0; group < 40; group += 1) { + const groupDir = path.join(deletedRoot, `group-${group}`); + addEntry(api, snapshot, groupDir, true); + deletePaths.push(groupDir); + for (let item = 0; item < 100; item += 1) { + const nestedDir = path.join(groupDir, `nested-${item}`); + addEntry(api, snapshot, nestedDir, true); + addEntry(api, snapshot, path.join(nestedDir, `deleted-${group}-${item}.txt`)); + deletePaths.push(nestedDir); + } + } + + addEntry(api, snapshot, survivorRoot, true); + for (let dir = 0; dir < 120; dir += 1) { + const dirPath = path.join(survivorRoot, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + addEntry(api, snapshot, path.join(dirPath, `survivor-${dir}-${file}.txt`)); + } + } + + return { + deletePaths, + deletedRoot, + expectedDeletedCount: 1 + (40 * 100) + 40 + (40 * 100), + name: 'directory-tree-batch', + snapshot, + survivorPath: path.join(survivorRoot, 'dir-119', 'survivor-119-499.txt'), + }; +} + +function buildDirectFileScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench-direct'; + const snapshot = api.makeSnapshot(); + const rootDir = path.join(homeDir, 'projects'); + const deletePaths = []; + + addEntry(api, snapshot, rootDir, true); + for (let dir = 0; dir < 160; dir += 1) { + const dirPath = path.join(rootDir, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + const filePath = path.join(dirPath, `file-${dir}-${file}.txt`); + addEntry(api, snapshot, filePath); + if (deletePaths.length < 500 && file % 8 === 0) { + deletePaths.push(filePath); + } + } + } + + return { + deletePaths, + expectedDeletedCount: deletePaths.length, + name: 'direct-file-batch', + snapshot, + survivorPath: path.join(rootDir, 'dir-159', 'file-159-499.txt'), + }; +} + +function measureScenario(api, scenario) { + const started = performance.now(); + api.tombstoneDeletedPaths(scenario.snapshot, scenario.deletePaths); + const elapsedMs = performance.now() - started; + + let deletedCount = 0; + for (const entry of scenario.snapshot.entries) { + if (entry.deleted) deletedCount += 1; + } + + const survivorId = scenario.snapshot.pathToEntryId.get(scenario.survivorPath); + assert.equal(deletedCount, scenario.expectedDeletedCount); + assert.notEqual(survivorId, undefined); + assert.equal(scenario.snapshot.entries[survivorId].deleted, undefined); + + return { + name: scenario.name, + entries: scenario.snapshot.entries.length, + deletePaths: scenario.deletePaths.length, + deletedCount, + elapsedMs: Number(elapsedMs.toFixed(3)), + }; +} + +const api = loadFileSearchIndexInternals(); +const scenarios = [ + measureScenario(api, buildDirectFileScenario(api)), + measureScenario(api, buildDirectoryScenario(api)), +]; + +console.log(JSON.stringify({ + benchmark: 'file-search-delete-batch', + scenarios, +}, null, 2)); diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 35f3172f..5cfa7edb 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -1,19 +1,78 @@ #!/usr/bin/env node import { execSync } from 'child_process'; -import { mkdirSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { createRequire } from 'module'; +import path from 'path'; +import { fileURLToPath } from 'url'; const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); mkdirSync('dist/native', { recursive: true }); const electronVersion = require('../node_modules/electron/package.json').version; const arch = process.arch; +const stampVersion = 1; function run(cmd) { execSync(cmd, { stdio: 'inherit' }); } +function readStamp(stampPath) { + try { + return JSON.parse(readFileSync(stampPath, 'utf8')); + } catch { + return null; + } +} + +function inputState(filePath) { + if (!existsSync(filePath)) return null; + const stats = statSync(filePath); + return { + path: filePath, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; +} + +function isUpToDate(out, stampPath, stamp) { + if (!existsSync(out)) return false; + const currentInputs = stamp.inputs.map(inputState); + if (currentInputs.some((input) => input === null)) return false; + + const previous = readStamp(stampPath); + if (JSON.stringify(previous) !== JSON.stringify({ ...stamp, inputState: currentInputs })) { + return false; + } + + const outputMtime = statSync(out).mtimeMs; + return currentInputs.every((input) => input.mtimeMs <= outputMtime); +} + +function writeStamp(stampPath, stamp) { + const currentInputs = stamp.inputs.map(inputState); + writeFileSync(stampPath, JSON.stringify({ ...stamp, inputState: currentInputs }, null, 2)); +} + +function buildIfStale({ label, out, inputs, command }) { + const stampPath = `${out}.stamp.json`; + const stamp = { + version: stampVersion, + label, + command, + inputs, + }; + + if (isUpToDate(out, stampPath, stamp)) { + console.log(`[native] ${path.basename(out)} is up to date, skipping rebuild`); + return; + } + + run(command); + writeStamp(stampPath, stamp); +} + const swift = [ ['dist/native/get-selected-text', 'src/native/get-selected-text.swift', '-framework Foundation -framework ApplicationServices -framework AppKit'], @@ -28,7 +87,7 @@ const swift = [ ['dist/native/menu-item-search', 'src/native/menu-item-search.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/emoji-trigger-monitor', - 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift', + 'src/native/emoji-trigger-monitor.swift src/native/ax-caret-query.swift src/native/emoji-caret-session-cache.swift', '-framework AppKit -framework ApplicationServices'], ['dist/native/hotkey-hold-monitor', 'src/native/hotkey-hold-monitor.swift', '-framework CoreGraphics -framework AppKit -framework Carbon'], @@ -49,17 +108,34 @@ const swift = [ ]; for (const [out, src, frameworks] of swift) { - run(`swiftc -O -o ${out} ${src} ${frameworks}`); + const inputs = [...src.split(' '), __filename]; + buildIfStale({ + label: path.basename(out), + out, + inputs, + command: `swiftc -O -o ${out} ${src} ${frameworks}`, + }); } // Build native Node addon (native_helpers.node) -run( - `cd src/native/native-helpers-addon && ` + - `HOME=~/.electron-gyp npx node-gyp rebuild ` + - `--target=${electronVersion} --arch=${arch} ` + - `--dist-url=https://electronjs.org/headers && ` + - `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node` -); +buildIfStale({ + label: 'native_helpers.node', + out: 'dist/native/native_helpers.node', + inputs: [ + 'src/native/native-helpers-addon/binding.gyp', + 'src/native/native-helpers-addon/native_helpers.mm', + 'package.json', + 'package-lock.json', + 'node_modules/electron/package.json', + __filename, + ], + command: + `cd src/native/native-helpers-addon && ` + + `HOME=~/.electron-gyp npx node-gyp rebuild ` + + `--target=${electronVersion} --arch=${arch} ` + + `--dist-url=https://electronjs.org/headers && ` + + `cp build/Release/native_helpers.node ../../../dist/native/native_helpers.node`, +}); run('node scripts/build-whispercpp.mjs'); run('node scripts/build-parakeet.mjs'); diff --git a/scripts/build-parakeet.mjs b/scripts/build-parakeet.mjs index 71fd8ff3..d837b2af 100644 --- a/scripts/build-parakeet.mjs +++ b/scripts/build-parakeet.mjs @@ -14,8 +14,10 @@ const distNativeDir = path.join(repoRoot, 'dist', 'native'); const binaryDest = path.join(distNativeDir, 'parakeet-transcriber'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-soulver-calculator.mjs b/scripts/build-soulver-calculator.mjs index e02d8a4d..75f7e75f 100644 --- a/scripts/build-soulver-calculator.mjs +++ b/scripts/build-soulver-calculator.mjs @@ -15,8 +15,10 @@ const binaryDest = path.join(outDir, 'soulver-calculator'); const frameworkDest = path.join(outDir, 'SoulverCore.framework'); const sourceFiles = [ + __filename, path.join(packageDir, 'Sources', 'main.swift'), path.join(packageDir, 'Package.swift'), + path.join(packageDir, 'Package.resolved'), ]; function needsRebuild() { diff --git a/scripts/build-whispercpp.mjs b/scripts/build-whispercpp.mjs index cad84f2c..81a40e61 100644 --- a/scripts/build-whispercpp.mjs +++ b/scripts/build-whispercpp.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; import { createHash } from 'crypto'; import path from 'path'; import { tmpdir } from 'os'; @@ -23,6 +23,18 @@ const runtimeDir = path.join(distNativeDir, 'whisper-runtime'); const frameworkDir = path.join(runtimeDir, 'whisper.framework'); const transcriberSource = path.join(repoRoot, 'src', 'native', 'whisper-transcriber.swift'); const transcriberBinary = path.join(distNativeDir, 'whisper-transcriber'); +const transcriberStamp = path.join(distNativeDir, 'whisper-transcriber.stamp.json'); +const stampVersion = 1; +const transcriberBuildArgs = [ + '-O', + '-module-cache-path', '/supercmd-swift-module-cache', + '-F', runtimeDir, + '-framework', 'whisper', + '-Xlinker', '-rpath', + '-Xlinker', '@executable_path/whisper-runtime', + '-o', transcriberBinary, + transcriberSource, +]; function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -85,17 +97,66 @@ function buildTranscriber() { console.log('[whisper.cpp] Building whisper-transcriber'); run('swiftc', [ - '-O', - '-module-cache-path', moduleCacheDir, - '-F', runtimeDir, - '-framework', 'whisper', - '-Xlinker', '-rpath', - '-Xlinker', '@executable_path/whisper-runtime', - '-o', transcriberBinary, - transcriberSource, + ...transcriberBuildArgs.map((arg) => arg === '/supercmd-swift-module-cache' ? moduleCacheDir : arg), ]); } +function newestMtimeMs(filePath) { + const stats = lstatSync(filePath); + if (stats.isSymbolicLink()) return stats.mtimeMs; + if (!stats.isDirectory()) return stats.mtimeMs; + + return readdirSync(filePath).reduce((newest, entry) => { + return Math.max(newest, newestMtimeMs(path.join(filePath, entry))); + }, stats.mtimeMs); +} + +function transcriberStampPayload() { + return { + version: stampVersion, + whisperVersion, + frameworkUrl, + frameworkSha256, + command: ['swiftc', ...transcriberBuildArgs], + source: { + path: transcriberSource, + mtimeMs: statSync(transcriberSource).mtimeMs, + size: statSync(transcriberSource).size, + }, + script: { + path: __filename, + mtimeMs: statSync(__filename).mtimeMs, + size: statSync(__filename).size, + }, + framework: { + path: frameworkDir, + mtimeMs: newestMtimeMs(frameworkDir), + }, + }; +} + +function readStamp() { + try { + return JSON.parse(readFileSync(transcriberStamp, 'utf8')); + } catch { + return null; + } +} + +function transcriberIsUpToDate() { + if (!existsSync(transcriberBinary) || !existsSync(transcriberSource) || !existsSync(frameworkDir)) return false; + + const payload = transcriberStampPayload(); + if (JSON.stringify(readStamp()) !== JSON.stringify(payload)) return false; + + const binaryMtime = statSync(transcriberBinary).mtimeMs; + return payload.source.mtimeMs <= binaryMtime && payload.framework.mtimeMs <= binaryMtime; +} + +function writeTranscriberStamp() { + writeFileSync(transcriberStamp, JSON.stringify(transcriberStampPayload(), null, 2)); +} + try { // Verify the framework actually has the whisper module, not just an empty directory const moduleMapPath = path.join(frameworkDir, 'Modules', 'module.modulemap'); @@ -104,7 +165,12 @@ try { } else { console.log('[whisper.cpp] Using existing macOS framework'); } - buildTranscriber(); + if (transcriberIsUpToDate()) { + console.log('[whisper.cpp] whisper-transcriber is up to date, skipping rebuild'); + } else { + buildTranscriber(); + writeTranscriberStamp(); + } console.log('[whisper.cpp] Ready'); } catch (error) { console.error('[whisper.cpp] Build failed:', error instanceof Error ? error.message : error); diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index d6835fe5..780ac61c 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -3,7 +3,11 @@ import path from 'path'; const localesDir = path.resolve('src/renderer/src/i18n/locales'); const baseLocale = 'en'; -const strictLocales = new Set((process.env.SUPERCMD_STRICT_LOCALES || 'ko').split(',').map((item) => 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/ensure-macos-runtime-deps.mjs b/scripts/ensure-macos-runtime-deps.mjs new file mode 100644 index 00000000..10dd167d --- /dev/null +++ b/scripts/ensure-macos-runtime-deps.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; + +if (process.platform !== 'darwin') { + process.exit(0); +} + +const require = createRequire(import.meta.url); +const REQUIRED_PACKAGES = ['electron-liquid-glass']; + +let missing = 0; + +for (const packageName of REQUIRED_PACKAGES) { + try { + require(packageName); + } catch (error) { + missing++; + console.error(`ensure-macos-runtime-deps: failed to load ${packageName}.`); + console.error(error); + } +} + +if (missing > 0) { + process.exit(1); +} + +console.log('ensure-macos-runtime-deps: macOS runtime packages are available.'); diff --git a/scripts/file-search-perf-harness.mjs b/scripts/file-search-perf-harness.mjs new file mode 100644 index 00000000..fb6ae292 --- /dev/null +++ b/scripts/file-search-perf-harness.mjs @@ -0,0 +1,561 @@ +#!/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 { monitorEventLoopDelay, 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-event-loop-lag-p95-ms Fail if measured event-loop lag 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')), + eventLoopLagP95Ms: readOptionalNumber(getArgValue(args, '--threshold-event-loop-lag-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), + }; +} + +function summarizeLag(samples) { + if (samples.length === 0) { + return { + samples: 0, + p95Ms: 0, + maxMs: 0, + }; + } + return { + samples: samples.length, + p95Ms: Number(percentile(samples, 95).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)), + }; +} + +async function measureEventLoopLagDuring(fn, intervalMs = 10) { + const samples = []; + let expectedAt = performance.now() + intervalMs; + const timer = setInterval(() => { + const now = performance.now(); + samples.push(Math.max(0, now - expectedAt)); + expectedAt = now + intervalMs; + }, intervalMs); + timer.unref?.(); + + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + const result = await fn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { + result, + eventLoopLag: summarizeLag(samples), + }; + } finally { + clearInterval(timer); + } +} + +async function measureDuration(fn) { + const startedAt = performance.now(); + const measured = await measureEventLoopLagDuring(fn); + return { + durationMs: performance.now() - startedAt, + result: measured.result, + eventLoopLag: measured.eventLoopLag, + }; +} + +function summarizeEventLoopDelay(histogram) { + return { + meanMs: Number((histogram.mean / 1_000_000).toFixed(3)), + maxMs: Number((histogram.max / 1_000_000).toFixed(3)), + p95Ms: Number((histogram.percentile(95) / 1_000_000).toFixed(3)), + }; +} + +async function measureDurationWithEventLoopDelay(fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + await new Promise((resolve) => setImmediate(resolve)); + const measured = await measureDuration(fn); + await new Promise((resolve) => setImmediate(resolve)); + histogram.disable(); + return { + ...measured, + eventLoopDelay: summarizeEventLoopDelay(histogram), + }; +} + +async function measureQueries(searchIndexedFiles, queries, config) { + const samples = []; + const lagMeasured = await measureEventLoopLagDuring(async () => { + 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, + eventLoopLagP95Ms: measured.eventLoopLag.p95Ms, + }); + } + } + }); + return { + ...summarizeDurations(samples), + eventLoopLag: lagMeasured.eventLoopLag, + 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], + ['eventLoopLagP95Ms', metrics.eventLoopLag.p95Ms, thresholds.eventLoopLagP95Ms], + ['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 (event-loop delay p95 ${summary.metrics.initialIndexEventLoopDelay.p95Ms.toFixed(3)}ms, max ${summary.metrics.initialIndexEventLoopDelay.maxMs.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}`, + `Event-loop lag: p95 ${summary.metrics.eventLoopLag.p95Ms.toFixed(3)}ms, max ${summary.metrics.eventLoopLag.maxMs.toFixed(3)}ms`, + `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 measureDurationWithEventLoopDelay(() => + 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), + initialIndexEventLoopDelay: initialIndex.eventLoopDelay, + normalQueries, + pathLikeQueries, + watchUpdateBatchMs: roundMetric(watchUpdate.durationMs), + postUpdateQueryMs: roundMetric(postUpdateQuery.durationMs), + deleteBatchMs: roundMetric(deleteBatch.durationMs), + postDeleteQueryMs: roundMetric(postDeleteQuery.durationMs), + }; + const eventLoopLagSamples = [ + initialIndex.eventLoopLag, + normalQueries.eventLoopLag, + pathLikeQueries.eventLoopLag, + watchUpdate.eventLoopLag, + postUpdateQuery.eventLoopLag, + deleteBatch.eventLoopLag, + postDeleteQuery.eventLoopLag, + ]; + metrics.eventLoopLag = { + p95Ms: Math.max(...eventLoopLagSamples.map((sample) => sample.p95Ms)), + maxMs: Math.max(...eventLoopLagSamples.map((sample) => sample.maxMs)), + samples: eventLoopLagSamples.reduce((sum, sample) => sum + sample.samples, 0), + }; + 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..62a768bd --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,219 @@ +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 makeChildProcessMockSource(childProcessKey) { + return ` + const mock = globalThis[${JSON.stringify(childProcessKey)}]; + + export function spawn(...args) { + return mock.spawn(...args); + } + `; +} + +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 statSync = realFs.statSync.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, + statSync, + 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, + mockChildProcess = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const childProcessKey = `__supercmdScriptCommandChildProcess_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + const childProcess = { + spawn() { + throw new Error('Unexpected child_process.spawn call'); + }, + }; + globalThis[metricsKey] = metrics; + if (mockChildProcess) { + globalThis[childProcessKey] = childProcess; + } + + 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', + })); + }, + }); + } + + if (mockChildProcess) { + plugins.push({ + name: 'supercmd-script-command-child-process-mock', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^child_process$/ }, () => ({ + path: 'child-process-mock', + namespace: 'supercmd-child-process', + })); + pluginBuild.onLoad({ filter: /^child-process-mock$/, namespace: 'supercmd-child-process' }, () => ({ + contents: makeChildProcessMockSource(childProcessKey), + 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, + childProcess, + 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-canvas-asset-event-loop.mjs b/scripts/measure-canvas-asset-event-loop.mjs new file mode 100644 index 00000000..ab6d1e5a --- /dev/null +++ b/scripts/measure-canvas-asset-event-loop.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; + +const fileSizeMb = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_MB || '32', 10); +const iterations = Number.parseInt(process.env.SUPERCMD_CANVAS_ASSET_ITERATIONS || '8', 10); +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sc-canvas-asset-delay-')); +const assetPath = path.join(tmpDir, 'asset.bin'); + +function summarize(histogram, elapsedMs) { + return { + elapsedMs: Number(elapsedMs.toFixed(3)), + meanDelayMs: Number((histogram.mean / 1e6).toFixed(3)), + maxDelayMs: Number((histogram.max / 1e6).toFixed(3)), + p95DelayMs: Number((histogram.percentile(95) / 1e6).toFixed(3)), + }; +} + +async function measure(label, fn) { + const histogram = monitorEventLoopDelay({ resolution: 1 }); + histogram.enable(); + const started = performance.now(); + await fn(); + await new Promise((resolve) => setTimeout(resolve, 5)); + const elapsedMs = performance.now() - started; + histogram.disable(); + return { label, ...summarize(histogram, elapsedMs) }; +} + +try { + fs.writeFileSync(assetPath, Buffer.alloc(fileSizeMb * 1024 * 1024, 7)); + + const syncRead = await measure('sync-readFileSync-baseline', async () => { + for (let index = 0; index < iterations; index += 1) { + fs.readFileSync(assetPath); + } + }); + + const asyncRead = await measure('async-fs.promises.readFile', async () => { + for (let index = 0; index < iterations; index += 1) { + await fs.promises.readFile(assetPath); + } + }); + + console.log(JSON.stringify({ + fileSizeMb, + iterations, + measurements: [syncRead, asyncRead], + }, null, 2)); +} finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} +} diff --git a/scripts/measure-extension-bundle-cache.mjs b/scripts/measure-extension-bundle-cache.mjs new file mode 100644 index 00000000..e30db23c --- /dev/null +++ b/scripts/measure-extension-bundle-cache.mjs @@ -0,0 +1,258 @@ +#!/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, + external: ['esbuild'], + 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/measure-renderer-bundle.mjs b/scripts/measure-renderer-bundle.mjs new file mode 100644 index 00000000..a83a587e --- /dev/null +++ b/scripts/measure-renderer-bundle.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = path.join(root, 'dist/renderer'); +const indexPath = path.join(distDir, 'index.html'); + +function walk(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(fullPath)); + } else { + out.push(fullPath); + } + } + return out; +} + +if (!fs.existsSync(indexPath)) { + console.error('dist/renderer/index.html not found. Run `npm run build:renderer` first.'); + process.exit(1); +} + +const html = fs.readFileSync(indexPath, 'utf8'); +const startupAssets = [...html.matchAll(/(?:src|href)="\.?\/?([^"]+)"/g)] + .map((match) => match[1]) + .filter((asset) => asset.startsWith('assets/')); + +const assets = walk(distDir) + .filter((filePath) => /\.(js|css|map)$/.test(filePath)) + .map((filePath) => { + const buffer = fs.readFileSync(filePath); + const relativePath = path.relative(distDir, filePath); + return { + path: relativePath, + bytes: buffer.length, + gzipBytes: zlib.gzipSync(buffer).length, + startup: startupAssets.includes(relativePath), + }; + }) + .sort((a, b) => b.bytes - a.bytes); + +const totals = assets.reduce( + (acc, asset) => { + acc.bytes += asset.bytes; + acc.gzipBytes += asset.gzipBytes; + if (asset.startup) { + acc.startupBytes += asset.bytes; + acc.startupGzipBytes += asset.gzipBytes; + } + return acc; + }, + { bytes: 0, gzipBytes: 0, startupBytes: 0, startupGzipBytes: 0 } +); + +console.log(JSON.stringify({ + generatedAt: new Date().toISOString(), + totals, + startupAssets, + largestAssets: assets.slice(0, 30), +}, null, 2)); diff --git a/scripts/test-abortable-promise-hooks.mjs b/scripts/test-abortable-promise-hooks.mjs new file mode 100644 index 00000000..a49b4433 --- /dev/null +++ b/scripts/test-abortable-promise-hooks.mjs @@ -0,0 +1,548 @@ +#!/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(); +}); + +test('usePromise keeps the latest default result when an older run resolves later', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { 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 default result when an older run resolves later', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { 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('usePromise ignores stale default results while a newer run is still loading', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, undefined); + assert.equal(host.output.isLoading, true); + assert.deepEqual(onDataCalls, []); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useCachedPromise ignores stale default results while a newer run is still loading', async () => { + const pending = []; + const onDataCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { onData: (data) => onDataCalls.push(data) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[0].resolve('stale'); + await flushAsync(); + assert.equal(host.output.data, undefined); + assert.equal(host.output.isLoading, true); + assert.deepEqual(onDataCalls, []); + + pending[1].resolve('latest'); + await flushAsync(); + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('usePromise ignores stale default errors after a newer run resolves', async () => { + const pending = []; + const onErrorCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => usePromise(fn, [], { onError: (error) => onErrorCalls.push(error.message) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + pending[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.error, undefined); + assert.deepEqual(onErrorCalls, []); + + host.unmount(); +}); + +test('useCachedPromise ignores stale default errors after a newer run resolves', async () => { + const pending = []; + const onErrorCalls = []; + const fn = () => { + const run = deferred(); + pending.push(run); + return run.promise; + }; + const host = new HookHost(() => useCachedPromise(fn, [], { onError: (error) => onErrorCalls.push(error.message) })); + + host.render(); + await flushAsync(); + host.output.revalidate(); + await flushAsync(); + + pending[1].resolve('latest'); + await flushAsync(); + pending[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.data, 'latest'); + assert.equal(host.output.error, undefined); + assert.deepEqual(onErrorCalls, []); + + 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-ai-provider-gemini-prompt-streaming.mjs b/scripts/test-ai-provider-gemini-prompt-streaming.mjs new file mode 100644 index 00000000..90bade89 --- /dev/null +++ b/scripts/test-ai-provider-gemini-prompt-streaming.mjs @@ -0,0 +1,227 @@ +#!/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 { PassThrough } from 'node:stream'; +import { setImmediate as waitImmediate } from 'node:timers/promises'; +import { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +export function request(options, callback) { + const harness = globalThis.__geminiHttpsHarness; + if (!harness) throw new Error('Gemini HTTPS harness is not installed'); + return harness.request(options, callback); +} +export default { request }; +`; + +const { streamAI } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { https: HTTPS_STUB }, +}); + +const GEMINI_CONFIG = { + enabled: true, + provider: 'gemini', + geminiApiKey: 'test-gemini-key', +}; + +function createGeminiHttpsHarness() { + const requests = []; + + return { + requests, + request(options, callback) { + const record = { + options, + body: '', + destroyed: false, + response: null, + responseBytesWritten: 0, + }; + + const req = new EventEmitter(); + req.write = (chunk) => { + record.body += chunk.toString(); + return true; + }; + req.end = () => { + const response = new PassThrough(); + response.statusCode = 200; + record.response = response; + callback(response); + }; + req.destroy = () => { + record.destroyed = true; + record.response?.destroy(); + }; + + requests.push(record); + return req; + }, + }; +} + +function installGeminiHarness() { + const harness = createGeminiHttpsHarness(); + globalThis.__geminiHttpsHarness = harness; + return harness; +} + +async function waitForRequestResponse(harness) { + for (let attempt = 0; attempt < 50; attempt += 1) { + const request = harness.requests[0]; + if (request?.response) return request; + await waitImmediate(); + } + assert.fail('timed out waiting for Gemini HTTPS request'); +} + +function geminiSseFrame(payload) { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +function geminiTextFrame(parts) { + return geminiSseFrame({ + candidates: [{ content: { parts } }], + }); +} + +function writeResponseChunk(request, chunk) { + request.responseBytesWritten += Buffer.byteLength(chunk); + request.response.write(chunk); +} + +function endResponse(request, chunk = '') { + if (chunk) writeResponseChunk(request, chunk); + request.response.end(); +} + +async function collectGeminiPrompt(frames, options = {}) { + const harness = installGeminiHarness(); + const chunks = []; + const stream = streamAI(GEMINI_CONFIG, { + prompt: options.prompt || 'Write a concise answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.3, + systemPrompt: options.systemPrompt, + }); + + const collecting = (async () => { + for await (const chunk of stream) chunks.push(chunk); + return chunks; + })(); + + const request = await waitForRequestResponse(harness); + for (const frame of frames) writeResponseChunk(request, frame); + request.response.end(); + + return { + chunks: await collecting, + request, + }; +} + +function legacyGeminiResponseText(parsed) { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; + return parts.map((part) => (typeof part?.text === 'string' ? part.text : '')).join(''); +} + +test('Gemini prompt streaming uses SSE and yields before the response ends', async (t) => { + const harness = installGeminiHarness(); + const stream = streamAI(GEMINI_CONFIG, { + prompt: 'Stream this answer', + model: 'gemini-gemini-2.5-flash', + creativity: 0.4, + systemPrompt: 'Keep it short', + }); + const iterator = stream[Symbol.asyncIterator](); + + let firstSettled = false; + const firstRead = iterator.next().then((result) => { + firstSettled = true; + return result; + }); + + const request = await waitForRequestResponse(harness); + await waitImmediate(); + assert.equal(firstSettled, false, 'first read should wait for provider bytes'); + assert.match(request.options.path, /:streamGenerateContent\?alt=sse&key=test-gemini-key$/); + assert.doesNotMatch(request.options.path, /:generateContent\?/); + + const body = JSON.parse(request.body); + assert.deepEqual(body.contents, [{ role: 'user', parts: [{ text: 'Stream this answer' }] }]); + assert.deepEqual(body.systemInstruction, { parts: [{ text: 'Keep it short' }] }); + + const firstFrame = geminiTextFrame([{ text: 'Hello ' }]); + const secondFrame = geminiTextFrame([{ text: 'world' }]); + const finishFrame = geminiSseFrame({ candidates: [{ finishReason: 'STOP' }] }); + const totalSseBytes = Buffer.byteLength(firstFrame + secondFrame + finishFrame); + + writeResponseChunk(request, firstFrame); + const first = await firstRead; + assert.deepEqual(first, { done: false, value: 'Hello ' }); + assert.equal(request.response.readableEnded, false, 'first chunk should be visible before stream end'); + + const bytesBeforeFirstYield = request.responseBytesWritten; + const secondRead = iterator.next(); + writeResponseChunk(request, secondFrame); + assert.deepEqual(await secondRead, { done: false, value: 'world' }); + + const doneRead = iterator.next(); + endResponse(request, finishFrame); + assert.deepEqual(await doneRead, { done: true, value: undefined }); + + const legacyGenerateContentBody = JSON.stringify({ + candidates: [{ content: { parts: [{ text: 'Hello ' }, { text: 'world' }] } }], + }); + + assert.ok(bytesBeforeFirstYield < totalSseBytes); + t.diagnostic(`legacy generateContent buffered bytes before first yield: ${Buffer.byteLength(legacyGenerateContentBody)}`); + t.diagnostic(`streamGenerateContent bytes before first yield: ${bytesBeforeFirstYield} of ${totalSseBytes}`); + t.diagnostic('streamGenerateContent first yield occurred before response end: true'); +}); + +test('Gemini prompt SSE extraction preserves final joined text', async () => { + const legacyShape = { + candidates: [{ + content: { + parts: [ + { text: 'Alpha ' }, + { text: 'beta' }, + { inlineData: { mimeType: 'text/plain' } }, + { text: ' gamma' }, + ], + }, + }], + }; + const frames = [ + geminiTextFrame([{ text: 'Alpha ' }, { text: 'beta' }, { inlineData: { mimeType: 'text/plain' } }]), + geminiTextFrame([{ text: ' gamma' }]), + ]; + + const { chunks } = await collectGeminiPrompt(frames); + + assert.deepEqual(chunks, ['Alpha beta', ' gamma']); + assert.equal(chunks.join(''), legacyGeminiResponseText(legacyShape)); +}); + +test('Gemini prompt streaming preserves finishReason no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ finishReason: 'SAFETY' }] }), + ]), + /Gemini returned no text \(SAFETY\)\./, + ); +}); + +test('Gemini prompt streaming preserves generic no-text errors', async () => { + await assert.rejects( + collectGeminiPrompt([ + geminiSseFrame({ candidates: [{ content: { parts: [{ text: '' }] } }] }), + ]), + /Gemini returned no text\./, + ); +}); diff --git a/scripts/test-ai-provider-stream-parser-stress.mjs b/scripts/test-ai-provider-stream-parser-stress.mjs new file mode 100644 index 00000000..d35214f0 --- /dev/null +++ b/scripts/test-ai-provider-stream-parser-stress.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { PassThrough } from 'node:stream'; +import { importTs } from './lib/ts-import.mjs'; + +const { parseNDJSON, parseSSE, streamAI } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { + http: ` + export function request(options, callback) { + const harness = globalThis.__aiProviderHttpHarness; + if (!harness) throw new Error('HTTP harness is not installed'); + return harness.request(options, callback); + } + export default { request }; + `, + }, +}); + +async function collect(generator) { + const chunks = []; + for await (const chunk of generator) chunks.push(chunk); + return chunks; +} + +test('SSE parser preserves partial lines and done frames while skipping malformed data', async (t) => { + const stream = new PassThrough(); + const collecting = collect(parseSSE(stream, (data) => { + if (data === '[DONE]') return null; + try { + return JSON.parse(data).text || null; + } catch { + return null; + } + })); + + stream.write('data: {"text":"hel'); + stream.write('lo"}\n\n'); + stream.write('event: ignored\n'); + stream.write('data: not json\n'); + stream.write('data: {"text":" world"}\n'); + stream.end('data: [DONE]'); + + const chunks = await collecting; + assert.deepEqual(chunks, ['hello', ' world']); + t.diagnostic('[ai-stream-parser-stress] sse partial/malformed/done frames preserved'); +}); + +test('NDJSON parser preserves partial objects and skips malformed lines', async (t) => { + const stream = new PassThrough(); + const collecting = collect(parseNDJSON(stream, (obj) => obj?.text || null)); + + stream.write('{"text":"one"}\n{"text":"tw'); + stream.write('o"}\nnot-json\n'); + stream.end('{"text":"three"}'); + + const chunks = await collecting; + assert.deepEqual(chunks, ['one', 'two', 'three']); + t.diagnostic('[ai-stream-parser-stress] ndjson partial/malformed frames preserved'); +}); + +test('stream parser caps malformed carry and HTTP error bodies', async (t) => { + const stream = new PassThrough(); + const collecting = collect(parseSSE(stream, (data) => data)); + stream.write('x'.repeat(1_100_000)); + stream.end('\ndata: ok\n'); + assert.deepEqual(await collecting, ['ok']); + + const requests = []; + globalThis.__aiProviderHttpHarness = { + request(_options, callback) { + const req = new PassThrough(); + req.destroyed = false; + req.destroy = () => { + req.destroyed = true; + }; + req.setHeader = () => {}; + req.getHeader = () => undefined; + req.removeHeader = () => {}; + req.end = () => { + const response = new PassThrough(); + response.statusCode = 500; + callback(response); + response.end('E'.repeat(20_000)); + }; + requests.push(req); + return req; + }, + }; + + await assert.rejects( + async () => { + for await (const _chunk of streamAI({ + enabled: true, + provider: 'ollama', + ollamaBaseUrl: 'http://127.0.0.1:11434', + }, { + prompt: 'hello', + model: 'ollama-llama3', + })) { + // unreachable + } + }, + (error) => { + assert.match(error.message, /^HTTP 500: E+/); + assert.ok(error.message.length < 600); + return true; + } + ); + + assert.equal(requests.length, 1); + t.diagnostic('[ai-stream-parser-stress] malformed carry capped and error body reporting bounded'); +}); diff --git a/scripts/test-ai-provider-whisper-upload.mjs b/scripts/test-ai-provider-whisper-upload.mjs new file mode 100644 index 00000000..19f56b8f --- /dev/null +++ b/scripts/test-ai-provider-whisper-upload.mjs @@ -0,0 +1,270 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const HTTPS_STUB = ` +module.exports = { + request: (...args) => globalThis.__supercmdWhisperHttps.request(...args), +}; +`; + +const { transcribeAudio } = await importTs(path.resolve('src/main/ai-provider.ts'), { + stubs: { + https: HTTPS_STUB, + }, +}); + +test('transcribeAudio streams Whisper multipart upload bytes without a full body concat', async (t) => { + const audioBuffer = Buffer.alloc(8 * 1024 * 1024, 0x61); + const https = createFakeHttps({ responseBody: ' hello from whisper \n' }); + const { result, concatCalls } = await instrumentBufferConcat(() => transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'whisper-1', + language: 'en', + mimeType: 'audio/wav', + })); + + assert.equal(result, 'hello from whisper'); + assert.equal(https.requests.length, 1); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + language: 'en', + mimeType: 'audio/wav', + model: 'whisper-1', + }); + const expectedBody = Buffer.concat(expectedParts); + const actualBody = Buffer.concat(req.writes); + + assert.deepEqual( + { + hostname: req.options.hostname, + path: req.options.path, + method: req.options.method, + authorization: req.options.headers.Authorization, + contentLength: req.options.headers['Content-Length'], + }, + { + hostname: 'api.openai.com', + path: '/v1/audio/transcriptions', + method: 'POST', + authorization: 'Bearer test-api-key', + contentLength: expectedBody.length, + }, + ); + assert.equal(actualBody.equals(expectedBody), true); + assert.equal(req.writes.length, expectedParts.length); + assert.equal(req.writes[1], audioBuffer, 'the original audio Buffer should be written directly'); + assert.equal(Math.max(...req.writes.map((chunk) => chunk.length)), audioBuffer.length); + assert.equal(concatCalls.length, 0, 'production upload path should not call Buffer.concat'); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"', 'name="language"']); + + const framingBytes = expectedBody.length - audioBuffer.length; + t.diagnostic(`before: legacy Buffer.concat would allocate a ${expectedBody.length} byte multipart body copy`); + t.diagnostic(`after: streamed upload wrote ${req.writes.length} buffers, reusing the ${audioBuffer.length} byte audio Buffer and allocating ${framingBytes} framing bytes`); +}); + +test('transcribeAudio preserves no-language multipart ordering and default upload metadata', async () => { + const audioBuffer = Buffer.from('tiny-audio'); + const https = createFakeHttps({ responseBody: 'ok' }); + + await transcribeAudio({ + audioBuffer, + apiKey: 'test-api-key', + model: 'gpt-4o-transcribe', + }); + + const req = https.requests[0]; + const boundary = extractBoundary(req.options.headers['Content-Type']); + const expectedParts = buildExpectedMultipartParts({ + audioBuffer, + boundary, + model: 'gpt-4o-transcribe', + }); + const actualBody = Buffer.concat(req.writes); + const actualText = actualBody.toString('utf8'); + + assert.equal(req.options.headers['Content-Length'], Buffer.concat(expectedParts).length); + assert.equal(actualBody.equals(Buffer.concat(expectedParts)), true); + assert.equal(req.writes.length, expectedParts.length); + assert.match(actualText, /filename="audio\.webm"/); + assert.match(actualText, /Content-Type: audio\/webm/); + assert.doesNotMatch(actualText, /name="language"/); + assertMultipartOrder(actualBody, ['name="file"', 'name="model"', 'name="response_format"']); +}); + +test('transcribeAudio preserves Whisper HTTP and request error handling', async () => { + { + createFakeHttps({ statusCode: 429, responseBody: 'rate limited by test' }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /Whisper API HTTP 429: rate limited by test/, + ); + } + + { + createFakeHttps({ requestError: new Error('socket failed') }); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + }), + /socket failed/, + ); + } +}); + +test('transcribeAudio preserves pre-aborted request behavior', async () => { + const controller = new AbortController(); + controller.abort(); + const https = createFakeHttps(); + + await assert.rejects( + transcribeAudio({ + audioBuffer: Buffer.from('audio'), + apiKey: 'test-api-key', + model: 'whisper-1', + signal: controller.signal, + }), + /Transcription aborted/, + ); + + assert.equal(https.requests.length, 1); + assert.equal(https.requests[0].destroyed, true); + assert.equal(https.requests[0].ended, false); + assert.equal(https.requests[0].writes.length, 0); +}); + +async function instrumentBufferConcat(fn) { + const originalConcat = Buffer.concat; + const concatCalls = []; + + Buffer.concat = function instrumentedConcat(list, totalLength) { + concatCalls.push({ + chunks: list.length, + totalLength: totalLength ?? list.reduce((total, chunk) => total + chunk.length, 0), + }); + return originalConcat.call(Buffer, list, totalLength); + }; + + try { + const result = await fn(); + return { result, concatCalls }; + } finally { + Buffer.concat = originalConcat; + } +} + +function createFakeHttps(options = {}) { + const requests = []; + const fakeHttps = { + requests, + request(requestOptions, onResponse) { + const req = new FakeClientRequest(requestOptions, onResponse, options); + requests.push(req); + return req; + }, + }; + + globalThis.__supercmdWhisperHttps = fakeHttps; + return fakeHttps; +} + +class FakeClientRequest extends EventEmitter { + constructor(options, onResponse, responseOptions) { + super(); + this.options = options; + this.onResponse = onResponse; + this.responseOptions = responseOptions; + this.destroyed = false; + this.ended = false; + this.writes = []; + } + + write(chunk) { + this.writes.push(chunk); + return true; + } + + end() { + this.ended = true; + queueMicrotask(() => { + if (this.responseOptions.requestError) { + this.emit('error', this.responseOptions.requestError); + return; + } + if (this.destroyed) return; + + const res = new EventEmitter(); + res.statusCode = this.responseOptions.statusCode ?? 200; + this.onResponse(res); + res.emit('data', this.responseOptions.responseBody ?? 'transcript'); + res.emit('end'); + }); + } + + destroy() { + this.destroyed = true; + } +} + +function extractBoundary(contentType) { + const match = /^multipart\/form-data; boundary=(.+)$/.exec(contentType); + assert.ok(match, `expected multipart content type, got ${contentType}`); + return match[1]; +} + +function buildExpectedMultipartParts({ audioBuffer, boundary, language, mimeType, model }) { + const uploadMeta = resolveExpectedUploadMeta(mimeType); + const parts = [ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${uploadMeta.filename}"\r\nContent-Type: ${uploadMeta.contentType}\r\n\r\n`), + audioBuffer, + Buffer.from('\r\n'), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\n${model}\r\n`), + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="response_format"\r\n\r\ntext\r\n`), + ]; + + if (language) { + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\n${language}\r\n`)); + } + + parts.push(Buffer.from(`--${boundary}--\r\n`)); + return parts; +} + +function resolveExpectedUploadMeta(mimeType) { + const normalized = String(mimeType || '').toLowerCase(); + if (normalized.includes('wav')) return { filename: 'audio.wav', contentType: 'audio/wav' }; + if (normalized.includes('mpeg') || normalized.includes('mp3')) return { filename: 'audio.mp3', contentType: 'audio/mpeg' }; + if (normalized.includes('mp4') || normalized.includes('m4a')) return { filename: 'audio.m4a', contentType: 'audio/mp4' }; + if (normalized.includes('ogg') || normalized.includes('oga')) return { filename: 'audio.ogg', contentType: 'audio/ogg' }; + if (normalized.includes('flac')) return { filename: 'audio.flac', contentType: 'audio/flac' }; + return { filename: 'audio.webm', contentType: 'audio/webm' }; +} + +function assertMultipartOrder(body, markers) { + const text = body.toString('utf8'); + let previous = -1; + + for (const marker of markers) { + const index = text.indexOf(marker); + assert.notEqual(index, -1, `expected multipart marker ${marker}`); + assert.ok(index > previous, `expected ${marker} after previous multipart field`); + previous = index; + } +} diff --git a/scripts/test-ai-stream-ipc-batching.mjs b/scripts/test-ai-stream-ipc-batching.mjs new file mode 100644 index 00000000..58f5608a --- /dev/null +++ b/scripts/test-ai-stream-ipc-batching.mjs @@ -0,0 +1,234 @@ +#!/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 = 600; + +const { + AI_STREAM_IPC_FLUSH_MS, + createAIStreamIpcCoalescer, + forwardAIStreamChunksToIpc, +} = await importTs(path.resolve('src/main/ai-stream-ipc.ts')); + +function makeChunks(count = CHUNK_COUNT) { + return Array.from({ length: count }, (_, index) => `chunk-${index};`); +} + +async function* streamChunks(chunks) { + for (const chunk of chunks) { + yield chunk; + } +} + +function createManualScheduler() { + let nextHandle = 1; + const callbacks = new Map(); + + return { + 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; + }, + pendingCount() { + return callbacks.size; + }, + }; +} + +function createSender() { + const events = []; + return { + events, + send(channel, payload) { + events.push({ channel, payload }); + }, + }; +} + +function aiStreamChunkEvents(events) { + return events.filter((event) => event.channel === 'ai-stream-chunk'); +} + +function joinedChunkText(events) { + return aiStreamChunkEvents(events).map((event) => event.payload.chunk).join(''); +} + +async function simulateLegacyIpcSend(chunks) { + const sender = createSender(); + for await (const chunk of streamChunks(chunks)) { + sender.send('ai-stream-chunk', { requestId: 'request-1', chunk }); + } + return sender.events; +} + +async function simulateBatchedIpcSend(chunks) { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'request-1', + sender, + flushIntervalMs: AI_STREAM_IPC_FLUSH_MS, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + await forwardAIStreamChunksToIpc(streamChunks(chunks), coalescer, controller.signal); + + assert.equal(sender.events.length, 0, 'burst should wait for the scheduled IPC flush'); + assert.equal(scheduler.pendingCount(), 1, 'burst should schedule one coalesced IPC flush'); + coalescer.flush(); + assert.equal(scheduler.pendingCount(), 0); + + return sender.events; +} + +test('main-process AI stream IPC batching coalesces provider chunk bursts', async (t) => { + const chunks = makeChunks(); + const expectedText = chunks.join(''); + + const legacyEvents = await simulateLegacyIpcSend(chunks); + const batchedEvents = await simulateBatchedIpcSend(chunks); + + const legacyChunkSends = aiStreamChunkEvents(legacyEvents).length; + const batchedChunkSends = aiStreamChunkEvents(batchedEvents).length; + + assert.equal(legacyChunkSends, CHUNK_COUNT); + assert.equal(joinedChunkText(legacyEvents), expectedText); + assert.equal(joinedChunkText(batchedEvents), expectedText); + assert.ok(batchedChunkSends < legacyChunkSends / 10); + + t.diagnostic(`baseline ai-stream-chunk IPC sends: ${legacyChunkSends} for ${CHUNK_COUNT} provider chunks`); + t.diagnostic(`batched ai-stream-chunk IPC sends: ${batchedChunkSends} for ${CHUNK_COUNT} provider chunks`); +}); + +test('main-process AI stream IPC flushes pending text before done', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'done-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + coalescer.appendChunk('final '); + coalescer.appendChunk('answer'); + coalescer.flush(); + sender.send('ai-stream-done', { requestId: 'done-request' }); + + assert.deepEqual(sender.events.map((event) => event.channel), [ + 'ai-stream-chunk', + 'ai-stream-done', + ]); + assert.equal(joinedChunkText(sender.events), 'final answer'); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes pending text before error', async () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'error-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + async function* failingStream() { + yield 'partial '; + yield 'answer'; + throw new Error('provider failed'); + } + + try { + await forwardAIStreamChunksToIpc(failingStream(), coalescer, controller.signal); + assert.fail('expected provider stream to fail'); + } catch (error) { + coalescer.flush(); + sender.send('ai-stream-error', { + requestId: 'error-request', + error: error instanceof Error ? error.message : 'AI request failed', + }); + } + + assert.deepEqual(sender.events.map((event) => event.channel), [ + 'ai-stream-chunk', + 'ai-stream-error', + ]); + assert.equal(joinedChunkText(sender.events), 'partial answer'); + assert.equal(sender.events[1].payload.error, 'provider failed'); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes pending text during cancellation cleanup', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const controller = new AbortController(); + const coalescer = createAIStreamIpcCoalescer({ + requestId: 'cancel-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + coalescer.appendChunk('visible before cancel'); + assert.equal(coalescer.hasPendingChunk(), true); + assert.equal(coalescer.hasPendingFlush(), true); + + coalescer.flush(); + controller.abort(); + + assert.equal(controller.signal.aborted, true); + assert.equal(joinedChunkText(sender.events), 'visible before cancel'); + assert.equal(aiStreamChunkEvents(sender.events).length, 1); + assert.equal(coalescer.hasPendingChunk(), false); + assert.equal(coalescer.hasPendingFlush(), false); + assert.equal(scheduler.pendingCount(), 0); +}); + +test('main-process AI stream IPC flushes the previous buffer before request replacement', () => { + const sender = createSender(); + const scheduler = createManualScheduler(); + const previous = createAIStreamIpcCoalescer({ + requestId: 'same-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + + previous.appendChunk('old buffered text'); + previous.flush(); + + const replacement = createAIStreamIpcCoalescer({ + requestId: 'same-request', + sender, + scheduleFlush: scheduler.schedule, + cancelFlush: scheduler.cancel, + }); + replacement.appendChunk('new buffered text'); + replacement.flush(); + + assert.deepEqual( + aiStreamChunkEvents(sender.events).map((event) => event.payload.chunk), + ['old buffered text', 'new buffered text'], + ); + assert.equal(scheduler.pendingCount(), 0); +}); diff --git a/scripts/test-audio-wav-bulk-write.swift b/scripts/test-audio-wav-bulk-write.swift new file mode 100644 index 00000000..3dd516c2 --- /dev/null +++ b/scripts/test-audio-wav-bulk-write.swift @@ -0,0 +1,140 @@ +import Foundation + +private let sampleRate: Double = 16_000 +private let sampleCount = Int(sampleRate * 30) +private let iterations = 5 + +private func appendLittleEndian(_ value: T, to data: inout Data) { + data.append(contentsOf: withUnsafeBytes(of: value.littleEndian) { Array($0) }) +} + +private func appendWaveHeader(sampleCount: Int, sampleRate: Double, to data: inout Data) { + let frameCount = UInt32(sampleCount) + let bytesPerSample: UInt32 = 2 + let channels: UInt32 = 1 + let dataSize = frameCount * bytesPerSample * channels + let fileSize = 36 + dataSize + + data.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) + appendLittleEndian(UInt32(fileSize), to: &data) + data.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) + + data.append(contentsOf: [0x66, 0x6D, 0x74, 0x20]) + appendLittleEndian(UInt32(16), to: &data) + appendLittleEndian(UInt16(1), to: &data) + appendLittleEndian(UInt16(channels), to: &data) + appendLittleEndian(UInt32(sampleRate), to: &data) + appendLittleEndian(UInt32(sampleRate) * channels * bytesPerSample, to: &data) + appendLittleEndian(UInt16(UInt16(channels) * UInt16(bytesPerSample)), to: &data) + appendLittleEndian(UInt16(bytesPerSample * 8), to: &data) + + data.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) + appendLittleEndian(UInt32(dataSize), to: &data) +} + +@inline(never) +private func legacyWaveData(samples: [Float], sampleRate: Double) -> Data { + let dataSize = UInt32(samples.count) * 2 + var data = Data(capacity: Int(44 + dataSize)) + appendWaveHeader(sampleCount: samples.count, sampleRate: sampleRate, to: &data) + + for sample in samples { + let clamped = max(-1.0, min(1.0, sample)) + let intVal = Int16(clamped * Float(Int16.max)) + data.append(contentsOf: withUnsafeBytes(of: Int16(littleEndian: intVal)) { Array($0) }) + } + + return data +} + +@inline(never) +private func bulkWaveData(samples: [Float], sampleRate: Double) -> Data { + let dataSize = UInt32(samples.count) * 2 + var data = Data(capacity: Int(44 + dataSize)) + appendWaveHeader(sampleCount: samples.count, sampleRate: sampleRate, to: &data) + + var pcmSamples = [Int16]() + pcmSamples.reserveCapacity(samples.count) + for sample in samples { + let clamped = max(-1.0, min(1.0, sample)) + let intVal = Int16(clamped * Float(Int16.max)) + pcmSamples.append(Int16(littleEndian: intVal)) + } + pcmSamples.withUnsafeBytes { rawBuffer in + data.append(rawBuffer.bindMemory(to: UInt8.self)) + } + + return data +} + +private func uint16LE(_ data: Data, at offset: Int) -> UInt16 { + data.withUnsafeBytes { rawBuffer in + rawBuffer.loadUnaligned(fromByteOffset: offset, as: UInt16.self).littleEndian + } +} + +private func uint32LE(_ data: Data, at offset: Int) -> UInt32 { + data.withUnsafeBytes { rawBuffer in + rawBuffer.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian + } +} + +@inline(never) +private func measure(label: String, samples: [Float], block: ([Float], Double) -> Data) -> (label: String, ms: Double, checksum: Int) { + var generated: [Data] = [] + generated.reserveCapacity(iterations) + + let start = DispatchTime.now().uptimeNanoseconds + for _ in 0.. Float in + let phase = Float(index % 1024) / 1024.0 + return (phase * 2.2) - 1.1 +} + +let legacy = legacyWaveData(samples: samples, sampleRate: sampleRate) +let bulk = bulkWaveData(samples: samples, sampleRate: sampleRate) +precondition(legacy == bulk, "bulk output must match legacy bytes for 30-second payload") +precondition(bulk.count == 44 + sampleCount * 2) +precondition(uint32LE(bulk, at: 4) == UInt32(36 + sampleCount * 2)) +precondition(uint32LE(bulk, at: 40) == UInt32(sampleCount * 2)) + +let legacyTiming = measure(label: "per-sample append", samples: samples, block: legacyWaveData) +let bulkTiming = measure(label: "bulk Int16 append", samples: samples, block: bulkWaveData) + +print("samples=\(sampleCount) sampleRate=\(Int(sampleRate)) iterations=\(iterations)") +print("validated byte-for-byte WAV output and header/data sizes") +print("\(legacyTiming.label): \(String(format: "%.2f", legacyTiming.ms)) ms avg checksum=\(legacyTiming.checksum)") +print("\(bulkTiming.label): \(String(format: "%.2f", bulkTiming.ms)) ms avg checksum=\(bulkTiming.checksum)") +print("speedup: \(String(format: "%.2fx", legacyTiming.ms / bulkTiming.ms))") 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..9924fa22 --- /dev/null +++ b/scripts/test-background-refresh-timer-diff.mjs @@ -0,0 +1,271 @@ +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 { + createNonOverlappingBackgroundRefreshTick, + 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]); + }); + + await t.test('slow background ticks do not overlap before launch-level dedupe', async () => { + let active = 0; + let maxActive = 0; + let completed = 0; + let skipped = 0; + let releaseFirstTick; + const firstTickDone = new Promise((resolve) => { + releaseFirstTick = resolve; + }); + + const tick = createNonOverlappingBackgroundRefreshTick(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await firstTickDone; + completed += 1; + active -= 1; + }, { + onSkipped: () => { + skipped += 1; + }, + }); + + const first = tick(); + const overlapping = await Promise.all([tick(), tick(), tick()]); + releaseFirstTick(); + assert.equal(await first, true); + + const afterSlowTick = await tick(); + + console.log( + `[background-refresh non-overlap] slow overlapping ticks maxConcurrency=${maxActive} ` + + `completed=${completed} skipped=${skipped} overlappingResults=${overlapping.join(',')}` + ); + assert.equal(maxActive, 1); + assert.equal(completed, 2, 'the initial slow tick and the later post-completion tick both run'); + assert.equal(skipped, 3, 'overlapping timer firings are skipped before no-view enqueue dedupe'); + assert.deepEqual(overlapping, [false, false, false]); + assert.equal(afterSlowTick, true); + }); +}); 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..0f1e9de4 --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,459 @@ +#!/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); +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; +const IS_PERF_REPORT = IS_PERF_CI + || process.env.SUPERCMD_PERF_REPORT === '1' + || process.argv.includes('--report') + || process.argv.includes('--json'); + +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(options = {}) { + const { + historyCount = 4_000, + bookmarkCount = 800, + tabCount = 120, + } = options; + 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 < historyCount; 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 < bookmarkCount; 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 < tabCount; 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; +} + +async function measureBlockedTurn(fn) { + const scheduledAt = performance.now(); + const delayPromise = new Promise((resolve) => { + setTimeout(() => resolve(performance.now() - scheduledAt), 0); + }); + const start = performance.now(); + const result = fn(); + const durationMs = performance.now() - start; + const eventLoopDelayMs = await delayPromise; + return { + result, + durationMs, + eventLoopDelayMs, + }; +} + +function browserResultGroups() { + return [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; +} + +function browserQueries() { + return [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; +} + +function assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, +}) { + 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}` + ); +} + +function roundReportValues(values) { + return Object.fromEntries( + Object.entries(values).map(([query, item]) => [query, { + avgMs: Number(item.avgMs.toFixed(2)), + checksum: item.checksum, + }]) + ); +} + +function printBrowserSearchPerfReport(report) { + if (!IS_PERF_REPORT) return; + console.log(JSON.stringify({ browserSearchPerf: report }, null, 2)); +} + +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 = browserResultGroups(); + const queries = browserQueries(); + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } +}); + +test('browser search indexed harness preserves profile filtering', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, filterBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 4 }, + { kind: 'open-tab', limit: 4 }, + { kind: 'history', limit: 4 }, + ]; + const profiles = [ + { id: 'chrome:Default', displayName: 'Chrome Default', detectedName: 'Default', profileId: 'Default', browserId: 'chrome', browserName: 'Chrome', order: 0 }, + { id: 'chrome:Work', displayName: 'Chrome Work', detectedName: 'Work', profileId: 'Work', browserId: 'chrome', browserName: 'Chrome', order: 1 }, + { id: 'arc:Default', displayName: 'Arc Default', detectedName: 'Default', profileId: 'Default', browserId: 'arc', browserName: 'Arc', order: 2 }, + { id: 'brave:Default', displayName: 'Brave Default', detectedName: 'Default', profileId: 'Default', browserId: 'brave', browserName: 'Brave', order: 3 }, + ]; + const filters = { + 'open-tab': ['chrome:Default'], + bookmark: ['chrome:Default'], + history: ['chrome:Default'], + }; + + const filtered = filterBrowserResults( + getRankedBrowserResults('github', groups, entries, entryIndex, tabs, nicknames, 60), + filters, + profiles + ); + + assert.ok(filtered.length > 0, 'profile-filtered search should keep matching results'); + assert.ok( + filtered.every((result) => !result.sourceProfileId || result.sourceProfileId === 'chrome:Default'), + 'profile filtering should only keep enabled profile IDs' + ); +}); + +test('browser search indexed harness stays under generous query thresholds', async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = browserResultGroups(); + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + 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)); + const report = { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedAvgMs: roundReportValues(rankedIndexed), + orderedIndexedAvgMs: roundReportValues(orderedIndexed), + }; + + if (IS_PERF_REPORT) { + report.rankedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + )); + report.orderedFullScanAvgMs = roundReportValues(measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + )); + } + printBrowserSearchPerfReport(report); + + assert.ok(indexMeasurement.durationMs < 1_500, `index build should stay below 1500ms, got ${indexMeasurement.durationMs.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`); +}); + +test('browser search perf CI covers large indexed responsiveness budgets', { skip: !IS_PERF_CI }, async () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const groups = browserResultGroups(); + const queries = ['github', 'supercmd', 'electron workspace', 'bookmark reference 42', 'https://typescript', 'gh']; + const datasets = [ + { + label: '25k', + options: { historyCount: 21_000, bookmarkCount: 3_999, tabCount: 240 }, + budgets: { indexMs: 3_500, eventLoopDelayMs: 4_000, queryAvgMs: 180, queryEventLoopDelayMs: 4_000 }, + }, + { + label: '50k', + options: { historyCount: 45_000, bookmarkCount: 4_999, tabCount: 360 }, + budgets: { indexMs: 7_000, eventLoopDelayMs: 7_500, queryAvgMs: 260, queryEventLoopDelayMs: 7_500 }, + }, + ]; + const reports = []; + + for (const dataset of datasets) { + const { entries, tabs, nicknames } = buildSyntheticBrowserData(dataset.options); + const indexMeasurement = await measureBlockedTurn(() => buildBrowserEntryIndex(entries)); + const entryIndex = indexMeasurement.result; + + for (const query of queries) { + assertBrowserSearchEquivalence({ + query, + groups, + entries, + entryIndex, + tabs, + nicknames, + getOrderedBrowserResults, + getRankedBrowserResults, + }); + } + + const rankedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + )); + const orderedMeasurement = await measureBlockedTurn(() => measureAverageMs(5, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + )); + const rankedMaxMs = Math.max(...Object.values(rankedMeasurement.result).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedMeasurement.result).map((item) => item.avgMs)); + + reports.push({ + dataset: { label: dataset.label, entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMeasurement.durationMs.toFixed(2)), + indexEventLoopDelayMs: Number(indexMeasurement.eventLoopDelayMs.toFixed(2)), + rankedIndexedMaxAvgMs: Number(rankedMaxMs.toFixed(2)), + orderedIndexedMaxAvgMs: Number(orderedMaxMs.toFixed(2)), + rankedEventLoopDelayMs: Number(rankedMeasurement.eventLoopDelayMs.toFixed(2)), + orderedEventLoopDelayMs: Number(orderedMeasurement.eventLoopDelayMs.toFixed(2)), + }); + + assert.ok(indexMeasurement.durationMs < dataset.budgets.indexMs, `${dataset.label} index build should stay below ${dataset.budgets.indexMs}ms, got ${indexMeasurement.durationMs.toFixed(2)}ms`); + assert.ok(indexMeasurement.eventLoopDelayMs < dataset.budgets.eventLoopDelayMs, `${dataset.label} index event-loop delay should stay below ${dataset.budgets.eventLoopDelayMs}ms, got ${indexMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ranked indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < dataset.budgets.queryAvgMs, `${dataset.label} ordered indexed search should stay below ${dataset.budgets.queryAvgMs}ms, got ${orderedMaxMs.toFixed(2)}ms`); + assert.ok(rankedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ranked query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${rankedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + assert.ok(orderedMeasurement.eventLoopDelayMs < dataset.budgets.queryEventLoopDelayMs, `${dataset.label} ordered query event-loop delay should stay below ${dataset.budgets.queryEventLoopDelayMs}ms, got ${orderedMeasurement.eventLoopDelayMs.toFixed(2)}ms`); + } + + printBrowserSearchPerfReport({ largeDatasets: reports }); +}); diff --git a/scripts/test-camera-capture-single-encode.mjs b/scripts/test-camera-capture-single-encode.mjs new file mode 100644 index 00000000..0a9ced86 --- /dev/null +++ b/scripts/test-camera-capture-single-encode.mjs @@ -0,0 +1,155 @@ +#!/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 mainPath = path.join(root, 'src/main/main.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();')); + }); +}); + +test('Camera capture save path avoids redundant mkdir subprocess', async (t) => { + await t.test('camera capture writes image bytes without shelling out for mkdir', () => { + const source = fs.readFileSync(cameraExtensionPath, 'utf8'); + + assert.ok(!source.includes('/bin/mkdir')); + assert.ok(!source.includes("['-p', saveDir]")); + assert.ok(source.includes('const saveDir = homeDir ? `${homeDir}/Pictures/SuperCmd Captures` : \'/tmp/SuperCmd Captures\';')); + assert.ok(source.includes('const savePath = `${saveDir}/supercmd-capture-${timestamp}.png`;')); + assert.ok(source.includes('const bytes = new Uint8Array(await captureBlob.arrayBuffer());')); + assert.ok(source.includes('await window.electron.fsWriteBinaryFile(savePath, bytes);')); + }); + + await t.test('fsWriteBinaryFile IPC creates parent directories recursively', () => { + const source = fs.readFileSync(mainPath, 'utf8'); + const mkdirLine = 'await fs.promises.mkdir(nodePath.dirname(filePath), { recursive: true });'; + const writeLine = 'await fs.promises.writeFile(filePath, Buffer.from(data));'; + + assert.ok(source.includes("ipcMain.handle('fs-write-binary-file'")); + assert.ok(source.includes(mkdirLine)); + assert.ok(source.includes(writeLine)); + assert.ok(source.indexOf(mkdirLine) < source.indexOf(writeLine)); + }); +}); 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-command-ipc-payload-cache.mjs b/scripts/test-command-ipc-payload-cache.mjs new file mode 100644 index 00000000..4b9ea684 --- /dev/null +++ b/scripts/test-command-ipc-payload-cache.mjs @@ -0,0 +1,59 @@ +#!/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 { + createLauncherCommandPayloadCache, + estimateLauncherCommandPayloadBytes, +} = await importTs(path.join(root, 'src/main/launcher-command-payload.ts'), { root }); + +const visibility = { + isAIDisabled(settings) { + return settings?.ai?.enabled === false; + }, + isAIDependentSystemCommand(commandId) { + return commandId === 'system-cursor-prompt'; + }, + isAISectionDisabledForCommand(commandId, settings) { + if (commandId === 'system-supercmd-speak') return settings?.ai?.readEnabled === false; + return false; + }, +}; + +test('launcher command payload cache reuses filtered IPC payloads until visibility inputs change', () => { + const cache = createLauncherCommandPayloadCache(); + const commands = [ + { id: 'app-visible', title: 'Visible', category: 'app' }, + { id: 'app-disabled', title: 'Disabled', category: 'app' }, + { id: 'app-default-disabled', title: 'Default Disabled', category: 'app', disabledByDefault: true }, + { id: 'system-cursor-prompt', title: 'AI Prompt', category: 'system' }, + ]; + const settings = { + disabledCommands: ['app-disabled'], + enabledCommands: [], + ai: { enabled: true, readEnabled: true }, + }; + + const first = cache.build(commands, settings, visibility); + const second = cache.build(commands, settings, visibility); + assert.equal(second, first); + assert.deepEqual(first.map((command) => command.id), ['app-visible', 'system-cursor-prompt']); + assert.ok(estimateLauncherCommandPayloadBytes(first) > 0); + + const aiDisabled = cache.build(commands, { + ...settings, + ai: { enabled: false, readEnabled: true }, + }, visibility); + assert.notEqual(aiDisabled, first); + assert.deepEqual(aiDisabled.map((command) => command.id), ['app-visible']); + + cache.clear(); + const afterClear = cache.build(commands, settings, visibility); + assert.notEqual(afterClear, first); + assert.deepEqual(afterClear.map((command) => command.id), ['app-visible', 'system-cursor-prompt']); +}); diff --git a/scripts/test-command-metadata-cache.mjs b/scripts/test-command-metadata-cache.mjs new file mode 100644 index 00000000..fcc64ba0 --- /dev/null +++ b/scripts/test-command-metadata-cache.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import test, { after } 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'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-command-metadata-cache-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath() { return ${JSON.stringify(tempRoot)}; }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return 'all'; } + export function loadSettings() { return globalThis.__superCmdCommandMetadataSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeCommands(count = 250) { + const commands = [ + { + id: 'ext-weather-today', + title: 'Today', + subtitle: 'Weather', + keywords: ['weather', 'today'], + category: 'extension', + path: 'weather/today', + mode: 'view', + }, + { + id: 'script-stock-ticker', + title: 'Stock Ticker', + keywords: ['stock'], + category: 'script', + path: '/tmp/stocks.sh', + mode: 'inline', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Maintenance', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'compact', + }, + ]; + + for (let index = 0; index < count; index += 1) { + commands.push({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + subtitle: `Application ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function findCommand(commands, id) { + const command = commands.find((entry) => entry.id === id); + assert.ok(command, `expected command ${id}`); + return command; +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +function seedFreshCache(commands = makeCommands()) { + commandsModule.__seedCommandCacheForTesting(commands, { cacheTimestamp: Date.now() }); +} + +test('command metadata updates patch cached subtitles without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + seedFreshCache(makeCommands(1_000)); + + const update = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: '72 F and sunny' }); + const afterExtensionUpdate = await commandsModule.getAvailableCommands(); + + assert.deepEqual( + { + matchedCommands: update.matchedCommands, + changedCommands: update.changedCommands, + patchedCachedCommands: update.patchedCachedCommands, + patchedStaleCommandsFallback: update.patchedStaleCommandsFallback, + }, + { + matchedCommands: 1, + changedCommands: 1, + patchedCachedCommands: true, + patchedStaleCommandsFallback: false, + } + ); + assert.equal(findCommand(afterExtensionUpdate, 'ext-weather-today').subtitle, '72 F and sunny'); + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + + const inlineUpdate = commandsModule.applyCommandMetadataUpdate('script-stock-ticker', { subtitle: 'AAPL 210.00' }); + const afterInlineUpdate = await commandsModule.getAvailableCommands(); + assert.equal(inlineUpdate.changedCommands, 1); + assert.equal(findCommand(afterInlineUpdate, 'script-stock-ticker').subtitle, 'AAPL 210.00'); + assert.equal(structuralDiscoveryRuns, 0); + + const ignoredCompactScript = commandsModule.applyCommandMetadataUpdate('script-cleanup', { subtitle: 'Ignored' }); + const afterIgnoredUpdate = await commandsModule.getAvailableCommands(); + assert.equal(ignoredCompactScript.matchedCommands, 0); + assert.equal(findCommand(afterIgnoredUpdate, 'script-cleanup').subtitle, 'Maintenance'); + assert.equal(structuralDiscoveryRuns, 0); + + const removal = commandsModule.applyCommandMetadataUpdate('weather/today', { subtitle: null }); + const afterRemoval = await commandsModule.getAvailableCommands(); + assert.equal(removal.changedCommands, 1); + assert.equal(findCommand(afterRemoval, 'ext-weather-today').subtitle, 'Weather'); + assert.equal(structuralDiscoveryRuns, 0); +}); + +test('legacy invalidation path starts structural discovery from stale fallback', async () => { + commandsModule.__resetCommandCacheForTesting(); + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeCommands(); + }); + const commands = makeCommands(1_000); + seedFreshCache(commands); + + commandsModule.invalidateCache(); + const returnedCommands = await commandsModule.getAvailableCommands(); + + assert.equal(returnedCommands, commands); + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); +}); + +test('command metadata cache benchmark', async () => { + const commandCount = 5_000; + const iterations = 60; + const targetCommandId = 'script-stock-ticker'; + + commandsModule.__resetCommandCacheForTesting(); + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.getAvailableCommands(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let optimizedStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + optimizedStructuralDiscoveryRuns += 1; + return makeCommands(commandCount); + }); + + const optimizedTimes = []; + for (let index = 0; index < iterations; index += 1) { + seedFreshCache(makeCommands(commandCount)); + const startedAt = performance.now(); + commandsModule.applyCommandMetadataUpdate(targetCommandId, { subtitle: `Tick ${index}` }); + await commandsModule.getAvailableCommands(); + optimizedTimes.push(performance.now() - startedAt); + } + + const report = { + commandCount, + iterations, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + getCommandsMs: stats(legacyTimes), + }, + optimized: { + structuralDiscoveryStarts: optimizedStructuralDiscoveryRuns, + getCommandsMs: stats(optimizedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - optimizedStructuralDiscoveryRuns, + }; + + console.log('Command metadata cache benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(optimizedStructuralDiscoveryRuns, 0); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); 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-detached-style-clone-count.mjs b/scripts/test-detached-style-clone-count.mjs new file mode 100644 index 00000000..0b1effbf --- /dev/null +++ b/scripts/test-detached-style-clone-count.mjs @@ -0,0 +1,108 @@ +#!/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 { cloneStylesIntoDocument } = await importTs(path.resolve('src/renderer/src/useDetachedPortalWindow.ts')); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.attributes = new Map(); + this.textContent = ''; + this.rel = ''; + this.href = ''; + this.parent = null; + this.appendCount = 0; + } + + appendChild(child) { + child.parent = this; + this.children.push(child); + this.appendCount += 1; + return child; + } + + remove() { + if (!this.parent) return; + this.parent.children = this.parent.children.filter((child) => child !== this); + this.parent = null; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + getAttribute(name) { + return this.attributes.has(name) ? this.attributes.get(name) : null; + } + + querySelectorAll(selector) { + if (selector === 'style, link[rel="stylesheet"]') { + return this.children.filter((child) => ( + child.tagName.toLowerCase() === 'style' || + (child.tagName.toLowerCase() === 'link' && child.rel === 'stylesheet') + )); + } + if (selector === '[data-sc-detached-style="1"]') { + return this.children.filter((child) => child.getAttribute('data-sc-detached-style') === '1'); + } + return []; + } +} + +class FakeDocument { + constructor() { + this.head = new FakeElement('head'); + this.body = new FakeElement('body'); + } + + createElement(tagName) { + return new FakeElement(tagName); + } +} + +function appendStyle(doc, text) { + const style = doc.createElement('style'); + style.textContent = text; + doc.head.appendChild(style); + return style; +} + +function appendLink(doc, href) { + const link = doc.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + doc.head.appendChild(link); + return link; +} + +test('detached portal style cloning skips unchanged lifecycle passes', (t) => { + const source = new FakeDocument(); + const target = new FakeDocument(); + appendStyle(source, '.speak { color: red; }'); + appendLink(source, 'app://style.css'); + + const first = cloneStylesIntoDocument(source, target); + const firstAppendCount = target.head.appendCount; + const second = cloneStylesIntoDocument(source, target); + const secondAppendCount = target.head.appendCount; + + assert.equal(first, true); + assert.equal(second, false); + assert.equal(firstAppendCount, 3); + assert.equal(secondAppendCount, firstAppendCount); + + source.head.children[0].textContent = '.speak { color: blue; }'; + const third = cloneStylesIntoDocument(source, target); + assert.equal(third, true); + assert.equal(target.head.querySelectorAll('[data-sc-detached-style="1"]').length, 3); + + t.diagnostic( + `[detached-style-clone-count] before unchangedPassAppends=${firstAppendCount * 2} ` + + `after unchangedPassAppends=${secondAppendCount} changedPassAppends=${target.head.appendCount}` + ); +}); 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-command-refresh-cache.mjs b/scripts/test-extension-command-refresh-cache.mjs new file mode 100644 index 00000000..96feb009 --- /dev/null +++ b/scripts/test-extension-command-refresh-cache.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +import test, { after } 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'; +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 tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-extension-command-refresh-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath() { return ${JSON.stringify(tempRoot)}; }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return []; } + export function loadSettings() { return globalThis.__superCmdExtensionRefreshSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function makeBaseCommands(extraAppCount = 0) { + const commands = [ + { + id: 'app-safari', + title: 'Safari', + keywords: ['browser'], + category: 'app', + path: '/Applications/Safari.app', + }, + { + id: 'settings-network', + title: 'Network', + keywords: ['wifi'], + category: 'settings', + path: 'com.apple.Network-Settings.extension', + }, + { + id: 'ext-old-search', + title: 'Old Search', + subtitle: 'Old Extension', + keywords: ['old'], + category: 'extension', + path: 'old/search', + mode: 'view', + deeplink: 'supercmd://extensions/old/search', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Scripts', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'inline', + }, + { + id: 'quicklink-docs', + title: 'Docs', + subtitle: 'Quick Link', + keywords: ['docs'], + category: 'system', + }, + { + id: 'system-open-settings', + title: 'SuperCmd Settings', + keywords: ['settings'], + category: 'system', + }, + ]; + + for (let index = 0; index < extraAppCount; index += 1) { + commands.unshift({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function makeExtensionCommands(count = 1) { + return Array.from({ length: count }, (_, index) => ({ + id: `ext-new-command-${index}`, + title: `New Command ${index}`, + subtitle: 'New Extension', + keywords: ['new', `command-${index}`], + category: 'extension', + path: `new/command-${index}`, + mode: 'view', + deeplink: `supercmd://extensions/new/command-${index}`, + })); +} + +function commandIds(commands) { + return commands.map((command) => command.id); +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +test('extension command refresh swaps installed extensions without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = { + commandMetadata: { + 'new/command-0': { subtitle: 'Runtime status' }, + }, + }; + + let structuralDiscoveryRuns = 0; + let extensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return makeExtensionCommands(1); + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + assert.equal(extensionDiscoveryRuns, 1); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-new-command-0', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); + assert.equal(refreshed.find((command) => command.id === 'ext-new-command-0')?.subtitle, 'Runtime status'); + assert.equal(refreshed.some((command) => command.id === 'ext-old-search'), false); + assert.equal(await commandsModule.getAvailableCommands(), refreshed); +}); + +test('extension command refresh removes uninstalled extensions from cached command list', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let extensionDiscoveryRuns = 0; + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + extensionDiscoveryRuns += 1; + return []; + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(extensionDiscoveryRuns, 1); + assert.equal(refreshed.some((command) => command.category === 'extension'), false); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'script-cleanup', + 'quicklink-docs', + 'system-open-settings', + ]); +}); + +test('extension command refresh falls back to full discovery without a command cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + + const refreshed = await commandsModule.refreshCommandsForExtensionChange(); + + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); + assert.equal(refreshed.some((command) => command.id === 'app-safari'), true); +}); + +test('extension command refresh benchmark avoids full discovery starts with a warm cache', async () => { + const iterations = 40; + const baseCommandCount = 2_000; + const simulatedStructuralDiscoveryMs = 8; + + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdExtensionRefreshSettings = {}; + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.refreshCommandsNow(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + let targetedStructuralDiscoveryRuns = 0; + let targetedExtensionDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + targetedStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + commandsModule.__setExtensionCommandInfoRunnerForTesting(() => { + targetedExtensionDiscoveryRuns += 1; + return makeExtensionCommands(3); + }); + + const targetedTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + await commandsModule.refreshCommandsForExtensionChange(); + targetedTimes.push(performance.now() - startedAt); + } + + const report = { + iterations, + baseCommandCount: makeBaseCommands(baseCommandCount).length, + simulatedStructuralDiscoveryMs, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + refreshMs: stats(legacyTimes), + }, + targeted: { + structuralDiscoveryStarts: targetedStructuralDiscoveryRuns, + extensionDiscoveryStarts: targetedExtensionDiscoveryRuns, + refreshMs: stats(targetedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - targetedStructuralDiscoveryRuns, + }; + + console.log('Extension command refresh benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(targetedStructuralDiscoveryRuns, 0); + assert.equal(targetedExtensionDiscoveryRuns, iterations); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/scripts/test-extension-lifecycle-sandbox.mjs b/scripts/test-extension-lifecycle-sandbox.mjs new file mode 100644 index 00000000..902df664 --- /dev/null +++ b/scripts/test-extension-lifecycle-sandbox.mjs @@ -0,0 +1,586 @@ +#!/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)), '..'); +const extensionViewPath = path.join(root, 'src/renderer/src/ExtensionView.tsx'); + +function captureFromOptions(options) { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function createFakeEventTarget(label) { + const listenerIds = new WeakMap(); + const listeners = new Map(); + let nextListenerId = 1; + + function listenerId(listener) { + if (!listener || (typeof listener !== 'function' && typeof listener !== 'object')) { + return 'null'; + } + let id = listenerIds.get(listener); + if (!id) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + return id; + } + + function key(type, listener, options) { + return `${String(type)}:${captureFromOptions(options)}:${listenerId(listener)}`; + } + + return { + label, + listeners, + addEventListener(type, listener, options) { + if (!listener) return; + listeners.set(key(type, listener, options), { type, listener, options }); + }, + removeEventListener(type, listener, options) { + listeners.delete(key(type, listener, options)); + }, + }; +} + +function createHostWindow() { + let nextTimerId = 1; + const intervals = new Set(); + const timeouts = new Map(); + const rafs = new Map(); + const windowTarget = createFakeEventTarget('window'); + const documentTarget = createFakeEventTarget('document'); + + const hostWindow = { + document: documentTarget, + navigator: { userAgent: 'SuperCmd lifecycle harness' }, + location: { href: 'supercmd://lifecycle-harness' }, + electron: { marker: 'electron-facade' }, + setInterval() { + const id = nextTimerId++; + intervals.add(id); + return id; + }, + clearInterval(id) { + intervals.delete(id); + }, + setTimeout(handler, _timeout, ...args) { + const id = nextTimerId++; + timeouts.set(id, { handler, args }); + return id; + }, + clearTimeout(id) { + timeouts.delete(id); + }, + requestAnimationFrame(callback) { + const id = nextTimerId++; + rafs.set(id, { callback }); + return id; + }, + cancelAnimationFrame(id) { + rafs.delete(id); + }, + addEventListener: windowTarget.addEventListener, + removeEventListener: windowTarget.removeEventListener, + }; + + hostWindow.window = hostWindow; + hostWindow.self = hostWindow; + hostWindow.globalThis = hostWindow; + hostWindow.global = hostWindow; + hostWindow.top = hostWindow; + hostWindow.parent = hostWindow; + documentTarget.defaultView = hostWindow; + + return { + hostWindow, + hostDocument: documentTarget, + snapshot() { + return { + intervals: intervals.size, + timeouts: timeouts.size, + rafs: rafs.size, + windowListeners: windowTarget.listeners.size, + documentListeners: documentTarget.listeners.size, + total: + intervals.size + + timeouts.size + + rafs.size + + windowTarget.listeners.size + + documentTarget.listeners.size, + }; + }, + reset() { + intervals.clear(); + timeouts.clear(); + rafs.clear(); + windowTarget.listeners.clear(); + documentTarget.listeners.clear(); + }, + flushTimeouts() { + const pending = Array.from(timeouts.entries()); + for (const [id, { handler, args }] of pending) { + if (!timeouts.has(id)) continue; + timeouts.delete(id); + if (typeof handler === 'function') { + handler.call(hostWindow, ...args); + } + } + }, + flushRafs(timestamp = 16.7) { + const pending = Array.from(rafs.entries()); + for (const [id, { callback }] of pending) { + if (!rafs.has(id)) continue; + rafs.delete(id); + callback.call(hostWindow, timestamp); + } + }, + }; +} + +const host = createHostWindow(); +globalThis.window = host.hostWindow; +globalThis.document = host.hostDocument; +globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, +}; + +function extractLifecycleSource() { + const source = fs.readFileSync(extensionViewPath, 'utf8'); + const start = source.indexOf('type ExtensionTimerHandle'); + const end = source.indexOf('/**\n * JS shim for the `swift:AppleReminders`', start); + assert.notEqual(start, -1, 'Could not locate lifecycle registry start marker'); + assert.notEqual(end, -1, 'Could not locate lifecycle registry end marker'); + return source + .slice(start, end) + .replace(/\bexport\s+(?=(interface|function)\b)/g, ''); +} + +function loadLifecycleHarness() { + const source = ` + ${extractLifecycleSource()} + globalThis.__extensionLifecycleHarness = { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, + }; + `; + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.None, + target: ts.ScriptTarget.ES2022, + }, + fileName: extensionViewPath, + }); + const sandbox = { + console, + window: host.hostWindow, + document: host.hostDocument, + }; + vm.createContext(sandbox); + vm.runInContext(transpiled.outputText, sandbox, { filename: extensionViewPath }); + return sandbox.__extensionLifecycleHarness; +} + +const { + clearTimerRegistry, + createExtensionLifecycleScope, + createTimerRegistry, +} = loadLifecycleHarness(); + +const extensionCode = ` +const onResize = () => {}; +const onVisibilityChange = () => {}; +const intervalId = window.setInterval(() => {}, 60000); +window.addEventListener("resize", onResize); +window.document.addEventListener("visibilitychange", onVisibilityChange); + +exports.default = function LifecycleProbe() { + return { + intervalId, + compatibility: { + documentRead: Boolean(window.document) && window.document === document, + navigatorRead: window.navigator.userAgent, + locationRead: window.location.href, + electronRead: window.electron.marker, + globalWindowRead: globalThis.window === window, + defaultViewRead: document.defaultView === window + } + }; +}; +`; + +const EXTENSION_WRAPPER_ARGUMENTS = [ + 'exports', + 'require', + 'module', + '__filename', + '__dirname', + 'process', + 'Buffer', + 'global', + 'globalThis', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'window', + 'document', + 'navigator', + '__scDynamicImport', +]; + +function createBareTimerApi(registry) { + const setIntervalTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setInterval(callback, timeout, ...args); + registry.intervals.add(id); + registry.intervalClearers.set(id, host.hostWindow.clearInterval); + return id; + }; + const clearIntervalTracked = (id) => { + registry.intervals.delete(id); + registry.intervalClearers.delete(id); + host.hostWindow.clearInterval(id); + }; + const setTimeoutTracked = (callback, timeout, ...args) => { + const id = host.hostWindow.setTimeout(callback, timeout, ...args); + registry.timeouts.add(id); + registry.timeoutClearers.set(id, host.hostWindow.clearTimeout); + return id; + }; + const clearTimeoutTracked = (id) => { + registry.timeouts.delete(id); + registry.timeoutClearers.delete(id); + host.hostWindow.clearTimeout(id); + }; + const requestAnimationFrameTracked = (callback) => { + const id = host.hostWindow.requestAnimationFrame(callback); + registry.rafs.add(id); + registry.rafClearers.set(id, host.hostWindow.cancelAnimationFrame); + return id; + }; + const cancelAnimationFrameTracked = (id) => { + registry.rafs.delete(id); + registry.rafClearers.delete(id); + host.hostWindow.cancelAnimationFrame(id); + }; + return { + setImmediate: (callback, ...args) => setTimeoutTracked(() => callback(...args), 0), + clearImmediate: clearTimeoutTracked, + setInterval: setIntervalTracked, + clearInterval: clearIntervalTracked, + setTimeout: setTimeoutTracked, + clearTimeout: clearTimeoutTracked, + requestAnimationFrame: requestAnimationFrameTracked, + cancelAnimationFrame: cancelAnimationFrameTracked, + }; +} + +function runWrapper({ lifecycleScope, registry, scoped }) { + const moduleExports = {}; + const fakeModule = { exports: moduleExports }; + const bareTimerApi = scoped ? lifecycleScope : createBareTimerApi(registry); + const wrapperWindow = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapperDocument = scoped ? lifecycleScope.scopedDocument : host.hostDocument; + const globalObject = scoped ? lifecycleScope.scopedWindow : host.hostWindow; + const wrapper = new Function(...EXTENSION_WRAPPER_ARGUMENTS, extensionCode); + + wrapper( + moduleExports, + () => ({}), + fakeModule, + '/extension/index.js', + '/extension', + { env: {} }, + Buffer, + globalObject, + globalObject, + bareTimerApi.setImmediate, + bareTimerApi.clearImmediate, + bareTimerApi.setInterval, + bareTimerApi.clearInterval, + bareTimerApi.setTimeout, + bareTimerApi.clearTimeout, + bareTimerApi.requestAnimationFrame, + bareTimerApi.cancelAnimationFrame, + wrapperWindow, + wrapperDocument, + undefined, + async () => ({}), + ); + + return fakeModule.exports.default(); +} + +test('extension lifecycle sandbox cleanup', async (t) => { + await t.test('documents the pre-fix window API leak', () => { + host.reset(); + const registry = createTimerRegistry(); + const result = runWrapper({ registry, scoped: false }); + const beforeClear = host.snapshot(); + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.equal(registry.intervals.size, 0, 'legacy window.setInterval bypasses the bare timer registry'); + assert.equal(registry.eventListeners.size, 0, 'legacy window.addEventListener bypasses lifecycle cleanup'); + assert.equal(afterClear.total, beforeClear.total, 'legacy registry cleanup leaves window handles behind'); + + console.log('extension lifecycle leak before fix:', { beforeClear, afterClear }); + }); + + await t.test('cleans scoped window timers and listeners on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const result = runWrapper({ lifecycleScope, registry, scoped: true }); + const beforeClear = host.snapshot(); + const registryBeforeClear = { + intervals: registry.intervals.size, + eventListeners: registry.eventListeners.size, + }; + + clearTimerRegistry(registry); + const afterClear = host.snapshot(); + + assert.deepEqual(result.compatibility, { + documentRead: true, + navigatorRead: 'SuperCmd lifecycle harness', + locationRead: 'supercmd://lifecycle-harness', + electronRead: 'electron-facade', + globalWindowRead: true, + defaultViewRead: true, + }); + assert.equal(beforeClear.intervals, 1); + assert.equal(beforeClear.windowListeners, 1); + assert.equal(beforeClear.documentListeners, 1); + assert.deepEqual(registryBeforeClear, { intervals: 1, eventListeners: 2 }); + assert.equal(afterClear.total, 0); + + console.log('extension lifecycle cleanup after fix:', { beforeClear, registryBeforeClear, afterClear }); + }); + + await t.test('prunes fired scoped one-shot timers and rafs', () => { + const oneShotCount = 1000; + + host.reset(); + const staleTimeoutRegistry = createTimerRegistry(); + const staleTimeoutApi = createBareTimerApi(staleTimeoutRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleTimeoutApi.setTimeout(() => {}, 0); + } + host.flushTimeouts(); + const staleTimeoutEntries = staleTimeoutRegistry.timeouts.size; + clearTimerRegistry(staleTimeoutRegistry); + + host.reset(); + const staleRafRegistry = createTimerRegistry(); + const staleRafApi = createBareTimerApi(staleRafRegistry); + for (let i = 0; i < oneShotCount; i += 1) { + staleRafApi.requestAnimationFrame(() => {}); + } + host.flushRafs(); + const staleRafEntries = staleRafRegistry.rafs.size; + clearTimerRegistry(staleRafRegistry); + + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + const intervalId = lifecycleScope.setInterval(() => {}, 60000); + let timeoutCalls = 0; + let timeoutThis = null; + let timeoutArgs = null; + for (let i = 0; i < oneShotCount; i += 1) { + lifecycleScope.setTimeout(function (...args) { + timeoutCalls += 1; + if (timeoutCalls === 1) { + timeoutThis = this; + timeoutArgs = args; + } + }, 0, i, 'timeout-arg'); + } + + assert.equal(registry.intervals.size, 1); + assert.equal(registry.timeouts.size, oneShotCount); + assert.equal(registry.timeoutClearers.size, oneShotCount); + + host.flushTimeouts(); + + assert.equal(timeoutCalls, oneShotCount); + assert.equal(timeoutThis, host.hostWindow); + assert.deepEqual(timeoutArgs, [0, 'timeout-arg']); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.intervals.size, 1, 'interval remains tracked after one-shot timeouts fire'); + assert.equal(registry.intervalClearers.size, 1); + + let rafCalls = 0; + let rafThis = null; + let rafTimestamp = null; + for (let i = 0; i < oneShotCount; i += 1) { + lifecycleScope.requestAnimationFrame(function (timestamp) { + rafCalls += 1; + if (rafCalls === 1) { + rafThis = this; + rafTimestamp = timestamp; + } + }); + } + + assert.equal(registry.rafs.size, oneShotCount); + assert.equal(registry.rafClearers.size, oneShotCount); + + host.flushRafs(123.4); + + assert.equal(rafCalls, oneShotCount); + assert.equal(rafThis, host.hostWindow); + assert.equal(rafTimestamp, 123.4); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + assert.equal(registry.intervals.size, 1, 'interval remains tracked after one-shot rafs fire'); + assert.equal(registry.intervalClearers.size, 1); + + lifecycleScope.clearInterval(intervalId); + assert.equal(registry.intervals.size, 0); + assert.equal(registry.intervalClearers.size, 0); + + console.log(`${oneShotCount} fired timeouts: before stale registry entries=${staleTimeoutEntries}, after=${registry.timeouts.size}`); + console.log(`${oneShotCount} fired RAFs: before stale registry entries=${staleRafEntries}, after=${registry.rafs.size}`); + }); + + await t.test('cleared one-shot handles do not fire or stay registered', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + let timeoutCalls = 0; + let rafCalls = 0; + + const timeoutId = lifecycleScope.setTimeout(() => { + timeoutCalls += 1; + }, 0); + lifecycleScope.clearTimeout(timeoutId); + host.flushTimeouts(); + + const rafId = lifecycleScope.requestAnimationFrame(() => { + rafCalls += 1; + }); + lifecycleScope.cancelAnimationFrame(rafId); + host.flushRafs(); + + assert.equal(timeoutCalls, 0); + assert.equal(rafCalls, 0); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + }); + + await t.test('clears pending scoped one-shots on unmount', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + let callbackCalls = 0; + + lifecycleScope.setTimeout(() => { + callbackCalls += 1; + }, 0); + lifecycleScope.requestAnimationFrame(() => { + callbackCalls += 1; + }); + + assert.equal(registry.timeouts.size, 1); + assert.equal(registry.rafs.size, 1); + assert.equal(host.snapshot().timeouts, 1); + assert.equal(host.snapshot().rafs, 1); + + clearTimerRegistry(registry); + host.flushTimeouts(); + host.flushRafs(); + + assert.equal(callbackCalls, 0); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + assert.equal(host.snapshot().timeouts, 0); + assert.equal(host.snapshot().rafs, 0); + }); + + await t.test('prunes one-shot handles when callbacks throw', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + + lifecycleScope.setTimeout(() => { + throw new Error('timeout boom'); + }, 0); + + assert.equal(registry.timeouts.size, 1); + assert.throws(() => host.flushTimeouts(), /timeout boom/); + assert.equal(registry.timeouts.size, 0); + assert.equal(registry.timeoutClearers.size, 0); + + lifecycleScope.requestAnimationFrame(() => { + throw new Error('raf boom'); + }); + + assert.equal(registry.rafs.size, 1); + assert.throws(() => host.flushRafs(), /raf boom/); + assert.equal(registry.rafs.size, 0); + assert.equal(registry.rafClearers.size, 0); + }); + + await t.test('cleans prior scoped handles before re-evaluation', () => { + host.reset(); + const registry = createTimerRegistry(); + const lifecycleScope = createExtensionLifecycleScope(registry, host.hostWindow, host.hostDocument); + + runWrapper({ lifecycleScope, registry, scoped: true }); + const firstEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterReevaluationCleanup = host.snapshot(); + runWrapper({ lifecycleScope, registry, scoped: true }); + const secondEvaluation = host.snapshot(); + clearTimerRegistry(registry); + const afterUnmount = host.snapshot(); + + assert.equal(firstEvaluation.total, 3); + assert.equal(afterReevaluationCleanup.total, 0); + assert.equal(secondEvaluation.total, 3); + assert.equal(afterUnmount.total, 0); + + console.log('extension lifecycle re-evaluation cleanup:', { + firstEvaluation, + afterReevaluationCleanup, + secondEvaluation, + afterUnmount, + }); + }); +}); diff --git a/scripts/test-extension-runner-multi-command-build.mjs b/scripts/test-extension-runner-multi-command-build.mjs new file mode 100644 index 00000000..a318372d --- /dev/null +++ b/scripts/test-extension-runner-multi-command-build.mjs @@ -0,0 +1,117 @@ +#!/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 { createRequire } from 'node:module'; +import { + bundleExtensionRunner, +} from './measure-extension-bundle-cache.mjs'; + +const require = createRequire(import.meta.url); +const Module = require('node:module'); + +function createBuildFixture(commandCount) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `sc-extension-multi-build-${commandCount}-`)); + const userDataDir = path.join(tmpDir, 'userData'); + const extDir = path.join(tmpDir, 'extension'); + const srcDir = path.join(extDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const commands = Array.from({ length: commandCount }, (_unused, index) => { + const name = `command-${index}`; + fs.writeFileSync( + path.join(srcDir, `${name}.ts`), + [ + `export default async function ${name.replace(/-/g, '_')}() {`, + ` return ${JSON.stringify(name)};`, + `}`, + '', + ].join('\n') + ); + return { + name, + title: `Command ${index}`, + description: `Command ${index}`, + mode: 'view', + }; + }); + + fs.writeFileSync( + path.join(extDir, 'package.json'), + JSON.stringify( + { + name: `multi-build-fixture-${commandCount}`, + title: `Multi Build Fixture ${commandCount}`, + description: 'Fixture for batched extension builds', + owner: 'codex', + commands, + }, + null, + 2 + ) + ); + + return { tmpDir, userDataDir, extDir }; +} + +test('buildAllCommands batches 1/5/20 command fixtures into one esbuild call each', async (t) => { + for (const commandCount of [1, 5, 20]) { + await t.test(`${commandCount} command fixture`, async () => { + const fixture = createBuildFixture(commandCount); + let bundledRunner; + const esbuild = require('esbuild'); + const originalBuild = esbuild.build; + const originalLoad = Module._load; + const buildCalls = []; + const patchedBuild = async function patchedBuild(options) { + if (options?.absWorkingDir === fixture.extDir) { + const entryCount = Array.isArray(options.entryPoints) + ? options.entryPoints.length + : Object.keys(options.entryPoints || {}).length; + buildCalls.push({ + entryCount, + outdir: options.outdir, + outfile: options.outfile, + }); + } + return originalBuild.call(this, options); + }; + Module._load = function patchedModuleLoad(request, parent, isMain) { + if (request === 'esbuild') { + return { ...esbuild, build: patchedBuild }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + bundledRunner = await bundleExtensionRunner(fixture.userDataDir); + const runner = require(bundledRunner); + const built = await runner.buildAllCommands(`multi-build-fixture-${commandCount}`, fixture.extDir); + + assert.equal(built, commandCount); + assert.equal(buildCalls.length, 1, 'expected one esbuild invocation for all commands'); + assert.equal(buildCalls[0].entryCount, commandCount); + assert.equal(buildCalls[0].outdir, path.join(fixture.extDir, '.sc-build')); + assert.equal(buildCalls[0].outfile, undefined); + + for (let index = 0; index < commandCount; index += 1) { + assert.ok( + fs.existsSync(path.join(fixture.extDir, '.sc-build', `command-${index}.js`)), + `expected command-${index}.js output` + ); + } + } finally { + Module._load = originalLoad; + try { + if (bundledRunner) fs.unlinkSync(bundledRunner); + } catch {} + try { + fs.rmSync(fixture.tmpDir, { recursive: true, force: true }); + } catch {} + } + }); + } +}); diff --git a/scripts/test-extension-runtime-logging.mjs b/scripts/test-extension-runtime-logging.mjs new file mode 100644 index 00000000..6f5cbcd3 --- /dev/null +++ b/scripts/test-extension-runtime-logging.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node + +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'; +import { createRequire } from 'node:module'; + +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 source = fs.readFileSync(EXTENSION_VIEW_PATH, 'utf8'); + +function sourceBetween(startNeedle, endNeedle) { + const start = source.indexOf(startNeedle); + assert.notEqual(start, -1, `Could not find start marker: ${startNeedle}`); + const end = source.indexOf(endNeedle, start); + assert.notEqual(end, -1, `Could not find end marker: ${endNeedle}`); + return source.slice(start, end); +} + +function runTranspiledSnippet(snippet) { + const transpiled = ts.transpileModule(snippet, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: EXTENSION_VIEW_PATH, + }); + const debugCalls = []; + const sandbox = { + console: { + debug: (...args) => debugCalls.push(args), + }, + __debugCalls: debugCalls, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: EXTENSION_VIEW_PATH }); + return sandbox.__result; +} + +test('baseline: legacy success diagnostics log and stringify exported functions', () => { + const logCalls = []; + let toStringCalls = 0; + const exported = function Command() {}; + exported.toString = () => { + toStringCalls += 1; + return 'function Command() { return "ok"; }'; + }; + + const legacyConsole = { + log: (...args) => logCalls.push(args), + }; + + legacyConsole.log('[loadExtensionExport] Extension loaded successfully'); + legacyConsole.log('[loadExtensionExport] Exported type:', typeof exported); + legacyConsole.log('[loadExtensionExport] Exported name:', exported?.name); + legacyConsole.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + + console.log( + `[baseline] extension runtime success diagnostics: logs=${logCalls.length} exportedToStringCalls=${toStringCalls}` + ); + + assert.equal(logCalls.length, 4); + assert.equal(toStringCalls, 1); +}); + +test('extension runtime debug helper is quiet unless explicitly enabled', () => { + const helperSource = sourceBetween( + 'const EXTENSION_RUNTIME_DEBUG_GLOBAL', + '// ─── React Module for Extensions' + ); + const result = runTranspiledSnippet(` + ${helperSource} + const beforeEnabled = isExtensionRuntimeDebugLoggingEnabled(); + logExtensionRuntimeDebug('quiet'); + globalThis[EXTENSION_RUNTIME_DEBUG_GLOBAL] = true; + const afterEnabled = isExtensionRuntimeDebugLoggingEnabled(); + logExtensionRuntimeDebug('loud', 42); + globalThis.__result = { + beforeEnabled, + afterEnabled, + debugCalls: globalThis.__debugCalls, + }; + `); + + console.log( + `[after] extension runtime debug helper: defaultDebugCalls=0 enabledDebugCalls=${result.debugCalls.length}` + ); + + assert.equal(result.beforeEnabled, false); + assert.equal(result.afterEnabled, true); + assert.deepEqual(result.debugCalls, [['loud', 42]]); +}); + +test('success-path extension runtime diagnostics are gated behind debug logging', () => { + const moduleReactSetup = sourceBetween( + '// Create React module for extensions', + '// ─── JSX Runtime for Extensions' + ); + const reactRequireBridge = sourceBetween( + 'let reactRequireCount = 0;', + '// ── Raycast API shim' + ); + const loadSuccessPath = sourceBetween( + 'const exported =\n fakeModule.exports.default || fakeModule.exports;', + 'if (typeof exported === \'function\')' + ); + const viewRenderer = sourceBetween( + 'const ViewRenderer: React.FC<{', + 'const ScopedExtensionContext: React.FC<{' + ); + + for (const [label, snippet] of [ + ['module React setup', moduleReactSetup], + ['fakeRequire React bridge', reactRequireBridge], + ['loadExtensionExport success path', loadSuccessPath], + ['ViewRenderer render path', viewRenderer], + ]) { + assert.doesNotMatch(snippet, /console\.log/, `${label} must not emit unconditional console.log diagnostics`); + } + + assert.match(moduleReactSetup, /logExtensionRuntimeDebug/); + assert.match(reactRequireBridge, /logExtensionRuntimeDebug/); + assert.match(loadSuccessPath, /if \(isExtensionRuntimeDebugLoggingEnabled\(\)\)/); + assert.match(viewRenderer, /logExtensionRuntimeDebug/); + + const beforeDebugGate = loadSuccessPath.slice( + 0, + loadSuccessPath.indexOf('if (isExtensionRuntimeDebugLoggingEnabled())') + ); + assert.doesNotMatch(beforeDebugGate, /toString/, 'exported function toString must not run before debug is enabled'); + assert.match(loadSuccessPath, /console\.debug\('\[loadExtensionExport\] Exported function:', exported\?\.toString/); +}); + +test('actionable warnings and errors remain available', () => { + assert.match(source, /console\.warn\('Extension exported an object, not a function\. Trying to wrap it\.'\)/); + assert.match(source, /console\.warn\(`Extension tried to require unknown module:/); + assert.match(source, /console\.error\('Extension did not export a function\. Got:'/); + assert.match(source, /console\.error\('Failed to load extension:'/); + assert.match(source, /console\.error\('Stack:'/); +}); diff --git a/scripts/test-extension-sync-facade.mjs b/scripts/test-extension-sync-facade.mjs new file mode 100644 index 00000000..639b7f89 --- /dev/null +++ b/scripts/test-extension-sync-facade.mjs @@ -0,0 +1,341 @@ +#!/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 module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + __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..e3309432 --- /dev/null +++ b/scripts/test-extension-wrapper-cache.mjs @@ -0,0 +1,195 @@ +#!/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, + () => {}, + globalThis, + globalThis.document || {}, + 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); + const stats = getCompiledExtensionWrapperCacheStats(); + assert.equal(stats.wrapperCreationCount, 1); + assert.equal(stats.fingerprintEntries, 1); + assert.equal(stats.fingerprintRetainedSourceBytes, 0); + }); + + 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); + }); + + await t.test('does not retain full bundle sources in fingerprint stats', () => { + clearCompiledExtensionWrapperCache(); + const largeCode = `const payload = ${JSON.stringify('x'.repeat(256_000))}; exports.default = () => payload.length;`; + + const first = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/large-command', + code: largeCode, + }); + const second = getCompiledExtensionWrapper({ + extensionIdentity: 'owner/extension/large-command', + code: largeCode, + }); + + assert.equal(first.cacheHit, false); + assert.equal(second.cacheHit, true); + const stats = getCompiledExtensionWrapperCacheStats(); + assert.equal(stats.fingerprintEntries, 1); + assert.equal(stats.fingerprintRetainedSourceBytes, 0); + console.log(JSON.stringify({ + mode: 'extension-wrapper-cache-retained-source', + codeLength: largeCode.length, + stats, + }, null, 2)); + }); +}); diff --git a/scripts/test-file-search-delete-batch.mjs b/scripts/test-file-search-delete-batch.mjs new file mode 100644 index 00000000..a72fc95e --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,240 @@ +#!/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('batched indexed file deletes do not scan unrelated entries', () => { + const snapshot = api.makeSnapshot(); + const deletedFiles = []; + const bulkDir = path.join(homeDir, 'bulk-files'); + + addEntry(snapshot, bulkDir, true); + for (let index = 0; index < 64; index += 1) { + const filePath = path.join(bulkDir, `deleted-${index}.txt`); + deletedFiles.push(filePath); + addEntry(snapshot, filePath); + } + + snapshot.entries.push({ + get path() { + throw new Error('direct indexed file deletes should not scan unrelated entry paths'); + }, + name: 'sentinel.txt', + parentPath: bulkDir, + normalizedName: 'sentinel txt', + normalizedPath: 'sentinel', + normalizedTildePath: 'sentinel', + compactName: 'sentineltxt', + tokens: ['sentinel', 'txt'], + pathTokens: ['sentinel'], + isDirectory: false, + }); + + api.tombstoneDeletedPaths(snapshot, deletedFiles); + + for (const filePath of deletedFiles) { + assert.equal(isDeleted(snapshot, filePath), true); + } +}); + +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('unknown deleted paths still tombstone indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'unknown-root'); + const childFile = path.join(deletedRoot, 'child.txt'); + const siblingFile = path.join(homeDir, 'unknown-root-copy', 'child.txt'); + + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(siblingFile), true); + addEntry(snapshot, siblingFile); + + api.tombstoneDeletedPaths(snapshot, [deletedRoot]); + + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, siblingFile), 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..106620a7 --- /dev/null +++ b/scripts/test-file-search-perf-harness.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/promises'; +import os from 'node:os'; +import { runFileSearchPerfHarness } from './file-search-perf-harness.mjs'; + +const IS_PERF_CI = process.env.SUPERCMD_PERF_CI === '1'; + +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, + eventLoopLagP95Ms: 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.metrics.eventLoopLag.p95Ms >= 0); + assert.ok(summary.verification.postUpdateResultCount > 0); + assert.equal(summary.verification.deletedExactMatches, 0); +}); + +test('file search perf CI covers large path-query p95 and event-loop lag budgets', { skip: !IS_PERF_CI }, async () => { + const summary = await runFileSearchPerfHarness({ + projects: 120, + modulesPerProject: 8, + filesPerModule: 32, + queryRuns: 3, + updateCount: 64, + deleteCount: 64, + limit: 16, + thresholds: { + initialIndexMs: 60_000, + normalQueryP95Ms: 3_000, + pathQueryP95Ms: 3_000, + eventLoopLagP95Ms: 5_000, + watchUpdateBatchMs: 10_000, + deleteBatchMs: 10_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + assert.ok( + summary.fixture.initialEntryCount >= 30_000 && summary.fixture.initialEntryCount <= 60_000, + `expected 30k-60k indexed entries, got ${summary.fixture.initialEntryCount}` + ); + assert.ok(summary.metrics.pathLikeQueries.p95Ms <= summary.thresholds.applied.pathQueryP95Ms); + assert.ok(summary.metrics.eventLoopLag.p95Ms <= summary.thresholds.applied.eventLoopLagP95Ms); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'large temp fixture should be cleaned up'); +}); diff --git a/scripts/test-form-runtime-error-state-noop.mjs b/scripts/test-form-runtime-error-state-noop.mjs new file mode 100644 index 00000000..13a387ca --- /dev/null +++ b/scripts/test-form-runtime-error-state-noop.mjs @@ -0,0 +1,109 @@ +#!/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'; + +const FORM_RUNTIME_PATH = 'src/renderer/src/raycast-api/form-runtime.tsx'; +const FORM_RUNTIME_STATE_PATH = 'src/renderer/src/raycast-api/form-runtime-state.ts'; +const FIELD_CHANGE_COUNTS = [10, 100, 1000]; + +function importStateHelpers() { + const source = fs.readFileSync(FORM_RUNTIME_STATE_PATH, 'utf8'); + const executableSource = source + .replace(/export type FormErrorMap = Record;\n\n/, '') + .replace(/export function /g, 'function ') + .replace(/: FormErrorMap/g, '') + .replace(/: string/g, ''); + + const context = {}; + vm.runInNewContext(`${executableSource}\nthis.clearFormFieldError = clearFormFieldError;\nthis.setFormFieldError = setFormFieldError;`, context); + return { + clearFormFieldError: context.clearFormFieldError, + setFormFieldError: context.setFormFieldError, + }; +} + +function analyzeFormRuntimeSource() { + const source = fs.readFileSync(FORM_RUNTIME_PATH, 'utf8'); + return { + usesClearHelper: source.includes('const next = clearFormFieldError(previous, id);'), + usesSetHelper: source.includes('const next = setFormFieldError(previous, id, error);'), + skipsPublishWhenUnchanged: source.includes('if (next === previous) return previous;'), + }; +} + +function measureNoErrorFieldChanges(clearFormFieldError, fieldChanges) { + let errors = {}; + let currentSnapshot = errors; + let errorIdentityChanges = 0; + + for (let index = 0; index < fieldChanges; index += 1) { + const next = clearFormFieldError(errors, `field-${index}`); + if (next !== currentSnapshot) { + errorIdentityChanges += 1; + } + currentSnapshot = next; + errors = next; + } + + return { fieldChanges, errorIdentityChanges }; +} + +function getMetrics() { + const { clearFormFieldError, setFormFieldError } = importStateHelpers(); + return { + noExistingErrors: FIELD_CHANGE_COUNTS.map((count) => measureNoErrorFieldChanges(clearFormFieldError, count)), + clearExistingErrorPreservesOthers: (() => { + const previous = { first: 'Required', second: 'Invalid' }; + const next = clearFormFieldError(previous, 'first'); + return { + changedIdentity: next !== previous, + clearedFieldMissing: !Object.prototype.hasOwnProperty.call(next, 'first'), + preservedOtherField: next.second === 'Invalid', + }; + })(), + setSameError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Required'); + return { changedIdentity: next !== previous }; + })(), + setNewError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Invalid'); + return { changedIdentity: next !== previous, value: next.first }; + })(), + }; +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ source: analyzeFormRuntimeSource(), metrics: getMetrics() }, null, 2)); +} else { + test('Form setValue skips error state updates when the field has no error', () => { + const source = analyzeFormRuntimeSource(); + assert.equal(source.usesClearHelper, true, 'Form setValue should use the guarded error clear helper'); + assert.equal(source.skipsPublishWhenUnchanged, true, 'unchanged errors should not republish the global error snapshot'); + + for (const metric of getMetrics().noExistingErrors) { + assert.equal(metric.errorIdentityChanges, 0, `${metric.fieldChanges} no-error changes should keep the same errors object`); + } + }); + + test('Form setValue still clears an existing field error only', () => { + const metric = getMetrics().clearExistingErrorPreservesOthers; + assert.equal(metric.changedIdentity, true, 'clearing an existing error should publish a new errors object'); + assert.equal(metric.clearedFieldMissing, true, 'the changed field error should be removed'); + assert.equal(metric.preservedOtherField, true, 'unrelated field errors should be preserved'); + }); + + test('Form setError skips same-value updates but publishes changed errors', () => { + const source = analyzeFormRuntimeSource(); + assert.equal(source.usesSetHelper, true, 'Form setError should use the guarded error set helper'); + + const metrics = getMetrics(); + assert.equal(metrics.setSameError.changedIdentity, false, 'setting the same error should keep the previous errors object'); + assert.equal(metrics.setNewError.changedIdentity, true, 'setting a different error should publish a new errors object'); + assert.equal(metrics.setNewError.value, 'Invalid'); + }); +} 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..d410cf87 --- /dev/null +++ b/scripts/test-icon-asset-cache.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function createIconRuntimeHarness() { + const moduleCache = new Map(); + const existingPaths = new Set(); + let statCalls = 0; + let nowMs = 0; + + const windowStub = { + electron: { + statSync(filePath) { + statCalls += 1; + const exists = existingPaths.has(filePath); + return { + exists, + isDirectory: false, + isFile: exists, + size: exists ? 123 : 0, + }; + }, + }, + }; + const performanceStub = { + now() { + return nowMs; + }, + }; + + 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, + performance: performanceStub, + 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); + }, + advanceTime(ms) { + nowMs += ms; + }, + get statCalls() { + return statCalls; + }, + }; +} + +test('icon asset existence cache', async (t) => { + await t.test('caches positive extension-relative asset checks by local path', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iconPath = `${assetsPath}/icon.png`; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + for (let i = 0; i < 5; i += 1) { + assert.equal(harness.assets.resolveIconSrc('icon.png', assetsPath), expectedUrl); + } + + assert.equal(harness.statCalls, 1); + }); + + await t.test('shares positive cache entries across sc-asset and absolute path resolution', () => { + const harness = createIconRuntimeHarness(); + const iconPath = '/tmp/supercmd-assets/shared icon.png'; + const expectedUrl = harness.assets.toScAssetUrl(iconPath); + harness.addExistingPath(iconPath); + + assert.equal(harness.assets.resolveIconSrc(expectedUrl), expectedUrl); + assert.equal(harness.assets.resolveIconSrc(iconPath), expectedUrl); + + assert.equal(harness.statCalls, 1); + }); + + await t.test('does not permanently cache misses so newly available assets resolve after the stale window', () => { + 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), ''); + + harness.advanceTime(300); + 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)}`); + }); + + await t.test('repeated missing measurement avoids repeated statSync calls within the stale window', () => { + const harness = createIconRuntimeHarness(); + const assetsPath = '/tmp/supercmd-assets'; + const iterations = 10_000; + + const start = performance.now(); + let result = 'not-run'; + for (let i = 0; i < iterations; i += 1) { + result = harness.assets.resolveIconSrc('missing.png', assetsPath); + } + const elapsedMs = performance.now() - start; + + assert.equal(result, ''); + assert.equal(harness.statCalls, 1); + console.log(`icon-asset-miss-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..18417bdc --- /dev/null +++ b/scripts/test-icon-runtime-file-icon-cache.mjs @@ -0,0 +1,370 @@ +#!/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, + fileIconKindCache, + inFlightFileIconRequests, + inFlightFileIconKindRequests, + loadFileIconDataUrl, + loadFileIconKind, + readCachedFileIcon, + renderIcon, + clearFileIconCaches: () => { + fileIconCache.clear(); + fileIconKindCache.clear(); + inFlightFileIconRequests.clear(); + inFlightFileIconKindRequests.clear(); + }, + cacheStats: () => ({ + cacheSize: fileIconCache.size, + kindCacheSize: fileIconKindCache.size, + inFlightSize: inFlightFileIconRequests.size, + kindInFlightSize: inFlightFileIconKindRequests.size, + keys: Array.from(fileIconCache.keys()), + kindKeys: Array.from(fileIconKindCache.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'); + }); + + await t.test('does not call statSync while rendering FileIcon fallback glyphs', async () => { + const filePath = '/tmp/supercmd-folder'; + let syncStatCalls = 0; + let asyncStatCalls = 0; + + const runtime = await importIconRuntimeHarness({ + async getFileIconDataUrl() { + return null; + }, + statSync() { + syncStatCalls += 1; + return { exists: true, isDirectory: true }; + }, + async stat(requestedPath) { + asyncStatCalls += 1; + assert.equal(requestedPath, filePath); + return { exists: true, isDirectory: true, isFile: false, size: 0 }; + }, + }); + + const initial = runtime.FileIcon({ filePath, className: 'w-4 h-4' }); + + assert.equal(syncStatCalls, 0, 'render should not use the synchronous stat bridge'); + assert.equal(asyncStatCalls, 1); + assert.equal(initial?.props?.children, '📄'); + assert.equal(runtime.cacheStats().kindInFlightSize, 1); + + const pending = Array.from(runtime.inFlightFileIconKindRequests.values())[0]; + assert.equal(await pending, 'directory'); + assert.equal(syncStatCalls, 0); + assert.equal(runtime.cacheStats().kindCacheSize, 1); + assert.equal(runtime.cacheStats().kindInFlightSize, 0); + + const afterCache = runtime.FileIcon({ filePath, className: 'w-4 h-4' }); + assert.equal(afterCache?.props?.children, '📁'); + assert.equal(syncStatCalls, 0); + }); +}); 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-icon-tint-cache.mjs b/scripts/test-icon-tint-cache.mjs new file mode 100644 index 00000000..a2f89c89 --- /dev/null +++ b/scripts/test-icon-tint-cache.mjs @@ -0,0 +1,341 @@ +#!/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 MEASURE_ONLY = process.env.SUPERCMD_TINT_CACHE_MEASURE_ONLY === '1'; +const ITERATIONS = 10_000; + +const NAMED_COLORS = new Map([ + ['black', { r: 0, g: 0, b: 0 }], + ['blue', { r: 0, g: 0, b: 255 }], + ['green', { r: 0, g: 128, b: 0 }], + ['red', { r: 255, g: 0, b: 0 }], + ['rebeccapurple', { r: 102, g: 51, b: 153 }], + ['white', { r: 255, g: 255, b: 255 }], +]); + +function parseRgbTriplet(value) { + const match = String(value).match(/^rgba?\(([^)]+)\)$/i); + if (!match) return null; + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; + return { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; +} + +function parseHexColor(value) { + const hex = String(value).trim(); + const match = hex.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i); + if (!match) return null; + const raw = match[1]; + const normalized = raw.length === 3 + ? raw.split('').map((part) => `${part}${part}`).join('') + : raw.slice(0, 6); + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; +} + +function toCssRgb(color) { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function createStyleDeclaration() { + let color = ''; + return { + position: '', + visibility: '', + pointerEvents: '', + get color() { + return color; + }, + set color(value) { + const raw = String(value || '').trim(); + if (!raw) { + color = ''; + return; + } + if (parseHexColor(raw) || parseRgbTriplet(raw) || NAMED_COLORS.has(raw.toLowerCase()) || /^var\(--[^)]+\)$/i.test(raw)) { + color = raw; + return; + } + color = ''; + }, + }; +} + +function resolveComputedColor(value, cssVariables) { + const raw = String(value || '').trim(); + const variableMatch = raw.match(/^var\((--[^)]+)\)$/i); + const color = variableMatch ? String(cssVariables.get(variableMatch[1]) || '').trim() : raw; + const rgb = parseHexColor(color) || parseRgbTriplet(color) || NAMED_COLORS.get(color.toLowerCase()); + return rgb ? toCssRgb(rgb) : color; +} + +function createIconTintHarness() { + const moduleCache = new Map(); + const cssVariables = new Map([ + ['--surface-base-rgb', '247, 248, 250'], + ['--accent-color', '#336699'], + ]); + let dark = false; + let rootStyleVersion = 0; + let elementCreations = 0; + let bodyAppends = 0; + let computedStyleCalls = 0; + + const documentElement = { + get className() { + return dark ? 'dark' : ''; + }, + classList: { + contains(className) { + return className === 'dark' && dark; + }, + }, + getAttribute(attributeName) { + if (attributeName !== 'style') return ''; + return `--style-version: ${rootStyleVersion}`; + }, + }; + + const documentStub = { + documentElement, + body: { + appendChild(element) { + bodyAppends += 1; + element.parentNode = this; + }, + }, + createElement() { + elementCreations += 1; + return { + parentNode: null, + style: createStyleDeclaration(), + remove() { + this.parentNode = null; + }, + }; + }, + }; + + const windowStub = { + getComputedStyle(target) { + computedStyleCalls += 1; + if (target === documentElement) { + return { + color: dark ? 'rgb(255, 255, 255)' : 'rgb(17, 23, 32)', + getPropertyValue(variableName) { + return cssVariables.get(variableName) || ''; + }, + }; + } + return { + color: resolveComputedColor(target?.style?.color, cssVariables), + getPropertyValue() { + return ''; + }, + }; + }, + }; + + class MutationObserverStub { + observe() {} + disconnect() {} + } + + 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: documentStub, + MutationObserver: MutationObserverStub, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; + } + + function setDark(nextDark) { + dark = nextDark; + cssVariables.set('--surface-base-rgb', dark ? '30, 31, 36' : '247, 248, 250'); + } + + function setCssVariable(variableName, value) { + cssVariables.set(variableName, value); + rootStyleVersion += 1; + } + + function resetCounts() { + elementCreations = 0; + bodyAppends = 0; + computedStyleCalls = 0; + } + + return { + assets: loadTsModule('src/renderer/src/raycast-api/icon-runtime-assets.tsx'), + setDark, + setCssVariable, + resetCounts, + get counts() { + return { + elementCreations, + bodyAppends, + computedStyleCalls, + }; + }, + }; +} + +function measureScenario(name, scenario) { + const harness = createIconTintHarness(); + const start = performance.now(); + const result = scenario(harness); + const elapsedMs = performance.now() - start; + const counts = harness.counts; + console.log( + `icon-tint-cache measurement: scenario=${name} iterations=${ITERATIONS} elementCreations=${counts.elementCreations} bodyAppends=${counts.bodyAppends} getComputedStyle=${counts.computedStyleCalls} elapsedMs=${elapsedMs.toFixed(3)}`, + ); + return { result, counts }; +} + +function assertCacheCount(actual, max, label) { + if (MEASURE_ONLY) return; + assert.ok(actual <= max, `${label}: expected <= ${max}, got ${actual}`); +} + +test('icon tint color cache', async (t) => { + await t.test('preserves string, hex, invalid, and object tint semantics', () => { + const harness = createIconTintHarness(); + const { resolveTintColor } = harness.assets; + + assert.equal(resolveTintColor('336699'), '#336699'); + assert.equal(resolveTintColor('#336699'), '#336699'); + assert.equal(resolveTintColor('rebeccapurple'), 'rebeccapurple'); + assert.equal(resolveTintColor('definitely-not-a-color'), undefined); + + const tint = { light: 'red', dark: 'blue' }; + assert.equal(resolveTintColor(tint), 'red'); + harness.setDark(true); + assert.equal(resolveTintColor(tint), 'blue'); + + assert.equal(resolveTintColor({ light: 'red' }), 'red'); + harness.setDark(false); + assert.equal(resolveTintColor({ dark: 'blue' }), 'blue'); + }); + + await t.test('caches repeated string tint validation', () => { + const { result, counts } = measureScenario('resolveTintColor-string', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor('336699'); + } + return resolved; + }); + + assert.equal(result, '#336699'); + assertCacheCount(counts.elementCreations, 1, 'string tint validation element creations'); + }); + + await t.test('caches repeated object tint validation per selected theme color', () => { + const { result, counts } = measureScenario('resolveTintColor-object-light-dark', (harness) => { + const tint = { light: 'red', dark: 'blue' }; + let resolved = ''; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + harness.setDark(true); + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveTintColor(tint) || ''; + } + return resolved; + }); + + assert.equal(result, 'blue'); + assertCacheCount(counts.elementCreations, 2, 'object tint validation element creations'); + }); + + await t.test('caches readable tint DOM parsing and root CSS variable reads', () => { + const { result, counts } = measureScenario('resolveReadableTintColor-repeat', (harness) => { + let resolved; + for (let i = 0; i < ITERATIONS; i += 1) { + resolved = harness.assets.resolveReadableTintColor('#777777', { minContrast: 4.25 }); + } + return resolved; + }); + + assert.equal(typeof result, 'string'); + assertCacheCount(counts.elementCreations, 2, 'readable tint element creations'); + assertCacheCount(counts.computedStyleCalls, 2, 'readable tint getComputedStyle calls'); + }); + + await t.test('invalidates readable tint results when theme or root style changes', () => { + const harness = createIconTintHarness(); + const { resolveReadableTintColor } = harness.assets; + + const light = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const lightCounts = harness.counts; + harness.setDark(true); + const dark = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + const darkCounts = harness.counts; + harness.setCssVariable('--surface-base-rgb', '5, 5, 5'); + const darker = resolveReadableTintColor('#777777', { minContrast: 4.25 }); + + assert.notEqual(light, dark); + assert.equal(typeof dark, 'string'); + assert.equal(darker, '#777777'); + assert.notEqual(dark, darker); + assert.ok(darkCounts.computedStyleCalls > lightCounts.computedStyleCalls); + assert.ok(harness.counts.computedStyleCalls > darkCounts.computedStyleCalls); + }); +}); 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-inline-emoji-picker-match-cache.mjs b/scripts/test-inline-emoji-picker-match-cache.mjs new file mode 100644 index 00000000..c1a0309c --- /dev/null +++ b/scripts/test-inline-emoji-picker-match-cache.mjs @@ -0,0 +1,290 @@ +#!/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'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const mainPath = path.join(repoRoot, 'src/main/main.ts'); +const emojiDataPath = path.join(repoRoot, 'src/main/emoji-data.json'); + +function extractEmojiPickerSource(source) { + const start = source.indexOf('type EmojiEntry = '); + const end = source.indexOf('\nfunction stopEmojiTriggerMonitor', start); + assert.notEqual(start, -1, 'Could not find inline emoji picker source start'); + assert.notEqual(end, -1, 'Could not find inline emoji picker source end'); + return source.slice(start, end); +} + +function instrumentEmojiPickerSource(source) { + let instrumented = source.replace( + 'let emojiTriggerData: EmojiEntry[] | null = null;', + 'let emojiTriggerData: EmojiEntry[] | null = __emojiData;' + ); + instrumented = instrumented.replace( + 'function searchEmojiTriggerMatches(query: string, max = 8): EmojiEntry[] {\n', + 'function searchEmojiTriggerMatches(query: string, max = 8): EmojiEntry[] {\n __searchCallCount += 1;\n' + ); + + assert.notEqual(instrumented, source, 'Emoji picker source was not instrumented'); + + return ` +let __searchCallCount = 0; +const __renderCalls = []; +const __writeCommands = []; +const __insertCalls = []; +let emojiTriggerProcess = { + stdin: { + write(line) { + __writeCommands.push(JSON.parse(line)); + }, + }, +}; +let emojiPickerWindow = null; +let emojiPickerCurrentQuery = ''; +let emojiPickerCurrentPrefixLen = 1; +let emojiPickerSelectedIdx = 0; +let emojiPickerCurrentMatches = []; +const path = { join: (...parts) => parts.join('/') }; +const app = { getAppPath: () => '' }; +const screen = { + getCursorScreenPoint: () => ({ x: 0, y: 0 }), + getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), +}; +const BrowserWindow = class {}; +const systemClipboard = { readText: () => '', writeText: () => {} }; +function loadSettings() { + return { emojiPickerExcludedAppBundleIds: [] }; +} +function disableWindowAnimation() {} +function createFakeEmojiWindow() { + return { + visible: true, + bounds: null, + isDestroyed() { return false; }, + isVisible() { return this.visible; }, + showInactive() { this.visible = true; }, + hide() { this.visible = false; }, + getSize() { return [340, 80]; }, + setBounds(bounds) { this.bounds = bounds; }, + webContents: { + executeJavaScript(js) { + __renderCalls.push(js); + return Promise.resolve(undefined); + }, + setBackgroundThrottling() {}, + }, + }; +} + +${instrumented} + +insertEmojiReplacingTrigger = async function(emoji, queryLen, prefixLen) { + __insertCalls.push({ emoji, queryLen, prefixLen }); +}; + +globalThis.__emojiPickerTestAccess = { + reset() { + emojiPickerCurrentQuery = ''; + emojiPickerCurrentPrefixLen = 1; + emojiPickerSelectedIdx = 0; + emojiPickerCurrentMatches = []; + emojiPickerWindow = createFakeEmojiWindow(); + __searchCallCount = 0; + __renderCalls.length = 0; + __writeCommands.length = 0; + __insertCalls.length = 0; + }, + async render(query, prefixLen = 1) { + await renderEmojiPicker(query, { x: 80, y: 120, w: 10, h: 18 }, prefixLen, 'com.supercmd.test'); + }, + nav(key) { + handleEmojiTriggerNav(key); + }, + hide() { + hideEmojiPicker(); + }, + search(query) { + return searchEmojiTriggerMatches(query); + }, + resetSearchCount() { + __searchCallCount = 0; + }, + state() { + return { + query: emojiPickerCurrentQuery, + prefixLen: emojiPickerCurrentPrefixLen, + selectedIdx: emojiPickerSelectedIdx, + matchNames: emojiPickerCurrentMatches.map((entry) => entry.name), + matchEmojis: emojiPickerCurrentMatches.map((entry) => entry.emoji), + searchCallCount: __searchCallCount, + renderCalls: __renderCalls.slice(), + writeCommands: __writeCommands.slice(), + insertCalls: __insertCalls.slice(), + windowVisible: Boolean(emojiPickerWindow?.visible), + }; + }, +}; +`; +} + +function loadEmojiPickerHarness() { + const source = fs.readFileSync(mainPath, 'utf8'); + const emojiData = JSON.parse(fs.readFileSync(emojiDataPath, 'utf8')).map((entry) => ({ + name: entry.n, + emoji: entry.e, + keywords: entry.k || [], + })); + const instrumented = instrumentEmojiPickerSource(extractEmojiPickerSource(source)); + const transpiled = ts.transpileModule(instrumented, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: 'inline-emoji-picker-harness.ts', + }); + const sandbox = { + __emojiData: emojiData, + console, + JSON, + Promise, + require, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: 'inline-emoji-picker-harness.js' }); + return { hooks: sandbox.__emojiPickerTestAccess, emojiData }; +} + +function toPlain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function measureRepeatedNavigationSearchWork(search, query, { sequences, steps }) { + let legacyChecksum = 0; + let legacySearchCalls = 0; + const legacyStart = performance.now(); + for (let sequence = 0; sequence < sequences; sequence += 1) { + let selectedIdx = 0; + for (let step = 0; step < steps; step += 1) { + const matchesForHandle = search(query); + legacySearchCalls += 1; + if (matchesForHandle.length === 0) break; + const matchesForUpdate = search(query); + legacySearchCalls += 1; + selectedIdx = (selectedIdx + 1 + matchesForUpdate.length) % matchesForUpdate.length; + legacyChecksum += selectedIdx + matchesForUpdate.length; + } + } + const legacyMs = performance.now() - legacyStart; + + let cachedChecksum = 0; + let cachedSearchCalls = 0; + const cachedStart = performance.now(); + for (let sequence = 0; sequence < sequences; sequence += 1) { + const matches = search(query); + cachedSearchCalls += 1; + let selectedIdx = 0; + for (let step = 0; step < steps; step += 1) { + selectedIdx = (selectedIdx + 1 + matches.length) % matches.length; + cachedChecksum += selectedIdx + matches.length; + } + } + const cachedMs = performance.now() - cachedStart; + + return { + query, + sequences, + steps, + legacy: { + searchCalls: legacySearchCalls, + totalMs: Number(legacyMs.toFixed(3)), + perSequenceMs: Number((legacyMs / sequences).toFixed(4)), + checksum: legacyChecksum, + }, + cached: { + searchCalls: cachedSearchCalls, + totalMs: Number(cachedMs.toFixed(3)), + perSequenceMs: Number((cachedMs / sequences).toFixed(4)), + checksum: cachedChecksum, + }, + searchCallReduction: Number((legacySearchCalls / cachedSearchCalls).toFixed(1)), + }; +} + +test('inline emoji picker reuses rendered matches for navigation and insertion', async () => { + const { hooks } = loadEmojiPickerHarness(); + + hooks.reset(); + await hooks.render('smi'); + const rendered = hooks.state(); + assert.equal(rendered.searchCallCount, 1, 'render should search once for the new query'); + assert.equal(rendered.matchNames.length, 8, 'fixture query should produce a full picker row'); + + hooks.resetSearchCount(); + for (let index = 0; index < 20; index += 1) { + hooks.nav('right'); + } + const afterRights = hooks.state(); + assert.equal(afterRights.searchCallCount, 0, 'right navigation should not rescan emoji data'); + assert.equal(afterRights.selectedIdx, 20 % rendered.matchNames.length); + + hooks.nav('left'); + const afterLeft = hooks.state(); + assert.equal(afterLeft.searchCallCount, 0, 'left navigation should not rescan emoji data'); + assert.equal(afterLeft.selectedIdx, (afterRights.selectedIdx - 1 + rendered.matchNames.length) % rendered.matchNames.length); + + hooks.nav('enter'); + const afterEnter = hooks.state(); + assert.equal(afterEnter.searchCallCount, 0, 'enter should reuse the rendered match list'); + assert.deepEqual(toPlain(afterEnter.insertCalls), [{ + emoji: rendered.matchEmojis[afterLeft.selectedIdx], + queryLen: 3, + prefixLen: 1, + }]); + assert.deepEqual(toPlain(afterEnter.matchNames), [], 'hide should clear cached matches after insertion'); + assert.equal(afterEnter.query, ''); +}); + +test('inline emoji picker clears cached matches when a query has no matches', async () => { + const { hooks } = loadEmojiPickerHarness(); + + hooks.reset(); + await hooks.render('smi'); + assert.ok(hooks.state().matchNames.length > 0); + + await hooks.render('zzzzzzzzzz'); + const state = hooks.state(); + assert.equal(state.searchCallCount, 2); + assert.deepEqual(toPlain(state.matchNames), []); + assert.equal(state.query, ''); + assert.equal(state.windowVisible, false); +}); + +test('inline emoji picker repeated navigation search work is cached', () => { + const { hooks, emojiData } = loadEmojiPickerHarness(); + const measurement = measureRepeatedNavigationSearchWork(hooks.search, 'smi', { + sequences: 500, + steps: 20, + }); + + console.log(JSON.stringify({ + inlineEmojiPickerMatchCache: { + emojiEntries: emojiData.length, + ...measurement, + }, + }, null, 2)); + + assert.equal(emojiData.length, 1870); + assert.equal(measurement.legacy.searchCalls, measurement.sequences * measurement.steps * 2); + assert.equal(measurement.cached.searchCalls, measurement.sequences); + assert.equal(measurement.searchCallReduction, 40); + assert.equal(measurement.cached.checksum, measurement.legacy.checksum); +}); 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-launcher-file-result-command-map.mjs b/scripts/test-launcher-file-result-command-map.mjs new file mode 100644 index 00000000..d627ecbf --- /dev/null +++ b/scripts/test-launcher-file-result-command-map.mjs @@ -0,0 +1,215 @@ +#!/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 { build } from 'esbuild'; + +const root = path.resolve('.'); +const { + buildLauncherFileCandidates, + buildLauncherFileResultCommandByPath, +} = await importBundledTs(path.join(root, 'src/renderer/src/utils/launcher-file-candidates.ts')); + +async function importBundledTs(absPath) { + const result = await build({ + absWorkingDir: root, + entryPoints: [absPath], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + '.js': 'js', + '.jsx': 'jsx', + '.json': 'json', + }, + }); + const code = result.outputFiles[0].text; + const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`; + return import(dataUrl); +} + +function makeResult(index, overrides = {}) { + const parentPath = `/Users/test/Documents/project-${String(index % 30).padStart(2, '0')}`; + const name = `alpha-file-${String(index).padStart(4, '0')}.txt`; + const filePath = `${parentPath}/${name}`; + return { + path: filePath, + name, + parentPath, + displayPath: `~/Documents/project-${String(index % 30).padStart(2, '0')}`, + isDirectory: false, + mtimeMs: Date.now() - (index % 24) * 60 * 60 * 1000, + birthtimeMs: Date.now() - (index % 24) * 60 * 60 * 1000, + topLevelRoot: 'Documents', + homeRelativeDepth: 3, + depth: 3, + noisyPathSegmentCount: 0, + ...overrides, + }; +} + +function makeCommand(result, index, overrides = {}) { + return { + id: `system-file-result:${index}`, + title: result.name, + subtitle: result.displayPath, + keywords: [result.name, result.parentPath, result.displayPath], + category: 'system', + path: result.path, + ...overrides, + }; +} + +function candidateSignature(candidates) { + return candidates.map((candidate) => [ + candidate.stableKey, + candidate.label, + candidate.pathOrUrl, + candidate.matchKind, + candidate.matchScore, + candidate.subtype, + candidate.command.id, + candidate.command.title, + candidate.command.path, + candidate.finalScore, + ]); +} + +function checksumCandidates(candidates) { + let checksum = candidates.length; + for (const candidate of candidates) { + checksum += candidate.command.id.length; + checksum += candidate.stableKey.length; + checksum += Math.round(candidate.finalScore); + } + return checksum; +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +function measure(iterations, fn) { + const times = []; + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const started = performance.now(); + checksum += checksumCandidates(fn()); + times.push(performance.now() - started); + } + return { ...stats(times), checksum }; +} + +test('launcher file command map preserves first path match and result order', () => { + const duplicatePath = '/Users/test/Documents/alpha-duplicate.txt'; + const duplicateResult = makeResult(1, { + path: duplicatePath, + name: 'alpha-duplicate.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + const missingCommandResult = makeResult(2, { + path: '/Users/test/Documents/alpha-missing.txt', + name: 'alpha-missing.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + const tailResult = makeResult(3, { + path: '/Users/test/Documents/alpha-tail.txt', + name: 'alpha-tail.txt', + parentPath: '/Users/test/Documents', + displayPath: '~/Documents', + }); + + const firstDuplicateCommand = makeCommand(duplicateResult, 1, { title: 'First duplicate command' }); + const secondDuplicateCommand = makeCommand(duplicateResult, 2, { title: 'Second duplicate command' }); + const tailCommand = makeCommand(tailResult, 3, { title: 'Tail command' }); + const commandByPath = buildLauncherFileResultCommandByPath([ + firstDuplicateCommand, + secondDuplicateCommand, + tailCommand, + ]); + + assert.equal(commandByPath.get(duplicatePath), firstDuplicateCommand); + + const candidates = buildLauncherFileCandidates({ + launcherFileResults: [duplicateResult, missingCommandResult, tailResult], + fileResultCommandByPath: commandByPath, + searchQuery: 'alpha', + rootSearchRanking: {}, + }); + + assert.deepEqual( + candidates.map((candidate) => candidate.command.title), + ['First duplicate command', 'Tail command'] + ); + assert.deepEqual( + candidates.map((candidate) => candidate.pathOrUrl), + [duplicatePath, tailResult.path] + ); +}); + +test('launcher file candidate assembly handles 3000 results with path-map lookup', () => { + const count = 3000; + const iterations = 25; + const launcherFileResults = Array.from({ length: count }, (_, index) => makeResult(index)); + const fileResultCommands = launcherFileResults.map((result, index) => makeCommand(result, index)); + const fileResultCommandByPath = buildLauncherFileResultCommandByPath(fileResultCommands); + const findBackedLookup = { + get(filePath) { + return fileResultCommands.find((command) => command.path === filePath); + }, + }; + + const input = { + launcherFileResults, + searchQuery: 'alpha', + rootSearchRanking: {}, + }; + const mapCandidates = buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath, + }); + const findCandidates = buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath: findBackedLookup, + }); + + assert.equal(mapCandidates.length, count); + assert.deepEqual(candidateSignature(mapCandidates), candidateSignature(findCandidates)); + + const mapStats = measure(iterations, () => buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath, + })); + const findStats = measure(iterations, () => buildLauncherFileCandidates({ + ...input, + fileResultCommandByPath: findBackedLookup, + })); + + assert.equal(mapStats.checksum, findStats.checksum); + assert.ok( + mapStats.median < findStats.median, + `expected path-map assembly median ${mapStats.median}ms to be below find-backed median ${findStats.median}ms` + ); + + console.log('Launcher file candidate assembly benchmark', JSON.stringify({ + count, + iterations, + mapStats, + findStats, + speedup: Number((findStats.median / mapStats.median).toFixed(2)), + })); +}); diff --git a/scripts/test-list-conditional-virtual-rows.mjs b/scripts/test-list-conditional-virtual-rows.mjs new file mode 100644 index 00000000..8d1987f6 --- /dev/null +++ b/scripts/test-list-conditional-virtual-rows.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const LIST_RUNTIME_PATH = 'src/renderer/src/raycast-api/list-runtime.tsx'; +const CASES = [5000, 20000]; + +function buildGroupedItems(itemCount) { + return [{ + title: 'Emoji', + items: Array.from({ length: itemCount }, (_, index) => ({ + item: { id: `emoji-${index}` }, + globalIdx: index, + })), + }]; +} + +function buildFlatRows(groupedItems) { + const rows = []; + 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}` }); + for (const entry of group.items) { + rows.push({ type: 'item', item: entry.item, globalIdx: entry.globalIdx, key: entry.item.id }); + } + } + return rows; +} + +function buildConditionalFlatRows(groupedItems, shouldUseEmojiGridValue) { + if (shouldUseEmojiGridValue) return []; + return buildFlatRows(groupedItems); +} + +function analyzeListRuntimeSource() { + const source = fs.readFileSync(LIST_RUNTIME_PATH, 'utf8'); + return { + skipsFlatRowsForEmojiGrid: source.includes('if (shouldUseEmojiGridValue) return [];'), + tracksEmojiGridDependency: source.includes('}, [groupedItems, shouldUseEmojiGridValue]);'), + scrollsRenderedEmojiCell: source.includes('querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView'), + }; +} + +function measureAvoidedRows() { + return CASES.map((itemCount) => { + const groupedItems = buildGroupedItems(itemCount); + const oldEmojiRows = buildFlatRows(groupedItems); + const newEmojiRows = buildConditionalFlatRows(groupedItems, true); + const oldNormalRows = buildFlatRows(groupedItems); + const newNormalRows = buildConditionalFlatRows(groupedItems, false); + + return { + itemCount, + oldEmojiRowCount: oldEmojiRows.length, + newEmojiRowCount: newEmojiRows.length, + avoidedEmojiRows: oldEmojiRows.length - newEmojiRows.length, + normalRowCountPreserved: oldNormalRows.length === newNormalRows.length, + }; + }); +} + +test('list runtime skips unused linear rows for emoji-grid mode', () => { + const source = analyzeListRuntimeSource(); + assert.equal(source.skipsFlatRowsForEmojiGrid, true, 'flat row construction should short-circuit in emoji-grid mode'); + assert.equal(source.tracksEmojiGridDependency, true, 'flat row memo should update when the layout mode changes'); + assert.equal(source.scrollsRenderedEmojiCell, true, 'emoji-grid selection should still scroll the rendered selected cell'); + + const measurements = measureAvoidedRows(); + for (const measurement of measurements) { + assert.equal(measurement.newEmojiRowCount, 0, `${measurement.itemCount} emoji items should not build linear rows`); + assert.equal(measurement.avoidedEmojiRows, measurement.oldEmojiRowCount); + assert.equal(measurement.normalRowCountPreserved, true, 'normal list row count should be unchanged'); + } +}); + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ listConditionalVirtualRows: measureAvoidedRows() }, null, 2)); +} 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-local-model-status-probe-cache.mjs b/scripts/test-local-model-status-probe-cache.mjs new file mode 100644 index 00000000..7bb75fc1 --- /dev/null +++ b/scripts/test-local-model-status-probe-cache.mjs @@ -0,0 +1,146 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const { + createLocalAsrHelperStatusProbeCache, + isCacheableLocalAsrHelperStatus, +} = await importTs(path.resolve('src/main/local-model-status-probe.ts')); + +function makeStatus(overrides = {}) { + return { + state: 'downloaded', + modelName: 'parakeet-tdt-0.6b-v3', + path: '/models/parakeet', + progress: 1, + ...overrides, + }; +} + +function spinForMs(durationMs) { + const end = performance.now() + durationMs; + while (performance.now() < end) {} +} + +test('local ASR helper status cache reuses stable statuses within the TTL', () => { + let now = 1_000; + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + now: () => now, + readStatus() { + probeCalls += 1; + return makeStatus({ path: `/models/parakeet-${probeCalls}` }); + }, + }); + + assert.equal(cache.getStatus().path, '/models/parakeet-1'); + assert.equal(cache.getStatus().path, '/models/parakeet-1'); + assert.equal(probeCalls, 1); + + now += 5_001; + assert.equal(cache.getStatus().path, '/models/parakeet-2'); + assert.equal(probeCalls, 2); +}); + +test('local ASR helper status cache bypasses cached stable status when force refreshing', () => { + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + probeCalls += 1; + return makeStatus({ path: `/models/qwen3-${probeCalls}` }); + }, + }); + + assert.equal(cache.getStatus().path, '/models/qwen3-1'); + assert.equal(cache.getStatus({ forceRefresh: true }).path, '/models/qwen3-2'); + assert.equal(cache.getStatus().path, '/models/qwen3-2'); + assert.equal(probeCalls, 2); +}); + +test('local ASR helper status cache does not retain transient statuses', () => { + const statuses = [ + makeStatus({ state: 'downloading', path: '', progress: 0.5 }), + makeStatus({ state: 'error', path: '', progress: 0, error: 'failed' }), + makeStatus({ state: 'not-downloaded', path: '', progress: 0 }), + ]; + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + const status = statuses[Math.min(probeCalls, statuses.length - 1)]; + probeCalls += 1; + return status; + }, + }); + + assert.equal(cache.getStatus().state, 'downloading'); + assert.equal(cache.getStatus().state, 'error'); + assert.equal(cache.getStatus().state, 'not-downloaded'); + assert.equal(cache.getStatus().state, 'not-downloaded'); + assert.equal(probeCalls, 3); +}); + +test('local ASR helper status cache can remember a fresh post-download status', () => { + let probeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + probeCalls += 1; + return makeStatus({ state: 'not-downloaded', path: '', progress: 0 }); + }, + }); + + assert.equal(cache.getStatus().state, 'not-downloaded'); + cache.rememberStatus(makeStatus({ state: 'downloaded', path: '/models/fresh', progress: 1 })); + + const status = cache.getStatus(); + assert.equal(status.state, 'downloaded'); + assert.equal(status.path, '/models/fresh'); + assert.equal(probeCalls, 1); +}); + +test('local ASR helper status cache measurement avoids repeated fake sync probes', () => { + const iterations = 60; + const fakeProbeMs = 1.75; + let legacyProbeCalls = 0; + const legacyStart = performance.now(); + for (let index = 0; index < iterations; index += 1) { + legacyProbeCalls += 1; + spinForMs(fakeProbeMs); + } + const legacyMs = performance.now() - legacyStart; + + let cachedProbeCalls = 0; + const cache = createLocalAsrHelperStatusProbeCache({ + ttlMs: 5_000, + readStatus() { + cachedProbeCalls += 1; + spinForMs(fakeProbeMs); + return makeStatus(); + }, + }); + + const cachedStart = performance.now(); + for (let index = 0; index < iterations; index += 1) { + cache.getStatus(); + } + const cachedMs = performance.now() - cachedStart; + + console.log( + `[local-model-status probe-cache] iterations=${iterations} ` + + `legacyCalls=${legacyProbeCalls} legacyMs=${legacyMs.toFixed(3)} ` + + `cachedCalls=${cachedProbeCalls} cachedMs=${cachedMs.toFixed(3)} ` + + `avoidedCalls=${legacyProbeCalls - cachedProbeCalls}` + ); + + assert.equal(legacyProbeCalls, iterations); + assert.equal(cachedProbeCalls, 1); + assert.equal(isCacheableLocalAsrHelperStatus(makeStatus()), true); + assert.ok(cachedMs < legacyMs / 4); +}); diff --git a/scripts/test-main-icon-ipc-cache.mjs b/scripts/test-main-icon-ipc-cache.mjs new file mode 100644 index 00000000..a91f8bc0 --- /dev/null +++ b/scripts/test-main-icon-ipc-cache.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { build } from 'esbuild'; +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/main/icon-ipc-cache.ts'); +let importNonce = 0; + +async function importMainIconCacheHarness() { + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#main-icon-cache-${importNonce++}`, + ].join(''); + return import(dataUrl); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeIcon(dataUrl, resizes = []) { + return { + isEmpty: () => false, + resize: (options) => { + resizes.push(options); + return { + toDataURL: () => dataUrl, + }; + }, + }; +} + +async function resolveFileIconUncached(getFileIcon, filePath, size = 20) { + try { + const bucket = size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal'; + const icon = await getFileIcon(filePath, { size: bucket }); + if (icon && !icon.isEmpty()) { + return icon.resize({ width: size, height: size }).toDataURL(); + } + return null; + } catch { + return null; + } +} + +async function measureFileIconCalls(api, { useCache, iterations, mode, delayMs = 1 }) { + const filePath = '/tmp/../tmp/supercmd-main-icon.txt'; + const dataUrl = 'data:image/png;base64,file'; + let nativeCalls = 0; + + const getFileIcon = async () => { + nativeCalls += 1; + await delay(delayMs); + return makeIcon(dataUrl); + }; + const cache = api.createFileIconDataUrlCache({ getFileIcon, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 20) + : (requestedPath) => resolveFileIconUncached(getFileIcon, requestedPath, 20); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(filePath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(filePath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +async function resolveAppIconUncached(resolveAppIconDataUrl, appPath, size = 32) { + try { + return resolveAppIconDataUrl(appPath, size); + } catch { + return null; + } +} + +async function measureAppIconCalls(api, { useCache, iterations, mode }) { + const appPath = '/Applications/../Applications/SuperCmd.app'; + const dataUrl = 'data:image/png;base64,app'; + let nativeCalls = 0; + + const resolveAppIconDataUrl = () => { + nativeCalls += 1; + return dataUrl; + }; + const cache = api.createAppIconDataUrlCache({ resolveAppIconDataUrl, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 32) + : (requestedPath) => resolveAppIconUncached(resolveAppIconDataUrl, requestedPath, 32); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(appPath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(appPath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +test('main icon IPC cache', async (t) => { + const api = await importMainIconCacheHarness(); + + await t.test('coalesces and caches file icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `file icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `file icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('keeps file icon cache keys normalized by path, size, and bucket', async () => { + const dataUrl = 'data:image/png;base64,file-normalized'; + const resizes = []; + const calls = []; + const cache = api.createFileIconDataUrlCache({ + maxEntries: 4, + getFileIcon: async (filePath, options) => { + calls.push({ filePath, options }); + return makeIcon(dataUrl, resizes); + }, + }); + + assert.equal(await cache.resolve('/tmp/../tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(calls.length, 1, 'normalized path alias should reuse cached data URL'); + + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 32), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 16), dataUrl); + assert.equal(calls.length, 3, 'logical size and icon bucket are part of the key'); + assert.deepEqual(calls.map((call) => call.options.size), ['normal', 'normal', 'small']); + assert.deepEqual(resizes, [ + { width: 20, height: 20 }, + { width: 32, height: 32 }, + { width: 16, height: 16 }, + ]); + }); + + await t.test('does not permanently cache file icon nulls or errors', async () => { + const recoveredDataUrl = 'data:image/png;base64,file-recovered'; + let calls = 0; + const cache = api.createFileIconDataUrlCache({ + getFileIcon: async () => { + calls += 1; + if (calls === 1) throw new Error('temporary file icon failure'); + return makeIcon(recoveredDataUrl); + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/tmp/supercmd-late-file.txt', 20), + cache.resolve('/tmp/supercmd-late-file.txt', 20), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(cache.stats().cacheSize, 1); + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + }); + + await t.test('coalesces and caches app icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `app icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `app icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('shares app icon cache with sync callers and does not permanently cache nulls', async () => { + const recoveredDataUrl = 'data:image/png;base64,app-recovered'; + let calls = 0; + const cache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath, size) => { + calls += 1; + if (calls === 1) return null; + return `${recoveredDataUrl}:${path.basename(appPath)}:${size}`; + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/Applications/SuperCmd.app', 32), + cache.resolve('/Applications/../Applications/SuperCmd.app', 32), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + const recovered = await cache.resolve('/Applications/SuperCmd.app', 32); + assert.equal(recovered, `${recoveredDataUrl}:SuperCmd.app:32`); + assert.equal(calls, 2); + assert.equal(cache.resolveSync('/Applications/../Applications/SuperCmd.app', 32), recovered); + assert.equal(calls, 2); + }); + + await t.test('bounds file and app icon caches with LRU eviction', async () => { + const fileCache = api.createFileIconDataUrlCache({ + maxEntries: 2, + getFileIcon: async (filePath) => makeIcon(`data:image/png;base64,${Buffer.from(filePath).toString('base64')}`), + }); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/b.txt', 20); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/c.txt', 20); + const fileKeys = fileCache.stats().keys.join('\n'); + assert.equal(fileCache.stats().cacheSize, 2); + assert.match(fileKeys, /\/tmp\/a\.txt/); + assert.match(fileKeys, /\/tmp\/c\.txt/); + assert.doesNotMatch(fileKeys, /\/tmp\/b\.txt/); + + const appCache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath) => `data:image/png;base64,${Buffer.from(appPath).toString('base64')}`, + }); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/B.app', 32)); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/C.app', 32)); + const appKeys = appCache.stats().keys.join('\n'); + assert.equal(appCache.stats().cacheSize, 2); + assert.match(appKeys, /\/Applications\/A\.app/); + assert.match(appKeys, /\/Applications\/C\.app/); + assert.doesNotMatch(appKeys, /\/Applications\/B\.app/); + }); +}); diff --git a/scripts/test-menubar-native-image-cache.mjs b/scripts/test-menubar-native-image-cache.mjs new file mode 100644 index 00000000..b6194a0f --- /dev/null +++ b/scripts/test-menubar-native-image-cache.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { + createCachedMenuBarNativeImage, + createMenuBarNativeImageCache, +} = await import(pathToFileURL(path.join(root, 'src/main/menubar-native-image-cache.ts')).href); + +function makeCounters() { + return { + createFromBuffer: 0, + createFromDataURL: 0, + createFromPath: 0, + readFileSync: 0, + resize: 0, + setTemplateImage: 0, + statSync: 0, + }; +} + +class MockNativeImage { + constructor(counters, source, width = 32, height = 32, empty = false) { + this.counters = counters; + this.source = source; + this.width = width; + this.height = height; + this.empty = empty; + this.template = undefined; + } + + isEmpty() { + return this.empty; + } + + getSize() { + return { width: this.width, height: this.height }; + } + + resize(options) { + this.counters.resize += 1; + const resized = new MockNativeImage( + this.counters, + `${this.source}:resized:${options.width}x${options.height}`, + options.width, + options.height, + this.empty, + ); + resized.resizeQuality = options.quality; + return resized; + } + + setTemplateImage(isTemplate) { + this.counters.setTemplateImage += 1; + this.template = isTemplate; + } +} + +function makeNativeImage(counters) { + return { + createFromBuffer(_buffer, options = {}) { + counters.createFromBuffer += 1; + const logicalSize = options.scaleFactor && options.scaleFactor > 1 ? 18 : 36; + return new MockNativeImage(counters, `buffer:${options.scaleFactor || 1}`, logicalSize, logicalSize); + }, + createFromDataURL(dataUrl) { + counters.createFromDataURL += 1; + const empty = dataUrl.includes('empty-native-path-svg'); + return new MockNativeImage(counters, `data:${dataUrl.slice(0, 40)}`, 32, 32, empty); + }, + createFromPath(pathValue) { + counters.createFromPath += 1; + const empty = /\.svg$/i.test(pathValue); + return new MockNativeImage(counters, `path:${pathValue}`, 32, 32, empty); + }, + }; +} + +function makeFs(counters, files) { + return { + statSync(pathValue) { + counters.statSync += 1; + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return { + size: file.size, + mtimeMs: file.mtimeMs, + isFile: () => true, + }; + }, + readFileSync(pathValue, encoding) { + assert.equal(encoding, 'utf8'); + counters.readFileSync += 1; + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return file.body; + }, + }; +} + +function pngDataUrl(name) { + return `data:image/png;base64,${Buffer.from(`png:${name}`).toString('base64')}`; +} + +function svgDataUrl(name) { + return `data:image/svg+xml;base64,${Buffer.from(`${name}`).toString('base64')}`; +} + +function createIconResolver({ counters, files, sharedCache }) { + const nativeImage = makeNativeImage(counters); + const fs = makeFs(counters, files); + + return (icon) => createCachedMenuBarNativeImage({ + cache: sharedCache || createMenuBarNativeImageCache(512), + nativeImage, + fs, + pathValue: icon.pathValue, + dataUrlValue: icon.dataUrlValue, + bitmapScale: icon.bitmapScale, + size: icon.size, + template: icon.template, + resizeQuality: icon.resizeQuality, + }); +} + +function makeMetricFiles() { + const files = new Map(); + for (let i = 0; i < 8; i += 1) { + files.set(`/icons/path-${i}.png`, { size: 100 + i, mtimeMs: 1000 + i, body: `png-${i}` }); + } + for (let i = 0; i < 4; i += 1) { + files.set(`/icons/submenu-${i}.svg`, { + size: 200 + i, + mtimeMs: 2000 + i, + body: `empty-native-path-svg-${i}`, + }); + } + return files; +} + +function makeMetricIcons() { + const icons = [ + { + dataUrlValue: pngDataUrl('tray'), + bitmapScale: 2, + size: 18, + template: true, + resizeQuality: 'best', + }, + ]; + + for (let i = 0; i < 16; i += 1) { + icons.push({ dataUrlValue: svgDataUrl(`item-${i}`), size: 16, template: true }); + } + for (let i = 0; i < 8; i += 1) { + icons.push({ dataUrlValue: pngDataUrl(`item-${i}`), bitmapScale: 2, size: 16, template: false }); + } + for (let i = 0; i < 8; i += 1) { + icons.push({ pathValue: `/icons/path-${i}.png`, size: 16, template: false }); + } + for (let i = 0; i < 4; i += 1) { + icons.push({ pathValue: `/icons/submenu-${i}.svg`, size: 16, template: false }); + } + return icons; +} + +function workCount(counters) { + return counters.createFromBuffer + + counters.createFromDataURL + + counters.createFromPath + + counters.readFileSync + + counters.resize; +} + +function runRepeatedPayloadMetric({ updates, useSharedCache }) { + const counters = makeCounters(); + const files = makeMetricFiles(); + const sharedCache = useSharedCache ? createMenuBarNativeImageCache(512) : null; + const resolveIcon = createIconResolver({ counters, files, sharedCache }); + const icons = makeMetricIcons(); + + for (let update = 0; update < updates; update += 1) { + for (const icon of icons) { + const image = resolveIcon(icon); + assert.ok(image, 'metric icon should resolve'); + } + } + + return counters; +} + +test('MenuBarExtra native image cache', async (t) => { + await t.test('reuses data URL decode and resize work for repeated menu item icons', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ dataUrlValue: svgDataUrl('shared'), size: 16, template: true }); + const second = resolveIcon({ dataUrlValue: svgDataUrl('shared'), size: 16, template: true }); + + assert.equal(first, second, 'same source, size, scale, and template state should reuse the image'); + assert.equal(counters.createFromDataURL, 1, 'data URL is decoded once'); + assert.equal(counters.resize, 1, 'image is resized once'); + assert.equal(counters.setTemplateImage, 1, 'template state is applied once to the cached image'); + }); + + await t.test('keeps template image states isolated in the cache key', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + const dataUrlValue = svgDataUrl('template-split'); + + const templated = resolveIcon({ dataUrlValue, size: 16, template: true }); + const original = resolveIcon({ dataUrlValue, size: 16, template: false }); + + assert.notEqual(templated, original, 'template and non-template requests must not share a NativeImage object'); + assert.equal(templated.template, true); + assert.equal(original.template, false); + assert.equal(counters.createFromDataURL, 2, 'different template states decode separately'); + }); + + await t.test('preserves retina data URL handling without repeat buffer decode', () => { + const counters = makeCounters(); + const resolveIcon = createIconResolver({ + counters, + files: new Map(), + sharedCache: createMenuBarNativeImageCache(), + }); + const dataUrlValue = pngDataUrl('retina-tray'); + + const first = resolveIcon({ dataUrlValue, bitmapScale: 2, size: 18, template: true, resizeQuality: 'best' }); + const second = resolveIcon({ dataUrlValue, bitmapScale: 2, size: 18, template: true, resizeQuality: 'best' }); + + assert.equal(first, second); + assert.equal(counters.createFromBuffer, 1, 'retina PNG data URL uses createFromBuffer once'); + assert.equal(counters.createFromDataURL, 0, 'successful buffer decode does not fall back to createFromDataURL'); + assert.equal(counters.resize, 0, 'logical-size retina reps are not resized'); + }); + + await t.test('reuses stable path icons and refreshes when file identity changes', () => { + const counters = makeCounters(); + const files = new Map([ + ['/icons/stable.png', { size: 64, mtimeMs: 10, body: 'png' }], + ]); + const resolveIcon = createIconResolver({ + counters, + files, + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + const second = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + files.set('/icons/stable.png', { size: 65, mtimeMs: 11, body: 'png-new' }); + const third = resolveIcon({ pathValue: '/icons/stable.png', size: 16, template: false }); + + assert.equal(first, second, 'stable file identity should reuse decoded image'); + assert.notEqual(first, third, 'changed file identity should refresh decoded image'); + assert.equal(counters.createFromPath, 2); + assert.equal(counters.resize, 2); + assert.equal(counters.statSync, 3, 'path identity is checked each call so changed assets refresh'); + }); + + await t.test('caches SVG fallback reads for stable path icons', () => { + const counters = makeCounters(); + const files = new Map([ + ['/icons/vector.svg', { size: 42, mtimeMs: 100, body: 'empty-native-path-svg' }], + ]); + const resolveIcon = createIconResolver({ + counters, + files, + sharedCache: createMenuBarNativeImageCache(), + }); + + const first = resolveIcon({ pathValue: '/icons/vector.svg', size: 16, template: false }); + const second = resolveIcon({ pathValue: '/icons/vector.svg', size: 16, template: false }); + + assert.equal(first, second); + assert.equal(counters.createFromPath, 1, 'native SVG path decode attempted once'); + assert.equal(counters.readFileSync, 1, 'SVG fallback body is read once'); + assert.equal(counters.createFromDataURL, 1, 'SVG fallback data URL is decoded once'); + }); + + await t.test('measures repeated accepted payload decode and resize savings', () => { + const updates = 20; + const before = runRepeatedPayloadMetric({ updates, useSharedCache: false }); + const after = runRepeatedPayloadMetric({ updates, useSharedCache: true }); + const beforeWork = workCount(before); + const afterWork = workCount(after); + + t.diagnostic( + `repeated payload (${updates} updates, 1 tray icon, 36 item/submenu icons): ` + + `before=${beforeWork} native decode/read/resize operations, after=${afterWork}`, + ); + t.diagnostic(`before=${JSON.stringify(before)} after=${JSON.stringify(after)}`); + + assert.equal(beforeWork, 1620); + assert.equal(afterWork, 81); + assert.ok(afterWork < beforeWork / 10, 'cached path should eliminate repeated native image work'); + }); +}); diff --git a/scripts/test-menubar-native-update-cache.mjs b/scripts/test-menubar-native-update-cache.mjs new file mode 100644 index 00000000..9230427a --- /dev/null +++ b/scripts/test-menubar-native-update-cache.mjs @@ -0,0 +1,307 @@ +#!/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 { + createMenuBarNativeUpdateState, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, + withMenuBarNativeIconFileIdentity, +} = await importTs(path.join(root, 'src/main/menubar-native-update-cache.ts')); + +function makeItems(overrides = {}) { + return [ + { + type: 'label', + title: 'Timer', + }, + { + type: 'submenu', + id: '__mbi_group', + title: 'Controls', + children: [ + { + type: 'item', + id: '__mbi_pause', + title: 'Pause', + subtitle: 'Current session', + disabled: false, + alternate: { + id: '__mbi_pause_alt', + title: 'Pause without alert', + }, + ...overrides, + }, + ], + }, + { + type: 'separator', + }, + { + type: 'item', + id: '__mbi_stop', + title: 'Stop', + disabled: true, + }, + ]; +} + +function makePayload(overrides = {}) { + return { + extId: 'demo/timer', + iconPath: undefined, + iconDataUrl: undefined, + iconEmoji: '*', + iconTemplate: undefined, + iconBitmapScale: undefined, + fallbackIconDataUrl: '', + title: 'Timer', + tooltip: 'Timer status', + items: makeItems(), + ...overrides, + }; +} + +function makeCounters() { + return { + createTray: 0, + resolveTrayIcon: 0, + setImage: 0, + setTitle: 0, + setToolTip: 0, + buildMenuBarTemplate: 0, + buildFromTemplate: 0, + setContextMenu: 0, + }; +} + +function makeFs(files = new Map()) { + return { + statSync(pathValue) { + const file = files.get(pathValue); + if (!file) throw new Error(`ENOENT: ${pathValue}`); + return { + size: file.size, + mtimeMs: file.mtimeMs, + isFile: () => true, + }; + }, + }; +} + +function runUncachedNativeWork(payloads) { + const counters = makeCounters(); + let trayExists = false; + + for (const payload of payloads) { + counters.resolveTrayIcon += 1; + if (!trayExists) { + counters.createTray += 1; + trayExists = true; + } else { + counters.setImage += 1; + } + + counters.setTitle += 1; + if (payload.tooltip) counters.setToolTip += 1; + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + } + + return counters; +} + +function runCachedNativeWork(payloads, { iconResolvesOk = true, fs = makeFs() } = {}) { + const counters = makeCounters(); + const state = createMenuBarNativeUpdateState(); + let trayExists = false; + + for (const payload of payloads) { + const iconPayload = withMenuBarNativeIconFileIdentity(fs, payload); + if (!trayExists) { + counters.resolveTrayIcon += 1; + counters.createTray += 1; + trayExists = true; + rememberMenuBarNativeIcon(state, iconPayload, iconResolvesOk); + } else if (isMenuBarNativeIconRefreshNeeded(state, iconPayload)) { + counters.resolveTrayIcon += 1; + counters.setImage += 1; + rememberMenuBarNativeIcon(state, iconPayload, iconResolvesOk); + } + + const nextTitle = getMenuBarNativeTitle(payload, state.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(state, nextTitle)) { + counters.setTitle += 1; + rememberMenuBarNativeTitle(state, nextTitle); + } + + const nextTooltip = getMenuBarNativeTooltip(payload); + if (isMenuBarNativeTooltipUpdateNeeded(state, nextTooltip)) { + counters.setToolTip += 1; + rememberMenuBarNativeTooltip(state, nextTooltip); + } + + const nextItemsKey = getMenuBarNativeItemsKey(payload.items); + if (isMenuBarNativeMenuUpdateNeeded(state, nextItemsKey)) { + counters.buildMenuBarTemplate += 1; + counters.buildFromTemplate += 1; + counters.setContextMenu += 1; + rememberMenuBarNativeMenu(state, nextItemsKey); + } + } + + return counters; +} + +test('MenuBarExtra native update cache', async (t) => { + await t.test('rebuilds stable native menus once across 60 title ticks', () => { + const payloads = Array.from({ length: 60 }, (_, index) => makePayload({ + title: `Timer ${String(index).padStart(2, '0')}`, + })); + const before = runUncachedNativeWork(payloads); + const after = runCachedNativeWork(payloads); + + t.diagnostic( + `60 title ticks with unchanged items: before=${before.buildFromTemplate} Menu.buildFromTemplate calls, ` + + `after=${after.buildFromTemplate}`, + ); + t.diagnostic( + `60 title ticks with unchanged items: before=${before.setContextMenu} setContextMenu calls, ` + + `after=${after.setContextMenu}`, + ); + + assert.equal(before.buildFromTemplate, 60); + assert.equal(before.setContextMenu, 60); + assert.equal(after.buildFromTemplate, 1); + assert.equal(after.setContextMenu, 1); + assert.equal(after.setTitle, 60, 'changing title text still updates the native title every tick'); + assert.equal(after.setToolTip, 1, 'stable tooltip is applied once'); + assert.equal(after.setImage, 0, 'stable non-file icon payload does not call setImage after tray creation'); + }); + + await t.test('rebuilds when serialized item payloads change', () => { + const payloads = [ + makePayload(), + makePayload({ title: 'Timer tick' }), + makePayload({ + title: 'Timer tick 2', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + makePayload({ + title: 'Timer tick 3', + items: makeItems({ + alternate: { + id: '__mbi_pause_alt', + title: 'Pause silently', + }, + }), + }), + ]; + const after = runCachedNativeWork(payloads); + + assert.equal(after.buildFromTemplate, 2, 'initial menu and changed serialized items rebuild'); + assert.equal(after.setContextMenu, 2); + }); + + await t.test('skips stable file-backed tray icon refreshes on accepted updates', () => { + const iconFiles = new Map([ + ['/tmp/supercmd-timer.png', { size: 128, mtimeMs: 1000 }], + ]); + const payloads = Array.from({ length: 3 }, (_, index) => makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + title: `Timer ${index}`, + })); + const after = runCachedNativeWork(payloads, { fs: makeFs(iconFiles) }); + + assert.equal(after.resolveTrayIcon, 1, 'unchanged file identity resolves only on tray creation'); + assert.equal(after.setImage, 0, 'stable file-backed icons do not call setImage after tray creation'); + assert.equal(after.buildFromTemplate, 1); + }); + + await t.test('refreshes file-backed tray icons when file identity or icon options change', () => { + const iconFiles = new Map([ + ['/tmp/supercmd-timer.png', { size: 128, mtimeMs: 1000 }], + ['/tmp/supercmd-timer-alt.png', { size: 128, mtimeMs: 1000 }], + ]); + const fs = makeFs(iconFiles); + const stable = makePayload({ iconEmoji: undefined, iconPath: '/tmp/supercmd-timer.png' }); + const payloads = [ + stable, + stable, + makePayload({ iconEmoji: undefined, iconPath: '/tmp/supercmd-timer-alt.png' }), + makePayload({ iconEmoji: undefined, iconPath: '/tmp/supercmd-timer-alt.png', iconTemplate: true }), + makePayload({ iconEmoji: undefined, iconPath: '/tmp/supercmd-timer-alt.png', iconTemplate: true, iconBitmapScale: 2 }), + ]; + + const after = runCachedNativeWork(payloads, { fs }); + + assert.equal(after.resolveTrayIcon, 4, 'path, template, and scale changes refresh the tray icon'); + assert.equal(after.setImage, 3); + }); + + await t.test('refreshes file-backed tray icons when mtime or size changes', () => { + const identities = [ + { size: 128, mtimeMs: 1000 }, + { size: 128, mtimeMs: 1000 }, + { size: 129, mtimeMs: 1001 }, + ]; + let statCalls = 0; + const fs = { + statSync() { + const identity = identities[Math.min(statCalls, identities.length - 1)]; + statCalls += 1; + return { + ...identity, + isFile: () => true, + }; + }, + }; + const payloads = Array.from({ length: 3 }, (_, index) => makePayload({ + iconEmoji: undefined, + iconPath: '/tmp/supercmd-timer.png', + title: `Timer ${index}`, + })); + const after = runCachedNativeWork(payloads, { fs }); + + assert.equal(after.resolveTrayIcon, 2, 'unchanged file identity skips once, changed mtime/size refreshes once'); + assert.equal(after.setImage, 1); + }); + + await t.test('preserves fallback title decisions', () => { + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '+' }), false), '+'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), false), '\u23f1'); + assert.equal(getMenuBarNativeTitle(makePayload({ title: '', iconEmoji: '' }), true), ''); + }); + + await t.test('keeps empty tooltip payloads non-clearing', () => { + const state = createMenuBarNativeUpdateState(); + const initialTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: 'Timer status' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, initialTooltip), true); + rememberMenuBarNativeTooltip(state, initialTooltip); + + const emptyTooltip = getMenuBarNativeTooltip(makePayload({ tooltip: '' })); + assert.equal(isMenuBarNativeTooltipUpdateNeeded(state, emptyTooltip), false); + assert.equal(state.tooltip, 'Timer status'); + }); +}); diff --git a/scripts/test-menubar-payload-cache.mjs b/scripts/test-menubar-payload-cache.mjs new file mode 100644 index 00000000..59b7a92c --- /dev/null +++ b/scripts/test-menubar-payload-cache.mjs @@ -0,0 +1,166 @@ +#!/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, + }; +} + +function simulateTitleOnlyTicks({ ticks, splitStaticPayload }) { + let itemSerializations = 0; + let iconSerializations = 0; + let sends = 0; + const cache = createMenuBarVisiblePayloadHashCache(); + + const serializeStaticPayload = () => { + itemSerializations += 12; + iconSerializations += 13; + return { + extId: 'demo/timer', + iconEmoji: '*', + fallbackIconDataUrl: '', + items: Array.from({ length: 12 }, (_, index) => ({ + type: 'item', + id: `__mbi_${index}`, + title: `Item ${index}`, + disabled: false, + iconEmoji: index % 2 === 0 ? '*' : undefined, + })), + }; + }; + + let staticPayload = splitStaticPayload ? serializeStaticPayload() : null; + for (let index = 0; index < ticks; index += 1) { + if (!splitStaticPayload) { + staticPayload = serializeStaticPayload(); + } + const payload = { + ...staticPayload, + title: `Timer ${String(index).padStart(2, '0')}`, + tooltip: 'Timer status', + }; + if (shouldSendMenuBarVisiblePayload(cache, payload)) { + sends += 1; + } + } + + return { itemSerializations, iconSerializations, sends }; +} + +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 staticEffectIndex = parentSource.indexOf('const syncMenuBarStaticPayload = async () =>'); + const actionIndex = parentSource.indexOf('setMenuBarActions(extId', staticEffectIndex); + const sendIndex = parentSource.indexOf('sendMenuBarVisiblePayload(staticPayload)', staticEffectIndex); + const cacheIndex = parentSource.indexOf('shouldSendMenuBarVisiblePayload('); + const updateIndex = parentSource.indexOf('updateMenuBar?.(payload)', cacheIndex); + + assert.ok(staticEffectIndex >= 0, 'parent has a static payload sync path'); + assert.ok(actionIndex >= 0, 'parent updates the action map'); + assert.ok(sendIndex >= 0, 'parent sends through the visible payload helper'); + assert.ok(cacheIndex >= 0, 'parent checks the visible payload cache'); + assert.ok(updateIndex >= 0, 'parent sends the cached payload object'); + assert.ok(actionIndex < sendIndex, 'actions update before unchanged payloads are skipped'); + assert.ok(cacheIndex < updateIndex, 'IPC send happens only after the cache check'); + }); + + await t.test('title-only ticks reuse serialized item and icon payloads', () => { + const ticks = 60; + const before = simulateTitleOnlyTicks({ ticks, splitStaticPayload: false }); + const after = simulateTitleOnlyTicks({ ticks, splitStaticPayload: true }); + + t.diagnostic( + `title-only ticks (${ticks} updates, 12 items, 13 icons): ` + + `before=${before.itemSerializations} item serializations/${before.iconSerializations} icon serializations, ` + + `after=${after.itemSerializations}/${after.iconSerializations}` + ); + + assert.equal(before.sends, ticks, 'changing titles still send visible title updates'); + assert.equal(after.sends, ticks, 'split payload keeps visible title updates intact'); + assert.equal(before.itemSerializations, 720); + assert.equal(before.iconSerializations, 780); + assert.equal(after.itemSerializations, 12); + assert.equal(after.iconSerializations, 13); + }); + + await t.test('parent keeps title and tooltip out of static serialization dependencies', () => { + const parentSource = fs.readFileSync( + path.join(root, 'src/renderer/src/raycast-api/menubar-runtime-parent.tsx'), + 'utf8', + ); + const staticEffectStart = parentSource.indexOf('const syncMenuBarStaticPayload = async () =>'); + const staticEffectDepsStart = parentSource.indexOf('}, [assetsPath', staticEffectStart); + const staticEffectDepsEnd = parentSource.indexOf(']);', staticEffectDepsStart); + const staticDeps = parentSource.slice(staticEffectDepsStart, staticEffectDepsEnd); + + assert.ok(staticEffectStart >= 0, 'parent has a static payload serialization effect'); + assert.ok(staticDeps.includes('registryVersion'), 'static payload still updates when menu registrations change'); + assert.equal(staticDeps.includes('title'), false, 'title changes use cached static serialization'); + assert.equal(staticDeps.includes('tooltip'), false, 'tooltip changes use cached static serialization'); + }); +}); 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-native-helper-process-lifecycle.mjs b/scripts/test-native-helper-process-lifecycle.mjs new file mode 100644 index 00000000..5cd2d73d --- /dev/null +++ b/scripts/test-native-helper-process-lifecycle.mjs @@ -0,0 +1,243 @@ +#!/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 { EventEmitter } from 'node:events'; + +const mainPath = path.resolve('src/main/main.ts'); +const mainSource = fs.readFileSync(mainPath, 'utf8'); + +const helpers = [ + { + label: 'Parakeet', + ensure: 'ensureParakeetServer', + kill: 'killParakeetServer', + processVar: 'parakeetServerProcess', + }, + { + label: 'Qwen3', + ensure: 'ensureQwen3Server', + kill: 'killQwen3Server', + processVar: 'qwen3ServerProcess', + }, + { + label: 'Whisper.cpp', + ensure: 'ensureWhisperCppServer', + kill: 'killWhisperCppServer', + processVar: 'whisperCppServerProcess', + }, + { + label: 'Audio capturer', + ensure: 'warmAudioCapturer', + kill: 'killAudioCapturer', + processVar: 'audioCapturerProcess', + }, +]; + +function extractFunction(source, functionName) { + const start = source.indexOf(`function ${functionName}`); + assert.notEqual(start, -1, `${functionName} should exist`); + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `${functionName} should have a body`); + + 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); + } + } + + throw new Error(`Could not find end of ${functionName}`); +} + +function expectContains(haystack, needle, message) { + assert.ok(haystack.includes(needle), `${message}\nExpected to find: ${needle}`); +} + +function countOccurrences(haystack, needle) { + return haystack.split(needle).length - 1; +} + +class FakeChild extends EventEmitter { + constructor(name) { + super(); + this.name = name; + this.killed = false; + this.stdout = new EventEmitter(); + this.stderr = new EventEmitter(); + this.stdin = { + writes: [], + write: (value) => { + this.stdin.writes.push(value); + }, + }; + } + + kill() { + this.killed = true; + } +} + +function createLifecycleHarness({ guarded, label }) { + let activeProcess = null; + let ready = false; + let starting = null; + let buffer = ''; + let pendingRequest = null; + const rejections = []; + + function rejectPending(message) { + if (!pendingRequest) return; + pendingRequest.reject(new Error(message)); + pendingRequest = null; + } + + function attach(child) { + child.on('exit', (code) => { + if (guarded && activeProcess !== child) return; + ready = false; + activeProcess = null; + starting = null; + buffer = ''; + rejectPending(`${label} exited with code ${code}`); + }); + + child.stdout.on('data', (chunk) => { + if (guarded && activeProcess !== child) return; + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const json = JSON.parse(trimmed); + if (json.ready) { + ready = true; + continue; + } + if (pendingRequest) { + const req = pendingRequest; + pendingRequest = null; + req.resolve(json); + } + } + }); + } + + return { + spawn(name) { + const child = new FakeChild(name); + activeProcess = child; + ready = false; + starting = Promise.resolve(); + attach(child); + return child; + }, + kill(childToKill = activeProcess) { + if (childToKill) { + childToKill.stdin.write('{"command":"exit"}\n'); + childToKill.kill(); + } + if (guarded && childToKill && activeProcess !== childToKill) return; + activeProcess = null; + ready = false; + starting = null; + buffer = ''; + rejectPending(`${label} killed`); + }, + setPending() { + pendingRequest = { + resolve: () => {}, + reject: (error) => { + rejections.push(error.message); + }, + }; + }, + get activeProcess() { + return activeProcess; + }, + get ready() { + return ready; + }, + get starting() { + return starting; + }, + get pendingRequest() { + return pendingRequest; + }, + get rejections() { + return rejections; + }, + }; +} + +test('native helper lifecycle source uses active-child guards', () => { + for (const helper of helpers) { + const killSource = extractFunction(mainSource, helper.kill); + const ensureSource = extractFunction(mainSource, helper.ensure); + + expectContains( + killSource, + `${helper.kill}(processToKill: any = ${helper.processVar})`, + `${helper.label} kill helper should accept the child being killed` + ); + expectContains( + killSource, + `if (processToKill && ${helper.processVar} !== processToKill) return;`, + `${helper.label} kill helper should not clear replacement state for a stale child` + ); + assert.ok( + countOccurrences(ensureSource, `if (${helper.processVar} !== child) return;`) >= 2, + `${helper.label} exit and stdout handlers should ignore stale child events` + ); + expectContains( + ensureSource, + `${helper.kill}(child);`, + `${helper.label} startup timeout should only kill the child it started` + ); + expectContains( + ensureSource, + `if (${helper.processVar} !== child) {`, + `${helper.label} startup wait should detect a superseded child by identity` + ); + } +}); + +test('legacy reproduction shows why stale exits are dangerous', () => { + const harness = createLifecycleHarness({ guarded: false, label: 'legacy helper' }); + const oldChild = harness.spawn('old'); + harness.kill(oldChild); + const replacement = harness.spawn('replacement'); + harness.setPending(); + + oldChild.emit('exit', 0); + + assert.equal(harness.activeProcess, null); + assert.equal(harness.pendingRequest, null); + assert.deepEqual(harness.rejections, ['legacy helper exited with code 0']); + assert.notEqual(replacement, null); +}); + +test('guarded lifecycle preserves replacement process and pending request', () => { + for (const helper of helpers) { + const harness = createLifecycleHarness({ guarded: true, label: helper.label }); + const oldChild = harness.spawn('old'); + harness.kill(oldChild); + const replacement = harness.spawn('replacement'); + harness.setPending(); + + oldChild.emit('exit', 0); + oldChild.stdout.emit('data', Buffer.from('{"ready":true}\n')); + + assert.equal(harness.activeProcess, replacement, `${helper.label} replacement should remain active`); + assert.equal(harness.ready, false, `${helper.label} stale stdout should not mark replacement ready`); + assert.notEqual(harness.starting, null, `${helper.label} replacement startup should remain tracked`); + assert.notEqual(harness.pendingRequest, null, `${helper.label} replacement pending request should remain pending`); + assert.deepEqual(harness.rejections, [], `${helper.label} stale exit should not reject replacement request`); + } +}); diff --git a/scripts/test-no-view-status-reporting.mjs b/scripts/test-no-view-status-reporting.mjs new file mode 100644 index 00000000..ae22ed39 --- /dev/null +++ b/scripts/test-no-view-status-reporting.mjs @@ -0,0 +1,67 @@ +#!/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 { + reportNoViewStatusIfChanged, +} = await importTs(path.join(root, 'src/renderer/src/raycast-api/no-view-status-reporting.ts')); + +test('No-view toast status reporting', async (t) => { + await t.test('coalesces duplicate show/refresh status payloads but keeps changes', () => { + const reports = []; + globalThis.window = { + __scNoViewStatusTracking: true, + __scNoViewStatusReported: false, + electron: { + reportNoViewStatus(variant, text) { + reports.push({ variant, text }); + }, + }, + }; + + assert.equal(reportNoViewStatusIfChanged('processing', 'Running'), true); + assert.equal(reportNoViewStatusIfChanged('processing', 'Running'), false); + assert.equal(reportNoViewStatusIfChanged('success', 'Running'), true); + assert.equal(reportNoViewStatusIfChanged('success', 'Running'), false); + assert.deepEqual(reports, [ + { variant: 'processing', text: 'Running' }, + { variant: 'success', text: 'Running' }, + ]); + + t.diagnostic(`duplicate no-view toast status reports: before=4 IPC calls, after=${reports.length}`); + }); + + await t.test('new no-view runs can report the same first payload again', () => { + const reports = []; + globalThis.window = { + __scNoViewStatusTracking: true, + __scNoViewStatusReported: false, + __scNoViewStatusLastPayloadKey: 'success\u0000Done', + electron: { + reportNoViewStatus(variant, text) { + reports.push({ variant, text }); + }, + }, + }; + + assert.equal(reportNoViewStatusIfChanged('success', 'Done'), true); + assert.deepEqual(reports, [{ variant: 'success', text: 'Done' }]); + }); + + await t.test('main process keeps an identical-payload burst guard on the IPC handler', () => { + const mainSource = fs.readFileSync(path.join(root, 'src/main/main.ts'), 'utf8'); + const helperIndex = mainSource.indexOf('function shouldAcceptNoViewStatusReport('); + const handlerIndex = mainSource.indexOf("ipcMain.handle('no-view-status'"); + const callIndex = mainSource.indexOf('shouldAcceptNoViewStatusReport(variant, normalizedText)', handlerIndex); + + assert.ok(helperIndex >= 0, 'main process has a no-view status burst guard'); + assert.ok(handlerIndex >= 0, 'main process registers no-view-status IPC'); + assert.ok(callIndex > handlerIndex, 'IPC handler consults the burst guard before showing the badge'); + }); +}); 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-ollama-pull-listener-cleanup.mjs b/scripts/test-ollama-pull-listener-cleanup.mjs new file mode 100644 index 00000000..bba9a4a6 --- /dev/null +++ b/scripts/test-ollama-pull-listener-cleanup.mjs @@ -0,0 +1,278 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const REMOUNT_CYCLES = 5; +const CURRENT_REQUEST_ID = 'ollama-pull-current'; +const OTHER_REQUEST_ID = 'ollama-pull-stale'; +const PREFERRED_MODEL = 'llama3.2'; + +const { + registerOllamaPullListeners, + toOllamaPullProgressState, +} = await importTs(path.resolve('src/renderer/src/settings/ollamaPullProgress.ts')); + +function createBridge({ returnCleanup }) { + const emitter = new EventEmitter(); + + function subscribe(channel, callback) { + const listener = (data) => callback(data); + emitter.on(channel, listener); + if (!returnCleanup) return undefined; + return () => emitter.removeListener(channel, listener); + } + + return { + onOllamaPullProgress: (callback) => subscribe('ollama-pull-progress', callback), + onOllamaPullDone: (callback) => subscribe('ollama-pull-done', callback), + onOllamaPullError: (callback) => subscribe('ollama-pull-error', callback), + emitProgress: (data) => emitter.emit('ollama-pull-progress', data), + emitDone: (data) => emitter.emit('ollama-pull-done', data), + emitError: (data) => emitter.emit('ollama-pull-error', data), + listenerCounts: () => ({ + progress: emitter.listenerCount('ollama-pull-progress'), + done: emitter.listenerCount('ollama-pull-done'), + error: emitter.listenerCount('ollama-pull-error'), + }), + }; +} + +function createStateRecorder() { + let activeRequestId = CURRENT_REQUEST_ID; + let preferredModel = PREFERRED_MODEL; + const metrics = { + progressWrites: 0, + progressResets: 0, + doneRefreshes: 0, + errorMessages: 0, + errorClearsScheduled: 0, + clearActivePulls: 0, + pullingModelClears: 0, + refreshedModels: [], + errors: [], + }; + + return { + get activeRequestId() { + return activeRequestId; + }, + get metrics() { + return metrics; + }, + resetActivePull() { + activeRequestId = CURRENT_REQUEST_ID; + preferredModel = PREFERRED_MODEL; + }, + optionsForFixed(bridge) { + return { + bridge, + getActiveRequestId: () => activeRequestId, + getPreferredModel: () => preferredModel, + clearActivePull: () => { + activeRequestId = null; + preferredModel = undefined; + metrics.clearActivePulls += 1; + }, + setPullingModel: (modelName) => { + if (modelName === null) metrics.pullingModelClears += 1; + }, + setPullProgress: (progress) => { + if (progress.status === '' && progress.percent === 0) { + metrics.progressResets += 1; + } else { + metrics.progressWrites += 1; + } + }, + setOllamaError: (error) => { + if (error) { + metrics.errorMessages += 1; + metrics.errors.push(error); + } + }, + scheduleErrorClear: () => { + metrics.errorClearsScheduled += 1; + }, + refreshOllamaStatus: (modelName) => { + metrics.doneRefreshes += 1; + metrics.refreshedModels.push(modelName); + }, + }; + }, + optionsForLegacy(bridge) { + return { + bridge, + getPreferredModel: () => preferredModel, + clearActivePull: () => { + activeRequestId = null; + preferredModel = undefined; + metrics.clearActivePulls += 1; + }, + setPullingModel: (modelName) => { + if (modelName === null) metrics.pullingModelClears += 1; + }, + setPullProgress: (progress) => { + if (progress.status === '' && progress.percent === 0) { + metrics.progressResets += 1; + } else { + metrics.progressWrites += 1; + } + }, + setOllamaError: (error) => { + if (error) { + metrics.errorMessages += 1; + metrics.errors.push(error); + } + }, + scheduleErrorClear: () => { + metrics.errorClearsScheduled += 1; + }, + refreshOllamaStatus: (modelName) => { + metrics.doneRefreshes += 1; + metrics.refreshedModels.push(modelName); + }, + }; + }, + }; +} + +function registerLegacyAITabPullListeners({ + bridge, + clearActivePull, + getPreferredModel, + refreshOllamaStatus, + scheduleErrorClear, + setOllamaError, + setPullingModel, + setPullProgress, +}) { + bridge.onOllamaPullProgress((data) => { + setPullProgress(toOllamaPullProgressState(data)); + }); + bridge.onOllamaPullDone(() => { + const preferredModel = getPreferredModel(); + clearActivePull(); + setPullingModel(null); + setPullProgress({ status: '', percent: 0 }); + refreshOllamaStatus(preferredModel); + }); + bridge.onOllamaPullError((data) => { + clearActivePull(); + setPullingModel(null); + setPullProgress({ status: '', percent: 0 }); + setOllamaError(data.error); + scheduleErrorClear(); + }); + return () => {}; +} + +function mountAndUnmountRepeatedly(register, bridge, state, cycles) { + for (let index = 0; index < cycles; index += 1) { + const cleanup = register(bridge, state); + cleanup(); + } + return register(bridge, state); +} + +function progressPayload(overrides = {}) { + return { + requestId: CURRENT_REQUEST_ID, + status: 'pulling manifest', + digest: 'sha256:test', + total: 100, + completed: 25, + ...overrides, + }; +} + +function runLifecycleScenario({ label, bridge, register }) { + const state = createStateRecorder(); + const cleanupActiveMount = mountAndUnmountRepeatedly(register, bridge, state, REMOUNT_CYCLES); + + bridge.emitProgress(progressPayload()); + state.resetActivePull(); + bridge.emitDone({ requestId: CURRENT_REQUEST_ID }); + state.resetActivePull(); + bridge.emitError({ requestId: CURRENT_REQUEST_ID, error: 'pull failed' }); + cleanupActiveMount(); + + const result = { + label, + remountCycles: REMOUNT_CYCLES, + metrics: state.metrics, + listenerCountsAfterCleanup: bridge.listenerCounts(), + }; + console.log(`[ollama-pull ${label}] ${JSON.stringify(result)}`); + return result; +} + +test('AITab-like remount churn does not multiply Ollama pull listeners after cleanup', () => { + const before = runLifecycleScenario({ + label: 'before-inline-listeners', + bridge: createBridge({ returnCleanup: false }), + register: (bridge, state) => registerLegacyAITabPullListeners(state.optionsForLegacy(bridge)), + }); + const after = runLifecycleScenario({ + label: 'after-cleanup-listeners', + bridge: createBridge({ returnCleanup: true }), + register: (bridge, state) => registerOllamaPullListeners(state.optionsForFixed(bridge)), + }); + + assert.deepEqual({ + progressWrites: before.metrics.progressWrites, + doneRefreshes: before.metrics.doneRefreshes, + errorMessages: before.metrics.errorMessages, + }, { + progressWrites: REMOUNT_CYCLES + 1, + doneRefreshes: REMOUNT_CYCLES + 1, + errorMessages: REMOUNT_CYCLES + 1, + }); + assert.deepEqual(before.listenerCountsAfterCleanup, { + progress: REMOUNT_CYCLES + 1, + done: REMOUNT_CYCLES + 1, + error: REMOUNT_CYCLES + 1, + }); + + assert.deepEqual({ + progressWrites: after.metrics.progressWrites, + doneRefreshes: after.metrics.doneRefreshes, + errorMessages: after.metrics.errorMessages, + }, { + progressWrites: 1, + doneRefreshes: 1, + errorMessages: 1, + }); + assert.deepEqual(after.listenerCountsAfterCleanup, { progress: 0, done: 0, error: 0 }); + assert.deepEqual(after.metrics.refreshedModels, [PREFERRED_MODEL]); +}); + +test('Ollama pull listeners ignore stale request ids and dedupe identical progress', () => { + const bridge = createBridge({ returnCleanup: true }); + const state = createStateRecorder(); + const cleanup = registerOllamaPullListeners(state.optionsForFixed(bridge)); + + bridge.emitProgress(progressPayload({ requestId: OTHER_REQUEST_ID, completed: 75 })); + bridge.emitDone({ requestId: OTHER_REQUEST_ID }); + bridge.emitError({ requestId: OTHER_REQUEST_ID, error: 'stale failure' }); + assert.deepEqual({ + progressWrites: state.metrics.progressWrites, + doneRefreshes: state.metrics.doneRefreshes, + errorMessages: state.metrics.errorMessages, + }, { + progressWrites: 0, + doneRefreshes: 0, + errorMessages: 0, + }); + + bridge.emitProgress(progressPayload({ completed: 25 })); + bridge.emitProgress(progressPayload({ completed: 25 })); + bridge.emitProgress(progressPayload({ completed: 25.1 })); + bridge.emitProgress(progressPayload({ status: 'pulling layers', completed: 25.1 })); + + assert.equal(state.metrics.progressWrites, 2); + cleanup(); + assert.deepEqual(bridge.listenerCounts(), { progress: 0, done: 0, error: 0 }); +}); diff --git a/scripts/test-raycast-ai-abort-listeners.mjs b/scripts/test-raycast-ai-abort-listeners.mjs new file mode 100644 index 00000000..7e4e3dd7 --- /dev/null +++ b/scripts/test-raycast-ai-abort-listeners.mjs @@ -0,0 +1,203 @@ +#!/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 RAYCAST_API_PATH = path.resolve('src/renderer/src/raycast-api/index.tsx'); + +test('AI.ask detaches caller abort listeners on every terminal path', async (t) => { + await t.test('stream success', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('success prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + electron.handlers.chunk({ requestId, chunk: 'hello ' }); + electron.handlers.chunk({ requestId, chunk: 'world' }); + electron.handlers.done({ requestId }); + + assert.equal(await stream, 'hello world'); + assertDetached('success', tracked); + }); + + await t.test('stream error', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('error prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + electron.handlers.error({ requestId, error: 'stream failed' }); + + await assert.rejects(stream, /stream failed/); + assertDetached('stream error', tracked); + }); + + await t.test('aiAsk rejection', async () => { + const { AI } = await loadRaycastAIShim({ rejectAiAsk: true }); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('ipc rejection prompt', { signal: tracked.signal }); + + await assert.rejects(stream, /aiAsk rejected/); + assertDetached('aiAsk rejection', tracked); + }); + + await t.test('explicit abort', async () => { + const { AI, electron } = await loadRaycastAIShim(); + const tracked = createTrackedAbortController(); + + const stream = AI.ask('abort prompt', { signal: tracked.signal }); + const requestId = electron.aiAskCalls[0].requestId; + + tracked.controller.abort(); + + await assert.rejects(stream, /Request aborted/); + assert.deepEqual(electron.cancellations, [requestId]); + assertDetached('explicit abort', tracked); + }); +}); + +function assertDetached(label, tracked) { + const stats = tracked.stats(); + console.log( + `AI.ask abort listeners after ${label}: active=${stats.active}, added=${stats.added}, removed=${stats.removed}` + ); + assert.equal(stats.active, 0, `${label} should not retain caller abort listeners`); + assert.equal(stats.added, 1, `${label} should attach one caller abort listener`); + assert.equal(stats.removed, 1, `${label} should detach the caller abort listener`); +} + +async function loadRaycastAIShim(options = {}) { + const electron = createFakeElectron(options); + const importNonce = `${Date.now()}-${Math.random()}`; + const testWindow = { + electron, + addEventListener() {}, + removeEventListener() {}, + }; + const testDocument = { + visibilityState: 'visible', + addEventListener() {}, + removeEventListener() {}, + }; + + globalThis.window = testWindow; + globalThis.document = testDocument; + + const source = fs.readFileSync(RAYCAST_API_PATH, 'utf8'); + const start = source.indexOf('type AICreativity ='); + const aiExport = source.indexOf('export const AI =', start); + const end = source.indexOf('// =====================================================================', aiExport); + + assert.notEqual(start, -1, 'expected to find AI section start'); + assert.notEqual(aiExport, -1, 'expected to find AI export'); + assert.notEqual(end, -1, 'expected to find AI section end'); + + const moduleSource = ` +async function refreshAIAvailabilityCache() {} +${source.slice(start, end)} +export const __testImportNonce = ${JSON.stringify(importNonce)}; +`; + + const { code } = await transform(moduleSource, { + loader: 'tsx', + format: 'esm', + target: 'es2020', + }); + const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); + const module = await import(dataUrl); + return { AI: module.AI, electron }; +} + +function createFakeElectron(options = {}) { + const handlers = { + chunk: undefined, + done: undefined, + error: undefined, + }; + const aiAskCalls = []; + const cancellations = []; + + return { + handlers, + aiAskCalls, + cancellations, + onAIStreamChunk(handler) { + handlers.chunk = handler; + return () => { + if (handlers.chunk === handler) handlers.chunk = undefined; + }; + }, + onAIStreamDone(handler) { + handlers.done = handler; + return () => { + if (handlers.done === handler) handlers.done = undefined; + }; + }, + onAIStreamError(handler) { + handlers.error = handler; + return () => { + if (handlers.error === handler) handlers.error = undefined; + }; + }, + aiAsk(requestId, prompt, askOptions) { + aiAskCalls.push({ requestId, prompt, askOptions }); + if (options.rejectAiAsk) { + return Promise.reject(new Error('aiAsk rejected')); + } + return Promise.resolve(); + }, + aiCancel(requestId) { + cancellations.push(requestId); + return Promise.resolve(); + }, + }; +} + +function createTrackedAbortController() { + const controller = new AbortController(); + const signal = controller.signal; + const originalAddEventListener = signal.addEventListener.bind(signal); + const originalRemoveEventListener = signal.removeEventListener.bind(signal); + const activeAbortListeners = new Set(); + let added = 0; + let removed = 0; + + Object.defineProperty(signal, 'addEventListener', { + configurable: true, + value(type, listener, options) { + if (type === 'abort') { + activeAbortListeners.add(listener); + added += 1; + } + return originalAddEventListener(type, listener, options); + }, + }); + + Object.defineProperty(signal, 'removeEventListener', { + configurable: true, + value(type, listener, options) { + if (type === 'abort' && activeAbortListeners.delete(listener)) { + removed += 1; + } + return originalRemoveEventListener(type, listener, options); + }, + }); + + return { + controller, + signal, + stats() { + return { + active: activeAbortListeners.size, + added, + removed, + }; + }, + }; +} 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-registry-dirty-checks.mjs b/scripts/test-registry-dirty-checks.mjs new file mode 100644 index 00000000..1da67cd2 --- /dev/null +++ b/scripts/test-registry-dirty-checks.mjs @@ -0,0 +1,413 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ITEM_COUNT = Number.parseInt(process.env.SUPERCMD_REGISTRY_DIRTY_ITEMS || '5000', 10); +const REACT_ELEMENT_TYPE = Symbol.for('react.element'); + +async function importHookModule(absPath) { + const result = await build({ + entryPoints: [absPath], + 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')); +} + +const listHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/list-runtime-hooks.ts')); +const gridHooks = await importHookModule(path.join(root, 'src/renderer/src/raycast-api/grid-runtime-hooks.ts')); + +function namedType(displayName) { + const component = function RuntimeElement() {}; + component.displayName = displayName; + return component; +} + +function makeElement(displayName, props = {}) { + return { + $$typeof: REACT_ELEMENT_TYPE, + type: namedType(displayName), + key: null, + props, + }; +} + +function makeListItem(index, overrides = {}) { + const props = { + id: `list-visible-${index}`, + title: { value: `Emoji ${index}`, tooltip: `Emoji title ${index}` }, + subtitle: { value: `Subtitle ${index}`, tooltip: `Subtitle tip ${index}` }, + icon: { + source: index % 2 === 0 ? '\u{1F600}' : { light: `icon-${index}.png`, dark: `icon-${index}-dark.png` }, + tintColor: index % 3 === 0 ? 'blue' : undefined, + }, + accessories: [ + { + text: { value: `Accessory ${index}`, color: 'red' }, + icon: { source: 'Icon.Star' }, + tooltip: `Accessory tip ${index}`, + }, + { + tag: { value: `Tag ${index % 17}`, color: 'green' }, + date: new Date(1_700_000_000_000 + index * 1000).toISOString(), + }, + ], + keywords: [`emoji-${index}`, `group-${index % 25}`, 'common'], + detail: makeElement('ListItemDetail', { + markdown: `# Emoji ${index}`, + metadata: makeElement('Metadata', { children: [`meta-${index}`] }), + isLoading: false, + }), + quickLook: { name: `Emoji ${index}`, path: `/tmp/emoji-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.CopyToClipboard', { title: 'Copy', content: `emoji-${index}` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `list-${index}`, + order: overrides.order ?? index, + sectionTitle: overrides.sectionTitle ?? (index < ITEM_COUNT / 2 ? 'Smileys' : 'Symbols'), + props, + }; +} + +function makeGridItem(index, overrides = {}) { + const defaultSection = index < ITEM_COUNT / 2 + ? { id: 'section-a', title: 'Section A' } + : { id: 'section-b', title: 'Section B' }; + const props = { + id: `grid-visible-${index}`, + title: `Grid ${index}`, + subtitle: `Grid subtitle ${index}`, + content: index % 3 === 0 + ? { source: `asset-${index}.png`, tintColor: 'blue', fallback: 'Icon.Image' } + : { value: `Icon.Circle.${index}`, color: index % 2 === 0 ? 'purple' : 'orange' }, + accessory: { + icon: { value: 'Icon.Star', color: 'yellow' }, + tooltip: `Pinned ${index}`, + }, + keywords: [`grid-${index}`, `bucket-${index % 20}`], + quickLook: { name: `Grid ${index}`, path: `/tmp/grid-${index}.png` }, + actions: makeElement('ActionPanel', { + children: [makeElement('Action.Open', { title: 'Open', target: `/tmp/grid-${index}.png` })], + }), + ...(overrides.props || {}), + }; + + return { + id: overrides.id || `grid-${index}`, + order: overrides.order ?? index, + section: overrides.section ?? defaultSection, + props, + }; +} + +function getReactTypeName(type) { + return String(type?.displayName || type?.name || type || ''); +} + +function isReactElement(value) { + return Boolean(value && value.$$typeof === REACT_ELEMENT_TYPE); +} + +function legacyBuildSnapshotSignature(value, seen = new WeakSet()) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (typeof value === 'function') return `fn:${value.name || 'anonymous'}`; + if (typeof value === 'symbol') return value.toString(); + + if (Array.isArray(value)) { + return `[${value.map((item) => legacyBuildSnapshotSignature(item, seen)).join(',')}]`; + } + + if (isReactElement(value)) { + return `element:${getReactTypeName(value.type)}:${legacyBuildSnapshotSignature(value.props, seen)}`; + } + + if (typeof value === 'object') { + if (seen.has(value)) return '[circular]'; + seen.add(value); + + const entries = Object.entries(value) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${legacyBuildSnapshotSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +function legacyListSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((item) => { + fullSnapshotItemWalks += 1; + const actionType = item.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + return legacyBuildSnapshotSignature({ + actionName, + accessories: item.props.accessories, + detail: item.props.detail, + icon: item.props.icon, + id: item.id, + keywords: item.props.keywords, + order: item.order, + sectionTitle: item.sectionTitle || '', + subtitle: item.props.subtitle, + title: item.props.title, + }); + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function legacyGridSnapshot(items) { + let fullSnapshotItemWalks = 0; + const snapshot = items.map((entry) => { + fullSnapshotItemWalks += 1; + const actionType = entry.props.actions?.type; + const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; + const section = entry.section; + return `${entry.id}:${entry.props.title || ''}:${section?.id || ''}:${section?.title || ''}:${actionName}`; + }).join('|'); + return { snapshot, fullSnapshotItemWalks }; +} + +function measureLegacySnapshot(baseItems, nextItems, snapshotFn) { + const beforeSnapshot = snapshotFn(baseItems).snapshot; + const start = performance.now(); + const after = snapshotFn(nextItems); + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: after.fullSnapshotItemWalks, + itemSignatureBuilds: 0, + versionUpdates: after.snapshot === beforeSnapshot ? 0 : 1, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function measurePerItemSignatures(baseItems, nextItems, signatureFn) { + const signatureById = new Map(baseItems.map((item) => [item.id, signatureFn(item)])); + let itemSignatureBuilds = 0; + let visibleChanges = 0; + const start = performance.now(); + for (const item of nextItems) { + itemSignatureBuilds += 1; + const nextSignature = signatureFn(item); + if (signatureById.get(item.id) !== nextSignature) { + visibleChanges += 1; + signatureById.set(item.id, nextSignature); + } + } + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: 0, + itemSignatureBuilds, + versionUpdates: visibleChanges > 0 ? 1 : 0, + visibleChanges, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function measureShallowGuardedSignatures(baseItems, nextItems, changedFn, signatureFn) { + const itemById = new Map(baseItems.map((item) => [item.id, item])); + let shallowSkips = 0; + let itemSignatureBuilds = 0; + let visibleChanges = 0; + const signatureById = new Map(baseItems.map((item) => [item.id, signatureFn(item)])); + const start = performance.now(); + for (const item of nextItems) { + const previous = itemById.get(item.id); + if (previous && !changedFn(previous, item)) { + shallowSkips += 1; + itemById.set(item.id, item); + continue; + } + itemSignatureBuilds += 1; + const nextSignature = signatureFn(item); + if (signatureById.get(item.id) !== nextSignature) { + visibleChanges += 1; + signatureById.set(item.id, nextSignature); + } + itemById.set(item.id, item); + } + const durationMs = performance.now() - start; + return { + fullSnapshotItemWalks: 0, + shallowSkips, + itemSignatureBuilds, + versionUpdates: visibleChanges > 0 ? 1 : 0, + visibleChanges, + durationMs: Number(durationMs.toFixed(3)), + }; +} + +function collectMetrics() { + const listBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeListItem(index)); + const listSingleEquivalentItems = listBaseItems.slice(); + listSingleEquivalentItems[ITEM_COUNT - 1] = makeListItem(ITEM_COUNT - 1); + const listSingleSameVisualRefs = listBaseItems.slice(); + listSingleSameVisualRefs[ITEM_COUNT - 1] = { + ...listBaseItems[ITEM_COUNT - 1], + props: { ...listBaseItems[ITEM_COUNT - 1].props }, + }; + const listOneTitleChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeListItem(index, { props: { title: { value: `Changed ${index}`, tooltip: `Emoji title ${index}` } } }) + : makeListItem(index) + )); + + const gridBaseItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridEquivalentItems = Array.from({ length: ITEM_COUNT }, (_, index) => makeGridItem(index)); + const gridSingleEquivalentItems = gridBaseItems.slice(); + gridSingleEquivalentItems[ITEM_COUNT - 1] = makeGridItem(ITEM_COUNT - 1); + const gridSingleSameVisualRefs = gridBaseItems.slice(); + gridSingleSameVisualRefs[ITEM_COUNT - 1] = { + ...gridBaseItems[ITEM_COUNT - 1], + props: { ...gridBaseItems[ITEM_COUNT - 1].props }, + section: { ...gridBaseItems[ITEM_COUNT - 1].section }, + }; + const gridOneContentChange = Array.from({ length: ITEM_COUNT }, (_, index) => ( + index === ITEM_COUNT - 1 + ? makeGridItem(index, { props: { content: { source: `changed-${index}.png`, tintColor: 'red' } } }) + : makeGridItem(index) + )); + + return { + itemCount: ITEM_COUNT, + list: { + singleSameVisualRefs: { + before: measurePerItemSignatures(listBaseItems, [listSingleSameVisualRefs[ITEM_COUNT - 1]], listHooks.buildListItemVisibleSignature), + after: measureShallowGuardedSignatures( + listBaseItems, + [listSingleSameVisualRefs[ITEM_COUNT - 1]], + listHooks.listItemVisibleInputsChanged, + listHooks.buildListItemVisibleSignature, + ), + }, + singleRecreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listSingleEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, [makeListItem(ITEM_COUNT - 1)], listHooks.buildListItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(listBaseItems, listEquivalentItems, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listEquivalentItems, listHooks.buildListItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(listBaseItems, listOneTitleChange, legacyListSnapshot), + after: measurePerItemSignatures(listBaseItems, listOneTitleChange, listHooks.buildListItemVisibleSignature), + }, + }, + grid: { + singleSameVisualRefs: { + before: measurePerItemSignatures(gridBaseItems, [gridSingleSameVisualRefs[ITEM_COUNT - 1]], gridHooks.buildGridItemVisibleSignature), + after: measureShallowGuardedSignatures( + gridBaseItems, + [gridSingleSameVisualRefs[ITEM_COUNT - 1]], + gridHooks.gridItemVisibleInputsChanged, + gridHooks.buildGridItemVisibleSignature, + ), + }, + singleRecreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridSingleEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, [makeGridItem(ITEM_COUNT - 1)], gridHooks.buildGridItemVisibleSignature), + }, + recreatedEquivalent: { + before: measureLegacySnapshot(gridBaseItems, gridEquivalentItems, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridEquivalentItems, gridHooks.buildGridItemVisibleSignature), + }, + oneVisibleChange: { + before: measureLegacySnapshot(gridBaseItems, gridOneContentChange, legacyGridSnapshot), + after: measurePerItemSignatures(gridBaseItems, gridOneContentChange, gridHooks.buildGridItemVisibleSignature), + }, + }, + }; +} + +function expectSignatureChange(signatureFn, base, next, label) { + assert.notEqual(signatureFn(base), signatureFn(next), `${label} should change the visible signature`); +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify(collectMetrics(), null, 2)); +} else { + test('List item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeListItem(7); + assert.equal(listHooks.buildListItemVisibleSignature(base), listHooks.buildListItemVisibleSignature(makeListItem(7))); + + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { icon: '\u{1F680}' } }), 'icon'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { accessories: [{ text: 'Changed' }] } }), 'accessories'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { detail: makeElement('ListItemDetail', { markdown: 'Changed' }) } }), 'detail'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { sectionTitle: 'Changed section' }), 'section'); + expectSignatureChange(listHooks.buildListItemVisibleSignature, base, makeListItem(7, { order: 999 }), 'order'); + }); + + test('Grid item visible signatures ignore recreated equivalent props and catch visible fields', () => { + const base = makeGridItem(9); + assert.equal(gridHooks.buildGridItemVisibleSignature(base), gridHooks.buildGridItemVisibleSignature(makeGridItem(9))); + + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { title: 'Changed' } }), 'title'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { subtitle: 'Changed' } }), 'subtitle'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { content: { source: 'changed.png' } } }), 'content'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { accessory: { tooltip: 'Changed' } } }), 'accessory'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { keywords: ['changed'] } }), 'keywords'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { quickLook: { path: '/tmp/changed.png' } } }), 'quickLook'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { props: { actions: makeElement('DifferentActionPanel') } }), 'action type'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { section: { id: 'changed-section', title: 'Changed section' } }), 'section'); + expectSignatureChange(gridHooks.buildGridItemVisibleSignature, base, makeGridItem(9, { order: 999 }), 'order'); + }); + + test('Registry dirty-check metrics avoid full-registry walks for recreated equivalent props', () => { + const metrics = collectMetrics(); + + assert.equal(metrics.list.singleSameVisualRefs.before.itemSignatureBuilds, 1); + assert.equal(metrics.list.singleSameVisualRefs.after.shallowSkips, 1); + assert.equal(metrics.list.singleSameVisualRefs.after.itemSignatureBuilds, 0); + assert.equal(metrics.list.singleSameVisualRefs.after.versionUpdates, 0); + assert.equal(metrics.list.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.list.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.list.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.list.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.list.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.list.oneVisibleChange.before.versionUpdates, 1); + assert.equal(metrics.list.oneVisibleChange.after.versionUpdates, 1); + + assert.equal(metrics.grid.singleSameVisualRefs.before.itemSignatureBuilds, 1); + assert.equal(metrics.grid.singleSameVisualRefs.after.shallowSkips, 1); + assert.equal(metrics.grid.singleSameVisualRefs.after.itemSignatureBuilds, 0); + assert.equal(metrics.grid.singleSameVisualRefs.after.versionUpdates, 0); + assert.equal(metrics.grid.singleRecreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.itemSignatureBuilds, 1); + assert.equal(metrics.grid.singleRecreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.fullSnapshotItemWalks, ITEM_COUNT); + assert.equal(metrics.grid.recreatedEquivalent.after.fullSnapshotItemWalks, 0); + assert.equal(metrics.grid.recreatedEquivalent.before.versionUpdates, 0); + assert.equal(metrics.grid.recreatedEquivalent.after.versionUpdates, 0); + assert.equal(metrics.grid.oneVisibleChange.after.versionUpdates, 1); + + console.log(JSON.stringify({ mode: 'registry-dirty-checks', metrics }, null, 2)); + }); +} diff --git a/scripts/test-renderer-lazy-route-imports.mjs b/scripts/test-renderer-lazy-route-imports.mjs new file mode 100644 index 00000000..03a622ba --- /dev/null +++ b/scripts/test-renderer-lazy-route-imports.mjs @@ -0,0 +1,76 @@ +#!/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'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const appSource = fs.readFileSync(path.join(root, 'src/renderer/src/App.tsx'), 'utf8'); +const hiddenRunnersSource = fs.readFileSync( + path.join(root, 'src/renderer/src/components/HiddenExtensionRunners.tsx'), + 'utf8' +); +const detachedRunnersSource = fs.readFileSync( + path.join(root, 'src/renderer/src/components/DetachedOverlayRunners.tsx'), + 'utf8' +); + +test('secondary launcher views stay lazy-loaded from App startup chunk', () => { + const lazyOnlySpecifiers = [ + './ExtensionView', + './ClipboardManager', + './SnippetManager', + './NotesSearchInline', + './CanvasSearchInline', + './QuickLinkManager', + './CameraExtension', + './ScheduleExtension', + './OnboardingExtension', + './FileSearchExtension', + './MenuItemSearchExtension', + './views/ScriptCommandSetupView', + './views/ScriptCommandOutputView', + './views/ExtensionPreferenceSetupView', + './views/AiChatView', + './views/CursorPromptView', + './views/AppUninstallView', + './views/BrowserResultsView', + './views/WebSearchView', + ]; + + for (const specifier of lazyOnlySpecifiers) { + assert.doesNotMatch( + appSource, + new RegExp(`^import\\s+.*?from ['"]${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"];`, 'm'), + `${specifier} should not be statically imported by App.tsx` + ); + assert.match( + appSource, + new RegExp(`lazy\\(\\(\\) => import\\(['"]${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]\\)\\)`), + `${specifier} should have an explicit React.lazy import` + ); + } + + assert.doesNotMatch(appSource, /from ['"]\.\/raycast-api['"];/, 'App.tsx should not eagerly import the Raycast shim'); + assert.match(appSource, /import\(['"]\.\/raycast-api\/oauth\/with-access-token['"]\)/); +}); + +test('extension and detached runners keep heavy runtime views behind lazy boundaries', () => { + assert.doesNotMatch(hiddenRunnersSource, /import\s+ExtensionView\s+from ['"]\.\.\/ExtensionView['"];/); + assert.match(hiddenRunnersSource, /lazy\(\(\) => import\(['"]\.\.\/ExtensionView['"]\)\)/); + assert.match(hiddenRunnersSource, //); + + for (const specifier of ['../SuperCmdWhisper', '../SuperCmdRead', '../WindowManagerPanel', '../views/CursorPromptView']) { + assert.doesNotMatch( + detachedRunnersSource, + new RegExp(`^import\\s+.*?from ['"]${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"];`, 'm') + ); + assert.match( + detachedRunnersSource, + new RegExp(`lazy\\(\\(\\) => import\\(['"]${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]\\)\\)`) + ); + } + assert.match(detachedRunnersSource, //); +}); diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..73c22ca9 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,473 @@ +#!/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 { + createCommandFilterIndex, + createRootCommandScoreIndex, + filterCommands, + filterCommandsWithIndex, + rankCommands, + rankCommandsWithIndex, +} = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 2000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 1); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 0); +const JSON_OUTPUT = process.env.SUPERCMD_ROOT_SEARCH_PERF_JSON === '1' || process.argv.includes('--json'); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +function readOptionalNumber(value) { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function readBudgets() { + return { + legacyMedianMs: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_LEGACY_MEDIAN_MS), + optimizedMedianMs: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS), + indexedMedianMs: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS), + compileMedianMs: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS), + indexedSpeedupMin: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN), + indexedVsOptimizedSpeedupMin: readOptionalNumber(process.env.SUPERCMD_ROOT_SEARCH_PERF_INDEXED_VS_OPTIMIZED_SPEEDUP_MIN), + }; +} + +function evaluateBudgets(summary, budgets) { + const checks = [ + ['legacyMedianMs', summary.metrics.legacy.median, budgets.legacyMedianMs, '<='], + ['optimizedMedianMs', summary.metrics.optimized.median, budgets.optimizedMedianMs, '<='], + ['indexedMedianMs', summary.metrics.indexed.median, budgets.indexedMedianMs, '<='], + ['compileMedianMs', summary.metrics.compile.median, budgets.compileMedianMs, '<='], + ['indexedSpeedupMin', summary.speedups.legacyVsIndexed, budgets.indexedSpeedupMin, '>='], + ['indexedVsOptimizedSpeedupMin', summary.speedups.optimizedVsIndexed, budgets.indexedVsOptimizedSpeedupMin, '>='], + ]; + const failures = checks + .filter(([, , budget]) => typeof budget === 'number') + .filter(([, actual, budget, operator]) => operator === '<=' ? actual > budget : actual < budget) + .map(([name, actual, budget, operator]) => `${name} expected ${operator} ${budget}, got ${actual.toFixed(3)}`); + + return { + passed: failures.length === 0, + failures, + applied: Object.fromEntries( + checks + .filter(([, , budget]) => typeof budget === 'number') + .map(([name, , budget]) => [name, budget]) + ), + }; +} + +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 indexedRootCommandMatches(index, query) { + return rankCommandsWithIndex(index, query) + .map((entry) => ({ + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + })); +} + +function signature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + .sort(); +} + +function assertSameRootCommandMatches(commands, aliases) { + const index = createRootCommandScoreIndex(commands, aliases); + const filterIndex = createCommandFilterIndex(commands, aliases); + for (const query of QUERIES) { + assert.deepEqual( + filterCommandsWithIndex(filterIndex, query).map((command) => command.id), + filterCommands(commands, query, aliases).map((command) => command.id), + `indexed contextual command filter changed order for query "${query}"` + ); + const legacySignature = signature(legacyRootCommandMatches(commands, query, aliases)); + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + legacySignature, + `root command matches changed for query "${query}"` + ); + assert.deepEqual( + signature(indexedRootCommandMatches(index, query)), + legacySignature, + `indexed 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 }; +} + +function measureSingle(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) fn(); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const start = performance.now(); + total = fn(); + samples.push(performance.now() - start); + } + + 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 commandFilterIndex = createCommandFilterIndex(commands, aliases); +const commandScoreIndex = createRootCommandScoreIndex(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 indexedFilter = measure('indexed contextual filter path', (query) => { + const filtered = filterCommandsWithIndex(commandFilterIndex, query); + return filtered.length; +}); + +const indexed = measure('indexed command scoring path', (query) => { + const matches = indexedRootCommandMatches(commandScoreIndex, query); + return matches.length; +}); + +const compile = measureSingle('command scoring index compile', () => { + return createRootCommandScoreIndex(commands, aliases).entries.length; +}); + +const optimizedSpeedup = legacy.median > 0 ? legacy.median / optimized.median : 0; +const indexedFilterSpeedup = legacy.median > 0 ? legacy.median / indexedFilter.median : 0; +const indexedSpeedup = legacy.median > 0 ? legacy.median / indexed.median : 0; +const indexedVsOptimizedSpeedup = optimized.median > 0 ? optimized.median / indexed.median : 0; +const summary = { + config: { + commands: COMMAND_COUNT, + queries: QUERIES.length, + iterations: ITERATIONS, + warmups: WARMUP_ITERATIONS, + }, + metrics: { + legacy, + optimized, + indexedFilter, + indexed, + compile, + }, + speedups: { + legacyVsOptimized: optimizedSpeedup, + legacyVsIndexedFilter: indexedFilterSpeedup, + legacyVsIndexed: indexedSpeedup, + optimizedVsIndexed: indexedVsOptimizedSpeedup, + }, +}; +summary.budgets = evaluateBudgets(summary, readBudgets()); + +if (JSON_OUTPUT) { + console.log(JSON.stringify(summary, null, 2)); +} else { + 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(`${indexedFilter.label}: median=${indexedFilter.median.toFixed(2)}ms min=${indexedFilter.min.toFixed(2)}ms max=${indexedFilter.max.toFixed(2)}ms total=${indexedFilter.total}`); + console.log(`${indexed.label}: median=${indexed.median.toFixed(2)}ms min=${indexed.min.toFixed(2)}ms max=${indexed.max.toFixed(2)}ms total=${indexed.total}`); + console.log(`${compile.label}: median=${compile.median.toFixed(2)}ms min=${compile.min.toFixed(2)}ms max=${compile.max.toFixed(2)}ms total=${compile.total}`); + console.log(`legacy-vs-optimized-speedup=${optimizedSpeedup.toFixed(2)}x`); + console.log(`legacy-vs-indexed-filter-speedup=${indexedFilterSpeedup.toFixed(2)}x`); + console.log(`legacy-vs-indexed-speedup=${indexedSpeedup.toFixed(2)}x`); + console.log(`optimized-vs-indexed-speedup=${indexedVsOptimizedSpeedup.toFixed(2)}x`); + if (Object.keys(summary.budgets.applied).length > 0) { + console.log(summary.budgets.passed + ? 'Thresholds: passed' + : `Thresholds: failed (${summary.budgets.failures.join('; ')})`); + } +} + +assert.equal(summary.budgets.passed, true, summary.budgets.failures.join('\n')); diff --git a/scripts/test-runtime-shortcut-listener-refs.mjs b/scripts/test-runtime-shortcut-listener-refs.mjs new file mode 100644 index 00000000..91f4328d --- /dev/null +++ b/scripts/test-runtime-shortcut-listener-refs.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +function readSource(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function listenerEffectBlock(source, addListenerLine) { + const start = source.indexOf(addListenerLine); + assert.notEqual(start, -1, `missing listener line: ${addListenerLine}`); + const end = source.indexOf(');', source.indexOf('}, [', start)); + assert.notEqual(end, -1, 'missing listener effect dependency terminator'); + return source.slice(start, end + 2); +} + +test('List global shortcut listener reads latest selected actions from a ref', () => { + const source = readSource('src/renderer/src/raycast-api/list-runtime.tsx'); + const listenerBlock = listenerEffectBlock(source, "window.addEventListener('keydown', handler, true);"); + assert.match(source, /const selectedActionsRef = useRef\(selectedActions\)/); + assert.match(source, /selectedActionsRef\.current = selectedActions/); + assert.match(source, /for \(const action of selectedActionsRef\.current\)/); + assert.doesNotMatch(listenerBlock, /selectedActions/); +}); + +test('Form global shortcut listener reads latest actions and primary action from refs', () => { + const source = readSource('src/renderer/src/raycast-api/form-runtime.tsx'); + const listenerBlock = listenerEffectBlock(source, "window.addEventListener('keydown', handler);"); + assert.match(source, /const formActionsRef = useRef\(formActions\)/); + assert.match(source, /const primaryActionRef = useRef\(primaryAction\)/); + assert.match(source, /for \(const action of formActionsRef\.current\)/); + assert.match(source, /const currentPrimaryAction = primaryActionRef\.current/); + assert.doesNotMatch(listenerBlock, /formActions|primaryAction/); +}); + +test('Detail global shortcut listener reads latest actions and primary action from refs', () => { + const source = readSource('src/renderer/src/raycast-api/detail-runtime.tsx'); + const listenerBlock = listenerEffectBlock(source, "window.addEventListener('keydown', handler);"); + assert.match(source, /const detailActionsRef = useRef\(detailActions\)/); + assert.match(source, /const primaryActionRef = useRef\(primaryAction\)/); + assert.match(source, /for \(const action of detailActionsRef\.current\)/); + assert.match(source, /const currentPrimaryAction = primaryActionRef\.current/); + assert.doesNotMatch(listenerBlock, /detailActions|primaryAction/); +}); diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..40b18637 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; + +async function withScriptCommandRunner(t, files, { + instrumentFs = false, + mockChildProcess = 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, + mockChildProcess, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +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); + }, + get activeCount() { + return active.size; + }, + get cleared() { + return cleared; + }, + }; +} + +function createFakeProc() { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.killCount = 0; + proc.kill = () => { + proc.killCount += 1; + }; + return proc; +} + +async function withFakeGlobalTimers(timer, callback) { + const previousSetTimeout = globalThis.setTimeout; + const previousClearTimeout = globalThis.clearTimeout; + globalThis.setTimeout = timer.setTimeout; + globalThis.clearTimeout = timer.clearTimeout; + try { + return await callback(); + } finally { + globalThis.setTimeout = previousSetTimeout; + globalThis.clearTimeout = previousClearTimeout; + } +} + +async function withFakeDateNow(now, callback) { + const previousDateNow = Date.now; + Date.now = () => now; + try { + return await callback(); + } finally { + Date.now = previousDateNow; + } +} + +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('reuses cached discovery after TTL when script file signatures are unchanged', async (t) => { + const { module: runner, metrics, resetMetrics, scriptsDir } = await withScriptCommandRunner(t, { + 'ttl-command.sh': `#!/bin/bash +${scriptHeader({ title: 'TTL Command' })} +echo "ok" +`, + }, { instrumentFs: true }); + + const firstCommands = await withFakeDateNow(1_000_000, () => runner.discoverScriptCommands()); + assert.equal(firstCommands.length, 1); + assert.equal(firstCommands[0].title, 'TTL Command'); + + resetMetrics(); + const secondCommands = await withFakeDateNow(1_020_000, () => runner.discoverScriptCommands()); + assert.equal(secondCommands, firstCommands); + assert.equal(metrics.readSyncCalls, 0); + assert.equal(metrics.readSyncBytes, 0); + + const scriptPath = path.join(scriptsDir, 'ttl-command.sh'); + fs.writeFileSync(scriptPath, `#!/bin/bash +${scriptHeader({ title: 'TTL Command Updated' })} +echo "updated" +`, { mode: 0o755 }); + + resetMetrics(); + const thirdCommands = await withFakeDateNow(1_040_000, () => runner.discoverScriptCommands()); + assert.notEqual(thirdCommands, firstCommands); + assert.equal(thirdCommands[0].title, 'TTL Command Updated'); + assert.ok(metrics.readSyncCalls > 0); + }); + + 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'); + }); + + for (const streamName of ['stdout', 'stderr']) { + await t.test(`clears timeout handle when ${streamName} exceeds output limit`, async (t) => { + const timer = createTimerHarness(); + await withFakeGlobalTimers(timer, async () => { + const { module: runner, childProcess } = await withScriptCommandRunner(t, { + 'overflow.sh': `#!/bin/bash +${scriptHeader({ title: 'Overflow Command' })} +echo "overflow" +`, + }, { mockChildProcess: true }); + const spawned = []; + childProcess.spawn = (command, args, options) => { + const proc = createFakeProc(); + spawned.push({ command, args, options, proc }); + return proc; + }; + + const [command] = runner.discoverScriptCommands(); + const promise = runner.executeScriptCommand(command.id, undefined, 60_000); + const activeBeforeOverflow = timer.activeCount; + assert.equal(activeBeforeOverflow, 1); + assert.equal(spawned.length, 1); + + const { proc } = spawned[0]; + proc[streamName].emit('data', Buffer.alloc(MAX_OUTPUT_BYTES + 1, 'x')); + + const result = await promise; + assert.equal(result.exitCode, 1); + assert.match( + result.stderr, + streamName === 'stdout' + ? /Output exceeded 2MB limit\./ + : /Error output exceeded 2MB limit\./, + ); + assert.equal(proc.killCount, 1); + assert.equal(timer.activeCount, 0); + assert.equal(timer.cleared.length, 1); + t.diagnostic( + `${streamName} overflow timeout handles: before=${activeBeforeOverflow} after=${timer.activeCount} cleared=${timer.cleared.length}`, + ); + }); + }); + } + + 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`, + ); + }); + + await t.test('caches file icon data across invalidated discoveries and refreshes changed icons', async (t) => { + const { module: runner, scriptsDir, metrics, resetMetrics } = await withScriptCommandRunner(t, {}, { + instrumentFs: true, + }); + const iconsDir = path.join(scriptsDir, '.icons'); + const iconPath = path.join(iconsDir, 'icon.png'); + const scriptPath = path.join(scriptsDir, 'with-file-icon.sh'); + const firstIcon = Buffer.alloc(4096, 1); + const secondIcon = Buffer.alloc(8192, 2); + + fs.mkdirSync(iconsDir, { recursive: true }); + fs.writeFileSync(iconPath, firstIcon); + fs.writeFileSync(scriptPath, `#!/bin/bash +${scriptHeader({ + title: 'File Icon Command', + extra: '# @raycast.icon .icons/icon.png\n', +})} +echo ok +`, { mode: 0o755 }); + + resetMetrics(); + const [initialCommand] = runner.discoverScriptCommands(); + assert.equal(initialCommand.title, 'File Icon Command'); + assert.ok(initialCommand.iconDataUrl?.startsWith('data:image/png;base64,')); + const initialReadBytes = metrics.readFileSyncBytes; + assert.ok( + initialReadBytes >= firstIcon.byteLength, + `expected first discovery to read the icon, got ${initialReadBytes} bytes`, + ); + + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [cachedCommand] = runner.discoverScriptCommands(); + assert.equal(cachedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.ok( + metrics.readFileSyncBytes < firstIcon.byteLength, + `expected cached discovery to skip unchanged icon bytes, got ${metrics.readFileSyncBytes} bytes`, + ); + + fs.writeFileSync(iconPath, secondIcon); + resetMetrics(); + runner.invalidateScriptCommandsCache(); + const [changedCommand] = runner.discoverScriptCommands(); + assert.notEqual(changedCommand.iconDataUrl, initialCommand.iconDataUrl); + assert.ok( + metrics.readFileSyncBytes >= secondIcon.byteLength, + `expected changed icon to be read again, got ${metrics.readFileSyncBytes} bytes`, + ); + }); +}); diff --git a/scripts/test-speak-render-profile.mjs b/scripts/test-speak-render-profile.mjs new file mode 100644 index 00000000..778aa327 --- /dev/null +++ b/scripts/test-speak-render-profile.mjs @@ -0,0 +1,62 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const { tokenizeSpeakText } = await importTs(path.resolve('src/renderer/src/SuperCmdRead.tsx')); + +function makeWords(count) { + const words = []; + for (let i = 0; i < count; i += 1) words.push(`word${i}`); + return words.join(' '); +} + +function legacyRetokenizeTicks(text, ticks) { + let renderedTokens = 0; + const start = performance.now(); + for (let tick = 0; tick < ticks; tick += 1) { + const tokens = text.split(/(\s+)/g); + let currentWord = 0; + for (const token of tokens) { + if (token.trim()) currentWord += 1; + renderedTokens += 1; + } + } + return { ms: performance.now() - start, renderedTokens }; +} + +function cachedTokenTicks(text, ticks) { + const start = performance.now(); + const tokens = tokenizeSpeakText(text); + let highlightedUpdates = 0; + for (let tick = 0; tick < ticks; tick += 1) { + highlightedUpdates += 1; + } + return { ms: performance.now() - start, renderedTokens: tokens.length, highlightedUpdates }; +} + +test('speak tokenization is stable across word-index ticks', (t) => { + for (const wordCount of [2000, 5000]) { + const text = makeWords(wordCount); + const ticks = Math.min(wordCount, 300); + const tokens = tokenizeSpeakText(text); + + assert.equal(tokens.filter((token) => token.kind === 'word').length, wordCount); + assert.equal(tokens[0].kind, 'word'); + assert.equal(tokens[0].wordIndex, 0); + assert.equal(tokens[tokens.length - 1].kind, 'word'); + assert.equal(tokens[tokens.length - 1].wordIndex, wordCount - 1); + + const before = legacyRetokenizeTicks(text, ticks); + const after = cachedTokenTicks(text, ticks); + + t.diagnostic( + `[speak-render-profile] words=${wordCount} ticks=${ticks} before=${before.ms.toFixed(2)}ms ` + + `after=${after.ms.toFixed(2)}ms renderedTokensBefore=${before.renderedTokens} renderedTokensAfter=${after.renderedTokens}` + ); + assert.ok(after.renderedTokens < before.renderedTokens); + } +}); 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-fetch-stale-runs.mjs b/scripts/test-use-fetch-stale-runs.mjs new file mode 100644 index 00000000..a17b490c --- /dev/null +++ b/scripts/test-use-fetch-stale-runs.mjs @@ -0,0 +1,334 @@ +#!/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 windowShim = { + electron: { + httpRequest: () => Promise.reject(new Error('httpRequest stub not configured')), + cancelHttpRequest: () => {}, + }, +}; + +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, + Blob, + console, + Date, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + queueMicrotask, + RegExp, + require: localRequire, + setTimeout, + clearTimeout, + String, + Symbol, + URL, + URLSearchParams, + window: windowShim, + 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 httpResponse(body, status = 200) { + return { + status, + statusText: status >= 200 && status < 300 ? 'OK' : 'Error', + headers: { 'content-type': 'application/json' }, + url: 'https://api.test/items', + bodyText: JSON.stringify(body), + }; +} + +const { useFetch } = loadTsModule('src/renderer/src/raycast-api/hooks/use-fetch.ts'); + +test('useFetch keeps the latest revalidate result when an older request resolves later', async () => { + const pending = []; + const requestedUrls = []; + const canceledRequestIds = []; + const onDataCalls = []; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + windowShim.electron.cancelHttpRequest = (requestId) => { + canceledRequestIds.push(requestId); + }; + + const host = new HookHost(() => useFetch('https://api.test/items', { + onData: (data) => onDataCalls.push(data.value), + })); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.deepEqual(requestedUrls, ['https://api.test/items', 'https://api.test/items']); + assert.equal(canceledRequestIds.length, 1); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.isLoading, false); + assert.deepEqual(onDataCalls, ['latest']); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(host.output.error, undefined); + assert.deepEqual(onDataCalls, ['latest']); + + host.unmount(); +}); + +test('useFetch refetches when a function URL resolves to a new request input', async () => { + const pending = []; + const requestedUrls = []; + const canceledRequestIds = []; + let query = 'one'; + + windowShim.electron.httpRequest = (request) => { + requestedUrls.push(request.url); + const run = deferred(); + pending.push(run); + return run.promise; + }; + windowShim.electron.cancelHttpRequest = (requestId) => { + canceledRequestIds.push(requestId); + }; + + const host = new HookHost(() => useFetch(() => `https://api.test/search?q=${query}`)); + + host.render(); + await flushAsync(); + assert.equal(pending.length, 1); + assert.deepEqual(requestedUrls, ['https://api.test/search?q=one']); + + query = 'two'; + host.render(); + await flushAsync(); + assert.equal(pending.length, 2); + assert.equal(canceledRequestIds.length, 1); + assert.deepEqual(requestedUrls, [ + 'https://api.test/search?q=one', + 'https://api.test/search?q=two', + ]); + + pending[1].resolve(httpResponse({ value: 'latest' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + pending[0].resolve(httpResponse({ value: 'stale' })); + await flushAsync(); + assert.deepEqual(host.output.data, { value: 'latest' }); + assert.equal(pending.length, 2); + + host.unmount(); +}); diff --git a/scripts/test-use-form-error-state-noop.mjs b/scripts/test-use-form-error-state-noop.mjs new file mode 100644 index 00000000..d19c58bb --- /dev/null +++ b/scripts/test-use-form-error-state-noop.mjs @@ -0,0 +1,128 @@ +#!/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'; + +const USE_FORM_PATH = 'src/renderer/src/raycast-api/hooks/use-form.ts'; +const FORM_RUNTIME_STATE_PATH = 'src/renderer/src/raycast-api/form-runtime-state.ts'; +const FIELD_CHANGE_COUNTS = [10, 100, 1000]; + +function importStateHelpers() { + const source = fs.readFileSync(FORM_RUNTIME_STATE_PATH, 'utf8'); + const executableSource = source + .replace(/export type FormErrorMap = Record;\n\n/, '') + .replace(/export function /g, 'function ') + .replace(/: FormErrorMap/g, '') + .replace(/: string/g, ''); + + const context = {}; + vm.runInNewContext(`${executableSource}\nthis.clearFormFieldError = clearFormFieldError;\nthis.setFormFieldError = setFormFieldError;`, context); + return { + clearFormFieldError: context.clearFormFieldError, + setFormFieldError: context.setFormFieldError, + }; +} + +function analyzeUseFormSource() { + const source = fs.readFileSync(USE_FORM_PATH, 'utf8'); + return { + importsStateHelpers: source.includes("from '../form-runtime-state'"), + usesClearHelper: source.includes('clearFormFieldError(prev, key as string)'), + usesSetValidationErrorHelper: source.includes('setFormFieldError(prev, key as string, error)'), + usesBlurSetHelper: source.includes('setFormFieldError(prev, key as string, err)'), + removedCloneDeleteClear: !source.includes('const next = { ...prev };\n delete next[key];\n return next;'), + }; +} + +function previousClearFormFieldError(previous, id) { + const next = { ...previous }; + delete next[id]; + return next; +} + +function measureNoErrorFieldChanges(clearFormFieldError, fieldChanges) { + let errors = {}; + let currentSnapshot = errors; + let errorIdentityChanges = 0; + + for (let index = 0; index < fieldChanges; index += 1) { + const next = clearFormFieldError(errors, `field-${index}`); + if (next !== currentSnapshot) { + errorIdentityChanges += 1; + } + currentSnapshot = next; + errors = next; + } + + return { fieldChanges, errorIdentityChanges }; +} + +function getMetrics() { + const { clearFormFieldError, setFormFieldError } = importStateHelpers(); + return { + noExistingErrors: FIELD_CHANGE_COUNTS.map((count) => { + const before = measureNoErrorFieldChanges(previousClearFormFieldError, count); + const after = measureNoErrorFieldChanges(clearFormFieldError, count); + return { + fieldChanges: count, + beforeIdentityChanges: before.errorIdentityChanges, + afterIdentityChanges: after.errorIdentityChanges, + }; + }), + clearExistingErrorPreservesOthers: (() => { + const previous = { first: 'Required', second: 'Invalid' }; + const next = clearFormFieldError(previous, 'first'); + return { + changedIdentity: next !== previous, + clearedFieldMissing: !Object.prototype.hasOwnProperty.call(next, 'first'), + preservedOtherField: next.second === 'Invalid', + }; + })(), + setSameError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Required'); + return { changedIdentity: next !== previous }; + })(), + setNewError: (() => { + const previous = { first: 'Required' }; + const next = setFormFieldError(previous, 'first', 'Invalid'); + return { changedIdentity: next !== previous, value: next.first }; + })(), + }; +} + +if (process.argv.includes('--report')) { + console.log(JSON.stringify({ source: analyzeUseFormSource(), metrics: getMetrics() }, null, 2)); +} else { + test('useForm setValue skips error state updates when the field has no error', () => { + const source = analyzeUseFormSource(); + assert.equal(source.importsStateHelpers, true, 'useForm should import the guarded error state helpers'); + assert.equal(source.usesClearHelper, true, 'useForm setValue should use the guarded error clear helper'); + assert.equal(source.removedCloneDeleteClear, true, 'useForm setValue should not clone errors for no-op clears'); + + for (const metric of getMetrics().noExistingErrors) { + assert.equal(metric.beforeIdentityChanges, metric.fieldChanges, `${metric.fieldChanges} previous no-error changes cloned errors every time`); + assert.equal(metric.afterIdentityChanges, 0, `${metric.fieldChanges} no-error changes should keep the same errors object`); + } + }); + + test('useForm setValue still clears an existing field error only', () => { + const metric = getMetrics().clearExistingErrorPreservesOthers; + assert.equal(metric.changedIdentity, true, 'clearing an existing error should publish a new errors object'); + assert.equal(metric.clearedFieldMissing, true, 'the changed field error should be removed'); + assert.equal(metric.preservedOtherField, true, 'unrelated field errors should be preserved'); + }); + + test('useForm validation error writes skip same-value updates but publish changed errors', () => { + const source = analyzeUseFormSource(); + assert.equal(source.usesSetValidationErrorHelper, true, 'useForm setValidationError should use the guarded error set helper'); + assert.equal(source.usesBlurSetHelper, true, 'useForm onBlur validation should use the guarded error set helper'); + + const metrics = getMetrics(); + assert.equal(metrics.setSameError.changedIdentity, false, 'setting the same error should keep the previous errors object'); + assert.equal(metrics.setNewError.changedIdentity, true, 'setting a different error should publish a new errors object'); + assert.equal(metrics.setNewError.value, 'Invalid'); + }); +} 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-use-stream-json-lifecycle.mjs b/scripts/test-use-stream-json-lifecycle.mjs new file mode 100644 index 00000000..a2fa439a --- /dev/null +++ b/scripts/test-use-stream-json-lifecycle.mjs @@ -0,0 +1,414 @@ +#!/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 hookPath = path.resolve('src/renderer/src/raycast-api/hooks/use-stream-json.ts'); + +let activeHost = null; +let activeFetch = 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 { useStreamJSON } = loadUseStreamJson(); + +function loadUseStreamJson() { + 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 module = { exports: {} }; + const sandbox = { + AbortController, + AbortSignal, + Array, + Blob, + console, + Error, + FormData, + Headers, + JSON, + Map, + Math, + module, + Object, + Promise, + RegExp, + Request, + Response, + String, + URL, + URLSearchParams, + exports: module.exports, + fetch: (...args) => { + if (!activeFetch) throw new Error('test fetch is not installed'); + return activeFetch(...args); + }, + queueMicrotask, + require: (request) => { + if (request === 'react') return fakeReact; + return require(request); + }, + setTimeout, + clearTimeout, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: hookPath }); + 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; + this.stateUpdatesAfterUnmount = 0; + } + + 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; + if (this.unmounted) { + this.stateUpdatesAfterUnmount += 1; + 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 createFetchMock() { + const requests = []; + + return { + requests, + fetch(url, init = {}) { + const pending = deferred(); + const request = { + url, + init, + promise: pending.promise, + resolveJson(value, responseOptions) { + pending.resolve(jsonResponse(value, responseOptions)); + }, + reject(error) { + pending.reject(error); + }, + }; + requests.push(request); + return pending.promise; + }, + }; +} + +function jsonResponse(value, { ok = true, status = 200 } = {}) { + return { + ok, + status, + async json() { + return value; + }, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +async function flushAsync() { + for (let index = 0; index < 8; index += 1) { + await Promise.resolve(); + } +} + +test('useStreamJSON keeps the latest revalidation result and callbacks', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + + fetchMock.requests[0].resolveJson([{ id: 'stale' }]); + await flushAsync(); + + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + assert.deepEqual(onData, ['latest']); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'previous fetch signal should be aborted'); + + host.unmount(); +}); + +test('useStreamJSON suppresses stale errors from older runs', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + + fetchMock.requests[1].resolveJson([{ id: 'latest' }]); + await flushAsync(); + fetchMock.requests[0].reject(new Error('stale failure')); + await flushAsync(); + + assert.equal(host.output.error, undefined); + assert.deepEqual(onError, []); + assert.deepEqual(host.output.data, [{ id: 'latest' }]); + + host.unmount(); +}); + +test('useStreamJSON aborts and ignores active work after unmount', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const onData = []; + const onError = []; + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + onData: (item) => onData.push(item.id), + onError: (error) => onError.push(error.message), + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + + host.unmount(); + assert.equal(fetchMock.requests[0].init.signal?.aborted, true, 'active fetch signal should be aborted on unmount'); + + fetchMock.requests[0].resolveJson([{ id: 'late-after-unmount' }]); + await flushAsync(); + + assert.equal(host.stateUpdatesAfterUnmount, 0); + assert.deepEqual(onData, []); + assert.deepEqual(onError, []); +}); + +test('useStreamJSON composes caller signal with lifecycle aborts', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const callerController = new AbortController(); + const host = new HookHost(() => useStreamJSON('https://example.test/items', { + signal: callerController.signal, + })); + + host.render(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 1); + const firstSignal = fetchMock.requests[0].init.signal; + assert.ok(firstSignal instanceof AbortSignal, 'fetch should receive an abort signal'); + + host.output.revalidate(); + await flushAsync(); + assert.equal(fetchMock.requests.length, 2); + assert.equal(firstSignal.aborted, true, 'lifecycle abort should cancel the previous composed signal'); + assert.equal(callerController.signal.aborted, false, 'lifecycle abort should not abort the caller-owned signal'); + + const secondSignal = fetchMock.requests[1].init.signal; + assert.ok(secondSignal instanceof AbortSignal, 'revalidated fetch should receive an abort signal'); + callerController.abort(); + assert.equal(secondSignal.aborted, true, 'caller abort should cancel the composed signal'); + + host.unmount(); +}); + +test('useStreamJSON keeps large visible slices stable across unrelated renders', async () => { + const fetchMock = createFetchMock(); + activeFetch = fetchMock.fetch; + const itemCount = 10000; + const items = Array.from({ length: itemCount }, (_, id) => ({ id, title: `Item ${id}` })); + const host = new HookHost(() => useStreamJSON('https://example.test/large-items', { + pageSize: itemCount, + })); + + host.render(); + await flushAsync(); + fetchMock.requests[0].resolveJson(items); + await flushAsync(); + + const firstData = host.output.data; + assert.equal(firstData.length, itemCount); + + const renderIterations = 100; + const stableStart = performance.now(); + for (let index = 0; index < renderIterations; index += 1) { + host.render(); + assert.strictEqual(host.output.data, firstData); + } + const stableRenderMs = performance.now() - stableStart; + + const legacyStart = performance.now(); + for (let index = 0; index < renderIterations; index += 1) { + items.slice(0, itemCount); + } + const legacySliceMs = performance.now() - legacyStart; + + console.log(JSON.stringify({ + mode: 'use-stream-json-large-payload', + itemCount, + renderIterations, + before: { + sliceAllocations: renderIterations, + durationMs: Number(legacySliceMs.toFixed(3)), + }, + after: { + sliceAllocations: 0, + durationMs: Number(stableRenderMs.toFixed(3)), + }, + }, null, 2)); + + host.unmount(); +}); diff --git a/scripts/test-virtual-fs-directory-index.mjs b/scripts/test-virtual-fs-directory-index.mjs new file mode 100644 index 00000000..a22b0da0 --- /dev/null +++ b/scripts/test-virtual-fs-directory-index.mjs @@ -0,0 +1,135 @@ +#!/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 { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FS_PREFIX = 'sc-fs:'; + +function createLocalStorage() { + const map = new Map(); + return { + get length() { + return map.size; + }, + key(index) { + return Array.from(map.keys())[index] ?? null; + }, + getItem(key) { + const normalized = String(key); + return map.has(normalized) ? map.get(normalized) : null; + }, + setItem(key, value) { + map.set(String(key), String(value)); + }, + removeItem(key) { + map.delete(String(key)); + }, + clear() { + map.clear(); + }, + }; +} + +globalThis.localStorage = createLocalStorage(); +globalThis.sessionStorage = createLocalStorage(); +globalThis.document = { + addEventListener: () => {}, + removeEventListener: () => {}, + createElement: () => ({ style: {}, setAttribute: () => {}, appendChild: () => {}, remove: () => {} }), + body: { appendChild: () => {}, removeChild: () => {} }, + documentElement: { + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + classList: { + contains: () => false, + add: () => {}, + remove: () => {}, + toggle: () => {}, + }, + style: {}, + }, +}; +globalThis.MutationObserver = class MutationObserver { + observe() {} + disconnect() {} + takeRecords() { + return []; + } +}; +globalThis.window = { + document: globalThis.document, + navigator: globalThis.navigator, + localStorage: globalThis.localStorage, + sessionStorage: globalThis.sessionStorage, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload: () => {} }, +}; + +const { + getVirtualFsDirectoryEntriesForTests, + getVirtualFsDirectoryIndexStatsForTests, +} = await importTs(path.join(root, 'src/renderer/src/ExtensionView.tsx'), { + stubModules: ['react-dom/server'], + stubs: { + 'react-dom/server': 'export const renderToStaticMarkup = () => ""; export default { renderToStaticMarkup };', + }, +}); + +test('virtual fs directory reads use a maintained localStorage index', () => { + const unrelatedKeys = 20000; + const targetEntries = 250; + + globalThis.localStorage.clear(); + for (let index = 0; index < unrelatedKeys; index += 1) { + globalThis.localStorage.setItem(`unrelated:${index}`, 'noise'); + } + for (let index = 0; index < targetEntries; index += 1) { + globalThis.localStorage.setItem(`${FS_PREFIX}/extensions/cache/items/item-${index}.json`, '{}'); + } + + const firstStart = performance.now(); + const firstEntries = getVirtualFsDirectoryEntriesForTests('/extensions/cache/items'); + const firstDurationMs = performance.now() - firstStart; + const firstStats = getVirtualFsDirectoryIndexStatsForTests(); + + const repeatedReads = 50; + const repeatedStart = performance.now(); + for (let index = 0; index < repeatedReads; index += 1) { + assert.equal(getVirtualFsDirectoryEntriesForTests('/extensions/cache/items').length, targetEntries); + } + const repeatedDurationMs = performance.now() - repeatedStart; + const repeatedStats = getVirtualFsDirectoryIndexStatsForTests(); + + assert.equal(firstEntries.length, targetEntries); + assert.equal(firstStats.buildScans, 1); + assert.equal(repeatedStats.buildScans, 1); + + console.log(JSON.stringify({ + mode: 'virtual-fs-directory-index', + unrelatedKeys, + targetEntries, + repeatedReads, + before: { + localStorageKeysScannedPerRead: unrelatedKeys + targetEntries, + }, + after: { + indexBuildScans: repeatedStats.buildScans, + repeatedReadScans: repeatedStats.buildScans - firstStats.buildScans, + indexedDirectories: repeatedStats.directories, + indexedEntries: repeatedStats.entries, + }, + durationMs: { + initialIndexedRead: Number(firstDurationMs.toFixed(3)), + repeatedIndexedReads: Number(repeatedDurationMs.toFixed(3)), + }, + }, null, 2)); +}); diff --git a/scripts/test-whisper-file-buffer-read.mjs b/scripts/test-whisper-file-buffer-read.mjs new file mode 100644 index 00000000..7d51d88e --- /dev/null +++ b/scripts/test-whisper-file-buffer-read.mjs @@ -0,0 +1,304 @@ +#!/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 { + resolveWhisperFileTranscriptionPlan, + shouldReadWhisperFileAudioBuffer, + transcribeWhisperAudioFile, +} = await importTs(path.resolve('src/main/whisper-transcribe-file.ts')); + +const AUDIO_PATH = '/tmp/supercmd-whisper-file-buffer-read/input.wav'; +const DEFAULT_FILE_BYTES = 73_400_320; + +function makeSettings(speechToTextModel, overrides = {}) { + const { ai: aiOverrides = {}, ...rest } = overrides; + return { + ...rest, + ai: { + enabled: true, + whisperEnabled: true, + speechToTextModel, + speechLanguage: 'en-US', + openaiApiKey: 'openai-key', + elevenlabsApiKey: 'elevenlabs-key', + mistralApiKey: 'mistral-key', + ...aiOverrides, + }, + }; +} + +function makeHarness({ + speechToTextModel = 'whispercpp', + exists = true, + fileBytes = DEFAULT_FILE_BYTES, + settingsOverrides = {}, + transcriberRejects = false, +} = {}) { + const events = []; + const fakeAudioBuffer = Buffer.from('fake wav bytes'); + const calls = { + parakeet: [], + qwen3: [], + openai: [], + elevenlabs: [], + mistral: [], + whispercpp: [], + }; + let readCalls = 0; + let nodeBufferBytes = 0; + + const fs = { + existsSync(filePath) { + events.push(`exists:${filePath}`); + return exists; + }, + readFileSync(filePath) { + events.push(`read:${filePath}`); + readCalls += 1; + nodeBufferBytes += fileBytes; + return fakeAudioBuffer; + }, + unlinkSync(filePath) { + events.push(`unlink:${filePath}`); + }, + rmdirSync(dirPath, options) { + events.push(`rmdir:${dirPath}:${JSON.stringify(options)}`); + }, + }; + + const makeTranscriber = (label) => async (opts) => { + events.push(`transcribe:${label}`); + calls[label].push(opts); + if (transcriberRejects) throw new Error(`${label} failed`); + return `${label} transcript`; + }; + + const deps = { + fs, + loadSettings: () => makeSettings(speechToTextModel, settingsOverrides), + isAIDisabledInSettings: (settings) => settings.ai?.enabled === false, + normalizeWhisperLanguageCode: (rawLanguage) => String(rawLanguage || 'en-US').split('-')[0].toLowerCase(), + resolveElevenLabsSttModel: (model) => model.replace(/^elevenlabs-/, '').replace(/-/g, '_') || 'scribe_v1', + getElevenLabsApiKey: (settings) => settings.ai?.elevenlabsApiKey || '', + getMistralApiKey: (settings) => settings.ai?.mistralApiKey || '', + getWhisperCppModelStatus: () => { + events.push('status:whispercpp'); + return { state: 'downloaded' }; + }, + ensureWhisperCppServer: async () => { + events.push('ensure:whispercpp'); + }, + sendWhisperCppRequest: async (request) => { + events.push('send:whispercpp'); + calls.whispercpp.push(request); + return { text: 'whispercpp transcript' }; + }, + transcribeAudioWithParakeet: makeTranscriber('parakeet'), + transcribeAudioWithQwen3: makeTranscriber('qwen3'), + transcribeAudioWithElevenLabs: makeTranscriber('elevenlabs'), + transcribeAudioWithMistralVoxtral: makeTranscriber('mistral'), + transcribeAudio: makeTranscriber('openai'), + whisperCppModelName: 'base', + }; + + return { + calls, + deps, + events, + fakeAudioBuffer, + fileBytes, + get nodeBufferBytes() { + return nodeBufferBytes; + }, + get readCalls() { + return readCalls; + }, + }; +} + +function assertCleanupAfter(events, marker) { + const markerIndex = events.indexOf(marker); + const unlinkIndex = events.findIndex((event) => event.startsWith('unlink:')); + const rmdirIndex = events.findIndex((event) => event.startsWith('rmdir:')); + + assert.notEqual(markerIndex, -1, `${marker} should be recorded`); + assert.ok(unlinkIndex > markerIndex, 'temp audio file should be unlinked after transcription succeeds'); + assert.ok(rmdirIndex > unlinkIndex, 'temp audio directory should be removed after the file unlink'); +} + +test('provider plan marks only buffer-backed file providers as requiring a Node buffer', () => { + const cases = [ + ['', 'whispercpp', false], + ['default', 'whispercpp', false], + ['whispercpp', 'whispercpp', false], + ['custom-local-model', 'whispercpp', false], + ['native', 'native', false], + ['parakeet', 'parakeet', true], + ['qwen3', 'qwen3', true], + ['openai-whisper-1', 'openai', true], + ['elevenlabs-scribe-v2', 'elevenlabs', true], + ['mistral-voxtral-mini-latest', 'mistral', true], + ]; + + for (const [speechToTextModel, expectedProvider, expectedRead] of cases) { + const plan = resolveWhisperFileTranscriptionPlan(speechToTextModel, { + whisperCppModelName: 'base', + resolveElevenLabsSttModel: (model) => model, + }); + assert.equal(plan.provider, expectedProvider, `${speechToTextModel || '(empty)'} provider`); + assert.equal( + shouldReadWhisperFileAudioBuffer(plan.provider), + expectedRead, + `${speechToTextModel || '(empty)'} read requirement` + ); + } +}); + +test('whispercpp file transcription sends the file path without reading a Node buffer', async () => { + const harness = makeHarness({ speechToTextModel: 'whispercpp' }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, 'whispercpp transcript'); + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.deepEqual(harness.calls.whispercpp, [ + { command: 'transcribe', file: AUDIO_PATH, language: 'en' }, + ]); + assertCleanupAfter(harness.events, 'send:whispercpp'); +}); + +test('buffer-backed file providers read the audio file once and clean up after transcription', async () => { + const cases = [ + ['parakeet', 'parakeet'], + ['qwen3', 'qwen3'], + ['openai-whisper-1', 'openai'], + ['elevenlabs-scribe-v2', 'elevenlabs'], + ['mistral-voxtral-mini-latest', 'mistral'], + ]; + + for (const [speechToTextModel, label] of cases) { + const harness = makeHarness({ speechToTextModel }); + + const text = await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + options: { language: 'en-US' }, + deps: harness.deps, + }); + + assert.equal(text, `${label} transcript`); + assert.equal(harness.readCalls, 1, `${label} should read the captured file exactly once`); + assert.equal(harness.nodeBufferBytes, harness.fileBytes, `${label} should account for one full file buffer read`); + assert.equal(harness.calls[label].length, 1, `${label} transcriber should be called once`); + assert.equal(harness.calls[label][0].audioBuffer, harness.fakeAudioBuffer); + assert.equal(harness.calls[label][0].language, 'en'); + assert.equal(harness.calls[label][0].mimeType, 'audio/wav'); + assert.equal(harness.calls.whispercpp.length, 0, `${label} should not use the whisper.cpp request path`); + assertCleanupAfter(harness.events, `transcribe:${label}`); + } +}); + +test('missing audio file does not read a buffer or run cleanup', async () => { + const harness = makeHarness({ speechToTextModel: 'openai-whisper-1', exists: false }); + + await assert.rejects( + transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: harness.deps, + }), + /Audio file not found/ + ); + + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.equal(harness.events.some((event) => event.startsWith('unlink:')), false); + assert.equal(harness.events.some((event) => event.startsWith('rmdir:')), false); +}); + +test('cloud provider validation runs before large file reads and still cleans up temp capture', async () => { + const harness = makeHarness({ + speechToTextModel: 'openai-whisper-1', + settingsOverrides: { ai: { openaiApiKey: '' } }, + }); + + await assert.rejects( + transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: harness.deps, + }), + /OpenAI API key not configured/ + ); + + assert.equal(harness.readCalls, 0); + assert.equal(harness.nodeBufferBytes, 0); + assert.deepEqual( + harness.events.filter((event) => event.startsWith('unlink:') || event.startsWith('rmdir:')), + [ + `unlink:${AUDIO_PATH}`, + `rmdir:${path.dirname(AUDIO_PATH)}:${JSON.stringify({ recursive: true })}`, + ] + ); +}); + +test('temp capture cleanup runs when buffer-backed transcription fails after reading', async () => { + const harness = makeHarness({ + speechToTextModel: 'mistral-voxtral-mini-latest', + transcriberRejects: true, + }); + + await assert.rejects( + transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: harness.deps, + }), + /mistral failed/ + ); + + assert.equal(harness.readCalls, 1); + assertCleanupAfter(harness.events, 'transcribe:mistral'); +}); + +test('instrumentation shows whispercpp avoids the legacy eager full-file read', async () => { + const legacy = makeHarness({ speechToTextModel: 'whispercpp' }); + legacy.deps.fs.readFileSync(AUDIO_PATH); + + const whisperCpp = makeHarness({ speechToTextModel: 'whispercpp' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: whisperCpp.deps, + }); + + const openai = makeHarness({ speechToTextModel: 'openai-whisper-1' }); + await transcribeWhisperAudioFile({ + audioPath: AUDIO_PATH, + deps: openai.deps, + }); + + console.log( + `[whisper-file-buffer-read] before provider=whispercpp readCalls=${legacy.readCalls} ` + + `nodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=whispercpp readCalls=${whisperCpp.readCalls} ` + + `nodeBufferBytes=${whisperCpp.nodeBufferBytes} avoidedNodeBufferBytes=${legacy.nodeBufferBytes}` + ); + console.log( + `[whisper-file-buffer-read] after provider=openai readCalls=${openai.readCalls} ` + + `nodeBufferBytes=${openai.nodeBufferBytes}` + ); + + assert.equal(legacy.readCalls, 1); + assert.equal(legacy.nodeBufferBytes, DEFAULT_FILE_BYTES); + assert.equal(whisperCpp.readCalls, 0); + assert.equal(whisperCpp.nodeBufferBytes, 0); + assert.equal(openai.readCalls, 1); + assert.equal(openai.nodeBufferBytes, DEFAULT_FILE_BYTES); +}); diff --git a/scripts/test-whisper-local-wave-snapshot-cache.mjs b/scripts/test-whisper-local-wave-snapshot-cache.mjs new file mode 100644 index 00000000..e3b48da4 --- /dev/null +++ b/scripts/test-whisper-local-wave-snapshot-cache.mjs @@ -0,0 +1,101 @@ +#!/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 { importTs } from './lib/ts-import.mjs'; + +const { + buildCachedLocalWaveSnapshot, + downsampleTo16k, + encodeWavePcm16, + flattenFloat32Chunks, +} = await importTs(path.resolve('src/renderer/src/SuperCmdWhisper.tsx'), { + stubs: { + './i18n': 'export function useI18n() { return { t: (key) => key }; }', + './utils/hyper-key': 'export function formatShortcutForDisplay(value) { return value || ""; }', + }, +}); + +function makeChunks({ seconds, sampleRate = 16000, chunkSize = 4096 }) { + const totalSamples = seconds * sampleRate; + const chunks = []; + for (let offset = 0; offset < totalSamples; offset += chunkSize) { + const length = Math.min(chunkSize, totalSamples - offset); + const chunk = new Float32Array(length); + for (let i = 0; i < length; i += 1) { + chunk[i] = Math.sin((offset + i) / 29) * 0.3; + } + chunks.push(chunk); + } + return chunks; +} + +function legacySnapshot(chunks, sampleRate) { + const merged = flattenFloat32Chunks(chunks); + const downsampled = downsampleTo16k(merged, sampleRate); + return encodeWavePcm16(downsampled, 16000); +} + +function benchSnapshots(chunks, sampleRate) { + const stepChunks = Math.max(1, Math.round((sampleRate * 3.5) / 4096)); + const windows = []; + for (let count = stepChunks; count < chunks.length; count += stepChunks) { + windows.push(count); + } + windows.push(chunks.length); + + let legacyBytes = 0; + const legacyStart = performance.now(); + for (const count of windows) { + legacyBytes += legacySnapshot(chunks.slice(0, count), sampleRate).byteLength; + } + const legacyMs = performance.now() - legacyStart; + + let cache = null; + let cachedBytes = 0; + const cachedStart = performance.now(); + for (const count of windows) { + const result = buildCachedLocalWaveSnapshot(chunks.slice(0, count), sampleRate, cache); + cache = result.cache; + cachedBytes += result.buffer?.byteLength || 0; + } + const cachedMs = performance.now() - cachedStart; + + return { + cachedBytes, + cachedMs, + legacyBytes, + legacyMs, + snapshots: windows.length, + }; +} + +test('cached local WAV snapshots preserve 16 kHz output and reduce repeated encode work', (t) => { + const chunks = makeChunks({ seconds: 30 }); + const legacy = legacySnapshot(chunks, 16000); + const cached = buildCachedLocalWaveSnapshot(chunks, 16000, null); + + assert.equal(cached.buffer?.byteLength, legacy.byteLength); + assert.deepEqual(new Uint8Array(cached.buffer), new Uint8Array(legacy)); + + const chunks48k = makeChunks({ seconds: 30, sampleRate: 48000 }); + const legacy48k = legacySnapshot(chunks48k, 48000); + const cached48k = buildCachedLocalWaveSnapshot(chunks48k, 48000, null); + assert.equal(cached48k.buffer?.byteLength, legacy48k.byteLength); + assert.deepEqual(new Uint8Array(cached48k.buffer), new Uint8Array(legacy48k)); + + const bench30 = benchSnapshots(makeChunks({ seconds: 30, sampleRate: 48000 }), 48000); + const bench120 = benchSnapshots(makeChunks({ seconds: 120, sampleRate: 48000 }), 48000); + + t.diagnostic( + `[whisper-local-wave] 30s before=${bench30.legacyMs.toFixed(2)}ms after=${bench30.cachedMs.toFixed(2)}ms snapshots=${bench30.snapshots} bytes=${bench30.cachedBytes}` + ); + t.diagnostic( + `[whisper-local-wave] 2m before=${bench120.legacyMs.toFixed(2)}ms after=${bench120.cachedMs.toFixed(2)}ms snapshots=${bench120.snapshots} bytes=${bench120.cachedBytes}` + ); + + assert.equal(bench30.cachedBytes, bench30.legacyBytes); + assert.equal(bench120.cachedBytes, bench120.legacyBytes); +}); 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/ai-provider.ts b/src/main/ai-provider.ts index 65942908..aa8d607a 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -8,6 +8,9 @@ import * as https from 'https'; import * as http from 'http'; import type { AISettings } from './settings-store'; +const MAX_STREAM_CARRY_CHARS = 1_000_000; +const MAX_ERROR_BODY_CHARS = 8_192; + export interface AIRequestOptions { prompt: string; model?: string; @@ -398,18 +401,7 @@ async function* streamGeminiChat( useHttps: true, }); - yield* parseSSE(response, (data) => { - try { - const parsed = JSON.parse(data); - const parts = parsed?.candidates?.[0]?.content?.parts; - if (Array.isArray(parts)) { - return parts.map((p: any) => (typeof p?.text === 'string' ? p.text : '')).join('') || null; - } - return null; - } catch { - return null; - } - }); + yield* streamGeminiSSE(response); } async function* streamOllamaChat( @@ -608,7 +600,7 @@ async function* streamGemini( const response = await httpRequest({ hostname: 'generativelanguage.googleapis.com', - path: `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`, + path: `/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -616,21 +608,54 @@ async function* streamGemini( useHttps: true, }); - const parsed = JSON.parse(await readResponseBody(response) || '{}'); - const text = Array.isArray(parsed?.candidates?.[0]?.content?.parts) - ? parsed.candidates[0].content.parts - .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) - .join('') - : ''; + yield* streamGeminiSSE(response, { throwOnNoText: true }); +} - if (text) { - yield text; - return; - } +function extractGeminiResponseText(parsed: any): string { + const parts = parsed?.candidates?.[0]?.content?.parts; + if (!Array.isArray(parts)) return ''; - const reason = String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); - if (reason) throw new Error(`Gemini returned no text (${reason}).`); - throw new Error('Gemini returned no text.'); + return parts + .map((part: any) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); +} + +function extractGeminiNoTextReason(parsed: any): string { + return String(parsed?.candidates?.[0]?.finishReason || parsed?.promptFeedback?.blockReason || '').trim(); +} + +function createGeminiNoTextError(reason: string): Error { + if (reason) return new Error(`Gemini returned no text (${reason}).`); + return new Error('Gemini returned no text.'); +} + +async function* streamGeminiSSE( + response: http.IncomingMessage, + options: { throwOnNoText?: boolean } = {} +): AsyncGenerator { + let yieldedText = false; + let noTextReason = ''; + + yield* parseSSE(response, (data) => { + try { + const parsed = JSON.parse(data); + const text = extractGeminiResponseText(parsed); + if (text) { + yieldedText = true; + return text; + } + + const reason = extractGeminiNoTextReason(parsed); + if (reason && !noTextReason) noTextReason = reason; + return null; + } catch { + return null; + } + }); + + if (options.throwOnNoText && !yieldedText) { + throw createGeminiNoTextError(noTextReason); + } } // ─── Ollama ────────────────────────────────────────────────────────── @@ -699,7 +724,13 @@ function httpRequest(opts: HttpRequestOptions): Promise { const req = mod.request(reqOpts, (res) => { if (res.statusCode && res.statusCode >= 400) { let body = ''; - res.on('data', (chunk) => { body += chunk; }); + res.on('data', (chunk) => { + if (body.length >= MAX_ERROR_BODY_CHARS) return; + body += chunk.toString(); + if (body.length > MAX_ERROR_BODY_CHARS) { + body = body.slice(0, MAX_ERROR_BODY_CHARS); + } + }); res.on('end', () => { reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 500)}`)); }); @@ -727,69 +758,83 @@ function httpRequest(opts: HttpRequestOptions): Promise { }); } -async function readResponseBody(response: http.IncomingMessage): Promise { - let body = ''; - for await (const rawChunk of response) { - body += rawChunk.toString(); +function appendStreamCarry(buffer: string, rawChunk: unknown): string { + const next = buffer + Buffer.from(rawChunk as any).toString(); + if (next.length <= MAX_STREAM_CARRY_CHARS) return next; + return next.slice(next.length - MAX_STREAM_CARRY_CHARS); +} + +function consumeCompleteLines( + buffer: string, + consumeLine: (line: string) => void +): string { + let start = 0; + for (;;) { + const newline = buffer.indexOf('\n', start); + if (newline < 0) break; + const lineEnd = newline > start && buffer.charCodeAt(newline - 1) === 13 ? newline - 1 : newline; + consumeLine(buffer.slice(start, lineEnd)); + start = newline + 1; } - return body; + return start === 0 ? buffer : buffer.slice(start); } -async function* parseSSE( +function extractSSEDataLine(line: string): string | null { + if (!line || line.charCodeAt(0) === 58) return null; + if (line.startsWith('data: ')) return line.slice(6).trim(); + if (line.startsWith('data:')) return line.slice(5).trim(); + return null; +} + +export async function* parseSSE( response: http.IncomingMessage, extractChunk: (data: string) => string | null ): AsyncGenerator { let buffer = ''; for await (const rawChunk of response) { - buffer += rawChunk.toString(); - - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // keep incomplete line + buffer = appendStreamCarry(buffer, rawChunk); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith('data: ')) continue; - const data = trimmed.slice(6); + const chunks: string[] = []; + buffer = consumeCompleteLines(buffer, (line) => { + const data = extractSSEDataLine(line.trim()); + if (data === null) return; const text = extractChunk(data); - if (text) yield text; - } + if (text) chunks.push(text); + }); + for (const text of chunks) yield text; } // Process remaining buffer - if (buffer.trim()) { - const trimmed = buffer.trim(); - if (trimmed.startsWith('data: ')) { - const data = trimmed.slice(6); - const text = extractChunk(data); - if (text) yield text; - } + const data = extractSSEDataLine(buffer.trim()); + if (data !== null) { + const text = extractChunk(data); + if (text) yield text; } } -async function* parseNDJSON( +export async function* parseNDJSON( response: http.IncomingMessage, extractChunk: (obj: any) => string | null ): AsyncGenerator { let buffer = ''; for await (const rawChunk of response) { - buffer += rawChunk.toString(); + buffer = appendStreamCarry(buffer, rawChunk); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - for (const line of lines) { + const chunks: string[] = []; + buffer = consumeCompleteLines(buffer, (line) => { const trimmed = line.trim(); - if (!trimmed) continue; + if (!trimmed) return; try { const obj = JSON.parse(trimmed); const text = extractChunk(obj); - if (text) yield text; + if (text) chunks.push(text); } catch { // skip malformed lines } - } + }); + for (const text of chunks) yield text; } if (buffer.trim()) { @@ -822,10 +867,17 @@ function resolveUploadMeta(mimeType?: string): { filename: string; contentType: return { filename: 'audio.webm', contentType: 'audio/webm' }; } -export function transcribeAudio(opts: TranscribeOptions): Promise { - const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; - const uploadMeta = resolveUploadMeta(opts.mimeType); +interface MultipartUpload { + parts: Buffer[]; + contentLength: number; +} +function getMultipartContentLength(parts: readonly Buffer[]): number { + return parts.reduce((total, part) => total + part.length, 0); +} + +function buildTranscriptionMultipartUpload(opts: TranscribeOptions, boundary: string): MultipartUpload { + const uploadMeta = resolveUploadMeta(opts.mimeType); const parts: Buffer[] = []; // file field @@ -854,7 +906,22 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { parts.push(Buffer.from(`--${boundary}--\r\n`)); - const body = Buffer.concat(parts); + return { + parts, + contentLength: getMultipartContentLength(parts), + }; +} + +function writeBufferedRequestParts(req: http.ClientRequest, parts: readonly Buffer[]): void { + for (const part of parts) { + req.write(part); + } + req.end(); +} + +export function transcribeAudio(opts: TranscribeOptions): Promise { + const boundary = `----SuperCmdBoundary${Date.now()}${Math.random().toString(36).slice(2)}`; + const upload = buildTranscriptionMultipartUpload(opts, boundary); return new Promise((resolve, reject) => { const req = https.request( @@ -865,7 +932,7 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, - 'Content-Length': body.length, + 'Content-Length': upload.contentLength, }, }, (res) => { @@ -895,7 +962,6 @@ export function transcribeAudio(opts: TranscribeOptions): Promise { }, { once: true }); } - req.write(body); - req.end(); + writeBufferedRequestParts(req, upload.parts); }); } diff --git a/src/main/ai-stream-ipc.ts b/src/main/ai-stream-ipc.ts new file mode 100644 index 00000000..662c56de --- /dev/null +++ b/src/main/ai-stream-ipc.ts @@ -0,0 +1,82 @@ +export const AI_STREAM_IPC_FLUSH_MS = 16; + +export interface AIStreamIpcSender { + send: (channel: 'ai-stream-chunk', payload: { requestId: string; chunk: string }) => void; +} + +export interface AIStreamIpcCoalescerOptions { + requestId: string; + sender: AIStreamIpcSender; + flushIntervalMs?: number; + scheduleFlush?: (callback: () => void, delayMs: number) => unknown; + cancelFlush?: (handle: unknown) => void; +} + +export interface AIStreamIpcCoalescer { + appendChunk: (chunk: string) => void; + cancelPendingFlush: () => void; + flush: () => boolean; + hasPendingChunk: () => boolean; + hasPendingFlush: () => boolean; +} + +export function createAIStreamIpcCoalescer({ + requestId, + sender, + flushIntervalMs = AI_STREAM_IPC_FLUSH_MS, + scheduleFlush = (callback, delayMs) => setTimeout(callback, delayMs), + cancelFlush = (handle) => clearTimeout(handle as ReturnType), +}: AIStreamIpcCoalescerOptions): AIStreamIpcCoalescer { + let pendingChunk = ''; + let flushHandle: unknown = null; + + const cancelPendingFlush = () => { + if (flushHandle === null) return; + cancelFlush(flushHandle); + flushHandle = null; + }; + + const flush = () => { + cancelPendingFlush(); + if (!pendingChunk) return false; + const chunk = pendingChunk; + pendingChunk = ''; + sender.send('ai-stream-chunk', { requestId, chunk }); + return true; + }; + + const scheduleNextFlush = () => { + if (flushHandle !== null) return; + flushHandle = scheduleFlush(() => { + flushHandle = null; + flush(); + }, flushIntervalMs); + }; + + return { + appendChunk(chunk) { + if (!chunk) return; + pendingChunk += chunk; + scheduleNextFlush(); + }, + cancelPendingFlush, + flush, + hasPendingChunk() { + return pendingChunk.length > 0; + }, + hasPendingFlush() { + return flushHandle !== null; + }, + }; +} + +export async function forwardAIStreamChunksToIpc( + chunks: AsyncIterable, + coalescer: AIStreamIpcCoalescer, + signal: AbortSignal +): Promise { + for await (const chunk of chunks) { + if (signal.aborted) break; + coalescer.appendChunk(chunk); + } +} 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/commands.ts b/src/main/commands.ts index 4a89f301..1fa20e56 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -18,7 +18,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { discoverInstalledExtensionCommands } from './extension-runner'; import { discoverScriptCommands } from './script-command-runner'; -import { getAllQuickLinks, getQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; +import { getAllQuickLinks, getQuickLinkCommandId, isQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; import { loadSettings,getSearchApplicationsScope} from './settings-store'; const execAsync = promisify(exec); @@ -112,6 +112,22 @@ let inflightDiscovery: Promise | null = null; let lastStaleRefreshRequestAt = 0; const CACHE_TTL = 30 * 60_000; // 30 min const STALE_REFRESH_COOLDOWN_MS = 15_000; +let commandDiscoveryStartCount = 0; +let commandDiscoveryRunnerForTesting: (() => Promise) | null = null; +let extensionCommandInfoRunnerForTesting: (() => CommandInfo[]) | null = null; + +export type CommandRuntimeMetadata = { subtitle?: string | null | undefined }; +type CommandRuntimeMetadataStore = Record; + +export interface CommandMetadataPatchResult { + matchedCommands: number; + changedCommands: number; + patchedCachedCommands: boolean; + patchedStaleCommandsFallback: boolean; +} + +const runtimeMetadataBaseSubtitleByKey = new Map(); +const runtimeMetadataCommandsByKey = new Map>(); // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -151,7 +167,18 @@ function loadCommandsDiskCache(): CommandInfo[] | null { function saveCommandsDiskCache(commands: CommandInfo[]): void { try { // Strip icon data — icons are persisted separately in icon-cache/. - const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => rest); + const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => { + const commandForDisk = { ...rest }; + const baseSubtitle = getRuntimeMetadataBaseSubtitle(commandForDisk); + if (baseSubtitle.known) { + if (baseSubtitle.subtitle) { + commandForDisk.subtitle = baseSubtitle.subtitle; + } else { + delete commandForDisk.subtitle; + } + } + return commandForDisk; + }); fs.writeFileSync( getCommandsDiskCachePath(), JSON.stringify({ version: COMMANDS_DISK_CACHE_VERSION, commands: stripped }), @@ -166,6 +193,11 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void { export function initCommandsCache(): void { const cmds = loadCommandsDiskCache(); if (cmds) { + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(cmds); + try { + applyStoredRuntimeCommandMetadata(cmds, loadSettings().commandMetadata || {}); + } catch {} cachedCommands = cmds; staleCommandsFallback = cmds; cacheTimestamp = 0; // mark stale so the next getAvailableCommands() triggers a background refresh @@ -178,6 +210,214 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +function normalizeCommandMetadataKey(value: string): string { + return String(value || '').trim(); +} + +function normalizeRuntimeSubtitle(value: string | null | undefined): string { + return String(value ?? '').trim(); +} + +function isRuntimeMetadataSubtitleEligible(command: CommandInfo): boolean { + return !(command.category === 'script' && command.mode !== 'inline'); +} + +function getRuntimeMetadataKeys(command: CommandInfo): string[] { + const keys = [normalizeCommandMetadataKey(command.id)]; + if (command.category === 'extension') { + keys.push(normalizeCommandMetadataKey(command.path || '')); + } + return Array.from(new Set(keys.filter(Boolean))); +} + +function commandMatchesRuntimeMetadataKey(command: CommandInfo, key: string): boolean { + const normalizedKey = normalizeCommandMetadataKey(key); + if (!normalizedKey) return false; + return getRuntimeMetadataKeys(command).includes(normalizedKey); +} + +function resetRuntimeMetadataTracking(): void { + runtimeMetadataBaseSubtitleByKey.clear(); + runtimeMetadataCommandsByKey.clear(); +} + +function rememberRuntimeMetadataBaseSubtitles(commands: CommandInfo[]): void { + for (const command of commands) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + for (const key of getRuntimeMetadataKeys(command)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + let commandsForKey = runtimeMetadataCommandsByKey.get(key); + if (!commandsForKey) { + commandsForKey = new Set(); + runtimeMetadataCommandsByKey.set(key, commandsForKey); + } + commandsForKey.add(command); + } + } +} + +function ensureRuntimeMetadataBaseSubtitle(command: CommandInfo): void { + if (!isRuntimeMetadataSubtitleEligible(command)) return; + for (const key of getRuntimeMetadataKeys(command)) { + if (!runtimeMetadataBaseSubtitleByKey.has(key)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + } + } +} + +function getRuntimeMetadataBaseSubtitle(command: CommandInfo): { known: boolean; subtitle?: string } { + if (!isRuntimeMetadataSubtitleEligible(command)) return { known: false }; + for (const key of getRuntimeMetadataKeys(command)) { + if (runtimeMetadataBaseSubtitleByKey.has(key)) { + return { known: true, subtitle: runtimeMetadataBaseSubtitleByKey.get(key) }; + } + } + return { known: false }; +} + +function getStoredRuntimeSubtitle( + command: CommandInfo, + commandMetadata: CommandRuntimeMetadataStore +): string { + if (!isRuntimeMetadataSubtitleEligible(command)) return ''; + for (const key of getRuntimeMetadataKeys(command)) { + const subtitle = normalizeRuntimeSubtitle(commandMetadata[key]?.subtitle); + if (subtitle) return subtitle; + } + return ''; +} + +function setRuntimeMetadataSubtitle(command: CommandInfo, subtitle: string): boolean { + ensureRuntimeMetadataBaseSubtitle(command); + const previousSubtitle = command.subtitle; + if (subtitle) { + command.subtitle = subtitle; + } else { + const baseSubtitle = getRuntimeMetadataBaseSubtitle(command); + if (baseSubtitle.known && baseSubtitle.subtitle) { + command.subtitle = baseSubtitle.subtitle; + } else { + delete command.subtitle; + } + } + return command.subtitle !== previousSubtitle; +} + +function applyStoredRuntimeCommandMetadata( + commands: CommandInfo[], + commandMetadata: CommandRuntimeMetadataStore +): void { + for (const command of commands) { + const subtitle = getStoredRuntimeSubtitle(command, commandMetadata); + if (subtitle) { + setRuntimeMetadataSubtitle(command, subtitle); + } + } +} + +function startCommandDiscovery(): Promise { + commandDiscoveryStartCount += 1; + return (commandDiscoveryRunnerForTesting || discoverAndBuildCommands)(); +} + +export function applyCommandMetadataUpdate( + commandId: string, + metadata: CommandRuntimeMetadata +): CommandMetadataPatchResult { + const normalizedCommandId = normalizeCommandMetadataKey(commandId); + if (!normalizedCommandId) { + return { + matchedCommands: 0, + changedCommands: 0, + patchedCachedCommands: false, + patchedStaleCommandsFallback: false, + }; + } + + const subtitle = normalizeRuntimeSubtitle(metadata?.subtitle); + const matchedCommandIds = new Set(); + const changedCommandIds = new Set(); + let patchedCachedCommands = false; + let patchedStaleCommandsFallback = false; + + const registeredCommands = runtimeMetadataCommandsByKey.get(normalizedCommandId); + const commandsToPatch = registeredCommands + ? Array.from(registeredCommands) + : [ + ...(cachedCommands || []), + ...((staleCommandsFallback && staleCommandsFallback !== cachedCommands) ? staleCommandsFallback : []), + ].filter((command) => commandMatchesRuntimeMetadataKey(command, normalizedCommandId)); + + for (const command of commandsToPatch) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + if (!commandMatchesRuntimeMetadataKey(command, normalizedCommandId)) continue; + matchedCommandIds.add(command.id); + if (setRuntimeMetadataSubtitle(command, subtitle)) { + changedCommandIds.add(command.id); + if (cachedCommands?.includes(command)) { + patchedCachedCommands = true; + } + if (staleCommandsFallback && staleCommandsFallback !== cachedCommands && staleCommandsFallback.includes(command)) { + patchedStaleCommandsFallback = true; + } + } + } + + return { + matchedCommands: matchedCommandIds.size, + changedCommands: changedCommandIds.size, + patchedCachedCommands, + patchedStaleCommandsFallback, + }; +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options: { cacheTimestamp?: number; staleCommandsFallback?: CommandInfo[] | null } = {} +): void { + cachedCommands = commands; + staleCommandsFallback = options.staleCommandsFallback === undefined + ? commands + : options.staleCommandsFallback; + cacheTimestamp = options.cacheTimestamp ?? Date.now(); + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + if (staleCommandsFallback && staleCommandsFallback !== commands) { + rememberRuntimeMetadataBaseSubtitles(staleCommandsFallback); + } +} + +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + extensionCommandInfoRunnerForTesting = null; + resetRuntimeMetadataTracking(); + commandsDiskCachePath = null; +} + +export function __setCommandDiscoveryRunnerForTesting( + runner: (() => Promise) | null +): void { + commandDiscoveryRunnerForTesting = runner; +} + +export function __setExtensionCommandInfoRunnerForTesting( + runner: (() => CommandInfo[]) | null +): void { + extensionCommandInfoRunnerForTesting = runner; +} + +export function __getCommandDiscoveryStartCountForTesting(): number { + return commandDiscoveryStartCount; +} + // ─── Icon Disk Cache ──────────────────────────────────────────────── let iconCacheDir: string | null = null; @@ -657,6 +897,100 @@ function buildQuickLinkKeywords(quickLink: QuickLink): string[] { return Array.from(set); } +function discoverExtensionCommandInfos(): CommandInfo[] { + if (extensionCommandInfoRunnerForTesting) { + return extensionCommandInfoRunnerForTesting(); + } + + try { + return discoverInstalledExtensionCommands().map((ext) => ({ + id: ext.id, + title: ext.title, + subtitle: ext.extensionTitle, + keywords: ext.keywords, + iconDataUrl: ext.iconDataUrl, + category: 'extension' as const, + path: `${ext.extName}/${ext.cmdName}`, + mode: ext.mode, + interval: ext.interval, + disabledByDefault: ext.disabledByDefault, + commandArgumentDefinitions: ext.commandArgumentDefinitions || [], + deeplink: ext.owner + ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` + : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, + })); + } catch (e) { + console.error('Failed to discover installed extensions:', e); + return []; + } +} + +function discoverScriptCommandInfos(): CommandInfo[] { + try { + return discoverScriptCommands().map((script) => ({ + id: script.id, + title: script.title, + subtitle: script.packageName, + keywords: script.keywords, + iconDataUrl: script.iconDataUrl, + iconEmoji: script.iconEmoji, + category: 'script' as const, + path: script.scriptPath, + mode: script.mode, + interval: script.interval, + needsConfirmation: script.needsConfirmation, + commandArgumentDefinitions: script.arguments.map((arg) => ({ + name: arg.name, + required: arg.required, + type: arg.type, + placeholder: arg.placeholder, + title: arg.placeholder, + data: arg.data, + })), + deeplink: script.slug + ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` + : undefined, + })); + } catch (e) { + console.error('Failed to discover script commands:', e); + return []; + } +} + +async function discoverQuickLinkCommandInfos(): Promise { + try { + const quickLinks = getAllQuickLinks(); + return await Promise.all( + quickLinks.map(async (quickLink) => { + const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); + let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); + + // Prefer real app icon for default quick-link icons so launcher search + // reflects the target application even when stored icon data is stale. + if (!resolvedIconName && quickLink.applicationPath) { + const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); + if (resolvedAppIconDataUrl) { + iconDataUrl = resolvedAppIconDataUrl; + } + } + + return { + id: getQuickLinkCommandId(quickLink.id), + title: quickLink.name, + subtitle: quickLink.applicationName || 'Quick Link', + keywords: buildQuickLinkKeywords(quickLink), + iconDataUrl, + iconName: iconDataUrl ? undefined : resolvedIconName, + category: 'system' as const, + }; + }) + ); + } catch (e) { + console.error('Failed to discover quick links:', e); + return []; + } +} + function getLocaleCandidates(): string[] { const set = new Set(); const preferredAppLanguage = loadSettings().appLanguage; @@ -1270,6 +1604,108 @@ async function openSettingsPane(identifier: string): Promise { // ─── Public API ───────────────────────────────────────────────────── +function assignUniversalDeeplinks(commands: CommandInfo[]): void { + for (const cmd of commands) { + if (!cmd.deeplink && cmd.id) { + cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; + } + } +} + +function applyRuntimeMetadataAndAliases(commands: CommandInfo[]): void { + try { + const loadedSettings = loadSettings(); + const commandMetadata = loadedSettings.commandMetadata || {}; + const commandAliases = loadedSettings.commandAliases || {}; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + applyStoredRuntimeCommandMetadata(commands, commandMetadata); + for (const cmd of commands) { + const alias = String(commandAliases[cmd.id] || '').trim(); + if (alias) { + cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); + } + } + } catch {} +} + +function publishCommandCache(commands: CommandInfo[]): CommandInfo[] { + assignUniversalDeeplinks(commands); + applyRuntimeMetadataAndAliases(commands); + + cachedCommands = commands; + cacheTimestamp = Date.now(); + staleCommandsFallback = commands; + saveCommandsDiskCache(commands); + + return cachedCommands; +} + +function cloneCommandForTargetedRefresh(command: CommandInfo): CommandInfo { + const cloned: CommandInfo = { + ...command, + keywords: command.keywords ? [...command.keywords] : undefined, + commandArgumentDefinitions: command.commandArgumentDefinitions + ? command.commandArgumentDefinitions.map((arg) => ({ + ...arg, + data: arg.data ? arg.data.map((item) => ({ ...item })) : arg.data, + })) + : undefined, + }; + + const baseSubtitle = getRuntimeMetadataBaseSubtitle(command); + if (baseSubtitle.known) { + if (baseSubtitle.subtitle) { + cloned.subtitle = baseSubtitle.subtitle; + } else { + delete cloned.subtitle; + } + } + + return cloned; +} + +function getExtensionInsertionIndex(commands: CommandInfo[]): number { + const existingExtensionIndex = commands.findIndex((command) => command.category === 'extension'); + if (existingExtensionIndex >= 0) return existingExtensionIndex; + + const scriptIndex = commands.findIndex((command) => command.category === 'script'); + if (scriptIndex >= 0) return scriptIndex; + + const quickLinkIndex = commands.findIndex((command) => isQuickLinkCommandId(command.id)); + if (quickLinkIndex >= 0) return quickLinkIndex; + + const systemIndex = commands.findIndex((command) => command.category === 'system'); + if (systemIndex >= 0) return systemIndex; + + return commands.length; +} + +function rebuildCommandsWithFreshExtensions( + baseCommands: CommandInfo[], + extensionCommands: CommandInfo[] +): CommandInfo[] { + const insertionIndex = getExtensionInsertionIndex(baseCommands); + const beforeExtensions: CommandInfo[] = []; + const afterExtensions: CommandInfo[] = []; + + baseCommands.forEach((command, index) => { + if (command.category === 'extension') return; + const cloned = cloneCommandForTargetedRefresh(command); + if (index < insertionIndex) { + beforeExtensions.push(cloned); + } else { + afterExtensions.push(cloned); + } + }); + + return [ + ...beforeExtensions, + ...extensionCommands.map(cloneCommandForTargetedRefresh), + ...afterExtensions, + ]; +} + async function discoverAndBuildCommands(): Promise { const t0 = Date.now(); console.log('Discovering applications and settings…'); @@ -1847,90 +2283,12 @@ async function discoverAndBuildCommands(): Promise { ]; // Installed community extensions - let extensionCommands: CommandInfo[] = []; - try { - extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({ - id: ext.id, - title: ext.title, - subtitle: ext.extensionTitle, - keywords: ext.keywords, - iconDataUrl: ext.iconDataUrl, - category: 'extension' as const, - path: `${ext.extName}/${ext.cmdName}`, - mode: ext.mode, - interval: ext.interval, - disabledByDefault: ext.disabledByDefault, - commandArgumentDefinitions: ext.commandArgumentDefinitions || [], - deeplink: ext.owner - ? `supercmd://extensions/${encodeURIComponent(ext.owner)}/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}` - : `supercmd://extensions/${encodeURIComponent(ext.extName)}/${encodeURIComponent(ext.cmdName)}`, - })); - } catch (e) { - console.error('Failed to discover installed extensions:', e); - } + const extensionCommands = discoverExtensionCommandInfos(); // Raycast-compatible script commands - let scriptCommands: CommandInfo[] = []; - try { - scriptCommands = discoverScriptCommands().map((script) => ({ - id: script.id, - title: script.title, - subtitle: script.packageName, - keywords: script.keywords, - iconDataUrl: script.iconDataUrl, - iconEmoji: script.iconEmoji, - category: 'script' as const, - path: script.scriptPath, - mode: script.mode, - interval: script.interval, - needsConfirmation: script.needsConfirmation, - commandArgumentDefinitions: script.arguments.map((arg) => ({ - name: arg.name, - required: arg.required, - type: arg.type, - placeholder: arg.placeholder, - title: arg.placeholder, - data: arg.data, - })), - deeplink: script.slug - ? `supercmd://script-commands/${encodeURIComponent(script.slug)}` - : undefined, - })); - } catch (e) { - console.error('Failed to discover script commands:', e); - } - - let quickLinkCommands: CommandInfo[] = []; - try { - const quickLinks = getAllQuickLinks(); - quickLinkCommands = await Promise.all( - quickLinks.map(async (quickLink) => { - const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); - let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); + const scriptCommands = discoverScriptCommandInfos(); - // Prefer real app icon for default quick-link icons so launcher search - // reflects the target application even when stored icon data is stale. - if (!resolvedIconName && quickLink.applicationPath) { - const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); - if (resolvedAppIconDataUrl) { - iconDataUrl = resolvedAppIconDataUrl; - } - } - - return { - id: getQuickLinkCommandId(quickLink.id), - title: quickLink.name, - subtitle: quickLink.applicationName || 'Quick Link', - keywords: buildQuickLinkKeywords(quickLink), - iconDataUrl, - iconName: iconDataUrl ? undefined : resolvedIconName, - category: 'system' as const, - }; - }) - ); - } catch (e) { - console.error('Failed to discover quick links:', e); - } + const quickLinkCommands = await discoverQuickLinkCommandInfos(); const allCommands = [...apps, ...settings, ...extensionCommands, ...scriptCommands, ...quickLinkCommands, ...systemCommands]; @@ -1974,47 +2332,13 @@ async function discoverAndBuildCommands(): Promise { delete cmd._bundlePath; } - // Assign a universal deeplink to any launcher command that doesn't already - // have one (extensions + scripts keep their owner/slug-based schemes above). - // This lets apps, settings, system, and quick-link commands be copied and - // re-invoked via `supercmd://commands/`. - for (const cmd of allCommands) { - if (!cmd.deeplink && cmd.id) { - cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; - } - } - - // Runtime metadata overlays (used by updateCommandMetadata and inline scripts). - try { - const loadedSettings = loadSettings(); - const commandMetadata = loadedSettings.commandMetadata || {}; - const commandAliases = loadedSettings.commandAliases || {}; - for (const cmd of allCommands) { - if (!(cmd.category === 'script' && cmd.mode !== 'inline')) { - const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); - if (subtitle) { - cmd.subtitle = subtitle; - } - } - const alias = String(commandAliases[cmd.id] || '').trim(); - if (alias) { - cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); - } - } - } catch {} - - cachedCommands = allCommands; - cacheTimestamp = Date.now(); - staleCommandsFallback = allCommands; + publishCommandCache(allCommands); console.log( `Discovered ${apps.length} apps, ${settings.length} settings panes, ${extensionCommands.length} extension commands, ${scriptCommands.length} script commands, ${quickLinkCommands.length} quick links in ${Date.now() - t0}ms` ); - // Persist to disk so the next startup can serve commands instantly. - saveCommandsDiskCache(allCommands); - - return cachedCommands; + return allCommands; } function ensureBackgroundRefreshForStaleCache(): void { @@ -2023,7 +2347,7 @@ function ensureBackgroundRefreshForStaleCache(): void { const now = Date.now(); if (now - lastStaleRefreshRequestAt < STALE_REFRESH_COOLDOWN_MS) return; lastStaleRefreshRequestAt = now; - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return cachedCommands || []; @@ -2038,12 +2362,40 @@ export async function refreshCommandsNow(): Promise { return inflightDiscovery; } - inflightDiscovery = discoverAndBuildCommands().finally(() => { + inflightDiscovery = startCommandDiscovery().finally(() => { inflightDiscovery = null; }); return inflightDiscovery; } +export async function refreshCommandsForExtensionChange(): Promise { + if (!cachedCommands && !staleCommandsFallback) { + return refreshCommandsNow(); + } + + if (inflightDiscovery) { + try { + await inflightDiscovery; + } catch (error) { + console.warn('[Commands] Inflight refresh failed before extension refresh:', error); + } + } + + const baseCommands = cachedCommands || staleCommandsFallback; + if (!baseCommands) { + return refreshCommandsNow(); + } + + const t0 = Date.now(); + const extensionCommands = discoverExtensionCommandInfos(); + const nextCommands = rebuildCommandsWithFreshExtensions(baseCommands, extensionCommands); + const refreshed = publishCommandCache(nextCommands); + console.log( + `[Commands] Refreshed ${extensionCommands.length} extension commands from cached app/settings base in ${Date.now() - t0}ms` + ); + return refreshed; +} + export async function getAvailableCommands(): Promise { const now = Date.now(); if (cachedCommands && now - cacheTimestamp < CACHE_TTL) { @@ -2062,7 +2414,7 @@ export async function getAvailableCommands(): Promise { // so the launcher never blocks on discovery after an invalidation event. if (staleCommandsFallback) { if (!inflightDiscovery) { - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return staleCommandsFallback || []; 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..23d83d82 100644 --- a/src/main/extension-runner.ts +++ b/src/main/extension-runner.ts @@ -111,6 +111,80 @@ 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 createNativeSchemeExternalPlugin(): any { + return { + name: 'native-scheme-external', + setup(build: any) { + build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ + path: args.path, + external: true, + })); + }, + }; +} + +function getExtensionBuildExternals(manifestExternal: string[]): string[] { + return [ + 'react', + 'react-dom', + 'react-dom/*', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@raycast/api', + '@raycast/utils', + 're2', + 'better-sqlite3', + 'fsevents', + 'raycast-cross-extension', + 'node-fetch', + 'undici', + 'undici/*', + 'axios', + 'tar', + 'extract-zip', + 'sha256-file', + ...manifestExternal, + ...nodeBuiltins, + ]; +} + function getManagedExtensionsDir(): string { const dir = path.join(app.getPath('userData'), 'extensions'); if (!fs.existsSync(dir)) { @@ -144,6 +218,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 +321,7 @@ function getConfiguredExtensionRoots(): string[] { return [...unique]; } -function collectInstalledExtensions(): InstalledExtensionSource[] { +function scanInstalledExtensions(roots: string[]): InstalledExtensionSource[] { const results: InstalledExtensionSource[] = []; const seen = new Set(); @@ -183,7 +342,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 +365,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 +393,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 +470,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 +492,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 +542,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 +607,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 +892,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; @@ -701,8 +936,7 @@ export async function buildAllCommands(extName: string, extPathOverride?: string fs.rmSync(buildDir, { recursive: true, force: true }); } catch {} fs.mkdirSync(buildDir, { recursive: true }); - let built = 0; - + const buildableCommands: Array<{ cmd: any; entryFile: string; outFile: string }> = []; for (const cmd of commands) { if (!cmd.name) continue; if (!isCommandPlatformCompatible(cmd)) continue; @@ -715,84 +949,81 @@ export async function buildAllCommands(extName: string, extPathOverride?: string const outFile = path.join(buildDir, `${cmd.name}.js`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); + buildableCommands.push({ cmd, entryFile, outFile }); + } - try { - console.log(` Building ${extName}/${cmd.name}…`); - - await runEsbuildBuild( - esbuild, - { - entryPoints: [entryFile], - absWorkingDir: extPath, - bundle: true, - format: 'cjs', - platform: 'node', - outfile: outFile, - plugins: [ - // Mark swift:/rust: imports as external so fakeRequire can handle them at runtime - { - name: 'native-scheme-external', - setup(build: any) { - build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ - path: args.path, - external: true, - })); - }, - }, - ], - external: [ - // React — provided by the renderer at runtime - 'react', - 'react-dom', - 'react-dom/*', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - // Raycast — provided by our shim - '@raycast/api', - '@raycast/utils', - // Native C++ addons — cannot be bundled, we stub them at runtime - 're2', - 'better-sqlite3', - 'fsevents', - // Cross-extension calls — not supported, stubbed - 'raycast-cross-extension', - // Fetch libs — use runtime shims in renderer instead of bundling Node internals - 'node-fetch', - 'undici', - 'undici/*', - // HTTP / file-download / archive packages — must be kept external so our renderer - // shim can intercept them and route file I/O through the main process (which has - // real filesystem access). Bundling them inline breaks binary downloads because the - // browser renderer cannot do streaming file writes or archive extraction natively. - 'axios', - 'tar', - 'extract-zip', - 'sha256-file', - // Respect extension-defined externals from manifest - ...manifestExternal, - // Node.js built-ins — stubbed at runtime in the renderer - ...nodeBuiltins, - ], - nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], - target: 'es2020', - jsx: 'automatic', - jsxImportSource: 'react', - tsconfigRaw: getEsbuildTsconfigRaw(extPath), - define: { - 'process.env.NODE_ENV': '"production"', - 'global': 'globalThis', + if (buildableCommands.length === 0) { + console.log(`Built 0/${commands.length} commands for ${extName}`); + return 0; + } + + const commonOptions = { + absWorkingDir: extPath, + bundle: true, + format: 'cjs', + platform: 'node', + plugins: [createNativeSchemeExternalPlugin()], + external: getExtensionBuildExternals(manifestExternal), + nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], + target: 'es2020', + jsx: 'automatic', + jsxImportSource: 'react', + tsconfigRaw: getEsbuildTsconfigRaw(extPath), + define: { + 'process.env.NODE_ENV': '"production"', + 'global': 'globalThis', + }, + logLevel: 'warning', + }; + + let built = 0; + try { + console.log(` Building ${buildableCommands.length} commands for ${extName}…`); + const entryPoints = Object.fromEntries( + buildableCommands.map(({ cmd, entryFile }) => [String(cmd.name), entryFile]) + ); + await runEsbuildBuild( + esbuild, + { + ...commonOptions, + entryPoints, + outdir: buildDir, + }, + extPath, + `${extName}/*` + ); + + for (const { outFile } of buildableCommands) { + if (fs.existsSync(outFile)) built++; + } + } catch (batchError) { + console.warn( + ` Batched esbuild failed for ${extName}; retrying commands individually:`, + (batchError as any)?.message || batchError + ); + + built = 0; + for (const { cmd, entryFile, outFile } of buildableCommands) { + try { + console.log(` Building ${extName}/${cmd.name}…`); + + await runEsbuildBuild( + esbuild, + { + ...commonOptions, + outfile: outFile, + entryPoints: [entryFile], }, - logLevel: 'warning', - }, - extPath, - `${extName}/${cmd.name}` - ); + extPath, + `${extName}/${cmd.name}` + ); - if (fs.existsSync(outFile)) { - built++; + if (fs.existsSync(outFile)) { + built++; + } + } catch (e) { + console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); } - } catch (e) { - console.error(` esbuild failed for ${extName}/${cmd.name}:`, e); } } @@ -954,11 +1185,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; @@ -1020,27 +1254,8 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom format: 'cjs', platform: 'node', outfile: outFile, - plugins: [ - { - name: 'native-scheme-external', - setup(build: any) { - build.onResolve({ filter: /^(swift|rust):/ }, (args: any) => ({ - path: args.path, - external: true, - })); - }, - }, - ], - external: [ - 'react', 'react-dom', 'react-dom/*', 'react/jsx-runtime', 'react/jsx-dev-runtime', - '@raycast/api', '@raycast/utils', - 're2', 'better-sqlite3', 'fsevents', - 'raycast-cross-extension', - 'node-fetch', 'undici', 'undici/*', - 'axios', 'tar', 'extract-zip', 'sha256-file', - ...manifestExternal, - ...nodeBuiltins, - ], + plugins: [createNativeSchemeExternalPlugin()], + external: getExtensionBuildExternals(manifestExternal), nodePaths: fs.existsSync(extNodeModules) ? [extNodeModules] : [], target: 'es2020', jsx: 'automatic', @@ -1172,8 +1387,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 +1417,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 +1443,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 +1479,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..8baee6ce 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,11 +64,16 @@ 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; const INDEX_SCAN_YIELD_EVERY_DIRECTORIES = 80; +const INDEX_SCAN_YIELD_EVERY_ENTRIES = 2_000; const INDEX_SCAN_PAUSE_MS = 6; +const PATH_QUERY_MAX_UNFILTERED_SCAN_ENTRIES = 80_000; +const PATH_QUERY_SELECTIVE_TERM_MIN_LENGTH = 3; +const SPOTLIGHT_MIN_NAME_QUERY_LENGTH = 2; const execFileAsync = promisify(execFile); @@ -132,6 +138,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 +150,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; @@ -154,6 +163,10 @@ type DirectoryQueueEntry = { resolvedPath?: string; }; +async function yieldIndexScan(): Promise { + await new Promise((resolve) => setTimeout(resolve, INDEX_SCAN_PAUSE_MS)); +} + function normalizeSearchText(value: string): string { return String(value || '') .normalize('NFKD') @@ -263,12 +276,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 +306,7 @@ function indexEntry( ...entry, normalizedName, normalizedPath, + normalizedTildePath, compactName, tokens, pathTokens, @@ -344,6 +359,7 @@ async function buildIndexSnapshot(homeDir: string): Promise { const visitedRealDirectories = new Set(); let queueIndex = 0; let scannedDirectories = 0; + let scannedDirentsSinceYield = 0; while (queueIndex < walkQueue.length) { if (snapshot.entries.length >= MAX_INDEX_ENTRIES) { @@ -373,6 +389,12 @@ async function buildIndexSnapshot(homeDir: string): Promise { } for (const dirent of dirents) { + scannedDirentsSinceYield += 1; + if (scannedDirentsSinceYield >= INDEX_SCAN_YIELD_EVERY_ENTRIES) { + scannedDirentsSinceYield = 0; + await yieldIndexScan(); + } + const name = dirent.name; const absoluteScanPath = path.join(currentDir, name); const absoluteDisplayPath = path.join(currentDisplayPath, name); @@ -445,7 +467,7 @@ async function buildIndexSnapshot(homeDir: string): Promise { scannedDirectories += 1; if (scannedDirectories % INDEX_SCAN_YIELD_EVERY_DIRECTORIES === 0) { - await new Promise((resolve) => setTimeout(resolve, INDEX_SCAN_PAUSE_MS)); + await yieldIndexScan(); } } @@ -589,6 +611,89 @@ 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 getSelectivePathLikeBoundaryTerms(...needles: string[]): string[] { + return [...new Set( + needles + .flatMap((needle) => getPathLikeBoundaryTerms(needle)) + .filter((term) => term.length >= PATH_QUERY_SELECTIVE_TERM_MIN_LENGTH) + )]; +} + +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; + + const indexedLists: number[][] = []; + 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 []; + indexedLists.push(matches); + if (!smallestBucket || matches.length < smallestBucket.length) { + smallestBucket = matches; + } + } + if (snapshot.entries.length <= PATH_QUERY_MAX_UNFILTERED_SCAN_ENTRIES) { + return smallestBucket ? [...smallestBucket] : []; + } + return intersectCandidates(indexedLists); +} + +function shouldScanAllPathEntriesForQuery(snapshot: IndexSnapshot, rawNeedle: string, expandedNeedle: string): boolean { + if (snapshot.entries.length <= PATH_QUERY_MAX_UNFILTERED_SCAN_ENTRIES) return true; + return getSelectivePathLikeBoundaryTerms(rawNeedle, expandedNeedle).length > 0; +} + +function shouldUseSpotlightFallback(spotlightTerm: string, pathLikeQuery: boolean): boolean { + const term = String(spotlightTerm || '').trim(); + if (term.length < SPOTLIGHT_MIN_NAME_QUERY_LENGTH) return false; + if (pathLikeQuery) { + return /[a-z0-9]/i.test(term) && term.replace(/[^a-z0-9]/gi, '').length >= SPOTLIGHT_MIN_NAME_QUERY_LENGTH; + } + return tokenizeSearchText(term).some((token) => token.length >= SPOTLIGHT_MIN_NAME_QUERY_LENGTH); +} + function resolveHomeDir(inputHomeDir?: string): string { const candidate = String(inputHomeDir || '').trim(); if (candidate) return path.resolve(candidate); @@ -632,7 +737,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 +746,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 +767,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 +822,7 @@ function startFileSearchWatcher(): void { if (!configuredHomeDir) return; try { - activeWatcher = fs.watch( + const watcher = fs.watch( configuredHomeDir, { recursive: true, persistent: false }, (_eventType, filename) => { @@ -705,13 +835,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,30 +940,99 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } -function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { - const directIds = new Set(); - for (const deletedPath of deletePaths) { - const id = snapshot.pathToEntryId.get(deletedPath); - if (id !== undefined) directIds.add(id); +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 prefixes = deletePaths.map((p) => p + path.sep); - for (let i = 0; i < snapshot.entries.length; i += 1) { - const entry = snapshot.entries[i]; - if (entry.deleted) continue; - if (directIds.has(i)) { - entry.deleted = true; + 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; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { + collapsedPaths.push(deletePath); + collapsedPathSet.add(deletePath); + } + + return collapsedPaths; +} + +function getDescendantPathPrefix(rootPath: string): string { + return rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; +} + +function markDescendantEntriesDeleted(snapshot: IndexSnapshot, deletedRootPaths: string[]): void { + if (deletedRootPaths.length === 0) return; + + if (deletedRootPaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(deletedRootPaths[0]); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (entry.path.startsWith(descendantPrefix)) { entry.deleted = true; - break; } } + return; + } + + const deletedPathSet = new Set(deletedRootPaths); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + entry.deleted = true; + } } } +function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + + const deletedRootPaths: string[] = []; + for (const deletedPath of collapsedDeletePaths) { + const id = snapshot.pathToEntryId.get(deletedPath); + if (id === undefined) { + deletedRootPaths.push(deletedPath); + continue; + } + + const entry = snapshot.entries[id]; + if (entry && !entry.deleted) { + entry.deleted = true; + } + if (entry?.isDirectory) { + deletedRootPaths.push(deletedPath); + } + } + + markDescendantEntriesDeleted(snapshot, deletedRootPaths); +} + async function walkAddedDirectory(snapshot: IndexSnapshot, dirPath: string): Promise { let dirents: fs.Dirent[] = []; try { @@ -828,8 +1041,15 @@ async function walkAddedDirectory(snapshot: IndexSnapshot, dirPath: string): Pro return; } + let scannedDirentsSinceYield = 0; for (const dirent of dirents) { if (snapshot.entries.length >= MAX_INDEX_ENTRIES) return; + scannedDirentsSinceYield += 1; + if (scannedDirentsSinceYield >= INDEX_SCAN_YIELD_EVERY_ENTRIES) { + scannedDirentsSinceYield = 0; + await yieldIndexScan(); + } + const name = dirent.name; const childPath = path.join(dirPath, name); if (!isWatchablePath(childPath)) continue; @@ -882,6 +1102,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 +1181,18 @@ 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 + ? shouldScanAllPathEntriesForQuery(snapshot, rawNeedle, expandedNeedle) + ? 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; @@ -1002,6 +1281,7 @@ export async function searchIndexedFiles( const spotlightTerm = String(spotlightSearchTerm || '').trim(); if (!spotlightTerm) return indexedResults; + if (!shouldUseSpotlightFallback(spotlightTerm, pathLikeQuery)) return indexedResults; let spotlightStdout = ''; try { @@ -1059,6 +1339,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/icon-ipc-cache.ts b/src/main/icon-ipc-cache.ts new file mode 100644 index 00000000..20dfcfd0 --- /dev/null +++ b/src/main/icon-ipc-cache.ts @@ -0,0 +1,201 @@ +import * as path from 'path'; + +export type FileIconSizeBucket = 'small' | 'normal' | 'large'; + +export type FileIconImageLike = { + isEmpty(): boolean; + resize(options: { width: number; height: number }): { toDataURL(): string }; +}; + +export type FileIconProvider = ( + filePath: string, + options: { size: FileIconSizeBucket }, +) => Promise; + +export type AppIconResolver = (appPath: string, size: number) => string | null; + +export const FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES = 512; +export const APP_ICON_DATA_URL_CACHE_MAX_ENTRIES = 256; + +export class BoundedIconLruCache { + private readonly entries = new Map(); + + constructor(private readonly maxEntries: number) {} + + get size(): number { + return this.entries.size; + } + + get(key: string): Value | undefined { + if (!this.entries.has(key)) return undefined; + const value = this.entries.get(key) as Value; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Value): void { + if (this.maxEntries <= 0) return; + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } + + keys(): string[] { + return Array.from(this.entries.keys()); + } +} + +export function normalizeIconLogicalSize(size: unknown, fallback: number): number { + const numeric = typeof size === 'number' ? size : Number(size); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.max(1, Math.round(numeric)); +} + +export function getFileIconSizeBucket(size: number): FileIconSizeBucket { + if (size <= 16) return 'small'; + if (size >= 64) return 'large'; + return 'normal'; +} + +export function normalizeIconCachePath(filePath: string): string { + const raw = String(filePath || ''); + if (!raw) return ''; + return path.resolve(raw); +} + +export function buildFileIconCacheKey(filePath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 20); + return [ + normalizeIconCachePath(filePath), + String(logicalSize), + getFileIconSizeBucket(logicalSize), + ].join('\0'); +} + +export function buildAppIconCacheKey(appPath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 32); + return [normalizeIconCachePath(appPath), String(logicalSize)].join('\0'); +} + +export function createFileIconDataUrlCache(options: { + getFileIcon: FileIconProvider; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + async function resolve(filePath: string, size: unknown = 20): Promise { + const logicalSize = normalizeIconLogicalSize(size, 20); + const bucket = getFileIconSizeBucket(logicalSize); + const key = buildFileIconCacheKey(filePath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(async () => { + const icon = await options.getFileIcon(filePath, { size: bucket }); + if (!icon || icon.isEmpty()) return null; + const dataUrl = icon.resize({ width: logicalSize, height: logicalSize }).toDataURL(); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + }) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} + +export function createAppIconDataUrlCache(options: { + resolveAppIconDataUrl: AppIconResolver; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + function resolveFromSource(appPath: string, logicalSize: number, key: string): string | null { + try { + const dataUrl = options.resolveAppIconDataUrl(appPath, logicalSize); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + } catch { + return null; + } + } + + function resolveSync(appPath: string, size: unknown = 32): string | null { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + return resolveFromSource(appPath, logicalSize, key); + } + + async function resolve(appPath: string, size: unknown = 32): Promise { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(() => resolveFromSource(appPath, logicalSize, key)) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + resolveSync, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} diff --git a/src/main/launcher-command-payload.ts b/src/main/launcher-command-payload.ts new file mode 100644 index 00000000..daf5c8ff --- /dev/null +++ b/src/main/launcher-command-payload.ts @@ -0,0 +1,66 @@ +export type LauncherCommandPayloadVisibility = { + isAIDisabled(settings: any): boolean; + isAIDependentSystemCommand(commandId: string): boolean; + isAISectionDisabledForCommand(commandId: string, settings: any): boolean; +}; + +export type LauncherCommandPayloadOptions = LauncherCommandPayloadVisibility & { + updateBannerCommand?: any; + updateBannerSignature?: string; +}; + +function getLauncherCommandPayloadCacheKey(settings: any, updateBannerSignature = ''): string { + return JSON.stringify({ + disabledCommands: settings?.disabledCommands || [], + enabledCommands: settings?.enabledCommands || [], + aiEnabled: settings?.ai?.enabled, + aiReadEnabled: settings?.ai?.readEnabled, + aiWhisperEnabled: settings?.ai?.whisperEnabled, + aiLlmEnabled: settings?.ai?.llmEnabled, + updateBanner: updateBannerSignature, + }); +} + +export function estimateLauncherCommandPayloadBytes(payload: any[]): number { + return Buffer.byteLength(JSON.stringify(payload), 'utf8'); +} + +export function createLauncherCommandPayloadCache() { + let cache: { + sourceCommands: any[]; + cacheKey: string; + payload: any[]; + } | null = null; + + return { + clear(): void { + cache = null; + }, + + build(commands: any[], settings: any, options: LauncherCommandPayloadOptions): any[] { + const cacheKey = getLauncherCommandPayloadCacheKey(settings, options.updateBannerSignature || ''); + if (cache && cache.sourceCommands === commands && cache.cacheKey === cacheKey) { + return cache.payload; + } + + const disabled = new Set(settings?.disabledCommands || []); + const enabled = new Set(settings?.enabledCommands || []); + const aiDisabled = options.isAIDisabled(settings); + const filtered = commands.filter((command: any) => { + const commandId = String(command?.id || ''); + if (aiDisabled && options.isAIDependentSystemCommand(commandId)) return false; + if (options.isAISectionDisabledForCommand(commandId, settings)) return false; + if (disabled.has(command.id)) return false; + if (command?.disabledByDefault && !enabled.has(command.id)) return false; + return true; + }); + + if (options.updateBannerCommand) { + filtered.unshift(options.updateBannerCommand); + } + + cache = { sourceCommands: commands, cacheKey, payload: filtered }; + return filtered; + }, + }; +} diff --git a/src/main/local-model-status-probe.ts b/src/main/local-model-status-probe.ts new file mode 100644 index 00000000..767d8f15 --- /dev/null +++ b/src/main/local-model-status-probe.ts @@ -0,0 +1,60 @@ +export const LOCAL_ASR_HELPER_STATUS_CACHE_TTL_MS = 5_000; + +export type LocalAsrHelperStatus = { + state: string; +}; + +export type LocalAsrHelperStatusProbeOptions = { + forceRefresh?: boolean; +}; + +export type LocalAsrHelperStatusProbeCache = { + getStatus: (options?: LocalAsrHelperStatusProbeOptions) => TStatus; + rememberStatus: (status: TStatus) => TStatus; + clear: () => void; +}; + +type CreateLocalAsrHelperStatusProbeCacheOptions = { + readStatus: () => TStatus; + ttlMs?: number; + now?: () => number; +}; + +export function isCacheableLocalAsrHelperStatus(status: LocalAsrHelperStatus | null | undefined): boolean { + return status?.state === 'downloaded' || status?.state === 'not-downloaded'; +} + +export function createLocalAsrHelperStatusProbeCache({ + readStatus, + ttlMs = LOCAL_ASR_HELPER_STATUS_CACHE_TTL_MS, + now = Date.now, +}: CreateLocalAsrHelperStatusProbeCacheOptions): LocalAsrHelperStatusProbeCache { + let cachedStatus: TStatus | null = null; + let cacheExpiresAt = 0; + + function rememberStatus(status: TStatus): TStatus { + if (isCacheableLocalAsrHelperStatus(status)) { + cachedStatus = status; + cacheExpiresAt = now() + ttlMs; + } else { + cachedStatus = null; + cacheExpiresAt = 0; + } + return status; + } + + return { + getStatus(options = {}) { + const currentTime = now(); + if (!options.forceRefresh && cachedStatus && currentTime < cacheExpiresAt) { + return cachedStatus; + } + return rememberStatus(readStatus()); + }, + rememberStatus, + clear() { + cachedStatus = null; + cacheExpiresAt = 0; + }, + }; +} diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..e8842ea1 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,8 +18,11 @@ 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 { createLocalAsrHelperStatusProbeCache } from './local-model-status-probe'; +import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow, refreshCommandsForExtensionChange, applyCommandMetadataUpdate } from './commands'; +import { createLauncherCommandPayloadCache } from './launcher-command-payload'; import { loadSettings, saveSettings, @@ -44,6 +47,12 @@ import { import type { AppSettings, BrowserProfileSetting, BrowserProfileFilters, BrowserProfileFilterKind, RelocateMode } from './settings-store'; import { recordRootSearchLaunchInState, type RootSearchRankingState } from '../shared/root-search-ranking-state'; import { streamAI, streamAIChat, isAIAvailable, transcribeAudio } from './ai-provider'; +import { + createAIStreamIpcCoalescer, + forwardAIStreamChunksToIpc, + type AIStreamIpcCoalescer, +} from './ai-stream-ipc'; +import { transcribeWhisperAudioFile } from './whisper-transcribe-file'; import { scanAppRemnants } from './app-uninstaller'; import * as soulverCalculator from './soulver-calculator'; import { addMemory, buildMemoryContextSystemPrompt } from './memory'; @@ -86,6 +95,10 @@ import { evaluateRendererCrash, RENDERER_RECOVERY_DELAY_MS, } from './renderer-recovery'; +import { + createAppIconDataUrlCache, + createFileIconDataUrlCache, +} from './icon-ipc-cache'; import { startClipboardMonitor, stopClipboardMonitor, @@ -100,6 +113,8 @@ import { togglePinClipboardItem, moveClipboardPinnedItem, pruneClipboardHistoryOlderThan, + flushClipboardHistoryWrites, + hasPendingClipboardHistoryWrites, } from './clipboard-manager'; import { initSnippetStore, @@ -193,6 +208,8 @@ import { exportNoteToFile, exportNotesToFile, importNotesFromFile, + flushNotesToDisk, + hasPendingNotesSave, } from './notes-store'; import { initCanvasStore, @@ -218,6 +235,27 @@ import { importRaycastConfigFromFile, previewRaycastConfigImport, } from './raycast-config-import'; +import { runExecCommand, type ExecCommandOptions } from './exec-command'; +import { + createCachedMenuBarNativeImage, + createMenuBarNativeImageCache, +} from './menubar-native-image-cache'; +import { + createMenuBarNativeUpdateState, + getMenuBarNativeItemsKey, + getMenuBarNativeTitle, + getMenuBarNativeTooltip, + isMenuBarNativeIconRefreshNeeded, + isMenuBarNativeMenuUpdateNeeded, + isMenuBarNativeTitleUpdateNeeded, + isMenuBarNativeTooltipUpdateNeeded, + rememberMenuBarNativeIcon, + rememberMenuBarNativeMenu, + rememberMenuBarNativeTitle, + rememberMenuBarNativeTooltip, + withMenuBarNativeIconFileIdentity, + type MenuBarNativeUpdateState, +} from './menubar-native-update-cache'; import { initialize as initAptabase, trackEvent } from "@aptabase/electron/main"; @@ -255,6 +293,7 @@ type ParakeetModelStatus = { }; let parakeetModelStatus: ParakeetModelStatus | null = null; let parakeetModelEnsurePromise: Promise | null = null; +let parakeetModelStatusProbeCache: ReturnType> | null = null; // Persistent serve-mode process for fast transcription (models stay loaded in memory) let parakeetServerProcess: any = null; // ChildProcess @@ -264,14 +303,15 @@ let parakeetServerBuffer = ''; type PendingParakeetRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let parakeetPendingRequest: PendingParakeetRequest | null = null; -function killParakeetServer(): void { - if (parakeetServerProcess) { +function killParakeetServer(processToKill: any = parakeetServerProcess): void { + if (processToKill) { try { - parakeetServerProcess.stdin?.write('{"command":"exit"}\n'); - parakeetServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - parakeetServerProcess = null; } + if (processToKill && parakeetServerProcess !== processToKill) return; + parakeetServerProcess = null; parakeetServerReady = false; parakeetServerStarting = null; parakeetServerBuffer = ''; @@ -287,7 +327,7 @@ function ensureParakeetServer(): Promise { } if (parakeetServerStarting) return parakeetServerStarting; - parakeetServerStarting = (async () => { + const startingPromise = (async () => { killParakeetServer(); const binaryPath = getParakeetTranscriberBinaryPath(); if (!fs.existsSync(binaryPath)) { @@ -305,6 +345,7 @@ function ensureParakeetServer(): Promise { }); child.on('exit', (code: number | null) => { + if (parakeetServerProcess !== child) return; console.log(`[Parakeet] Server process exited with code ${code}`); parakeetServerReady = false; parakeetServerProcess = null; @@ -316,6 +357,7 @@ function ensureParakeetServer(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { + if (parakeetServerProcess !== child) return; parakeetServerBuffer += chunk.toString(); const lines = parakeetServerBuffer.split('\n'); parakeetServerBuffer = lines.pop() || ''; @@ -345,28 +387,39 @@ function ensureParakeetServer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (parakeetServerProcess !== child) return; reject(new Error('Parakeet server startup timed out (120s)')); - killParakeetServer(); + killParakeetServer(child); }, 120_000); const checkReady = setInterval(() => { + if (parakeetServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Parakeet server startup superseded')); + return; + } if (parakeetServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!parakeetServerProcess || parakeetServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Parakeet server process died during startup')); } }, 50); }); - - parakeetServerStarting = null; })(); - return parakeetServerStarting; + parakeetServerStarting = startingPromise; + startingPromise.then( + () => { if (parakeetServerStarting === startingPromise) parakeetServerStarting = null; }, + () => { if (parakeetServerStarting === startingPromise) parakeetServerStarting = null; } + ); + return startingPromise; } function sendParakeetRequest(request: Record): Promise { @@ -393,15 +446,7 @@ function getParakeetTranscriberBinaryPath(): string { return getNativeBinaryPath('parakeet-transcriber'); } -function getParakeetModelStatus(): ParakeetModelStatus { - if (parakeetModelStatus?.state === 'downloading') { - return { ...parakeetModelStatus }; - } - if (parakeetModelStatus?.state === 'error') { - return { ...parakeetModelStatus }; - } - - // Ask the binary for the real status +function readParakeetModelStatusFromHelper(): ParakeetModelStatus { const binaryPath = getParakeetTranscriberBinaryPath(); try { if (!fs.existsSync(binaryPath)) { @@ -446,9 +491,36 @@ function getParakeetModelStatus(): ParakeetModelStatus { return parakeetModelStatus; } +function getParakeetModelStatusProbeCache(): ReturnType> { + if (!parakeetModelStatusProbeCache) { + parakeetModelStatusProbeCache = createLocalAsrHelperStatusProbeCache({ + readStatus: readParakeetModelStatusFromHelper, + }); + } + return parakeetModelStatusProbeCache; +} + +function rememberParakeetModelStatus(status: ParakeetModelStatus): ParakeetModelStatus { + parakeetModelStatus = status; + getParakeetModelStatusProbeCache().rememberStatus(status); + return status; +} + +function getParakeetModelStatus(options: { forceRefresh?: boolean } = {}): ParakeetModelStatus { + if (parakeetModelStatus?.state === 'downloading') { + return { ...parakeetModelStatus }; + } + if (parakeetModelStatus?.state === 'error') { + return { ...parakeetModelStatus }; + } + + parakeetModelStatus = getParakeetModelStatusProbeCache().getStatus(options); + return parakeetModelStatus; +} + async function ensureParakeetModelDownloaded(): Promise { // Check if already downloaded - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state === 'downloaded' && status.path) { return status.path; } @@ -522,12 +594,12 @@ async function ensureParakeetModelDownloaded(): Promise { }); }); - parakeetModelStatus = { + rememberParakeetModelStatus({ state: 'downloaded', modelName: 'parakeet-tdt-0.6b-v3', path: modelPath, progress: 1, - }; + }); console.log(`[Parakeet] Models ready at ${modelPath}`); return modelPath; } catch (error) { @@ -573,7 +645,7 @@ async function transcribeAudioWithParakeet(opts: { language?: string; mimeType?: string; }): Promise { - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state === 'downloading') { throw new Error('Parakeet models are still downloading. Finish setup from onboarding or Settings -> AI -> SuperCmd Whisper.'); } @@ -611,6 +683,7 @@ type Qwen3ModelStatus = { }; let qwen3ModelStatus: Qwen3ModelStatus | null = null; let qwen3ModelEnsurePromise: Promise | null = null; +let qwen3ModelStatusProbeCache: ReturnType> | null = null; let qwen3ServerProcess: any = null; let qwen3ServerReady = false; @@ -619,14 +692,15 @@ let qwen3ServerBuffer = ''; type PendingQwen3Request = { resolve: (json: any) => void; reject: (err: Error) => void }; let qwen3PendingRequest: PendingQwen3Request | null = null; -function killQwen3Server(): void { - if (qwen3ServerProcess) { +function killQwen3Server(processToKill: any = qwen3ServerProcess): void { + if (processToKill) { try { - qwen3ServerProcess.stdin?.write('{"command":"exit"}\n'); - qwen3ServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - qwen3ServerProcess = null; } + if (processToKill && qwen3ServerProcess !== processToKill) return; + qwen3ServerProcess = null; qwen3ServerReady = false; qwen3ServerStarting = null; qwen3ServerBuffer = ''; @@ -642,7 +716,7 @@ function ensureQwen3Server(): Promise { } if (qwen3ServerStarting) return qwen3ServerStarting; - qwen3ServerStarting = (async () => { + const startingPromise = (async () => { killQwen3Server(); const binaryPath = getParakeetTranscriberBinaryPath(); if (!fs.existsSync(binaryPath)) { @@ -660,6 +734,7 @@ function ensureQwen3Server(): Promise { }); child.on('exit', (code: number | null) => { + if (qwen3ServerProcess !== child) return; console.log(`[Qwen3] Server process exited with code ${code}`); qwen3ServerReady = false; qwen3ServerProcess = null; @@ -671,6 +746,7 @@ function ensureQwen3Server(): Promise { }); child.stdout.on('data', (chunk: Buffer) => { + if (qwen3ServerProcess !== child) return; qwen3ServerBuffer += chunk.toString(); const lines = qwen3ServerBuffer.split('\n'); qwen3ServerBuffer = lines.pop() || ''; @@ -699,28 +775,39 @@ function ensureQwen3Server(): Promise { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (qwen3ServerProcess !== child) return; reject(new Error('Qwen3 server startup timed out (120s)')); - killQwen3Server(); + killQwen3Server(child); }, 120_000); const checkReady = setInterval(() => { + if (qwen3ServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Qwen3 server startup superseded')); + return; + } if (qwen3ServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!qwen3ServerProcess || qwen3ServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Qwen3 server process died during startup')); } }, 50); }); - - qwen3ServerStarting = null; })(); - return qwen3ServerStarting; + qwen3ServerStarting = startingPromise; + startingPromise.then( + () => { if (qwen3ServerStarting === startingPromise) qwen3ServerStarting = null; }, + () => { if (qwen3ServerStarting === startingPromise) qwen3ServerStarting = null; } + ); + return startingPromise; } function sendQwen3Request(request: Record): Promise { @@ -743,10 +830,7 @@ function sendQwen3Request(request: Record): Promise { }); } -function getQwen3ModelStatus(): Qwen3ModelStatus { - if (qwen3ModelStatus?.state === 'downloading') return { ...qwen3ModelStatus }; - if (qwen3ModelStatus?.state === 'error') return { ...qwen3ModelStatus }; - +function readQwen3ModelStatusFromHelper(): Qwen3ModelStatus { const binaryPath = getParakeetTranscriberBinaryPath(); try { if (!fs.existsSync(binaryPath)) { @@ -771,8 +855,31 @@ function getQwen3ModelStatus(): Qwen3ModelStatus { return qwen3ModelStatus; } +function getQwen3ModelStatusProbeCache(): ReturnType> { + if (!qwen3ModelStatusProbeCache) { + qwen3ModelStatusProbeCache = createLocalAsrHelperStatusProbeCache({ + readStatus: readQwen3ModelStatusFromHelper, + }); + } + return qwen3ModelStatusProbeCache; +} + +function rememberQwen3ModelStatus(status: Qwen3ModelStatus): Qwen3ModelStatus { + qwen3ModelStatus = status; + getQwen3ModelStatusProbeCache().rememberStatus(status); + return status; +} + +function getQwen3ModelStatus(options: { forceRefresh?: boolean } = {}): Qwen3ModelStatus { + if (qwen3ModelStatus?.state === 'downloading') return { ...qwen3ModelStatus }; + if (qwen3ModelStatus?.state === 'error') return { ...qwen3ModelStatus }; + + qwen3ModelStatus = getQwen3ModelStatusProbeCache().getStatus(options); + return qwen3ModelStatus; +} + async function ensureQwen3ModelDownloaded(): Promise { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state === 'downloaded' && status.path) return status.path; if (qwen3ModelEnsurePromise) return await qwen3ModelEnsurePromise; @@ -816,7 +923,7 @@ async function ensureQwen3ModelDownloaded(): Promise { }); }); - qwen3ModelStatus = { state: 'downloaded', modelName: 'qwen3-asr-0.6b', path: modelPath, progress: 1 }; + rememberQwen3ModelStatus({ state: 'downloaded', modelName: 'qwen3-asr-0.6b', path: modelPath, progress: 1 }); console.log(`[Qwen3] Models ready at ${modelPath}`); return modelPath; } catch (error) { @@ -835,7 +942,7 @@ async function transcribeAudioWithQwen3(opts: { language?: string; mimeType?: string; }): Promise { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state === 'downloading') throw new Error('Qwen3 models are still downloading.'); if (status.state !== 'downloaded') throw new Error('Qwen3 models have not been downloaded yet. Download them from Settings -> AI -> SuperCmd Whisper.'); @@ -1153,14 +1260,15 @@ let whisperCppServerBuffer = ''; type PendingWhisperCppRequest = { resolve: (json: any) => void; reject: (err: Error) => void }; let whisperCppPendingRequest: PendingWhisperCppRequest | null = null; -function killWhisperCppServer(): void { - if (whisperCppServerProcess) { +function killWhisperCppServer(processToKill: any = whisperCppServerProcess): void { + if (processToKill) { try { - whisperCppServerProcess.stdin?.write('{"command":"exit"}\n'); - whisperCppServerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - whisperCppServerProcess = null; } + if (processToKill && whisperCppServerProcess !== processToKill) return; + whisperCppServerProcess = null; whisperCppServerReady = false; whisperCppServerStarting = null; whisperCppServerBuffer = ''; @@ -1176,7 +1284,7 @@ function ensureWhisperCppServer(): Promise { } if (whisperCppServerStarting) return whisperCppServerStarting; - whisperCppServerStarting = (async () => { + const startingPromise = (async () => { killWhisperCppServer(); const binaryPath = ensureWhisperCppTranscriberBinary(); const modelStatus = getWhisperCppModelStatus(); @@ -1191,6 +1299,7 @@ function ensureWhisperCppServer(): Promise { whisperCppServerProcess = child; child.on('exit', (code: number | null) => { + if (whisperCppServerProcess !== child) return; console.log(`[Whisper][whisper.cpp] Server process exited with code ${code}`); whisperCppServerReady = false; whisperCppServerProcess = null; @@ -1202,6 +1311,7 @@ function ensureWhisperCppServer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { + if (whisperCppServerProcess !== child) return; whisperCppServerBuffer += chunk.toString(); const lines = whisperCppServerBuffer.split('\n'); whisperCppServerBuffer = lines.pop() || ''; @@ -1236,28 +1346,39 @@ function ensureWhisperCppServer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (whisperCppServerProcess !== child) return; reject(new Error('Whisper.cpp server startup timed out (60s)')); - killWhisperCppServer(); + killWhisperCppServer(child); }, 60_000); const checkReady = setInterval(() => { + if (whisperCppServerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Whisper.cpp server startup superseded')); + return; + } if (whisperCppServerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!whisperCppServerProcess || whisperCppServerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Whisper.cpp server process died during startup')); } }, 50); }); - - whisperCppServerStarting = null; })(); - return whisperCppServerStarting; + whisperCppServerStarting = startingPromise; + startingPromise.then( + () => { if (whisperCppServerStarting === startingPromise) whisperCppServerStarting = null; }, + () => { if (whisperCppServerStarting === startingPromise) whisperCppServerStarting = null; } + ); + return startingPromise; } function sendWhisperCppRequest(request: Record): Promise { @@ -1420,14 +1541,15 @@ function getAudioCapturerBinaryPath(): string { return getNativeBinaryPath('audio-capturer'); } -function killAudioCapturer(): void { - if (audioCapturerProcess) { +function killAudioCapturer(processToKill: any = audioCapturerProcess): void { + if (processToKill) { try { - audioCapturerProcess.stdin?.write('{"command":"exit"}\n'); - audioCapturerProcess.kill(); + processToKill.stdin?.write('{"command":"exit"}\n'); + processToKill.kill(); } catch {} - audioCapturerProcess = null; } + if (processToKill && audioCapturerProcess !== processToKill) return; + audioCapturerProcess = null; audioCapturerReady = false; audioCapturerStarting = null; audioCapturerBuffer = ''; @@ -1499,7 +1621,7 @@ function warmAudioCapturer(): Promise { } if (audioCapturerStarting) return audioCapturerStarting; - audioCapturerStarting = (async () => { + const startingPromise = (async () => { killAudioCapturer(); const binaryPath = ensureAudioCapturerBinary(); @@ -1510,6 +1632,7 @@ function warmAudioCapturer(): Promise { audioCapturerProcess = child; child.on('exit', (code: number | null) => { + if (audioCapturerProcess !== child) return; console.log(`[AudioCapturer] Process exited with code ${code}`); audioCapturerReady = false; audioCapturerProcess = null; @@ -1522,6 +1645,7 @@ function warmAudioCapturer(): Promise { }); child.stdout.on('data', (chunk: Buffer | string) => { + if (audioCapturerProcess !== child) return; audioCapturerBuffer += chunk.toString(); const lines = audioCapturerBuffer.split('\n'); audioCapturerBuffer = lines.pop() || ''; @@ -1580,28 +1704,39 @@ function warmAudioCapturer(): Promise { // Wait for "ready" signal await new Promise((resolve, reject) => { const timeout = setTimeout(() => { + if (audioCapturerProcess !== child) return; reject(new Error('Audio capturer warmup timed out (30s)')); - killAudioCapturer(); + killAudioCapturer(child); }, 30_000); const checkReady = setInterval(() => { + if (audioCapturerProcess !== child) { + clearInterval(checkReady); + clearTimeout(timeout); + reject(new Error('Audio capturer warmup superseded')); + return; + } if (audioCapturerReady) { clearInterval(checkReady); clearTimeout(timeout); resolve(); + return; } - if (!audioCapturerProcess || audioCapturerProcess.killed) { + if (child.killed) { clearInterval(checkReady); clearTimeout(timeout); reject(new Error('Audio capturer process died during warmup')); } }, 50); }); - - audioCapturerStarting = null; })(); - return audioCapturerStarting; + audioCapturerStarting = startingPromise; + startingPromise.then( + () => { if (audioCapturerStarting === startingPromise) audioCapturerStarting = null; }, + () => { if (audioCapturerStarting === startingPromise) audioCapturerStarting = null; } + ); + return startingPromise; } async function startNativeAudioCapture(): Promise { @@ -2480,6 +2615,8 @@ let memoryStatusHideTimer: NodeJS.Timeout | null = null; let memoryStatusFadeFinalizeTimer: NodeJS.Timeout | null = null; let memoryStatusRenderSeq = 0; let memoryStatusHideTimerSeq = 0; +let lastNoViewStatusReportKey = ''; +let lastNoViewStatusReportAt = 0; let confettiWindow: InstanceType | null = null; let confettiCloseTimer: NodeJS.Timeout | null = null; let settingsWindow: InstanceType | null = null; @@ -2814,6 +2951,20 @@ async function showMemoryStatusBar( } } +function shouldAcceptNoViewStatusReport( + variant: 'processing' | 'success' | 'error', + text: string, + now = Date.now(), +): boolean { + const key = `${variant}\u0000${String(text || '')}`; + if (lastNoViewStatusReportKey === key && now - lastNoViewStatusReportAt < 100) { + return false; + } + lastNoViewStatusReportKey = key; + lastNoViewStatusReportAt = now; + return true; +} + function getConfettiWindowHtml(): string { return ` @@ -3072,9 +3223,18 @@ function resolveAppIconDataUrl(appPath: string, size = 32): string | null { return null; } } +const appIconDataUrlCache = createAppIconDataUrlCache({ resolveAppIconDataUrl }); +const fileIconDataUrlCache = createFileIconDataUrlCache({ + getFileIcon: (filePath, options) => app.getFileIcon(filePath, options), +}); let launcherEntryFrontmostApp: FrontmostAppContext | null = null; const registeredHotkeys = new Map(); // shortcut → commandId -const activeAIRequests = new Map(); // requestId → controller +type ActiveAIRequest = { + controller: AbortController; + stream: AIStreamIpcCoalescer; +}; +const activeAIRequests = new Map(); // requestId → active request +const activeOllamaPullRequests = new Map(); // requestId → controller const pendingOAuthCallbackUrls: string[] = []; let snippetExpanderProcess: any = null; let snippetExpanderStdoutBuffer = ''; @@ -3096,7 +3256,26 @@ let emojiPickerWindow: InstanceType | null = null; let emojiPickerCurrentQuery = ''; let emojiPickerCurrentPrefixLen = 1; let emojiPickerSelectedIdx = 0; +let emojiPickerCurrentMatches: EmojiEntry[] = []; let nativeSpeechProcess: any = null; + +function startActiveAIRequest(requestId: string, sender: { send: (channel: 'ai-stream-chunk', payload: { requestId: string; chunk: string }) => void }): ActiveAIRequest { + activeAIRequests.get(requestId)?.stream.flush(); + const controller = new AbortController(); + const request = { + controller, + stream: createAIStreamIpcCoalescer({ requestId, sender }), + }; + activeAIRequests.set(requestId, request); + return request; +} + +function finishActiveAIRequest(requestId: string, request: ActiveAIRequest): void { + request.stream.flush(); + if (activeAIRequests.get(requestId) === request) { + activeAIRequests.delete(requestId); + } +} let nativeSpeechStdoutBuffer = ''; let nativeColorPickerPromise: Promise | null = null; let keyboardLockProcess: any = null; @@ -3197,48 +3376,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 { @@ -7369,8 +7512,19 @@ app.on('open-url', (event: any, url: string) => { // ─── Menu Bar (Tray) Management ───────────────────────────────────── const menuBarTrays = new Map>(); +const menuBarNativeImageCache = createMenuBarNativeImageCache(); +const menuBarNativeUpdateStates = new Map(); let appTray: InstanceType | null = null; +function getMenuBarNativeUpdateState(extId: string): MenuBarNativeUpdateState { + let state = menuBarNativeUpdateStates.get(extId); + if (!state) { + state = createMenuBarNativeUpdateState(); + menuBarNativeUpdateStates.set(extId, state); + } + return state; +} + function buildDefaultMacTrayTemplateIcon(): any | null { if (process.platform !== 'darwin') return null; try { @@ -10104,6 +10258,7 @@ async function renderEmojiPicker(query: string, caret: CaretRect | null, prefixL emojiPickerCurrentPrefixLen = prefixLen; emojiPickerSelectedIdx = 0; const matches = searchEmojiTriggerMatches(query); + emojiPickerCurrentMatches = matches; if (matches.length === 0) { hideEmojiPicker(); return; @@ -10126,7 +10281,7 @@ async function renderEmojiPicker(query: string, caret: CaretRect | null, prefixL function updateEmojiPickerSelection(delta: number): void { if (!emojiPickerWindow || emojiPickerWindow.isDestroyed() || !emojiPickerWindow.isVisible()) return; - const matches = searchEmojiTriggerMatches(emojiPickerCurrentQuery); + const matches = emojiPickerCurrentMatches; if (matches.length === 0) return; emojiPickerSelectedIdx = (emojiPickerSelectedIdx + delta + matches.length) % matches.length; const js = `window.__render(${JSON.stringify(matches)}, ${emojiPickerSelectedIdx});`; @@ -10137,6 +10292,7 @@ function hideEmojiPicker(): void { emojiPickerCurrentQuery = ''; emojiPickerCurrentPrefixLen = 1; // reset so a stale value never corrupts deletion count emojiPickerSelectedIdx = 0; + emojiPickerCurrentMatches = []; writeEmojiTriggerCmd({ cmd: 'intercept', enabled: false }); if (emojiPickerWindow && !emojiPickerWindow.isDestroyed() && emojiPickerWindow.isVisible()) { try { emojiPickerWindow.hide(); } catch {} @@ -10181,7 +10337,7 @@ async function insertEmojiReplacingTrigger(emoji: string, queryLen: number, pref } function handleEmojiTriggerNav(key: string): void { - const matches = searchEmojiTriggerMatches(emojiPickerCurrentQuery); + const matches = emojiPickerCurrentMatches; if (matches.length === 0) { hideEmojiPicker(); return; } if (key === 'left') { updateEmojiPickerSelection(-1); @@ -10244,6 +10400,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', ]); @@ -12664,6 +12821,7 @@ function broadcastExtensionUninstalled(extensionName: string): void { } function broadcastCommandsUpdated(): void { + clearLauncherCommandPayloadCache(); for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; try { @@ -12672,6 +12830,49 @@ function broadcastCommandsUpdated(): void { } } +const launcherCommandPayloadCache = createLauncherCommandPayloadCache(); + +function getUpdateBannerCommandVisibilityKey(): string { + return [ + appUpdaterStatusSnapshot.state, + appUpdaterStatusSnapshot.latestVersion || '', + autoUpdateDownloadedVersion || '', + isUpdateBannerDismissed() ? 'dismissed' : 'visible', + ].join('|'); +} + +function clearLauncherCommandPayloadCache(): void { + launcherCommandPayloadCache.clear(); +} + +function getUpdateBannerCommand(): any | undefined { + if (appUpdaterStatusSnapshot.state !== 'downloaded' || isUpdateBannerDismissed()) return undefined; + const version = appUpdaterStatusSnapshot.latestVersion || autoUpdateDownloadedVersion || app.getVersion(); + const tadaIconDataUrl = `data:image/svg+xml;base64,${Buffer.from( + '', + 'utf8' + ).toString('base64')}`; + return { + id: 'system-update-and-reopen', + title: 'Update and Restart', + subtitle: `Version ${version} downloaded and ready to install`, + keywords: ['update', 'reopen', 'restart', 'install', 'version', version], + category: 'system', + iconDataUrl: tadaIconDataUrl, + alwaysOnTop: true, + }; +} + +function buildLauncherCommandPayload(commands: Awaited>, settings: AppSettings): any[] { + return launcherCommandPayloadCache.build(commands, settings, { + isAIDisabled: isAIDisabledInSettings, + isAIDependentSystemCommand, + isAISectionDisabledForCommand, + updateBannerCommand: getUpdateBannerCommand(), + updateBannerSignature: getUpdateBannerCommandVisibilityKey(), + }); +} + function broadcastExtensionPreferencesUpdated(extensionName: string): void { const normalized = String(extensionName || '').trim(); if (!normalized) return; @@ -13315,19 +13516,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(() => { @@ -13650,7 +13855,7 @@ app.whenReady().then(async () => { } // Register the sc-asset:// protocol handler to serve extension asset files - protocol.handle('sc-asset', (request: any) => { + protocol.handle('sc-asset', async (request: any) => { // URL format: sc-asset://ext-asset/path/to/file try { const url = new URL(request.url); @@ -13660,9 +13865,8 @@ app.whenReady().then(async () => { if (!relPath) return new Response('Bad Request', { status: 400 }); const canvasLibPath = path.join(app.getPath('userData'), 'canvas-lib'); const fullPath = path.join(canvasLibPath, relPath); - console.log('[sc-asset:canvas-lib] Serving:', fullPath); try { - const data = fs.readFileSync(fullPath); + const data = await fs.promises.readFile(fullPath); const ext = path.extname(fullPath).toLowerCase(); const mimeTypes: Record = { '.js': 'application/javascript', @@ -13677,7 +13881,6 @@ app.whenReady().then(async () => { headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }, }); } catch (e) { - console.error('[sc-asset:canvas-lib] File not found:', fullPath); return new Response('Not Found', { status: 404 }); } } @@ -13825,38 +14028,7 @@ app.whenReady().then(async () => { ipcMain.handle('get-commands', async () => { const s = loadSettings(); const commands = await getAvailableCommands(); - const disabled = new Set(s.disabledCommands || []); - const enabled = new Set((s as any).enabledCommands || []); - const aiDisabled = isAIDisabledInSettings(s); - const filtered = commands.filter((c: any) => { - const commandId = String(c?.id || ''); - if (aiDisabled && isAIDependentSystemCommand(commandId)) return false; - if (isAISectionDisabledForCommand(commandId, s)) return false; - if (disabled.has(c.id)) return false; - if (c?.disabledByDefault && !enabled.has(c.id)) return false; - return true; - }); - - // Prepend the update banner command whenever an update is downloaded and ready — - // regardless of whether it came from the background auto-check or a manual trigger. - if (appUpdaterStatusSnapshot.state === 'downloaded' && !isUpdateBannerDismissed()) { - const version = appUpdaterStatusSnapshot.latestVersion || autoUpdateDownloadedVersion || app.getVersion(); - const tadaIconDataUrl = `data:image/svg+xml;base64,${Buffer.from( - '', - 'utf8' - ).toString('base64')}`; - filtered.unshift({ - id: 'system-update-and-reopen', - title: 'Update and Restart', - subtitle: `Version ${version} downloaded and ready to install`, - keywords: ['update', 'reopen', 'restart', 'install', 'version', version], - category: 'system', - iconDataUrl: tadaIconDataUrl, - alwaysOnTop: true, - }); - } - - return filtered; + return buildLauncherCommandPayload(commands, s); }); ipcMain.handle('dismiss-update-banner', () => { @@ -13901,7 +14073,9 @@ app.whenReady().then(async () => { }); ipcMain.handle('no-view-status', (_event: Electron.IpcMainInvokeEvent, variant: 'processing' | 'success' | 'error', text: string) => { - void showMemoryStatusBar(variant, String(text || '')); + const normalizedText = String(text || ''); + if (!shouldAcceptNoViewStatusReport(variant, normalizedText)) return; + void showMemoryStatusBar(variant, normalizedText); }); ipcMain.handle('show-confetti', () => { @@ -15142,7 +15316,7 @@ app.whenReady().then(async () => { delete metadata[executed.commandId]; } saveSettings({ commandMetadata: metadata }); - invalidateCache(); + applyCommandMetadataUpdate(executed.commandId, { subtitle: subtitle || null }); } if (!background && (executed.mode === 'compact' || executed.mode === 'silent')) { @@ -15237,7 +15411,10 @@ app.whenReady().then(async () => { saveSettings({ commandMetadata: settings.commandMetadata }); // Notify all windows to refresh command list - invalidateCache(); + const patchResult = applyCommandMetadataUpdate(commandId, metadata); + if (patchResult.changedCommands > 0) { + broadcastCommandsUpdated(); + } return { success: true }; } catch (e: any) { console.error('update-command-metadata error:', e); @@ -15249,6 +15426,15 @@ app.whenReady().then(async () => { // ─── IPC: Extension APIs (for @raycast/api compatibility) ──────── // HTTP request proxy (so extensions can make Node.js HTTP requests without CORS) + const activeHttpRequests = new Map void>(); + + ipcMain.on('http-request-cancel', (_event: any, requestId: string) => { + if (!requestId) return; + const cancel = activeHttpRequests.get(requestId); + if (!cancel) return; + cancel(); + }); + ipcMain.handle( 'http-request', async ( @@ -15258,6 +15444,7 @@ app.whenReady().then(async () => { method?: string; headers?: Record; body?: string; + requestId?: string; } ) => { const http = require('http'); @@ -15275,8 +15462,24 @@ app.whenReady().then(async () => { } } catch {} + const requestId = typeof options.requestId === 'string' ? options.requestId : ''; + let canceled = false; + + const resolveCanceled = (url: string) => ({ + status: 0, + statusText: 'Request canceled', + headers: {}, + bodyText: '', + url, + }); + const doRequest = (url: string, method: string, headers: Record, body: string | undefined, redirectsLeft: number): Promise => { return new Promise((resolve) => { + if (canceled) { + resolve(resolveCanceled(url)); + return; + } + try { const parsedUrl = new URL(url); const transport = parsedUrl.protocol === 'https:' ? https : http; @@ -15293,6 +15496,12 @@ app.whenReady().then(async () => { }; const req = transport.request(reqOptions, (res: any) => { + if (canceled) { + res.resume(); + resolve(resolveCanceled(url)); + return; + } + // Follow redirects (301, 302, 303, 307, 308) if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); // drain the response @@ -15304,8 +15513,15 @@ app.whenReady().then(async () => { } const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('data', (chunk: Buffer) => { + if (!canceled) chunks.push(chunk); + }); res.on('end', () => { + if (canceled) { + resolve(resolveCanceled(url)); + return; + } + const bodyBuffer = Buffer.concat(chunks); const contentEncoding = String(res.headers['content-encoding'] || '').toLowerCase(); let decodedBuffer = bodyBuffer; @@ -15337,6 +15553,11 @@ app.whenReady().then(async () => { }); req.on('error', (err: Error) => { + if (canceled) { + resolve(resolveCanceled(url)); + return; + } + resolve({ status: 0, statusText: err.message, @@ -15357,6 +15578,17 @@ app.whenReady().then(async () => { }); }); + if (requestId) { + activeHttpRequests.set(requestId, () => { + canceled = true; + try { + req.destroy(new Error('Request canceled')); + } catch { + req.destroy(); + } + }); + } + if (body) { req.write(body); } @@ -15373,7 +15605,11 @@ app.whenReady().then(async () => { }); }; - return doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + try { + return await doRequest(requestUrl, (options.method || 'GET').toUpperCase(), options.headers || {}, options.body, 5); + } finally { + if (requestId) activeHttpRequests.delete(requestId); + } } ); @@ -15384,91 +15620,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); } ); @@ -15988,7 +16142,7 @@ return appURL's |path|() as text`, // actually targeted (bundlePath), so it does not depend on // lastFrontmostApp.path being populated. const iconPath = String(result?.appPath || targetAppPath || '').trim(); - const appIconDataUrl = iconPath ? resolveAppIconDataUrl(iconPath, 32) : null; + const appIconDataUrl = iconPath ? appIconDataUrlCache.resolveSync(iconPath, 32) : null; resolve({ ...result, appIconDataUrl }); } catch { resolve({ ok: false, error: stderr || 'Failed to parse menu item search output' }); @@ -16302,6 +16456,28 @@ return appURL's |path|() as text`, } }); + ipcMain.handle('stat', async (_event: any, filePath: string) => { + try { + const stat = await fs.promises.stat(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, + }; + } catch { + return { exists: false, isDirectory: false, isFile: false, size: 0 }; + } + }); + ipcMain.handle('write-file', async (_event: any, filePath: string, content: string) => { try { // Ensure directory exists @@ -16331,20 +16507,12 @@ return appURL's |path|() as text`, }); ipcMain.handle('get-file-icon-data-url', async (_event: any, filePath: string, size = 20) => { - try { - const icon = await app.getFileIcon(filePath, { size: size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal' }); - if (icon && !icon.isEmpty()) { - return icon.resize({ width: size, height: size }).toDataURL(); - } - return null; - } catch { - return null; - } + return fileIconDataUrlCache.resolve(filePath, size); }); // Get .app bundle icon by reading its .icns file directly (avoids template-image transparency issues) ipcMain.handle('get-app-icon-data-url', async (_event: any, appPath: string, size = 32) => { - return resolveAppIconDataUrl(appPath, size); + return appIconDataUrlCache.resolve(appPath, size); }); ipcMain.handle('file-search-query', async (_event: any, query: string, options?: { limit?: number }) => { @@ -16429,12 +16597,10 @@ return appURL's |path|() as text`, if (!success) { throw new Error(`Failed to install extension "${name}". Check SuperCmd main-process logs for details.`); } - // Invalidate the command cache and rebuild it BEFORE we broadcast, so + // Refresh extension commands BEFORE we broadcast, so // the renderer's follow-up get-commands fetch lands on fresh data - // rather than the stale fallback that getAvailableCommands() returns - // immediately after an invalidation. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after install failed:', e); } + // without rediscovering unrelated apps/settings when a cache exists. + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after install failed:', e); } broadcastExtensionsUpdated(); // The launcher's root list listens for 'commands-updated', not // 'extensions-updated' — without this, the new extension wouldn't @@ -16452,10 +16618,9 @@ return appURL's |path|() as text`, async (_event: any, name: string) => { const success = await uninstallExtension(name); if (success) { - // Invalidate the command cache and rebuild synchronously before + // Refresh extension commands synchronously before // broadcasting — see install-extension handler for context. - invalidateCache(); - try { await refreshCommandsNow(); } catch (e) { console.warn('refreshCommandsNow after uninstall failed:', e); } + try { await refreshCommandsForExtensionChange(); } catch (e) { console.warn('refreshCommandsForExtensionChange after uninstall failed:', e); } // Tell the launcher renderer to tear down any live runners (menu-bar // tray, background no-view loop, interval re-runner) for this // extension before its bundle keeps trying to re-mount itself. @@ -16531,12 +16696,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) => { @@ -17644,8 +17809,8 @@ if let tiff = image?.tiffRepresentation { return; } - const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + const activeRequest = startActiveAIRequest(requestId, event.sender); + const { controller, stream } = activeRequest; try { const memoryContextSystemPrompt = await buildMemoryContextSystemPrompt( @@ -17665,30 +17830,35 @@ if let tiff = image?.tiffRepresentation { signal: controller.signal, }); - for await (const chunk of gen) { - if (controller.signal.aborted) break; - event.sender.send('ai-stream-chunk', { requestId, chunk }); - } + await forwardAIStreamChunksToIpc(gen, stream, controller.signal); if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-done', { requestId }); } } catch (e: any) { if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-error', { requestId, error: e?.message || 'AI request failed' }); } } finally { - activeAIRequests.delete(requestId); + finishActiveAIRequest(requestId, activeRequest); } } ); ipcMain.handle('ai-cancel', (_event: any, requestId: string) => { - const controller = activeAIRequests.get(requestId); - if (controller) { - controller.abort(); + const activeRequest = activeAIRequests.get(requestId); + if (activeRequest) { + activeRequest.stream.flush(); + activeRequest.controller.abort(); activeAIRequests.delete(requestId); } + const ollamaPullController = activeOllamaPullRequests.get(requestId); + if (ollamaPullController) { + ollamaPullController.abort(); + activeOllamaPullRequests.delete(requestId); + } }); ipcMain.handle( @@ -17709,8 +17879,8 @@ if let tiff = image?.tiffRepresentation { return; } - const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + const activeRequest = startActiveAIRequest(requestId, event.sender); + const { controller, stream } = activeRequest; try { const latestUser = [...(messages || [])].reverse().find((m) => m.role === 'user'); @@ -17731,20 +17901,19 @@ if let tiff = image?.tiffRepresentation { signal: controller.signal, }); - for await (const chunk of gen) { - if (controller.signal.aborted) break; - event.sender.send('ai-stream-chunk', { requestId, chunk }); - } + await forwardAIStreamChunksToIpc(gen, stream, controller.signal); if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-done', { requestId }); } } catch (e: any) { if (!controller.signal.aborted) { + stream.flush(); event.sender.send('ai-stream-error', { requestId, error: e?.message || 'AI request failed' }); } } finally { - activeAIRequests.delete(requestId); + finishActiveAIRequest(requestId, activeRequest); } } ); @@ -17778,11 +17947,11 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle('parakeet-download-model', async () => { await ensureParakeetModelDownloaded(); - return getParakeetModelStatus(); + return getParakeetModelStatus({ forceRefresh: true }); }); ipcMain.handle('parakeet-warmup', async () => { - const status = getParakeetModelStatus(); + const status = getParakeetModelStatus({ forceRefresh: true }); if (status.state !== 'downloaded') { return { ready: false, error: 'Models not downloaded' }; } @@ -17803,11 +17972,11 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle('qwen3-download-model', async () => { await ensureQwen3ModelDownloaded(); - return getQwen3ModelStatus(); + return getQwen3ModelStatus({ forceRefresh: true }); }); ipcMain.handle('qwen3-warmup', async () => { - const status = getQwen3ModelStatus(); + const status = getQwen3ModelStatus({ forceRefresh: true }); if (status.state !== 'downloaded') { return { ready: false, error: 'Models not downloaded' }; } @@ -17892,102 +18061,28 @@ if let tiff = image?.tiffRepresentation { ipcMain.handle( 'whisper-transcribe-file', async (_event: any, audioPath: string, options?: { language?: string }) => { - const s = loadSettings(); - if (isAIDisabledInSettings(s)) { - throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); - } - if (s.ai?.whisperEnabled === false) { - throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); - } - - if (!fs.existsSync(audioPath)) { - throw new Error(`Audio file not found: ${audioPath}`); - } - - const audioBuffer = fs.readFileSync(audioPath); - - // Reuse the existing whisper-transcribe logic by reading the file into a buffer - const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; - const language = normalizeWhisperLanguageCode(rawLang); - - let provider: 'parakeet' | 'qwen3' | 'whispercpp' | 'openai' | 'elevenlabs' | 'mistral' = 'whispercpp'; - let model = `ggml-${WHISPERCPP_MODEL_NAME}`; - const sttModel = s.ai.speechToTextModel || ''; - if (sttModel === 'parakeet') { - provider = 'parakeet'; - model = 'parakeet-tdt-0.6b-v3'; - } else if (sttModel === 'qwen3') { - provider = 'qwen3'; - model = 'qwen3-asr-0.6b'; - } else if (!sttModel || sttModel === 'default' || sttModel === 'whispercpp') { - provider = 'whispercpp'; - model = `ggml-${WHISPERCPP_MODEL_NAME}`; - } else if (sttModel === 'native') { - return ''; - } else if (sttModel.startsWith('openai-')) { - provider = 'openai'; - model = sttModel.slice('openai-'.length); - } else if (sttModel.startsWith('elevenlabs-')) { - provider = 'elevenlabs'; - model = resolveElevenLabsSttModel(sttModel); - } else if (sttModel.startsWith('mistral-')) { - provider = 'mistral'; - model = sttModel.slice('mistral-'.length) || 'voxtral-mini-latest'; - } else if (sttModel) { - model = sttModel; - } - - if (provider === 'openai' && !s.ai.openaiApiKey) { - throw new Error('OpenAI API key not configured.'); - } - const elevenLabsApiKey = getElevenLabsApiKey(s); - if (provider === 'elevenlabs' && !elevenLabsApiKey) { - throw new Error('ElevenLabs API key not configured.'); - } - const mistralApiKey = getMistralApiKey(s); - if (provider === 'mistral' && !mistralApiKey) { - throw new Error('Mistral API key not configured.'); - } - - // For whisper.cpp, use the file-path directly via the persistent server - // to avoid reading the file into a Node buffer just to write it again. - if (provider === 'whispercpp') { - const status = getWhisperCppModelStatus(); - if (status.state === 'downloading') { - throw new Error('Whisper model still downloading.'); - } - if (status.state !== 'downloaded') { - throw new Error('Whisper model not downloaded.'); - } - await ensureWhisperCppServer(); - const result = await sendWhisperCppRequest({ - command: 'transcribe', - file: audioPath, - language, - }); - // Clean up the temp file after transcription - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - return result.text || ''; - } - - // For cloud providers, use buffer-based transcription - const mimeType = 'audio/wav'; - const text = provider === 'parakeet' - ? await transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) - : provider === 'qwen3' - ? await transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) - : provider === 'elevenlabs' - ? await transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) - : provider === 'mistral' - ? await transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) - : await transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); - - // Clean up the temp file - try { fs.unlinkSync(audioPath); } catch {} - try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} - - return text; + return await transcribeWhisperAudioFile({ + audioPath, + options, + deps: { + fs, + loadSettings, + isAIDisabledInSettings, + normalizeWhisperLanguageCode, + resolveElevenLabsSttModel, + getElevenLabsApiKey, + getMistralApiKey, + getWhisperCppModelStatus, + ensureWhisperCppServer, + sendWhisperCppRequest, + transcribeAudioWithParakeet, + transcribeAudioWithQwen3, + transcribeAudioWithElevenLabs, + transcribeAudioWithMistralVoxtral, + transcribeAudio, + whisperCppModelName: WHISPERCPP_MODEL_NAME, + }, + }); } ); ipcMain.handle( @@ -18270,7 +18365,7 @@ if let tiff = image?.tiffRepresentation { const mod = url.protocol === 'https:' ? require('https') : require('http'); const controller = new AbortController(); - activeAIRequests.set(requestId, controller); + activeOllamaPullRequests.set(requestId, controller); const body = JSON.stringify({ name: modelName, stream: true }); @@ -18291,7 +18386,7 @@ if let tiff = image?.tiffRepresentation { requestId, error: `HTTP ${res.statusCode}: ${errBody.slice(0, 200)}`, }); - activeAIRequests.delete(requestId); + activeOllamaPullRequests.delete(requestId); }); return; } @@ -18335,7 +18430,7 @@ if let tiff = image?.tiffRepresentation { if (!controller.signal.aborted) { event.sender.send('ollama-pull-done', { requestId }); } - activeAIRequests.delete(requestId); + activeOllamaPullRequests.delete(requestId); }); } ); @@ -18347,7 +18442,7 @@ if let tiff = image?.tiffRepresentation { error: err.message || 'Failed to pull model', }); } - activeAIRequests.delete(requestId); + activeOllamaPullRequests.delete(requestId); }); if (controller.signal.aborted) { @@ -19086,97 +19181,50 @@ if let tiff = image?.tiffRepresentation { // Update / create a menu-bar Tray when the renderer sends menu structure ipcMain.on('menubar-update', (_event: any, data: any) => { - const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, title, tooltip, items } = data; + const { extId, iconPath, iconDataUrl, iconEmoji, iconTemplate, iconBitmapScale, fallbackIconDataUrl, items } = data; + const iconUpdatePayload = withMenuBarNativeIconFileIdentity(fs, data); let tray = menuBarTrays.get(extId); - - const createNativeImageFromMenuIcon = ( - payload: { pathValue?: string; dataUrlValue?: string; bitmapScale?: number }, - size: number, - ) => { - try { - const fs = require('fs'); - let image: any; - const dataUrlValue = String(payload?.dataUrlValue || '').trim(); - const pathValue = String(payload?.pathValue || '').trim(); - const requestedScale = Number(payload?.bitmapScale); - const bitmapScale = Number.isFinite(requestedScale) && requestedScale >= 1 ? requestedScale : 1; - - if (dataUrlValue.startsWith('data:')) { - // For raster PNG data URLs that the renderer pre-rasterized at higher - // DPR (bitmapScale > 1), reconstruct via createFromBuffer with the - // matching scaleFactor so the Tray treats it as a retina rep instead - // of stretching a low-res bitmap. - const isRasterPng = dataUrlValue.startsWith('data:image/png'); - if (isRasterPng && bitmapScale > 1) { - const commaIdx = dataUrlValue.indexOf(','); - const base64Body = commaIdx >= 0 ? dataUrlValue.slice(commaIdx + 1) : ''; - const buf = base64Body ? Buffer.from(base64Body, 'base64') : null; - if (buf && buf.length > 0) { - image = nativeImage.createFromBuffer(buf, { scaleFactor: bitmapScale }); - } - } - if (!image || image.isEmpty?.()) { - image = nativeImage.createFromDataURL(dataUrlValue); - } - } else { - if (!pathValue || !fs.existsSync(pathValue)) return null; - image = nativeImage.createFromPath(pathValue); - if ((!image || image.isEmpty()) && /\.svg$/i.test(pathValue)) { - const svg = fs.readFileSync(pathValue, 'utf8'); - const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; - image = nativeImage.createFromDataURL(svgDataUrl); - } - } - if (!image || image.isEmpty()) return null; - - // When the source already carries a retina backing (scaleFactor > 1), - // resizing to logical px would discard the @2x rep — keep it intact. - const currentSize = image.getSize?.() || { width: 0, height: 0 }; - if (bitmapScale > 1 && currentSize.width === size && currentSize.height === size) { - return image; - } - return image.resize({ width: size, height: size, quality: 'best' }); - } catch { - return null; - } - }; + const updateState = getMenuBarNativeUpdateState(extId); let lastResolvedTrayIconOk = false; const hasEmojiIcon = typeof iconEmoji === 'string' && iconEmoji.trim().length > 0; + const isPrimaryGeneratedDataUrl = typeof iconDataUrl === 'string' && iconDataUrl.startsWith('data:'); + const isPrimarySvgPath = /\.svg$/i.test(iconPath || ''); + const primaryTemplate = + typeof iconTemplate === 'boolean' + ? iconTemplate + : (isPrimaryGeneratedDataUrl ? true : !isPrimarySvgPath); const resolveTrayIcon = () => { - const primaryImg = createNativeImageFromMenuIcon( - { pathValue: iconPath, dataUrlValue: iconDataUrl, bitmapScale: iconBitmapScale }, - 18, - ); - const usingPrimary = Boolean(primaryImg); + const primaryImg = createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + pathValue: iconPath, + dataUrlValue: iconDataUrl, + bitmapScale: iconBitmapScale, + size: 18, + template: primaryTemplate, + resizeQuality: 'best', + }); // When the extension supplies an emoji as its tray icon (e.g. "🎉"), we render that // emoji as the tray title and leave the tray image empty. Falling back to the // extension's package icon here would produce two visuals side-by-side in one slot. const img = primaryImg || - (hasEmojiIcon ? null : createNativeImageFromMenuIcon({ dataUrlValue: fallbackIconDataUrl }, 18)); + (hasEmojiIcon + ? null + : createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + dataUrlValue: fallbackIconDataUrl, + size: 18, + template: false, + resizeQuality: 'best', + })); lastResolvedTrayIconOk = Boolean(img); - if (img) { - // Raycast icon tokens are serialized as data URLs and should be template images - // so macOS can adapt them to menu bar foreground contrast. - const isGeneratedDataUrl = typeof iconDataUrl === 'string' && iconDataUrl.startsWith('data:'); - // Keep template rendering for bitmap assets (classic menubar style). - // For SVG asset paths, preserve source appearance (e.g., explicit light/dark icon variants). - const isSvg = /\.svg$/i.test(iconPath || ''); - const shouldTemplate = - !usingPrimary - ? false - : ( - typeof iconTemplate === 'boolean' - ? iconTemplate - : (isGeneratedDataUrl ? true : !isSvg) - ); - try { - img.setTemplateImage(shouldTemplate); - } catch {} - return img; - } + if (img) return img; return nativeImage.createEmpty(); }; @@ -19184,33 +19232,39 @@ if let tiff = image?.tiffRepresentation { const icon = resolveTrayIcon(); tray = new Tray(icon); menuBarTrays.set(extId, tray); + rememberMenuBarNativeIcon(updateState, iconUpdatePayload, lastResolvedTrayIconOk); + } else if (isMenuBarNativeIconRefreshNeeded(updateState, iconUpdatePayload)) { + // Refresh icon on accepted updates after creation (first payload can be incomplete). + tray.setImage(resolveTrayIcon()); + rememberMenuBarNativeIcon(updateState, iconUpdatePayload, lastResolvedTrayIconOk); } - // Always refresh icon on update (first payload can be incomplete). - tray.setImage(resolveTrayIcon()); - // Update title: if there's a text title, show it; if only emoji icon, show that - if (title) { - tray.setTitle(title); - } else if (iconEmoji) { - tray.setTitle(iconEmoji); - } else if (!lastResolvedTrayIconOk) { - // Keep tray visible even when extension provides neither icon nor title. - tray.setTitle('⏱'); - } else { - tray.setTitle(''); + const nextTitle = getMenuBarNativeTitle(data, updateState.lastResolvedTrayIconOk); + if (isMenuBarNativeTitleUpdateNeeded(updateState, nextTitle)) { + tray.setTitle(nextTitle); + rememberMenuBarNativeTitle(updateState, nextTitle); + } + const nextTooltip = getMenuBarNativeTooltip(data); + if (isMenuBarNativeTooltipUpdateNeeded(updateState, nextTooltip)) { + tray.setToolTip(nextTooltip); + rememberMenuBarNativeTooltip(updateState, nextTooltip); } - if (tooltip) tray.setToolTip(tooltip); // Build native menu from serialized items - const menuTemplate = buildMenuBarTemplate(items, extId); - const menu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(menu); + const nextItemsKey = getMenuBarNativeItemsKey(items); + if (isMenuBarNativeMenuUpdateNeeded(updateState, nextItemsKey)) { + const menuTemplate = buildMenuBarTemplate(items, extId); + const menu = Menu.buildFromTemplate(menuTemplate); + tray.setContextMenu(menu); + rememberMenuBarNativeMenu(updateState, nextItemsKey); + } }); ipcMain.on('menubar-remove', (_event: any, data: any) => { const extId = String(data?.extId || '').trim(); if (!extId) return; + menuBarNativeUpdateStates.delete(extId); const tray = menuBarTrays.get(extId); if (!tray) return; try { @@ -19225,31 +19279,19 @@ if let tiff = image?.tiffRepresentation { const iconDataUrl = typeof item?.iconDataUrl === 'string' ? item.iconDataUrl.trim() : ''; const iconPath = typeof item?.iconPath === 'string' ? item.iconPath : ''; const explicitTemplate = typeof item?.iconTemplate === 'boolean' ? item.iconTemplate : undefined; - try { - let img: any; - if (iconDataUrl.startsWith('data:')) { - img = nativeImage.createFromDataURL(iconDataUrl); - } else { - if (!iconPath) return undefined; - const fs = require('fs'); - if (!fs.existsSync(iconPath)) return undefined; - img = nativeImage.createFromPath(iconPath); - if ((!img || img.isEmpty()) && /\.svg$/i.test(iconPath)) { - const svg = fs.readFileSync(iconPath, 'utf8'); - const svgDataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; - img = nativeImage.createFromDataURL(svgDataUrl); - } - } - if (!img || img.isEmpty()) return undefined; - const shouldTemplate = - explicitTemplate ?? (iconDataUrl.startsWith('data:image/svg+xml') ? true : false); - const resized = img.resize({ width: 16, height: 16 }); - try { - resized.setTemplateImage(shouldTemplate); - } catch {} - return resized; - } catch {} - return undefined; + const shouldTemplate = + explicitTemplate ?? (iconDataUrl.startsWith('data:image/svg+xml') ? true : false); + const image = createCachedMenuBarNativeImage({ + cache: menuBarNativeImageCache, + nativeImage, + fs, + pathValue: iconPath, + dataUrlValue: iconDataUrl, + bitmapScale: item?.iconBitmapScale, + size: 16, + template: shouldTemplate, + }); + return image || undefined; }; const labelWithEmoji = (item: any) => { @@ -19424,8 +19466,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 +19527,7 @@ app.on('will-quit', () => { killParakeetServer(); killQwen3Server(); killAudioCapturer(); - stopClipboardMonitor(); + void stopClipboardMonitor(); stopSnippetExpander(); stopEmojiTriggerMonitor(); stopFileSearchIndexing(); @@ -19465,4 +19541,6 @@ app.on('will-quit', () => { tray.destroy(); } menuBarTrays.clear(); + menuBarNativeImageCache.clear(); + menuBarNativeUpdateStates.clear(); }); diff --git a/src/main/menubar-native-image-cache.ts b/src/main/menubar-native-image-cache.ts new file mode 100644 index 00000000..6a6abc77 --- /dev/null +++ b/src/main/menubar-native-image-cache.ts @@ -0,0 +1,273 @@ +/** + * Main-process MenuBarExtra native image cache. + * + * Electron's NativeImage creation can decode and resize bitmap data every time + * a menu-bar payload is accepted. This helper keeps successful decoded/resized + * images keyed by visible icon inputs while preserving template-image state. + */ + +export type MenuBarNativeImageLike = { + isEmpty?: () => boolean; + getSize?: () => { width: number; height: number }; + resize?: (options: { width: number; height: number; quality?: string }) => MenuBarNativeImageLike; + setTemplateImage?: (isTemplate: boolean) => void; +}; + +export type MenuBarNativeImageFactory = { + createFromBuffer: (buffer: Buffer, options?: { scaleFactor?: number }) => MenuBarNativeImageLike; + createFromDataURL: (dataUrl: string) => MenuBarNativeImageLike; + createFromPath: (pathValue: string) => MenuBarNativeImageLike; +}; + +export type MenuBarFsLike = { + statSync: (pathValue: string) => { + size?: number; + mtimeMs?: number; + isFile?: () => boolean; + }; + readFileSync: (pathValue: string, encoding: 'utf8') => string; +}; + +export type MenuBarNativeImageCache = { + getImage: (key: string) => MenuBarNativeImageLike | null; + setImage: (key: string, image: MenuBarNativeImageLike) => void; + getSvgDataUrl: (key: string) => string | null; + setSvgDataUrl: (key: string, dataUrl: string) => void; + clear: () => void; + readonly imageSize: number; + readonly svgDataUrlSize: number; +}; + +export type CreateCachedMenuBarNativeImageOptions = { + cache: MenuBarNativeImageCache; + nativeImage: MenuBarNativeImageFactory; + fs: MenuBarFsLike; + pathValue?: string; + dataUrlValue?: string; + bitmapScale?: number; + size: number; + template: boolean; + resizeQuality?: string; +}; + +const DEFAULT_MAX_ENTRIES = 256; + +class BoundedLruMap { + private readonly maxEntries: number; + private readonly entries = new Map(); + + constructor(maxEntries: number) { + this.maxEntries = Math.max(1, Math.floor(maxEntries)); + } + + get size(): number { + return this.entries.size; + } + + get(key: string): T | null { + if (!this.entries.has(key)) return null; + const value = this.entries.get(key) as T; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: T): void { + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } +} + +class MenuBarNativeImageCacheImpl implements MenuBarNativeImageCache { + private readonly images: BoundedLruMap; + private readonly svgDataUrls: BoundedLruMap; + + constructor(maxEntries: number) { + this.images = new BoundedLruMap(maxEntries); + this.svgDataUrls = new BoundedLruMap(maxEntries); + } + + get imageSize(): number { + return this.images.size; + } + + get svgDataUrlSize(): number { + return this.svgDataUrls.size; + } + + getImage(key: string): MenuBarNativeImageLike | null { + return this.images.get(key); + } + + setImage(key: string, image: MenuBarNativeImageLike): void { + this.images.set(key, image); + } + + getSvgDataUrl(key: string): string | null { + return this.svgDataUrls.get(key); + } + + setSvgDataUrl(key: string, dataUrl: string): void { + this.svgDataUrls.set(key, dataUrl); + } + + clear(): void { + this.images.clear(); + this.svgDataUrls.clear(); + } +} + +export function createMenuBarNativeImageCache(maxEntries = DEFAULT_MAX_ENTRIES): MenuBarNativeImageCache { + return new MenuBarNativeImageCacheImpl(maxEntries); +} + +export function normalizeMenuBarBitmapScale(bitmapScale: unknown): number { + const requestedScale = Number(bitmapScale); + return Number.isFinite(requestedScale) && requestedScale >= 1 ? requestedScale : 1; +} + +export function createCachedMenuBarNativeImage( + options: CreateCachedMenuBarNativeImageOptions, +): MenuBarNativeImageLike | null { + const { + cache, + nativeImage, + fs, + size, + template, + resizeQuality, + } = options; + const dataUrlValue = String(options.dataUrlValue || '').trim(); + const pathValue = String(options.pathValue || '').trim(); + const bitmapScale = normalizeMenuBarBitmapScale(options.bitmapScale); + const resizeKey = `size=${size}|scale=${bitmapScale}|template=${template ? 1 : 0}|quality=${resizeQuality || ''}`; + + try { + if (dataUrlValue.startsWith('data:')) { + const key = `data:${dataUrlValue}|${resizeKey}`; + const cached = cache.getImage(key); + if (cached) return cached; + + let image: MenuBarNativeImageLike | null = null; + const isRasterPng = dataUrlValue.startsWith('data:image/png'); + if (isRasterPng && bitmapScale > 1) { + const commaIdx = dataUrlValue.indexOf(','); + const base64Body = commaIdx >= 0 ? dataUrlValue.slice(commaIdx + 1) : ''; + const buffer = base64Body ? Buffer.from(base64Body, 'base64') : null; + if (buffer && buffer.length > 0) { + image = nativeImage.createFromBuffer(buffer, { scaleFactor: bitmapScale }); + } + } + if (isEmptyNativeImage(image)) { + image = nativeImage.createFromDataURL(dataUrlValue); + } + const prepared = prepareNativeImageForMenu(image, size, bitmapScale, template, resizeQuality); + if (!prepared) return null; + cache.setImage(key, prepared); + return prepared; + } + + if (!pathValue) return null; + const pathIdentity = getMenuBarPathIdentity(fs, pathValue); + if (!pathIdentity) return null; + + const key = `path:${pathIdentity.cacheKey}|${resizeKey}`; + const cached = cache.getImage(key); + if (cached) return cached; + + let image = nativeImage.createFromPath(pathValue); + if (isEmptyNativeImage(image) && pathIdentity.isSvg) { + const svgDataUrl = getCachedSvgDataUrl(cache, fs, pathValue, pathIdentity.cacheKey); + if (svgDataUrl) image = nativeImage.createFromDataURL(svgDataUrl); + } + const prepared = prepareNativeImageForMenu(image, size, bitmapScale, template, resizeQuality); + if (!prepared) return null; + cache.setImage(key, prepared); + return prepared; + } catch { + return null; + } +} + +function getMenuBarPathIdentity( + fs: MenuBarFsLike, + pathValue: string, +): { cacheKey: string; isSvg: boolean } | null { + try { + const stat = fs.statSync(pathValue); + if (typeof stat?.isFile === 'function' && !stat.isFile()) return null; + const size = Number.isFinite(Number(stat?.size)) ? Number(stat.size) : 0; + const mtimeMs = Number.isFinite(Number(stat?.mtimeMs)) ? Number(stat.mtimeMs) : 0; + return { + cacheKey: `${pathValue}|mtimeMs=${mtimeMs}|bytes=${size}`, + isSvg: /\.svg$/i.test(pathValue), + }; + } catch { + return null; + } +} + +function getCachedSvgDataUrl( + cache: MenuBarNativeImageCache, + fs: MenuBarFsLike, + pathValue: string, + pathIdentityKey: string, +): string | null { + const cached = cache.getSvgDataUrl(pathIdentityKey); + if (cached) return cached; + try { + const svg = fs.readFileSync(pathValue, 'utf8'); + const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + cache.setSvgDataUrl(pathIdentityKey, dataUrl); + return dataUrl; + } catch { + return null; + } +} + +function prepareNativeImageForMenu( + image: MenuBarNativeImageLike | null | undefined, + size: number, + bitmapScale: number, + template: boolean, + resizeQuality?: string, +): MenuBarNativeImageLike | null { + if (isEmptyNativeImage(image)) return null; + let prepared = image as MenuBarNativeImageLike; + + const currentSize = prepared.getSize?.() || { width: 0, height: 0 }; + const shouldKeepRetinaRep = + bitmapScale > 1 && currentSize.width === size && currentSize.height === size; + if (!shouldKeepRetinaRep) { + if (typeof prepared.resize !== 'function') return null; + prepared = prepared.resize({ + width: size, + height: size, + ...(resizeQuality ? { quality: resizeQuality } : {}), + }); + if (isEmptyNativeImage(prepared)) return null; + } + + try { + prepared.setTemplateImage?.(template); + } catch {} + return prepared; +} + +function isEmptyNativeImage(image: MenuBarNativeImageLike | null | undefined): boolean { + if (!image) return true; + try { + return Boolean(image.isEmpty?.()); + } catch { + return true; + } +} diff --git a/src/main/menubar-native-update-cache.ts b/src/main/menubar-native-update-cache.ts new file mode 100644 index 00000000..399da51b --- /dev/null +++ b/src/main/menubar-native-update-cache.ts @@ -0,0 +1,180 @@ +/** + * Main-process MenuBarExtra native update cache. + * + * Tracks the last native tray sub-payload applied per extension so title-only + * ticks do not rebuild unchanged Electron menu templates. + */ + +export type MenuBarNativeUpdatePayload = { + iconPath?: unknown; + iconPathMtimeMs?: unknown; + iconPathSize?: unknown; + iconDataUrl?: unknown; + iconEmoji?: unknown; + iconTemplate?: unknown; + iconBitmapScale?: unknown; + fallbackIconDataUrl?: unknown; + title?: unknown; + tooltip?: unknown; + items?: unknown; +}; + +export type MenuBarNativeUpdateFsLike = { + statSync: (pathValue: string) => { + size?: number; + mtimeMs?: number; + isFile?: () => boolean; + }; +}; + +export type MenuBarNativeUpdateState = { + iconKey: string | null; + lastResolvedTrayIconOk: boolean; + title: string | null; + tooltip: string | null; + itemsKey: string | null; +}; + +export function createMenuBarNativeUpdateState(): MenuBarNativeUpdateState { + return { + iconKey: null, + lastResolvedTrayIconOk: false, + title: null, + tooltip: null, + itemsKey: null, + }; +} + +export function getMenuBarNativeIconKey(payload: MenuBarNativeUpdatePayload): string { + return safeJsonStringify([ + payload.iconPath ?? null, + payload.iconPathMtimeMs ?? null, + payload.iconPathSize ?? null, + payload.iconDataUrl ?? null, + payload.iconEmoji ?? null, + typeof payload.iconTemplate === 'boolean' ? payload.iconTemplate : null, + payload.iconBitmapScale ?? null, + payload.fallbackIconDataUrl ?? null, + ]); +} + +export function hasFileBackedMenuBarNativeIcon(payload: MenuBarNativeUpdatePayload): boolean { + return typeof payload.iconPath === 'string' && payload.iconPath.trim().length > 0; +} + +export function withMenuBarNativeIconFileIdentity( + fs: MenuBarNativeUpdateFsLike, + payload: MenuBarNativeUpdatePayload, +): MenuBarNativeUpdatePayload { + if (!hasFileBackedMenuBarNativeIcon(payload)) return payload; + + const iconPath = String(payload.iconPath || '').trim(); + try { + const stat = fs.statSync(iconPath); + if (typeof stat?.isFile === 'function' && !stat.isFile()) return payload; + return { + ...payload, + iconPathMtimeMs: Number.isFinite(Number(stat?.mtimeMs)) ? Number(stat.mtimeMs) : 0, + iconPathSize: Number.isFinite(Number(stat?.size)) ? Number(stat.size) : 0, + }; + } catch { + return { + ...payload, + iconPathMtimeMs: null, + iconPathSize: null, + }; + } +} + +export function isMenuBarNativeIconRefreshNeeded( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, +): boolean { + const nextIconKey = getMenuBarNativeIconKey(payload); + return state.iconKey !== nextIconKey; +} + +export function rememberMenuBarNativeIcon( + state: MenuBarNativeUpdateState, + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, +): void { + state.iconKey = getMenuBarNativeIconKey(payload); + state.lastResolvedTrayIconOk = lastResolvedTrayIconOk; +} + +export function getMenuBarNativeTitle( + payload: MenuBarNativeUpdatePayload, + lastResolvedTrayIconOk: boolean, +): string { + const title = normalizeMenuBarNativeString(payload.title); + if (title) return title; + const iconEmoji = normalizeMenuBarNativeString(payload.iconEmoji); + if (iconEmoji) return iconEmoji; + return lastResolvedTrayIconOk ? '' : '\u23f1'; +} + +export function isMenuBarNativeTitleUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTitle: string, +): boolean { + return state.title !== nextTitle; +} + +export function rememberMenuBarNativeTitle( + state: MenuBarNativeUpdateState, + nextTitle: string, +): void { + state.title = nextTitle; +} + +export function getMenuBarNativeTooltip(payload: MenuBarNativeUpdatePayload): string { + return normalizeMenuBarNativeString(payload.tooltip); +} + +export function isMenuBarNativeTooltipUpdateNeeded( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): boolean { + return nextTooltip.length > 0 && state.tooltip !== nextTooltip; +} + +export function rememberMenuBarNativeTooltip( + state: MenuBarNativeUpdateState, + nextTooltip: string, +): void { + state.tooltip = nextTooltip; +} + +export function getMenuBarNativeItemsKey(items: unknown): string { + return safeJsonStringify(items ?? []); +} + +export function isMenuBarNativeMenuUpdateNeeded( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): boolean { + return state.itemsKey !== nextItemsKey; +} + +export function rememberMenuBarNativeMenu( + state: MenuBarNativeUpdateState, + nextItemsKey: string, +): void { + state.itemsKey = nextItemsKey; +} + +function normalizeMenuBarNativeString(value: unknown): string { + if (typeof value === 'string') return value; + if (value == null) return ''; + return String(value); +} + +function safeJsonStringify(value: unknown): string { + try { + const result = JSON.stringify(value); + return typeof result === 'string' ? result : ''; + } catch { + return String(value); + } +} 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/preload.ts b/src/main/preload.ts index eff106ef..9abe82e5 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -454,6 +454,7 @@ const electronAPI = { method?: string; headers?: Record; body?: string; + requestId?: string; }): Promise<{ status: number; statusText: string; @@ -461,6 +462,9 @@ const electronAPI = { bodyText: string; url: string; }> => ipcRenderer.invoke('http-request', options), + cancelHttpRequest: (requestId: string): void => { + ipcRenderer.send('http-request-cancel', requestId); + }, // Download a URL via Node.js (avoids renderer CORS restrictions for binary CDN downloads) httpDownloadBinary: (url: string): Promise => @@ -580,6 +584,8 @@ const electronAPI = { ipcRenderer.sendSync('file-exists-sync', filePath), statSync: (filePath: string): { exists: boolean; isDirectory: boolean; isFile: boolean; size: number } => ipcRenderer.sendSync('stat-sync', filePath), + stat: (filePath: string): Promise<{ exists: boolean; isDirectory: boolean; isFile: boolean; size: number }> => + ipcRenderer.invoke('stat', filePath), // Write file writeFile: (filePath: string, content: string): Promise => @@ -1108,13 +1114,19 @@ const electronAPI = { ollamaOpenDownload: (): Promise => ipcRenderer.invoke('ollama-open-download'), onOllamaPullProgress: (callback: (data: { requestId: string; status: string; digest: string; total: number; completed: number }) => void) => { - ipcRenderer.on('ollama-pull-progress', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-progress', listener); + return () => { ipcRenderer.removeListener('ollama-pull-progress', listener); }; }, onOllamaPullDone: (callback: (data: { requestId: string }) => void) => { - ipcRenderer.on('ollama-pull-done', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-done', listener); + return () => { ipcRenderer.removeListener('ollama-pull-done', listener); }; }, onOllamaPullError: (callback: (data: { requestId: string; error: string }) => void) => { - ipcRenderer.on('ollama-pull-error', (_event: any, data: any) => callback(data)); + const listener = (_event: any, data: any) => callback(data); + ipcRenderer.on('ollama-pull-error', listener); + return () => { ipcRenderer.removeListener('ollama-pull-error', listener); }; }, // ─── Hyper Key ────────────────────────────────────────────────── diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..b2463cef 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,8 +57,12 @@ 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; +let cache: { fetchedAt: number; commands: ScriptCommandInfo[]; signature: string } | null = null; +const iconDataUrlCache = new Map(); function getSuperCmdScriptsDir(): string { const dir = path.join(app.getPath('userData'), 'script-commands'); @@ -125,6 +131,33 @@ function fileToDataUrl(filePath: string): string | undefined { } } +function getIconFileSignature(iconPath: string): string | null { + try { + const stat = fs.statSync(iconPath); + if (!stat.isFile()) return null; + return `${stat.size}:${stat.mtimeMs}`; + } catch { + return null; + } +} + +function getCachedIconDataUrl(iconPath: string): string | undefined { + const signature = getIconFileSignature(iconPath); + if (!signature) { + iconDataUrlCache.delete(iconPath); + return undefined; + } + + const cached = iconDataUrlCache.get(iconPath); + if (cached && cached.signature === signature) { + return cached.dataUrl; + } + + const dataUrl = fileToDataUrl(iconPath); + iconDataUrlCache.set(iconPath, { signature, dataUrl }); + return dataUrl; +} + function resolveIcon( rawIcon: string, scriptDir: string @@ -144,9 +177,8 @@ function resolveIcon( const iconPath = path.isAbsolute(expanded) ? expanded : path.resolve(scriptDir, expanded); - if (!fs.existsSync(iconPath)) return {}; - const dataUrl = fileToDataUrl(iconPath); + const dataUrl = getCachedIconDataUrl(iconPath); if (dataUrl) return { iconDataUrl: dataUrl }; return {}; } @@ -302,24 +334,83 @@ function discoverScriptFiles(rootDir: string): string[] { return out; } -function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { - const scriptPath = path.resolve(filePath); - const scriptDir = path.dirname(scriptPath); +function getScriptFileSignature(filePath: string): string { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return `${filePath}:missing`; + return `${filePath}:${stat.size}:${stat.mtimeMs}:${stat.mode}`; + } catch { + return `${filePath}:missing`; + } +} + +function getScriptDiscoverySignature(files: string[]): string { + return files + .map((filePath) => getScriptFileSignature(filePath)) + .sort() + .join('\n'); +} + +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 +484,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)), @@ -409,17 +502,21 @@ export function discoverScriptCommands(): ScriptCommandInfo[] { return cache.commands; } + const files = getScriptCommandDirectories().flatMap((dir) => discoverScriptFiles(dir)); + const signature = getScriptDiscoverySignature(files); + if (cache && cache.signature === signature) { + cache = { ...cache, fetchedAt: now }; + return cache.commands; + } + const results: ScriptCommandInfo[] = []; - for (const dir of getScriptCommandDirectories()) { - const files = discoverScriptFiles(dir); - for (const filePath of files) { - const parsed = parseScriptCommandFile(filePath); - if (parsed) results.push(parsed); - } + for (const filePath of files) { + const parsed = parseScriptCommandFile(filePath); + if (parsed) results.push(parsed); } results.sort((a, b) => a.title.localeCompare(b.title)); - cache = { fetchedAt: now, commands: results }; + cache = { fetchedAt: now, commands: results, signature }; return results; } @@ -497,9 +594,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 +609,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<{ @@ -530,6 +625,7 @@ export async function executeScriptCommand( let settled = false; let stdoutBytes = 0; let stderrBytes = 0; + let timeout: ReturnType | null = null; const proc = spawn(spawnCommand, spawnArgs, { cwd, @@ -540,6 +636,10 @@ export async function executeScriptCommand( const finalize = (payload: { stdout: string; stderr: string; exitCode: number }) => { if (settled) return; settled = true; + if (timeout) { + clearTimeout(timeout); + timeout = null; + } resolve(payload); }; @@ -575,7 +675,7 @@ export async function executeScriptCommand( } }); - const timeout = setTimeout(() => { + timeout = setTimeout(() => { if (settled) return; try { proc.kill(); } catch {} finalize({ @@ -586,7 +686,6 @@ export async function executeScriptCommand( }, timeoutMs); proc.on('close', (code: number | null) => { - clearTimeout(timeout); finalize({ stdout, stderr, @@ -595,7 +694,6 @@ export async function executeScriptCommand( }); proc.on('error', (error: Error) => { - clearTimeout(timeout); finalize({ stdout, stderr: `${stderr}\n${error.message}`, diff --git a/src/main/whisper-transcribe-file.ts b/src/main/whisper-transcribe-file.ts new file mode 100644 index 00000000..30ebff84 --- /dev/null +++ b/src/main/whisper-transcribe-file.ts @@ -0,0 +1,198 @@ +import * as path from 'path'; +import type { AppSettings } from './settings-store'; + +export type WhisperFileTranscriptionProvider = + | 'parakeet' + | 'qwen3' + | 'whispercpp' + | 'openai' + | 'elevenlabs' + | 'mistral' + | 'native'; + +export interface WhisperFileTranscriptionPlan { + provider: WhisperFileTranscriptionProvider; + model: string; +} + +export interface WhisperFileSystem { + existsSync(filePath: string): boolean; + readFileSync(filePath: string): Buffer; + unlinkSync(filePath: string): void; + rmdirSync(filePath: string, options?: { recursive?: boolean }): void; +} + +type WhisperCppModelStatus = { + state: string; +}; + +type BufferTranscriber = (opts: { + audioBuffer: Buffer; + language?: string; + mimeType?: string; +}) => Promise; + +type CloudBufferTranscriber = (opts: { + audioBuffer: Buffer; + apiKey: string; + model: string; + language?: string; + mimeType?: string; +}) => Promise; + +export interface TranscribeWhisperAudioFileDeps { + fs: WhisperFileSystem; + loadSettings(): AppSettings; + isAIDisabledInSettings(settings: AppSettings): boolean; + normalizeWhisperLanguageCode(rawLanguage?: string): string; + resolveElevenLabsSttModel(model: string): string; + getElevenLabsApiKey(settings: AppSettings): string; + getMistralApiKey(settings: AppSettings): string; + getWhisperCppModelStatus(): WhisperCppModelStatus; + ensureWhisperCppServer(): Promise; + sendWhisperCppRequest(request: Record): Promise; + transcribeAudioWithParakeet: BufferTranscriber; + transcribeAudioWithQwen3: BufferTranscriber; + transcribeAudioWithElevenLabs: CloudBufferTranscriber; + transcribeAudioWithMistralVoxtral: CloudBufferTranscriber; + transcribeAudio: CloudBufferTranscriber; + whisperCppModelName: string; +} + +export function resolveWhisperFileTranscriptionPlan( + sttModel: string | undefined, + deps: { + whisperCppModelName: string; + resolveElevenLabsSttModel(model: string): string; + } +): WhisperFileTranscriptionPlan { + const rawModel = sttModel || ''; + let provider: WhisperFileTranscriptionProvider = 'whispercpp'; + let model = `ggml-${deps.whisperCppModelName}`; + + if (rawModel === 'parakeet') { + provider = 'parakeet'; + model = 'parakeet-tdt-0.6b-v3'; + } else if (rawModel === 'qwen3') { + provider = 'qwen3'; + model = 'qwen3-asr-0.6b'; + } else if (!rawModel || rawModel === 'default' || rawModel === 'whispercpp') { + provider = 'whispercpp'; + model = `ggml-${deps.whisperCppModelName}`; + } else if (rawModel === 'native') { + provider = 'native'; + model = ''; + } else if (rawModel.startsWith('openai-')) { + provider = 'openai'; + model = rawModel.slice('openai-'.length); + } else if (rawModel.startsWith('elevenlabs-')) { + provider = 'elevenlabs'; + model = deps.resolveElevenLabsSttModel(rawModel); + } else if (rawModel.startsWith('mistral-')) { + provider = 'mistral'; + model = rawModel.slice('mistral-'.length) || 'voxtral-mini-latest'; + } else if (rawModel) { + model = rawModel; + } + + return { provider, model }; +} + +export function shouldReadWhisperFileAudioBuffer(provider: WhisperFileTranscriptionProvider): boolean { + return provider === 'parakeet' + || provider === 'qwen3' + || provider === 'openai' + || provider === 'elevenlabs' + || provider === 'mistral'; +} + +function cleanupWhisperFileAudioPath(fs: WhisperFileSystem, audioPath: string): void { + try { fs.unlinkSync(audioPath); } catch {} + try { fs.rmdirSync(path.dirname(audioPath), { recursive: true }); } catch {} +} + +export async function transcribeWhisperAudioFile(params: { + audioPath: string; + options?: { language?: string }; + deps: TranscribeWhisperAudioFileDeps; +}): Promise { + const { audioPath, options, deps } = params; + const s = deps.loadSettings(); + if (deps.isAIDisabledInSettings(s)) { + throw new Error('AI is disabled. Enable AI in Settings -> AI to use Whisper.'); + } + if (s.ai?.whisperEnabled === false) { + throw new Error('SuperCmd Whisper is disabled in Settings -> AI.'); + } + + if (!deps.fs.existsSync(audioPath)) { + throw new Error(`Audio file not found: ${audioPath}`); + } + + const { provider, model } = resolveWhisperFileTranscriptionPlan( + s.ai.speechToTextModel || '', + { + whisperCppModelName: deps.whisperCppModelName, + resolveElevenLabsSttModel: deps.resolveElevenLabsSttModel, + } + ); + if (provider === 'native') { + cleanupWhisperFileAudioPath(deps.fs, audioPath); + return ''; + } + + try { + const rawLang = options?.language || s.ai.speechLanguage || 'en-US'; + const language = deps.normalizeWhisperLanguageCode(rawLang); + + if (provider === 'openai' && !s.ai.openaiApiKey) { + throw new Error('OpenAI API key not configured.'); + } + const elevenLabsApiKey = deps.getElevenLabsApiKey(s); + if (provider === 'elevenlabs' && !elevenLabsApiKey) { + throw new Error('ElevenLabs API key not configured.'); + } + const mistralApiKey = deps.getMistralApiKey(s); + if (provider === 'mistral' && !mistralApiKey) { + throw new Error('Mistral API key not configured.'); + } + + if (provider === 'whispercpp') { + const status = deps.getWhisperCppModelStatus(); + if (status.state === 'downloading') { + throw new Error('Whisper model still downloading.'); + } + if (status.state !== 'downloaded') { + throw new Error('Whisper model not downloaded.'); + } + await deps.ensureWhisperCppServer(); + const result = await deps.sendWhisperCppRequest({ + command: 'transcribe', + file: audioPath, + language, + }); + return result.text || ''; + } + + const audioBuffer = shouldReadWhisperFileAudioBuffer(provider) + ? deps.fs.readFileSync(audioPath) + : null; + + if (!audioBuffer) { + throw new Error(`No audio buffer available for ${provider} transcription.`); + } + + const mimeType = 'audio/wav'; + return provider === 'parakeet' + ? await deps.transcribeAudioWithParakeet({ audioBuffer, language, mimeType }) + : provider === 'qwen3' + ? await deps.transcribeAudioWithQwen3({ audioBuffer, language, mimeType }) + : provider === 'elevenlabs' + ? await deps.transcribeAudioWithElevenLabs({ audioBuffer, apiKey: elevenLabsApiKey, model, language, mimeType }) + : provider === 'mistral' + ? await deps.transcribeAudioWithMistralVoxtral({ audioBuffer, apiKey: mistralApiKey, model, language, mimeType }) + : await deps.transcribeAudio({ audioBuffer, apiKey: s.ai.openaiApiKey, model, language, mimeType }); + } finally { + cleanupWhisperFileAudioPath(deps.fs, audioPath); + } +} diff --git a/src/native/audio-capturer.swift b/src/native/audio-capturer.swift index 752b6e3c..9d8ac447 100644 --- a/src/native/audio-capturer.swift +++ b/src/native/audio-capturer.swift @@ -127,10 +127,15 @@ func writeWaveFile(samples: [Float], sampleRate: Double, toPath path: String) th data.append(contentsOf: withUnsafeBytes(of: UInt32(littleEndian: dataSize)) { Array($0) }) // PCM samples (float32 → int16) + var pcmSamples = [Int16]() + pcmSamples.reserveCapacity(samples.count) for sample in samples { let clamped = max(-1.0, min(1.0, sample)) let intVal = Int16(clamped * Float(Int16.max)) - data.append(contentsOf: withUnsafeBytes(of: Int16(littleEndian: intVal)) { Array($0) }) + pcmSamples.append(Int16(littleEndian: intVal)) + } + pcmSamples.withUnsafeBytes { rawBuffer in + data.append(rawBuffer.bindMemory(to: UInt8.self)) } try data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -279,6 +284,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 +305,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..0cb4c1df 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,7 +5,7 @@ * Shows category labels like Raycast. */ -import React, { useState, useEffect, useRef, useCallback, useMemo, useDeferredValue } from 'react'; +import React, { lazy, useState, useEffect, useRef, useCallback, useMemo, useDeferredValue } from 'react'; import supercmdLogo from '../../../supercmd.png'; import type { CommandInfo, @@ -15,17 +15,6 @@ import type { BrowserSearchSource, BrowserSearchResultGroupSetting, } from '../types/electron'; -import ExtensionView from './ExtensionView'; -import ClipboardManager from './ClipboardManager'; -import SnippetManager from './SnippetManager'; -import NotesSearchInline from './NotesSearchInline'; -import CanvasSearchInline from './CanvasSearchInline'; -import QuickLinkManager from './QuickLinkManager'; -import CameraExtension from './CameraExtension'; -import ScheduleExtension from './ScheduleExtension'; -import OnboardingExtension from './OnboardingExtension'; -import FileSearchExtension from './FileSearchExtension'; -import MenuItemSearch from './MenuItemSearchExtension'; import { useDetachedPortalWindow } from './useDetachedPortalWindow'; import { useAppViewManager } from './hooks/useAppViewManager'; import { useAiChat } from './hooks/useAiChat'; @@ -46,7 +35,6 @@ import { useLauncherWindowShownHandler } from './hooks/useLauncherWindowShownHan import { useLauncherKeyboardControls } from './hooks/useLauncherKeyboardControls'; import { AI_CHAT_STORAGE_KEY, LAST_EXT_KEY, LAST_LAUNCHER_QUERY_KEY, MAX_LAUNCHER_QUERY_HISTORY, MAX_RECENT_COMMANDS } from './utils/constants'; import { applyBaseColor } from './utils/base-color'; -import { resetAccessToken } from './raycast-api'; import { type MemoryFeedback, formatShortcutLabel, @@ -66,14 +54,6 @@ import { import { applyAppFontSize, getDefaultAppFontSize } from './utils/font-size'; import { refreshThemeFromStorage, setForcedTheme } from './utils/theme'; import { applyUiStyle } from './utils/ui-style'; -import ScriptCommandSetupView from './views/ScriptCommandSetupView'; -import ScriptCommandOutputView from './views/ScriptCommandOutputView'; -import ExtensionPreferenceSetupView from './views/ExtensionPreferenceSetupView'; -import AiChatView from './views/AiChatView'; -import CursorPromptView from './views/CursorPromptView'; -import AppUninstallView from './views/AppUninstallView'; -import BrowserResultsView from './views/BrowserResultsView'; -import WebSearchView from './views/WebSearchView'; import LauncherMainView from './views/LauncherMainView'; import HiddenExtensionRunners from './components/HiddenExtensionRunners'; import DetachedOverlayRunners from './components/DetachedOverlayRunners'; @@ -111,6 +91,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, @@ -122,6 +103,26 @@ import { type RootSearchRankingState, } from './utils/root-search-ranking'; +const ExtensionView = lazy(() => import('./ExtensionView')); +const ClipboardManager = lazy(() => import('./ClipboardManager')); +const SnippetManager = lazy(() => import('./SnippetManager')); +const NotesSearchInline = lazy(() => import('./NotesSearchInline')); +const CanvasSearchInline = lazy(() => import('./CanvasSearchInline')); +const QuickLinkManager = lazy(() => import('./QuickLinkManager')); +const CameraExtension = lazy(() => import('./CameraExtension')); +const ScheduleExtension = lazy(() => import('./ScheduleExtension')); +const OnboardingExtension = lazy(() => import('./OnboardingExtension')); +const FileSearchExtension = lazy(() => import('./FileSearchExtension')); +const MenuItemSearch = lazy(() => import('./MenuItemSearchExtension')); +const ScriptCommandSetupView = lazy(() => import('./views/ScriptCommandSetupView')); +const ScriptCommandOutputView = lazy(() => import('./views/ScriptCommandOutputView')); +const ExtensionPreferenceSetupView = lazy(() => import('./views/ExtensionPreferenceSetupView')); +const AiChatView = lazy(() => import('./views/AiChatView')); +const CursorPromptView = lazy(() => import('./views/CursorPromptView')); +const AppUninstallView = lazy(() => import('./views/AppUninstallView')); +const BrowserResultsView = lazy(() => import('./views/BrowserResultsView')); +const WebSearchView = lazy(() => import('./views/WebSearchView')); + const BROWSER_APP_PATHS: Partial> = { chrome: [ '/Applications/Google Chrome.app', @@ -135,6 +136,15 @@ const BROWSER_APP_PATHS: Partial> = { }; const DEFAULT_POP_TO_ROOT_TIMEOUT_SECONDS = 90; +const LAZY_VIEW_FALLBACK = null; + +function resetRaycastAccessToken(): void { + void import('./raycast-api/oauth/with-access-token') + .then((module) => { + module.resetAccessToken(); + }) + .catch(() => {}); +} // Intern cache: commandId → stable iconDataUrl string reference. // Prevents duplicate base64 strings accumulating across repeated fetchCommands() IPC calls. @@ -310,8 +320,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(() => { @@ -334,6 +345,12 @@ const App: React.FC = () => { setAiMode, onExitAiMode, }); + const setAiAvailableFromWindowShown = useCallback>>( + (value) => { + setAiAvailable(typeof value === 'function' ? value(aiAvailable) : value); + }, + [aiAvailable, setAiAvailable] + ); const { cursorPromptText, setCursorPromptText, @@ -346,7 +363,7 @@ const App: React.FC = () => { } = useCursorPrompt({ showCursorPrompt, setShowCursorPrompt, - setAiAvailable, + setAiAvailable: setAiAvailableFromWindowShown, }); const acceptCursorPrompt = applyCursorPromptResultToEditor; @@ -754,7 +771,7 @@ const App: React.FC = () => { openSchedule, openCamera, openOnboarding, - setAiAvailable, + setAiAvailable: setAiAvailableFromWindowShown, setSelectedTextSnapshot, setMemoryFeedback, setMemoryActionLoading, @@ -852,7 +869,7 @@ const App: React.FC = () => { } catch {} // Clear the in-memory OAuth token and tear down the extension view // so the auth prompt shows on next launch. - resetAccessToken(); + resetRaycastAccessToken(); setExtensionView(null); localStorage.removeItem(LAST_EXT_KEY); }); @@ -1076,7 +1093,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); @@ -2416,83 +2433,89 @@ const App: React.FC = () => { // ─── Script Command Setup ─────────────────────────────────────── if (scriptCommandSetup) { return ( - { - setScriptCommandSetup(null); - setSearchQuery(''); - setSelectedIndex(0); - }} - onContinue={(command, values) => { - setScriptCommandSetup(null); - void runScriptCommand(command, values); - }} - setScriptCommandSetup={setScriptCommandSetup} - /> + + { + setScriptCommandSetup(null); + setSearchQuery(''); + setSelectedIndex(0); + }} + onContinue={(command, values) => { + setScriptCommandSetup(null); + void runScriptCommand(command, values); + }} + setScriptCommandSetup={setScriptCommandSetup} + /> + ); } // ─── Script Output ────────────────────────────────────────────── if (scriptCommandOutput) { return ( - { - setScriptCommandOutput(null); - setSearchQuery(''); - setSelectedIndex(0); - }} - /> + + { + setScriptCommandOutput(null); + setSearchQuery(''); + setSelectedIndex(0); + }} + /> + ); } // ─── Extension Preferences Setup ──────────────────────────────── if (extensionPreferenceSetup) { return ( - { - setExtensionPreferenceSetup(null); - setScriptCommandSetup(null); - setScriptCommandOutput(null); - setSearchQuery(''); - setSelectedIndex(0); - }} - onLaunchExtension={(updatedBundle) => { - setExtensionPreferenceSetup(null); - setScriptCommandSetup(null); - setScriptCommandOutput(null); - if (updatedBundle.mode === 'no-view') { - queueNoViewBundleRun(updatedBundle, 'userInitiated'); - localStorage.removeItem(LAST_EXT_KEY); - return; - } - setExtensionView(updatedBundle); - const extName = updatedBundle.extName || (updatedBundle as any).extensionName || ''; - const cmdName = updatedBundle.cmdName || (updatedBundle as any).commandName || ''; - if (updatedBundle.mode === 'view') { - localStorage.setItem(LAST_EXT_KEY, JSON.stringify({ extName, cmdName })); - } else { + + { + setExtensionPreferenceSetup(null); + setScriptCommandSetup(null); + setScriptCommandOutput(null); + setSearchQuery(''); + setSelectedIndex(0); + }} + onLaunchExtension={(updatedBundle) => { + setExtensionPreferenceSetup(null); + setScriptCommandSetup(null); + setScriptCommandOutput(null); + if (updatedBundle.mode === 'no-view') { + queueNoViewBundleRun(updatedBundle, 'userInitiated'); + localStorage.removeItem(LAST_EXT_KEY); + return; + } + setExtensionView(updatedBundle); + const extName = updatedBundle.extName || (updatedBundle as any).extensionName || ''; + const cmdName = updatedBundle.cmdName || (updatedBundle as any).commandName || ''; + if (updatedBundle.mode === 'view') { + localStorage.setItem(LAST_EXT_KEY, JSON.stringify({ extName, cmdName })); + } else { + localStorage.removeItem(LAST_EXT_KEY); + } + }} + onLaunchMenuBar={(updatedBundle) => { + setExtensionPreferenceSetup(null); + setScriptCommandSetup(null); + setScriptCommandOutput(null); + if (isMenuBarExtensionMounted(updatedBundle)) { + hideMenuBarExtension(updatedBundle); + } else { + upsertMenuBarExtension(updatedBundle); + } + window.electron.hideWindow(); localStorage.removeItem(LAST_EXT_KEY); - } - }} - onLaunchMenuBar={(updatedBundle) => { - setExtensionPreferenceSetup(null); - setScriptCommandSetup(null); - setScriptCommandOutput(null); - if (isMenuBarExtensionMounted(updatedBundle)) { - hideMenuBarExtension(updatedBundle); - } else { - upsertMenuBarExtension(updatedBundle); - } - window.electron.hideWindow(); - localStorage.removeItem(LAST_EXT_KEY); - }} - setExtensionPreferenceSetup={setExtensionPreferenceSetup} - /> + }} + setExtensionPreferenceSetup={setExtensionPreferenceSetup} + /> + ); } @@ -3050,4 +3073,10 @@ const App: React.FC = () => { ); }; -export default App; +const AppWithLazyBoundaries: React.FC = () => ( + + + +); + +export default AppWithLazyBoundaries; diff --git a/src/renderer/src/CameraExtension.tsx b/src/renderer/src/CameraExtension.tsx index 7de6a3f8..6c5e9940 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,14 +350,14 @@ 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) { try { - await window.electron.execCommand('/bin/mkdir', ['-p', saveDir], {}); const bytes = new Uint8Array(await captureBlob.arrayBuffer()); await window.electron.fsWriteBinaryFile(savePath, bytes); savedToDisk = true; @@ -349,7 +383,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 +397,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 +621,14 @@ const CameraExtension: React.FC = ({ onClose }) => { {selectedCameraLabel} - {capturePreviewDataUrl ? ( + {capturePreviewUrl ? (
Latest capture(); +const fsVirtualDirectoryIndex = new Map>(); +let fsVirtualDirectoryIndexReady = false; +let fsVirtualDirectoryIndexBuildScans = 0; function getStoredText(path: string): string | null { if (fsMemoryStore.has(path)) return fsMemoryStore.get(path) ?? null; @@ -442,11 +458,13 @@ function setStoredText(path: string, value: string): void { // Fallback for large payloads (e.g. cached JSON files) that exceed localStorage quota. fsMemoryStore.set(path, value); } + indexVirtualFilePath(path); } function removeStoredText(path: string): void { fsMemoryStore.delete(path); localStorage.removeItem(FS_PREFIX + path); + if (fsVirtualDirectoryIndexReady) rebuildVirtualDirectoryIndex(); } function normalizeFsPath(input: any): string { @@ -529,6 +547,203 @@ function fsStatResult( const commandPathCache = new Map(); +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 +755,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 +768,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 +787,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) { @@ -627,9 +842,68 @@ function normalizeDirectoryPath(dirPath: string): string { return trimmed.replace(/\/+$/, '') || '/'; } -function buildDirectoryPrefix(dirPath: string): string { - const normalized = normalizeDirectoryPath(dirPath); - return normalized === '/' ? '/' : `${normalized}/`; +function splitStoredPath(storedPath: string): { parent: string; name: string } | null { + const normalized = String(storedPath || '').replace(/\/+$/, ''); + if (!normalized) return null; + const slashIndex = normalized.lastIndexOf('/'); + if (slashIndex < 0) return { parent: '.', name: normalized }; + if (slashIndex === 0) return { parent: '/', name: normalized.slice(1) }; + return { + parent: normalized.slice(0, slashIndex), + name: normalized.slice(slashIndex + 1), + }; +} + +function upsertVirtualDirectoryEntry(dirPath: string, name: string, kind: FsEntryKind): void { + if (!name) return; + const normalizedDir = normalizeDirectoryPath(dirPath); + let entries = fsVirtualDirectoryIndex.get(normalizedDir); + if (!entries) { + entries = new Map(); + fsVirtualDirectoryIndex.set(normalizedDir, entries); + } + + const existing = entries.get(name); + if (existing === 'directory') return; + if (existing === 'file' && kind === 'unknown') return; + if (!existing || kind === 'directory') { + entries.set(name, kind); + } +} + +function indexVirtualFilePath(storedPath: string): void { + if (!fsVirtualDirectoryIndexReady) return; + let current = splitStoredPath(storedPath); + if (!current) return; + + upsertVirtualDirectoryEntry(current.parent, current.name, 'file'); + + while (current.parent !== '/' && current.parent !== '.') { + const parentDirectory = current.parent; + current = splitStoredPath(parentDirectory); + if (!current) break; + upsertVirtualDirectoryEntry(current.parent, current.name, 'directory'); + } +} + +function rebuildVirtualDirectoryIndex(): void { + fsVirtualDirectoryIndex.clear(); + fsVirtualDirectoryIndexReady = true; + fsVirtualDirectoryIndexBuildScans += 1; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(FS_PREFIX)) continue; + indexVirtualFilePath(key.slice(FS_PREFIX.length)); + } + for (const memoryPath of fsMemoryStore.keys()) { + indexVirtualFilePath(memoryPath); + } +} + +function ensureVirtualDirectoryIndex(): void { + if (fsVirtualDirectoryIndexReady) return; + rebuildVirtualDirectoryIndex(); } function joinDirectoryPath(dirPath: string, entryName: string): string { @@ -669,44 +943,42 @@ function createDirentLike(name: string, kind: FsEntryKind, encoding: string | nu } function collectVirtualDirectoryEntries(dirPath: string): Map { - const entries = new Map(); - const prefix = buildDirectoryPrefix(dirPath); - - const upsert = (name: string, kind: FsEntryKind) => { - if (!name) return; - const existing = entries.get(name); - if (existing === 'directory') return; - if (existing === 'file' && kind === 'unknown') return; - if (!existing || kind === 'directory') { - entries.set(name, kind); - } - }; - - const addFromStoredPath = (storedPath: string) => { - if (!storedPath || !storedPath.startsWith(prefix)) return; - const rest = storedPath.slice(prefix.length); - if (!rest) return; - const firstSlash = rest.indexOf('/'); - const entryName = firstSlash === -1 ? rest : rest.slice(0, firstSlash); - const kind: FsEntryKind = firstSlash === -1 ? 'file' : 'directory'; - upsert(entryName, kind); - }; + ensureVirtualDirectoryIndex(); + return new Map(fsVirtualDirectoryIndex.get(normalizeDirectoryPath(dirPath)) || []); +} - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(FS_PREFIX)) continue; - addFromStoredPath(key.slice(FS_PREFIX.length)); - } - for (const memoryPath of fsMemoryStore.keys()) { - addFromStoredPath(memoryPath); +export function getVirtualFsDirectoryIndexStatsForTests(): { + directories: number; + entries: number; + buildScans: number; +} { + ensureVirtualDirectoryIndex(); + let entries = 0; + for (const directoryEntries of fsVirtualDirectoryIndex.values()) { + entries += directoryEntries.size; } + return { + directories: fsVirtualDirectoryIndex.size, + entries, + buildScans: fsVirtualDirectoryIndexBuildScans, + }; +} - return entries; +export function getVirtualFsDirectoryEntriesForTests(dirPath: string): Array<[string, FsEntryKind]> { + return Array.from(collectVirtualDirectoryEntries(dirPath).entries()); } function getRealDirectoryEntriesSync(dirPath: string): string[] { + const fs = getRealNodeFsModule(); + if (fs && typeof fs.readdirSync === 'function') { + try { + return fs.readdirSync(dirPath).map((entry: any) => 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 +1001,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 +1045,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 +1091,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 +1106,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 +1148,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 +1158,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 +1187,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 +1279,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 +1321,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 +1339,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 +1357,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 +1403,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 +1439,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 +1449,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 +1459,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 +2146,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 +2254,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 +2475,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 +3309,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. @@ -3362,29 +3639,370 @@ function ensureGlobals() { } } +type ExtensionTimerHandle = any; + +interface TrackedEventListener { + target: EventTarget; + type: string; + listener: EventListenerOrEventListenerObject; + options?: boolean | AddEventListenerOptions; + capture: boolean; +} + /** - * Per-ExtensionView registry of timer handles created by the extension's - * sandboxed setInterval/setTimeout/requestAnimationFrame. Cleared on unmount - * so a buggy extension (e.g. raycast/timers) cannot leak timers + retained - * fibers into the host renderer. + * Per-ExtensionView registry of lifecycle handles created by the extension's + * sandboxed timers and scoped event targets. Cleared on unmount so a buggy + * extension (e.g. raycast/timers) cannot leak DOM handles + retained fibers + * into the host renderer. */ export interface TimerRegistry { - intervals: Set; - timeouts: Set; - rafs: Set; + intervals: Set; + timeouts: Set; + rafs: Set; + intervalClearers: Map void>; + timeoutClearers: Map void>; + rafClearers: Map void>; + eventListeners: Set; } export function createTimerRegistry(): TimerRegistry { - return { intervals: new Set(), timeouts: new Set(), rafs: new Set() }; + return { + intervals: new Set(), + timeouts: new Set(), + rafs: new Set(), + intervalClearers: new Map(), + timeoutClearers: new Map(), + rafClearers: new Map(), + eventListeners: new Set(), + }; } export function clearTimerRegistry(registry: TimerRegistry): void { - registry.intervals.forEach((id) => window.clearInterval(id)); - registry.timeouts.forEach((id) => window.clearTimeout(id)); - registry.rafs.forEach((id) => window.cancelAnimationFrame(id)); + Array.from(registry.eventListeners).forEach((entry) => { + try { + entry.target.removeEventListener(entry.type, entry.listener, entry.options); + } catch {} + }); + Array.from(registry.intervals).forEach((id) => { + const clear = registry.intervalClearers.get(id) || window.clearInterval.bind(window); + clear(id); + }); + Array.from(registry.timeouts).forEach((id) => { + const clear = registry.timeoutClearers.get(id) || window.clearTimeout.bind(window); + clear(id); + }); + Array.from(registry.rafs).forEach((id) => { + const clear = registry.rafClearers.get(id) || window.cancelAnimationFrame.bind(window); + clear(id); + }); + registry.eventListeners.clear(); registry.intervals.clear(); registry.timeouts.clear(); registry.rafs.clear(); + registry.intervalClearers.clear(); + registry.timeoutClearers.clear(); + registry.rafClearers.clear(); +} + +function getEventListenerCapture(options?: boolean | AddEventListenerOptions): boolean { + if (typeof options === 'boolean') return options; + return Boolean(options?.capture); +} + +function findTrackedEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): TrackedEventListener | null { + if (!registry || !listener) return null; + const capture = getEventListenerCapture(options); + for (const entry of registry.eventListeners) { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + return entry; + } + } + return null; +} + +function trackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): void { + if (!registry || !listener) return; + if (findTrackedEventListener(registry, target, type, listener, options)) return; + registry.eventListeners.add({ + target, + type, + listener, + options, + capture: getEventListenerCapture(options), + }); +} + +function untrackEventListener( + registry: TimerRegistry | undefined, + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions +): void { + if (!registry || !listener) return; + const capture = getEventListenerCapture(options); + Array.from(registry.eventListeners).forEach((entry) => { + if ( + entry.target === target && + entry.type === type && + entry.listener === listener && + entry.capture === capture + ) { + registry.eventListeners.delete(entry); + } + }); +} + +function shouldBindHostFunction(prop: string | symbol, value: Function): boolean { + if (typeof prop === 'string' && /^[A-Z]/.test(prop)) return false; + try { + if (/^class\s/.test(Function.prototype.toString.call(value))) return false; + } catch {} + return true; +} + +function getWindowPeer(hostWindow: any, scopedWindow: any, prop: 'top' | 'parent' | 'opener'): any { + try { + const value = hostWindow?.[prop]; + return value === hostWindow ? scopedWindow : value; + } catch { + return scopedWindow; + } +} + +function createScopedHostProxy( + host: any, + registry: TimerRegistry | undefined, + kind: 'window' | 'document', + getScopedWindow: () => any, + getScopedDocument: () => any, + timerApi?: { + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + } +): any { + const boundFunctions = new WeakMap(); + const target = {}; + + return new Proxy(target, { + get(_target, prop) { + if (kind === 'window') { + if (prop === 'window' || prop === 'self' || prop === 'globalThis' || prop === 'global') return getScopedWindow(); + if (prop === 'top' || prop === 'parent' || prop === 'opener') return getWindowPeer(host, getScopedWindow(), prop); + if (prop === 'document') return getScopedDocument(); + if (prop === 'setInterval') return timerApi?.setInterval; + if (prop === 'clearInterval') return timerApi?.clearInterval; + if (prop === 'setTimeout') return timerApi?.setTimeout; + if (prop === 'clearTimeout') return timerApi?.clearTimeout; + if (prop === 'requestAnimationFrame') return timerApi?.requestAnimationFrame; + if (prop === 'cancelAnimationFrame') return timerApi?.cancelAnimationFrame; + } else if (prop === 'defaultView') { + return getScopedWindow(); + } + + if (prop === 'addEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) => { + host.addEventListener(type, listener as any, options); + trackEventListener(registry, host, type, listener, options); + }; + } + + if (prop === 'removeEventListener') { + return (type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) => { + host.removeEventListener(type, listener as any, options); + untrackEventListener(registry, host, type, listener, options); + }; + } + + const value = Reflect.get(host, prop, host); + if (typeof value !== 'function' || !shouldBindHostFunction(prop, value)) return value; + const cached = boundFunctions.get(value); + if (cached) return cached; + const bound = value.bind(host) as Function; + boundFunctions.set(value, bound); + return bound; + }, + set(_target, prop, value) { + return Reflect.set(host, prop, value, host); + }, + has(_target, prop) { + return prop in host; + }, + deleteProperty(_target, prop) { + return Reflect.deleteProperty(host, prop); + }, + defineProperty(_target, prop, descriptor) { + return Reflect.defineProperty(host, prop, descriptor); + }, + getOwnPropertyDescriptor(_target, prop) { + const descriptor = Reflect.getOwnPropertyDescriptor(host, prop); + if (!descriptor) return undefined; + return { ...descriptor, configurable: true }; + }, + ownKeys() { + return Reflect.ownKeys(host); + }, + getPrototypeOf() { + return Reflect.getPrototypeOf(host); + }, + }); +} + +export function createExtensionLifecycleScope( + registry: TimerRegistry | undefined, + hostWindow: any = window, + hostDocument: any = hostWindow?.document ?? document +): { + scopedWindow: any; + scopedDocument: any; + setInterval: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearInterval: (id?: ExtensionTimerHandle) => void; + setTimeout: (handler: TimerHandler, timeout?: number, ...args: any[]) => ExtensionTimerHandle; + clearTimeout: (id?: ExtensionTimerHandle) => void; + requestAnimationFrame: (callback: FrameRequestCallback) => ExtensionTimerHandle; + cancelAnimationFrame: (id?: ExtensionTimerHandle) => void; + setImmediate: (callback: Function, ...args: any[]) => ExtensionTimerHandle; + clearImmediate: (id?: ExtensionTimerHandle) => void; +} { + const nativeSetInterval = hostWindow.setInterval.bind(hostWindow); + const nativeClearInterval = hostWindow.clearInterval.bind(hostWindow); + const nativeSetTimeout = hostWindow.setTimeout.bind(hostWindow); + const nativeClearTimeout = hostWindow.clearTimeout.bind(hostWindow); + const nativeRequestAnimationFrame = hostWindow.requestAnimationFrame.bind(hostWindow); + const nativeCancelAnimationFrame = hostWindow.cancelAnimationFrame.bind(hostWindow); + + const trackInterval = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + const id = nativeSetInterval(handler as any, timeout as any, ...args); + registry?.intervals.add(id); + registry?.intervalClearers.set(id, nativeClearInterval); + return id; + }; + const trackClearInterval = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.intervals.delete(id); + registry?.intervalClearers.delete(id); + } + nativeClearInterval(id); + }; + const untrackTimeout = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.timeouts.delete(id); + registry?.timeoutClearers.delete(id); + } + }; + const runTimeoutHandler = (handler: TimerHandler, thisArg: any, callbackArgs: any[]) => { + if (typeof handler === 'function') { + return handler.apply(thisArg, callbackArgs); + } + const source = String(handler); + if (typeof hostWindow.eval === 'function') { + return hostWindow.eval(source); + } + return Function(source).call(thisArg); + }; + const trackTimeout = (handler: TimerHandler, timeout?: number, ...args: any[]) => { + let id: ExtensionTimerHandle | undefined; + const wrappedHandler = function (this: any, ...callbackArgs: any[]) { + untrackTimeout(id); + return runTimeoutHandler(handler, this, callbackArgs); + }; + id = nativeSetTimeout(wrappedHandler as any, timeout as any, ...args); + registry?.timeouts.add(id); + registry?.timeoutClearers.set(id, nativeClearTimeout); + return id; + }; + const trackClearTimeout = (id?: ExtensionTimerHandle) => { + untrackTimeout(id); + nativeClearTimeout(id); + }; + const untrackRaf = (id?: ExtensionTimerHandle) => { + if (id != null) { + registry?.rafs.delete(id); + registry?.rafClearers.delete(id); + } + }; + const trackRaf = (callback: FrameRequestCallback) => { + if (typeof callback !== 'function') { + return nativeRequestAnimationFrame(callback as any); + } + let id: ExtensionTimerHandle | undefined; + const wrappedCallback = function (this: any, timestamp: DOMHighResTimeStamp) { + untrackRaf(id); + callback.call(this, timestamp); + }; + id = nativeRequestAnimationFrame(wrappedCallback); + registry?.rafs.add(id); + registry?.rafClearers.set(id, nativeCancelAnimationFrame); + return id; + }; + const trackCancelRaf = (id?: ExtensionTimerHandle) => { + untrackRaf(id); + nativeCancelAnimationFrame(id); + }; + const trackSetImmediate = (callback: Function, ...args: any[]) => + trackTimeout(() => callback(...args), 0); + const trackClearImmediate = (id?: ExtensionTimerHandle) => trackClearTimeout(id); + + const timerApi = { + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + }; + + let scopedWindow: any; + let scopedDocument: any; + scopedDocument = createScopedHostProxy( + hostDocument, + registry, + 'document', + () => scopedWindow, + () => scopedDocument + ); + scopedWindow = createScopedHostProxy( + hostWindow, + registry, + 'window', + () => scopedWindow, + () => scopedDocument, + timerApi + ); + + return { + scopedWindow, + scopedDocument, + setInterval: trackInterval, + clearInterval: trackClearInterval, + setTimeout: trackTimeout, + clearTimeout: trackClearTimeout, + requestAnimationFrame: trackRaf, + cancelAnimationFrame: trackCancelRaf, + setImmediate: trackSetImmediate, + clearImmediate: trackClearImmediate, + }; } /** @@ -3571,7 +4189,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 @@ -3607,14 +4226,11 @@ function loadExtensionExport( // This is the critical bridge between extension code and the // SuperCmd renderer environment. Every module an extension // might `require()` must be handled here. - // - // IMPORTANT: We track React requires to verify the same instance is always returned. let reactRequireCount = 0; const fakeRequire: any = (name: string): any => { - // Track all requires for debugging if (name === 'react' || name.startsWith('react/') || name === 'react-dom') { reactRequireCount++; - console.log(`[fakeRequire] #${reactRequireCount} require("${name}")`); + logExtensionRuntimeDebug(`[fakeRequire] #${reactRequireCount} require("${name}")`); } // ── React & friends ───────────────────────────────────── // CRITICAL: Extensions MUST use the same React instance as the host. @@ -3626,14 +4242,14 @@ function loadExtensionExport( switch (name) { case 'react': { // Return React directly - the exact same module the host uses - console.log('[fakeRequire] Providing React directly'); + logExtensionRuntimeDebug('[fakeRequire] Providing React directly'); (globalThis as any).__SUPERCMD_REACT = React; return React; } case 'react-dom': case 'react-dom/client': - console.log('[fakeRequire] Providing ReactDOM'); - console.log('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot); + logExtensionRuntimeDebug('[fakeRequire] Providing ReactDOM'); + logExtensionRuntimeDebug('[fakeRequire] ReactDOM.createRoot:', (ReactDOM as any).createRoot); return ReactDOM; case 'react-dom/server': return reactDomServerStub; @@ -3641,9 +4257,9 @@ function loadExtensionExport( case 'react/jsx-dev-runtime': { // Return the actual jsx-runtime to ensure JSX creates elements // using the same React.createElement - console.log('[fakeRequire] Providing jsx-runtime'); - console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); - console.log('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); + logExtensionRuntimeDebug('[fakeRequire] Providing jsx-runtime'); + logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); + logExtensionRuntimeDebug('[fakeRequire] JsxRuntime.Fragment === React.Fragment:', JsxRuntime.Fragment === React.Fragment); return JsxRuntime; } @@ -3747,6 +4363,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; @@ -3909,44 +4529,10 @@ function loadExtensionExport( throw new Error(`Unsupported dynamic import in extension runtime: ${id}`); }; - // Sandboxed timer APIs — passed as named parameters so the extension's - // bundle resolves bare `setInterval`/`setTimeout`/`requestAnimationFrame` - // references against this scope instead of the host `window`. Handles are - // tracked in `timerRegistry` so the consumer (ExtensionView) can clear - // anything still pending on unmount, defending against extensions that - // forget their own cleanup (e.g. raycast/timers). - const trackInterval = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setInterval(cb as any, ms as any, ...rest); - timerRegistry?.intervals.add(id); - return id; - }; - const trackClearInterval = (id: any) => { - if (typeof id === 'number') timerRegistry?.intervals.delete(id); - window.clearInterval(id); - }; - const trackTimeout = (cb: any, ms?: any, ...rest: any[]) => { - const id = window.setTimeout(cb as any, ms as any, ...rest); - timerRegistry?.timeouts.add(id); - return id; - }; - const trackClearTimeout = (id: any) => { - if (typeof id === 'number') timerRegistry?.timeouts.delete(id); - window.clearTimeout(id); - }; - const trackRaf = (cb: FrameRequestCallback) => { - const id = window.requestAnimationFrame(cb); - timerRegistry?.rafs.add(id); - return id; - }; - const trackCancelRaf = (id: any) => { - if (typeof id === 'number') timerRegistry?.rafs.delete(id); - window.cancelAnimationFrame(id); - }; - // setImmediate / clearImmediate are Node-isms not on `window` — polyfill - // via setTimeout(0) and route through the same registry. - const trackSetImmediate = (cb: Function, ...args: any[]) => - trackTimeout(() => cb(...args), 0); - const trackClearImmediate = (id: any) => trackClearTimeout(id); + // Sandboxed lifecycle APIs — passed as named parameters so the extension's + // bundle resolves bare timers and `window.*`/`document.*` event listeners + // against this ExtensionView instance instead of the host renderer globals. + const lifecycleScope = createExtensionLifecycleScope(timerRegistry); // Execute the CJS bundle in a function scope. // We pass all the standard CJS arguments plus `process`, `Buffer`, @@ -3962,28 +4548,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, + }); + logExtensionRuntimeDebug(`[loadExtensionExport] Wrapper cache ${cacheHit ? 'hit' : 'miss'} for ${extensionIdentity}`); fn( moduleExports, @@ -3993,16 +4563,18 @@ function loadExtensionExport( '/extension', bundleProcess, bundleBuffer, - globalThis, - globalThis, - trackSetImmediate, - trackClearImmediate, - trackInterval, - trackClearInterval, - trackTimeout, - trackClearTimeout, - trackRaf, - trackCancelRaf, + lifecycleScope.scopedWindow, + lifecycleScope.scopedWindow, + lifecycleScope.setImmediate, + lifecycleScope.clearImmediate, + lifecycleScope.setInterval, + lifecycleScope.clearInterval, + lifecycleScope.setTimeout, + lifecycleScope.clearTimeout, + lifecycleScope.requestAnimationFrame, + lifecycleScope.cancelAnimationFrame, + lifecycleScope.scopedWindow, + lifecycleScope.scopedDocument, undefined, // navigator — see comment above scDynamicImport, ); @@ -4011,10 +4583,12 @@ function loadExtensionExport( const exported = fakeModule.exports.default || fakeModule.exports; - console.log('[loadExtensionExport] Extension loaded successfully'); - console.log('[loadExtensionExport] Exported type:', typeof exported); - console.log('[loadExtensionExport] Exported name:', exported?.name); - console.log('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + if (isExtensionRuntimeDebugLoggingEnabled()) { + console.debug('[loadExtensionExport] Extension loaded successfully'); + console.debug('[loadExtensionExport] Exported type:', typeof exported); + console.debug('[loadExtensionExport] Exported name:', exported?.name); + console.debug('[loadExtensionExport] Exported function:', exported?.toString?.().slice(0, 200)); + } if (typeof exported === 'function') { return exported; @@ -4165,9 +4739,8 @@ const ViewRenderer: React.FC<{ fallbackText, launchType = 'userInitiated', }) => { - // Simple test that hooks work here const [test] = useState('ok'); - console.log('[ViewRenderer] Hooks work here, rendering extension...'); + logExtensionRuntimeDebug('[ViewRenderer] Hooks work here, rendering extension...'); // Pass standard Raycast props: arguments (command arguments) and launchType return React.createElement(Component, { arguments: launchArguments, @@ -4239,6 +4812,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 +4844,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/SuperCmdRead.tsx b/src/renderer/src/SuperCmdRead.tsx index 9b544694..2e278453 100644 --- a/src/renderer/src/SuperCmdRead.tsx +++ b/src/renderer/src/SuperCmdRead.tsx @@ -32,6 +32,23 @@ const SPEED_PRESETS = [ { value: '+30%', label: '1.3x' }, ]; +export type SpeakTextToken = + | { kind: 'space'; text: string; key: string } + | { kind: 'word'; text: string; key: string; wordIndex: number }; + +export function tokenizeSpeakText(text: string): SpeakTextToken[] { + const parts = String(text || '').split(/(\s+)/g); + let currentWord = 0; + return parts.map((part, index) => { + if (!part.trim()) { + return { kind: 'space', text: part, key: `sp-${index}` }; + } + const token = { kind: 'word' as const, text: part, key: `wd-${index}`, wordIndex: currentWord }; + currentWord += 1; + return token; + }); +} + const SuperCmdRead: React.FC = ({ status, voice, @@ -45,10 +62,11 @@ const SuperCmdRead: React.FC = ({ onClose, portalTarget, }) => { - if (typeof document === 'undefined') return null; - const target = portalTarget || document.body; - if (!target) return null; const textScrollRef = useRef(null); + const highlightedWordRef = useRef(null); + const scrollRafRef = useRef(null); + const lastScrollAtRef = useRef(0); + const target = typeof document === 'undefined' ? null : (portalTarget || document.body); const caption = status.state === 'speaking' || status.state === 'paused' @@ -74,45 +92,53 @@ const SuperCmdRead: React.FC = ({ const canGoPrevious = isSessionActive && status.index > 1; const canGoNext = isSessionActive && status.index > 0 && status.index < status.total; - const renderedText = useMemo(() => { - const text = mainText; - const wordIndex = status.state === 'speaking' || status.state === 'paused' ? status.wordIndex : undefined; - if (typeof wordIndex !== 'number' || wordIndex < 0) { - return text; - } - const tokens = text.split(/(\s+)/g); - let currentWord = 0; - return tokens.map((token, idx) => { - if (!token.trim()) { - return {token}; - } - const highlighted = currentWord === wordIndex; - const thisWordIndex = currentWord; - currentWord += 1; - return ( - - {token} - - ); - }); - }, [mainText, status.state, status.wordIndex]); + const textTokens = useMemo(() => tokenizeSpeakText(mainText), [mainText]); + + const renderedText = useMemo(() => ( + textTokens.map((token) => ( + token.kind === 'space' + ? {token.text} + : {token.text} + )) + ), [textTokens]); useEffect(() => { - if (status.state !== 'speaking' || typeof status.wordIndex !== 'number') return; + const previous = highlightedWordRef.current; + if (previous) { + previous.classList.remove('speak-word-highlight'); + highlightedWordRef.current = null; + } + if ((status.state !== 'speaking' && status.state !== 'paused') || typeof status.wordIndex !== 'number') return; const root = textScrollRef.current; if (!root) return; const el = root.querySelector(`[data-word-idx="${status.wordIndex}"]`) as HTMLElement | null; if (!el) return; - el.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - behavior: 'smooth', + el.classList.add('speak-word-highlight'); + highlightedWordRef.current = el; + + if (status.state !== 'speaking') return; + if (scrollRafRef.current !== null) return; + const now = performance.now(); + if (now - lastScrollAtRef.current < 140) return; + scrollRafRef.current = requestAnimationFrame(() => { + scrollRafRef.current = null; + lastScrollAtRef.current = performance.now(); + el.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); }); - }, [status.state, status.wordIndex]); + }, [status.state, status.wordIndex, textTokens]); + + useEffect(() => () => { + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }, []); + + if (!target) return null; return createPortal(
diff --git a/src/renderer/src/SuperCmdWhisper.tsx b/src/renderer/src/SuperCmdWhisper.tsx index 956d219a..9dd88e11 100644 --- a/src/renderer/src/SuperCmdWhisper.tsx +++ b/src/renderer/src/SuperCmdWhisper.tsx @@ -310,7 +310,7 @@ function insertIntoLocalWhisperTextTarget(target: LocalWhisperTextTarget | null, return true; } -function flattenFloat32Chunks(chunks: Float32Array[]): Float32Array { +export function flattenFloat32Chunks(chunks: Float32Array[]): Float32Array { const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const merged = new Float32Array(totalLength); let offset = 0; @@ -321,7 +321,7 @@ function flattenFloat32Chunks(chunks: Float32Array[]): Float32Array { return merged; } -function downsampleTo16k(samples: Float32Array, sourceSampleRate: number): Float32Array { +export function downsampleTo16k(samples: Float32Array, sourceSampleRate: number): Float32Array { if (!samples.length || !sourceSampleRate || sourceSampleRate === 16000) { return samples; } @@ -348,7 +348,7 @@ function downsampleTo16k(samples: Float32Array, sourceSampleRate: number): Float return downsampled; } -function encodeWavePcm16(samples: Float32Array, sampleRate: number): ArrayBuffer { +export function encodeWavePcm16(samples: Float32Array, sampleRate: number): ArrayBuffer { const bytesPerSample = 2; const blockAlign = bytesPerSample; const byteRate = sampleRate * blockAlign; @@ -387,6 +387,165 @@ function encodeWavePcm16(samples: Float32Array, sampleRate: number): ArrayBuffer return buffer; } +export interface LocalWaveSnapshotCache { + sourceSampleRate: number; + chunkCount: number; + sampleCount: number; + downsampledSampleCount: number; + pcmBytes: Uint8Array; +} + +function encodePcm16Bytes(samples: Float32Array): Uint8Array { + const bytes = new Uint8Array(samples.length * 2); + const view = new DataView(bytes.buffer); + let offset = 0; + for (let i = 0; i < samples.length; i += 1) { + const normalized = Math.max(-1, Math.min(1, samples[i])); + view.setInt16(offset, normalized < 0 ? normalized * 0x8000 : normalized * 0x7fff, true); + offset += 2; + } + return bytes; +} + +function encodeWaveFromPcm16Bytes(pcmBytes: Uint8Array, sampleRate: number): ArrayBuffer { + const bytesPerSample = 2; + const blockAlign = bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = pcmBytes.byteLength; + const buffer = new ArrayBuffer(44 + dataSize); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + + let offset = 0; + const writeString = (value: string) => { + for (let i = 0; i < value.length; i += 1) { + view.setUint8(offset + i, value.charCodeAt(i)); + } + offset += value.length; + }; + + writeString('RIFF'); + view.setUint32(offset, 36 + dataSize, true); offset += 4; + writeString('WAVE'); + writeString('fmt '); + view.setUint32(offset, 16, true); offset += 4; + view.setUint16(offset, 1, true); offset += 2; + view.setUint16(offset, 1, true); offset += 2; + view.setUint32(offset, sampleRate, true); offset += 4; + view.setUint32(offset, byteRate, true); offset += 4; + view.setUint16(offset, blockAlign, true); offset += 2; + view.setUint16(offset, 16, true); offset += 2; + writeString('data'); + view.setUint32(offset, dataSize, true); offset += 4; + bytes.set(pcmBytes, offset); + + return buffer; +} + +function mergeUint8(a: Uint8Array, b: Uint8Array): Uint8Array { + if (!a.byteLength) return b; + if (!b.byteLength) return a; + const merged = new Uint8Array(a.byteLength + b.byteLength); + merged.set(a, 0); + merged.set(b, a.byteLength); + return merged; +} + +function countFloat32Samples(chunks: Float32Array[]): number { + let total = 0; + for (const chunk of chunks) total += chunk.length; + return total; +} + +function downsampleNewRangeTo16k( + chunks: Float32Array[], + sourceSampleRate: number, + startOutputSample: number, + endOutputSample: number, + totalSourceSamples: number +): Float32Array { + if (endOutputSample <= startOutputSample) return new Float32Array(0); + const ratio = sourceSampleRate / 16000; + const output = new Float32Array(endOutputSample - startOutputSample); + let chunkIndex = 0; + let chunkStart = 0; + + const sampleAt = (globalIndex: number): number => { + while ( + chunkIndex < chunks.length - 1 && + globalIndex >= chunkStart + chunks[chunkIndex].length + ) { + chunkStart += chunks[chunkIndex].length; + chunkIndex += 1; + } + return chunks[chunkIndex]?.[globalIndex - chunkStart] || 0; + }; + + for (let outIndex = startOutputSample; outIndex < endOutputSample; outIndex += 1) { + const start = Math.min(totalSourceSamples, Math.round(outIndex * ratio)); + const end = Math.min(totalSourceSamples, Math.round((outIndex + 1) * ratio)); + let accumulator = 0; + let count = 0; + for (let sourceIndex = start; sourceIndex < end; sourceIndex += 1) { + accumulator += sampleAt(sourceIndex); + count += 1; + } + output[outIndex - startOutputSample] = count > 0 + ? accumulator / count + : sampleAt(Math.min(start, totalSourceSamples - 1)); + } + + return output; +} + +export function buildCachedLocalWaveSnapshot( + chunks: Float32Array[], + sourceSampleRate: number, + cache: LocalWaveSnapshotCache | null +): { buffer: ArrayBuffer | null; cache: LocalWaveSnapshotCache | null } { + if (!chunks.length) return { buffer: null, cache: null }; + const sampleRate = sourceSampleRate || 16000; + const totalSourceSamples = countFloat32Samples(chunks); + const canReuse = !!cache + && cache.sourceSampleRate === sampleRate + && cache.chunkCount <= chunks.length + && cache.sampleCount <= totalSourceSamples; + const previousChunkCount = canReuse ? cache.chunkCount : 0; + const previousPcmBytes = canReuse ? cache.pcmBytes : new Uint8Array(0); + const previousDownsampledSamples = canReuse ? cache.downsampledSampleCount : 0; + let newSourceSampleCount = 0; + let newDownsampled: Float32Array; + if (sampleRate === 16000) { + const newChunks = chunks.slice(previousChunkCount); + const newSourceSamples = flattenFloat32Chunks(newChunks); + newSourceSampleCount = newSourceSamples.length; + newDownsampled = newSourceSamples; + } else { + const nextDownsampledSamples = Math.max(1, Math.round(totalSourceSamples / (sampleRate / 16000))); + newSourceSampleCount = totalSourceSamples - (canReuse ? cache.sampleCount : 0); + newDownsampled = downsampleNewRangeTo16k( + chunks, + sampleRate, + previousDownsampledSamples, + nextDownsampledSamples, + totalSourceSamples + ); + } + const nextPcmBytes = mergeUint8(previousPcmBytes, encodePcm16Bytes(newDownsampled)); + if (!nextPcmBytes.byteLength) return { buffer: null, cache }; + const nextCache: LocalWaveSnapshotCache = { + sourceSampleRate: sampleRate, + chunkCount: chunks.length, + sampleCount: (canReuse ? cache.sampleCount : 0) + newSourceSampleCount, + downsampledSampleCount: previousDownsampledSamples + newDownsampled.length, + pcmBytes: nextPcmBytes, + }; + return { + buffer: encodeWaveFromPcm16Bytes(nextPcmBytes, 16000), + cache: nextCache, + }; +} + const SuperCmdWhisper: React.FC = ({ onClose, portalTarget, @@ -462,6 +621,7 @@ const SuperCmdWhisper: React.FC = ({ const captureGainRef = useRef(null); const captureSampleRateRef = useRef(16000); const pcmCaptureChunksRef = useRef([]); + const localWaveSnapshotCacheRef = useRef(null); const rafRef = useRef(null); // MediaRecorder refs (Whisper API backend) @@ -562,6 +722,7 @@ const SuperCmdWhisper: React.FC = ({ sourceNodeRef.current = source; captureSampleRateRef.current = audioContext.sampleRate || 16000; pcmCaptureChunksRef.current = []; + localWaveSnapshotCacheRef.current = null; if (capturePcm) { const processor = audioContext.createScriptProcessor(4096, 1, 1); @@ -652,13 +813,13 @@ const SuperCmdWhisper: React.FC = ({ }, []); const buildLocalWaveSnapshot = useCallback((): ArrayBuffer | null => { - const chunks = pcmCaptureChunksRef.current; - if (!chunks.length) return null; - const merged = flattenFloat32Chunks(chunks); - if (!merged.length) return null; - const downsampled = downsampleTo16k(merged, captureSampleRateRef.current || 16000); - if (!downsampled.length) return null; - return encodeWavePcm16(downsampled, 16000); + const result = buildCachedLocalWaveSnapshot( + pcmCaptureChunksRef.current, + captureSampleRateRef.current || 16000, + localWaveSnapshotCacheRef.current + ); + localWaveSnapshotCacheRef.current = result.cache; + return result.buffer; }, []); const restoreEditorFocusOnce = useCallback((delayMs = 0) => { @@ -1818,6 +1979,7 @@ const SuperCmdWhisper: React.FC = ({ // All local models (whispercpp, parakeet, qwen3) use PCM; cloud/native don't but // capturing a few extra chunks is harmless. pcmCaptureChunksRef.current = []; + localWaveSnapshotCacheRef.current = null; captureSampleRateRef.current = 16000; startVisualizer(preflightStream!, true); 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/DetachedOverlayRunners.tsx b/src/renderer/src/components/DetachedOverlayRunners.tsx index 3770254c..a0edbbe4 100644 --- a/src/renderer/src/components/DetachedOverlayRunners.tsx +++ b/src/renderer/src/components/DetachedOverlayRunners.tsx @@ -1,12 +1,13 @@ -import React, { memo } from 'react'; +import React, { lazy, memo } from 'react'; import { createPortal } from 'react-dom'; -import SuperCmdWhisper from '../SuperCmdWhisper'; -import SuperCmdRead from '../SuperCmdRead'; -import WindowManagerPanel from '../WindowManagerPanel'; import type { SpeakStatus } from '../hooks/useSpeakManager'; import type { UseCursorPromptReturn } from '../hooks/useCursorPrompt'; import type { ReadVoiceOption } from '../utils/command-helpers'; -import CursorPromptView from '../views/CursorPromptView'; + +const SuperCmdWhisper = lazy(() => import('../SuperCmdWhisper')); +const SuperCmdRead = lazy(() => import('../SuperCmdRead')); +const WindowManagerPanel = lazy(() => import('../WindowManagerPanel')); +const CursorPromptView = lazy(() => import('../views/CursorPromptView')); type DetachedOverlayRunnersProps = { showWhisper: boolean; @@ -85,7 +86,7 @@ const DetachedOverlayRunners: React.FC = ({ acceptCursorPrompt, }) => { return ( - <> + {showWhisper && whisperPortalTarget ? ( = ({ cursorPromptPortalTarget ) : null} - + ); }; diff --git a/src/renderer/src/components/HiddenExtensionRunners.tsx b/src/renderer/src/components/HiddenExtensionRunners.tsx index 6f21f494..0b4f56c0 100644 --- a/src/renderer/src/components/HiddenExtensionRunners.tsx +++ b/src/renderer/src/components/HiddenExtensionRunners.tsx @@ -1,7 +1,9 @@ -import React, { memo, useMemo } from 'react'; -import ExtensionView from '../ExtensionView'; +import React, { lazy, memo, useCallback, useEffect, useMemo } from 'react'; import type { BackgroundNoViewRun, MenuBarEntry } from '../hooks/useMenuBarExtensions'; import { NOOP_ON_CLOSE } from '../utils/launcher-misc'; +import { removeBackgroundNoViewRun } from '../utils/background-no-view-runs'; + +const ExtensionView = lazy(() => import('../ExtensionView')); type HiddenExtensionRunnersProps = { menuBarExtensions: MenuBarEntry[]; @@ -9,11 +11,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,39 +95,21 @@ 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 ( - <> + {menuBarRunner} {backgroundNoViewRunner} - + ); }; diff --git a/src/renderer/src/components/LauncherCommandList.tsx b/src/renderer/src/components/LauncherCommandList.tsx index d6873475..82cbeda3 100644 --- a/src/renderer/src/components/LauncherCommandList.tsx +++ b/src/renderer/src/components/LauncherCommandList.tsx @@ -9,6 +9,192 @@ 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[]; + selectableEntryByAbsoluteIndex: Map; + totalHeight: number; +}; + +function buildVirtualEntries( + sections: LauncherCommandSection[], + calcResult: CalcResult | null, + calcOffset: number +): LauncherVirtualList { + const entries: LauncherVirtualEntry[] = []; + const selectableEntryByAbsoluteIndex = new Map(); + let top = 0; + let flatIndexCursor = 0; + + if (calcResult) { + entries.push({ + kind: 'calculator', + key: 'calculator', + top, + height: CALCULATOR_CARD_HEIGHT, + absoluteIndex: 0, + }); + selectableEntryByAbsoluteIndex.set(0, entries[entries.length - 1]); + 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, + }); + selectableEntryByAbsoluteIndex.set(flatIndex + calcOffset, entries[entries.length - 1]); + top += COMMAND_ROW_HEIGHT; + }); + flatIndexCursor += section.items.length; + }); + + return { entries, selectableEntryByAbsoluteIndex, 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( + virtualList: LauncherVirtualList, + scrollTop: number, + viewportHeight: number, + selectedIndex: number +): LauncherVirtualEntry[] { + const { entries } = virtualList; + const viewportStart = Math.max(0, scrollTop - VIRTUALIZATION_OVERSCAN_PX); + const viewportEnd = scrollTop + viewportHeight + VIRTUALIZATION_OVERSCAN_PX; + const ranges = [getEntryRangeForOffsets(entries, viewportStart, viewportEnd)]; + const selectedEntry = virtualList.selectableEntryByAbsoluteIndex.get(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 + ) + ); + } + + const mergedRanges = mergeEntryRanges(ranges); + let visibleCount = 0; + for (const range of mergedRanges) visibleCount += range.end - range.start; + const visibleEntries = new Array(visibleCount); + let cursor = 0; + for (const range of mergedRanges) { + for (let index = range.start; index < range.end; index += 1) { + visibleEntries[cursor] = entries[index]; + cursor += 1; + } + } + return visibleEntries; +} + +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 +233,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, scrollTop, viewportHeight, selectedIndex) + : virtualList.entries, + [scrollTop, selectedIndex, shouldVirtualize, viewportHeight, virtualList] + ); + const selectedEntry = React.useMemo( + () => virtualList.selectableEntryByAbsoluteIndex.get(selectedIndex), + [selectedIndex, virtualList] + ); + + 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..731bb4c7 --- /dev/null +++ b/src/renderer/src/hooks/backgroundRefreshTimers.ts @@ -0,0 +1,169 @@ +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 interface BackgroundRefreshTickRunnerOptions { + onSkipped?: () => void; +} + +export type BackgroundRefreshTickRunner = (() => Promise) & { + isRunning: () => boolean; +}; + +export function createNonOverlappingBackgroundRefreshTick( + runTick: () => Promise | void, + options: BackgroundRefreshTickRunnerOptions = {} +): BackgroundRefreshTickRunner { + let running = false; + + const tick = (async () => { + if (running) { + options.onSkipped?.(); + return false; + } + + running = true; + try { + await runTick(); + return true; + } finally { + running = false; + } + }) as BackgroundRefreshTickRunner; + + tick.isRunning = () => running; + return tick; +} + +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..744e610b 100644 --- a/src/renderer/src/hooks/useBackgroundRefresh.ts +++ b/src/renderer/src/hooks/useBackgroundRefresh.ts @@ -7,13 +7,22 @@ * - 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, + createNonOverlappingBackgroundRefreshTick, + getBackgroundRefreshTimerDescriptors, + reconcileBackgroundRefreshTimers, + type BackgroundRefreshTimerEntry, + type BackgroundRefreshTimerDescriptor, +} from './backgroundRefreshTimers'; import { readJsonObject, getScriptCmdArgsKey, @@ -36,41 +45,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 - ); - - for (const cmd of extensionCommands) { - const ms = parseIntervalToMs(cmd.interval); - if (!ms) continue; + const createTimer = useCallback((descriptor: BackgroundRefreshTimerDescriptor): number => { + const cmd = descriptor.command; - const identity = parseExtensionCommandPath(cmd.path || ''); - if (!identity) continue; - const { extName, cmdName } = identity; + if (descriptor.kind === 'extension') { + const { extName, cmdName } = descriptor.extensionCommand!; - const timerId = window.setInterval(async () => { + const tick = createNonOverlappingBackgroundRefreshTick(async () => { try { const result = await window.electron.runExtension(extName, cmdName); if (!result || !result.code) return; @@ -110,47 +98,45 @@ export function useBackgroundRefresh({ commands, fetchCommands, isMenuBarCommand } catch (error) { console.error('[BackgroundRefresh] Failed to run command:', cmd.id, error); } - }, ms); + }); - intervalTimerIdsRef.current.push(timerId); + return window.setInterval(tick, 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); + const tick = createNonOverlappingBackgroundRefreshTick(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); + } + }); - intervalTimerIdsRef.current.push(timerId); - } + return window.setInterval(tick, descriptor.intervalMs); + }, []); + 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..e4c34ced 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, @@ -619,9 +621,10 @@ function filterBrowserResults( filters: BrowserProfileFilters, profiles: BrowserProfileSetting[] ): BrowserSearchResult[] { + const enabledByKind = getEnabledProfileIdSets(filters, profiles); return results.filter((result) => { if (!result.sourceProfileId) return true; - const enabled = new Set(getEnabledProfileIds(result.kind, filters, profiles)); + const enabled = enabledByKind[result.kind]; return enabled.has(result.sourceProfileId); }); } @@ -632,13 +635,32 @@ function filterBrowserResultsForKind( filters: BrowserProfileFilters, profiles: BrowserProfileSetting[] ): BrowserSearchResult[] { - const enabled = new Set(getEnabledProfileIds(kind, filters, profiles)); + const enabled = getEnabledProfileIdSet(kind, filters, profiles); return results.filter((result) => !result.sourceProfileId || enabled.has(result.sourceProfileId) ); } +function getEnabledProfileIdSet( + kind: BrowserSearchResultKind, + filters: BrowserProfileFilters, + profiles: BrowserProfileSetting[] +): Set { + return new Set(getEnabledProfileIds(kind, filters, profiles)); +} + +function getEnabledProfileIdSets( + filters: BrowserProfileFilters, + profiles: BrowserProfileSetting[] +): Record> { + return { + 'open-tab': getEnabledProfileIdSet('open-tab', filters, profiles), + bookmark: getEnabledProfileIdSet('bookmark', filters, profiles), + history: getEnabledProfileIdSet('history', filters, profiles), + }; +} + function decorateBrowserResults(results: BrowserSearchResult[], profiles: BrowserProfileSetting[]): BrowserSearchResult[] { return results.map((result) => { const profileLabel = getProfileLabelById(result.sourceProfileId, profiles) || result.profileLabel || ''; @@ -665,43 +687,76 @@ type BrowserEntrySearchIndex = { normalizedQuery: string; normalizedUrl: string; searchFields: TokenSearchField[]; + searchBlob: string; +}; + +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 +768,12 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde return { historyByTimeEntryIds, bookmarksByBrowserOrderEntryIds, + entrySearchIndexes, + entriesByKind: { + history: historyIndex, + bookmark: bookmarkIndex, + }, + bookmarkEntryIdsByNicknameKey, profileCountsByKind: { history: historyProfileCounts, bookmark: bookmarkProfileCounts, @@ -720,6 +781,73 @@ 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 token of searchIndex.searchBlob.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 +996,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 +1015,7 @@ function getBrowserEntryCandidates( input: string, entries: BrowserSearchEntry[], options: { + entryIndex?: BrowserEntryIndex | null; preserveBookmarkOrder?: boolean; preserveHistoryChronology?: boolean; includeHistoryTimestamp?: boolean; @@ -905,6 +1035,9 @@ function getBrowserEntryCandidates( const queryTokens = getSearchTokens(trimmed); const shouldBoundResults = Boolean(options.limit && options.limit > 0 && !options.preserveBookmarkOrder && !options.preserveHistoryChronology); const workingLimit = shouldBoundResults ? Math.max(Number(options.limit) * 3, Number(options.limit) + 80) : 0; + const nicknameLookup = kind === 'bookmark' + ? createBookmarkNicknameLookup(options.nicknames || []) + : EMPTY_BOOKMARK_NICKNAME_LOOKUP; const results: BrowserSearchResult[] = []; if (!hasQuery && options.limit && options.limit > 0 && options.preferredEntryIds) { for (const entryId of options.preferredEntryIds) { @@ -912,9 +1045,8 @@ 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 || []) + ? findBookmarkNickname(entry, nicknameLookup) : ''; const freshnessFactor = kind === 'history' ? getHistoryFreshnessFactor(entry.lastUsedAt) : 1; results.push({ @@ -948,16 +1080,27 @@ 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 || []) + ? findBookmarkNickname(entry, nicknameLookup) : ''; const nicknameMatch = kind === 'bookmark' && hasQuery && !/\s/.test(trimmed) - ? getBookmarkNicknameMatch(entry, trimmed, options.nicknames || []) + ? getBookmarkNicknameMatch(entry, trimmed, nicknameLookup) : null; const searchInput = nicknameMatch ? nicknameMatch.remainingInput : trimmed; const hasSearchInput = searchInput.length > 0; @@ -965,10 +1108,9 @@ function getBrowserEntryCandidates( const activeStrippedInput = nicknameMatch ? activeLowerInput.replace(/^https?:\/\//, '') : strippedInput; const activeQueryTokens = nicknameMatch ? getSearchTokens(searchInput) : queryTokens; if (!nicknameMatch && hasSearchInput && activeQueryTokens.length > 0) { - const searchBlob = index.searchFields.map((f) => f.value).filter(Boolean).join(' '); let tokenMatched = true; for (const token of activeQueryTokens) { - if (!searchBlob.includes(token)) { + if (!index.searchBlob.includes(token)) { tokenMatched = false; break; } @@ -1035,6 +1177,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; @@ -1266,6 +1557,14 @@ type TokenSearchField = { weight: number; }; +type BookmarkNicknameLookup = { + byBookmarkKey: Map; +}; + +const EMPTY_BOOKMARK_NICKNAME_LOOKUP: BookmarkNicknameLookup = { + byBookmarkKey: new Map(), +}; + type BookmarkNicknameMatch = { nickname: string; completion: string; @@ -1275,7 +1574,7 @@ type BookmarkNicknameMatch = { function getBookmarkNicknameMatch( entry: BrowserSearchEntry, input: string, - nicknames: BrowserSearchNicknameSetting[] + nicknames: BookmarkNicknameLookup ): BookmarkNicknameMatch | null { const parsed = parseNicknameQuery(input); if (!parsed.firstToken) return null; @@ -1291,17 +1590,28 @@ function getBookmarkNicknameMatch( }; } -function findBookmarkNickname(entry: BrowserSearchEntry, nicknames: BrowserSearchNicknameSetting[]): string { +function createBookmarkNicknameLookup(nicknames: BrowserSearchNicknameSetting[]): BookmarkNicknameLookup { + if (nicknames.length === 0) return EMPTY_BOOKMARK_NICKNAME_LOOKUP; + const byBookmarkKey = new Map(); + for (const item of nicknames) { + const source = String(item.source || ''); + const sourceProfileId = String(item.sourceProfileId || ''); + const url = normalizeNicknameUrl(item.url); + const nickname = String(item.nickname || '').trim(); + if (!url || !nickname) continue; + byBookmarkKey.set(getBookmarkNicknameLookupKey(source, sourceProfileId, url), nickname); + } + return { byBookmarkKey }; +} + +function findBookmarkNickname(entry: BrowserSearchEntry, nicknames: BookmarkNicknameLookup): string { const entrySource = String(entry.source || ''); const entryProfileId = String(entry.sourceProfileId || ''); const entryFullProfileId = getEntryProfileKey(entry); const entryUrl = normalizeNicknameUrl(entry.url); - const match = nicknames.find((item) => - String(item.source || '') === entrySource && - (String(item.sourceProfileId || '') === entryProfileId || String(item.sourceProfileId || '') === entryFullProfileId) && - normalizeNicknameUrl(item.url) === entryUrl - ); - return String(match?.nickname || '').trim(); + return nicknames.byBookmarkKey.get(getBookmarkNicknameLookupKey(entrySource, entryProfileId, entryUrl)) || + nicknames.byBookmarkKey.get(getBookmarkNicknameLookupKey(entrySource, entryFullProfileId, entryUrl)) || + ''; } function parseNicknameQuery(input: string): { firstToken: string; remainingInput: string } { @@ -1338,6 +1648,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 }, @@ -1350,12 +1670,8 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear normalizedQuery: String(entry.query || '').trim().toLowerCase(), normalizedUrl: normalizeUrlForCompletion(entry.url || entry.host, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), searchFields, + searchBlob: searchFields.map((field) => field.value).filter(Boolean).join(' '), }; - 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 +1888,10 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + filterBrowserResults, + 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..324324eb 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -9,7 +9,12 @@ import type { import type { BrowserSearchResult, useBrowserSearch } from './useBrowserSearch'; import type { CalcResult } from '../smart-calculator'; import { tryCalculate, tryCalculateAsync } from '../smart-calculator'; -import { filterCommands, rankCommands } from '../utils/command-helpers'; +import { + createCommandFilterIndex, + createRootCommandScoreIndex, + filterCommandsWithIndex, + rankCommandsWithIndex, +} from '../utils/command-helpers'; import { asTildePath, buildFileResultCommandId, @@ -40,10 +45,12 @@ import { getRootSearchFrecencyBoost, normalizeRootSearchStableValue, normalizeRootSearchUrl, + precompileRootSearchQuery, + precompileRootSearchScoringFields, rankRootSearchCandidates, - scoreRootSearchCandidate, - scoreRootSearchFields, - type MatchKind, + scorePrecompiledRootSearchFields, + scoreRootSearchCandidateWithContext, + createRootSearchCandidateScoreContext, type RootSearchCandidate, type RootSearchRankingState, type RootSearchSubtype, @@ -52,6 +59,11 @@ import { assembleRootSearchSections, isRootResultPromotionCandidate, } from '../utils/root-search-sections'; +import { + buildLauncherFileCandidates, + buildLauncherFileResultCommandByPath, + coerceRootSearchMatchKind as coerceMatchKind, +} from '../utils/launcher-file-candidates'; import type { LauncherCommandSection } from '../components/LauncherCommandList'; export type GroupedLauncherCommands = { @@ -138,56 +150,9 @@ function inferCommandSubtype(command: CommandInfo): RootSearchSubtype { return 'system-command'; } -function coerceMatchKind(value: string | undefined, fallback: MatchKind): MatchKind { - switch (value) { - case 'exact': - case 'alias-exact': - case 'nickname-exact': - case 'prefix': - case 'token-prefix': - case 'compact-prefix': - case 'word-boundary-fuzzy': - case 'contains': - case 'subsequence': - case 'description': - case 'path': - case 'url': - return value; - default: - return fallback; - } -} - -function getFileFreshnessBoost(result: IndexedFileSearchResult): number { - const touched = Math.max(Number(result.mtimeMs || 0), Number(result.birthtimeMs || 0)); - if (!touched) return 0; - const ageHours = Math.max(0, (Date.now() - touched) / (60 * 60 * 1000)); - const ageDays = ageHours / 24; - if (ageHours <= 24) return 120; - if (ageDays <= 7) return 90; - if (ageDays <= 30) return 45; - if (ageDays <= 90) return 15; - return 0; -} - -function getFileLocationBoost(result: IndexedFileSearchResult): number { - const topLevelRoot = String(result.topLevelRoot || '').trim(); - const depth = Number(result.homeRelativeDepth || result.depth || 0); - const protectedRoot = topLevelRoot === 'Desktop' || topLevelRoot === 'Documents' || topLevelRoot === 'Downloads'; - if (!protectedRoot) return 20; - return 120 - Math.min(90, Math.max(0, depth - 2) * 18); -} - -function getFileDepthPenalty(result: IndexedFileSearchResult): number { - const depth = Math.max(0, Number(result.homeRelativeDepth || result.depth || 0)); - if (depth <= 2) return 0; - if (depth <= 4) return (depth - 2) * 25; - return Math.min(260, 50 + (depth - 4) * 35); -} - type BrowserLauncherProfile = { id?: string; - browserId?: BrowserSearchSource | string; + browserId?: BrowserSearchSource; displayName: string; detectedName?: string; profileId: string; @@ -339,9 +304,25 @@ export function useLauncherCommandModel({ const calcResult = syncCalcResult ?? asyncCalcResult; const calcOffset = calcResult ? 1 : 0; const contextualCommands = commands; + const hasSearchQuery = searchQuery.trim().length > 0; + const rootSearchQuery = useMemo( + () => hasSearchQuery ? precompileRootSearchQuery(searchQuery) : null, + [hasSearchQuery, searchQuery] + ); + const rootSearchScoreContext = useMemo( + () => hasSearchQuery ? createRootSearchCandidateScoreContext(searchQuery) : null, + [hasSearchQuery, searchQuery] + ); + const commandFilterIndex = useMemo( + () => createCommandFilterIndex(contextualCommands, commandAliases), + [contextualCommands, commandAliases] + ); + const shouldComputeLegacyCommandList = !hasSearchQuery || Boolean(calcResult); const filteredCommands = useMemo( - () => filterCommands(contextualCommands, searchQuery, commandAliases), - [contextualCommands, searchQuery, commandAliases] + () => shouldComputeLegacyCommandList + ? filterCommandsWithIndex(commandFilterIndex, searchQuery) + : contextualCommands, + [contextualCommands, searchQuery, commandFilterIndex, shouldComputeLegacyCommandList] ); // When calculator is showing but no commands match, show unfiltered list below. @@ -355,7 +336,20 @@ export function useLauncherCommandModel({ () => new Set(['system-add-to-memory', 'system-cursor-prompt', 'system-emoji-picker']), [] ); - const hasSearchQuery = searchQuery.trim().length > 0; + const shouldIndexRootCommands = hasSearchQuery && !aiMode && rootBangState.mode === 'none'; + const rootSearchableCommands = useMemo( + () => shouldIndexRootCommands + ? contextualCommands.filter((cmd) => + cmd.id !== WEB_SEARCH_COMMAND_ID && + (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) + ) + : [], + [contextualCommands, hiddenListOnlyCommandIds, hasSearchQuery, shouldIndexRootCommands] + ); + const rootCommandScoreIndex = useMemo( + () => createRootCommandScoreIndex(rootSearchableCommands, commandAliases), + [rootSearchableCommands, commandAliases] + ); const visibleSourceCommands = useMemo( () => sourceCommands .filter((cmd) => !hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) @@ -391,6 +385,10 @@ export function useLauncherCommandModel({ }), [launcherFileResults, launcherFileIcons, homeDir] ); + const fileResultCommandByPath = useMemo( + () => buildLauncherFileResultCommandByPath(fileResultCommands), + [fileResultCommands] + ); const pinnedFileCommands = useMemo( () => @@ -521,24 +519,12 @@ export function useLauncherCommandModel({ const browserSearchResultCommands = useMemo(() => [], []); const commandCandidates = useMemo(() => { - if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - const searchableCommands = contextualCommands.filter((cmd) => - cmd.id !== WEB_SEARCH_COMMAND_ID && - (!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; + if (!shouldIndexRootCommands || !rootSearchQuery || !rootSearchScoreContext) return []; + return rankCommandsWithIndex(rootCommandScoreIndex, searchQuery, rootSearchQuery) + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; - return scoreRootSearchCandidate({ + return scoreRootSearchCandidateWithContext({ command: { ...command, rootSearchStableKey: stableKey, @@ -551,62 +537,32 @@ 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, noisePenalty: 0, depthPenalty: 0, - }, searchQuery, rootSearchRanking); + }, rootSearchScoreContext, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, contextualCommands, hiddenListOnlyCommandIds, searchQuery, commandAliases, rootSearchRanking]); + }, [shouldIndexRootCommands, rootCommandScoreIndex, searchQuery, rootSearchQuery, rootSearchScoreContext, rootSearchRanking]); const fileCandidates = useMemo(() => { - if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - return launcherFileResults - .map((result) => { - const command = fileResultCommands.find((item) => item.path === result.path); - if (!command) return null; - const scored = scoreRootSearchFields(searchQuery, [ - { value: result.name, kind: 'label', weight: 1 }, - { value: result.parentPath, kind: 'path', weight: 0.72 }, - { value: result.displayPath, kind: 'path', weight: 0.72 }, - { value: result.path, kind: 'path', weight: 0.68 }, - ]); - if (!scored.matched) return null; - const subtype: RootSearchSubtype = result.isDirectory ? 'folder' : 'file'; - const matchKind = coerceMatchKind(result.matchKind, scored.matchKind); - const weakFolderMatch = subtype === 'folder' && (matchKind === 'contains' || matchKind === 'subsequence' || matchKind === 'path'); - const stableKey = `file:${normalizeRootSearchStableValue(result.path)}`; - return scoreRootSearchCandidate({ - command: { - ...command, - rootSearchStableKey: stableKey, - rootSearchSource: 'file', - rootSearchSubtype: subtype, - }, - source: 'file', - subtype, - stableKey, - label: result.name, - description: result.displayPath, - pathOrUrl: result.path, - matchKind, - matchScore: scored.matchScore, - sourceQualityBoost: subtype === 'file' ? 8 : weakFolderMatch ? -10 : 0, - freshnessBoost: getFileFreshnessBoost(result), - pathLocationBoost: getFileLocationBoost(result), - noisePenalty: Math.max(0, Number(result.noisyPathSegmentCount || 0)) * 70, - depthPenalty: getFileDepthPenalty(result), - }, searchQuery, rootSearchRanking); - }) - .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, launcherFileResults, fileResultCommands, searchQuery, rootSearchRanking]); + if (!hasSearchQuery || !rootSearchQuery || !rootSearchScoreContext || aiMode || rootBangState.mode !== 'none') return []; + return buildLauncherFileCandidates({ + launcherFileResults, + fileResultCommandByPath, + searchQuery, + compiledQuery: rootSearchQuery, + scoreContext: rootSearchScoreContext, + rootSearchRanking, + }); + }, [hasSearchQuery, rootSearchQuery, rootSearchScoreContext, aiMode, rootBangState, launcherFileResults, fileResultCommandByPath, searchQuery, rootSearchRanking]); const browserCandidates = useMemo(() => { - if (!browserSearch.enabled || !browserSearch.alphaChromiumRootSearchEnabled || !hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; + if (!browserSearch.enabled || !browserSearch.alphaChromiumRootSearchEnabled || !hasSearchQuery || !rootSearchQuery || !rootSearchScoreContext || aiMode || rootBangState.mode !== 'none') return []; return browserSearch.getAllResults(searchQuery, browserSearchResultGroups) .map((result, index) => { const defaultProfile = getDefaultBrowserProfile(browserSearch); @@ -628,7 +584,7 @@ export function useLauncherCommandModel({ { value: result.url, kind: 'url' as const, weight: 0.82 }, { value: result.subtitle, kind: 'description' as const, weight: 0.62 }, ]; - const scored = scoreRootSearchFields(searchQuery, fields); + const scored = scorePrecompiledRootSearchFields(rootSearchQuery, precompileRootSearchScoringFields(fields)); if (!scored.matched) return null; const subtype: RootSearchSubtype = nicknameMatched ? 'nickname' : result.kind; const stableKey = command.rootSearchStableKey || `browser:${normalizeRootSearchUrl(result.url) || result.id}`; @@ -662,7 +618,7 @@ export function useLauncherCommandModel({ let noisePenalty = 0; if (result.kind === 'history' && (matchKind === 'contains' || matchKind === 'subsequence')) noisePenalty += 120; if (result.kind === 'bookmark' && !nicknameMatched && (matchKind === 'contains' || matchKind === 'subsequence')) noisePenalty += 60; - return scoreRootSearchCandidate({ + return scoreRootSearchCandidateWithContext({ command: { ...command, rootSearchScore: result.score, @@ -680,10 +636,10 @@ export function useLauncherCommandModel({ pathLocationBoost: 0, noisePenalty, depthPenalty: 0, - }, searchQuery, rootSearchRanking); + }, rootSearchScoreContext, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [browserSearch, browserSearchResultGroups, hasSearchQuery, searchQuery, aiMode, rootBangState, rootSearchRanking, browserAppIconDataUrls]); + }, [browserSearch, browserSearchResultGroups, hasSearchQuery, rootSearchQuery, rootSearchScoreContext, searchQuery, aiMode, rootBangState, rootSearchRanking, browserAppIconDataUrls]); const rootRankedCandidates = useMemo( () => rankRootSearchCandidates([...commandCandidates, ...fileCandidates, ...browserCandidates]), @@ -857,41 +813,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..ec8c7a0b 100644 --- a/src/renderer/src/raycast-api/detail-runtime.tsx +++ b/src/renderer/src/raycast-api/detail-runtime.tsx @@ -3,7 +3,7 @@ * Purpose: Detail component runtime and metadata primitives. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { normalizeScAssetUrl, resolveReadableTintColor, resolveTintColor, toScAssetUrl } from './icon-runtime-assets'; import { renderSimpleMarkdown } from './detail-markdown'; import { useI18n } from '../i18n'; @@ -74,6 +74,12 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { const { pop } = deps.useNavigation(); const { collectedActions: detailActions, registryAPI: detailActionRegistry } = deps.useCollectedActions(); const primaryAction = detailActions[0]; + const detailActionsRef = useRef(detailActions); + const primaryActionRef = useRef(primaryAction); + useEffect(() => { + detailActionsRef.current = detailActions; + primaryActionRef.current = primaryAction; + }, [detailActions, primaryAction]); const { detailMetadata, detailChildren } = useMemo(() => { if (metadata) { @@ -90,7 +96,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; @@ -106,15 +112,19 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { }, [children, metadata]); const extensionContext = deps.getExtensionContext(); + const renderedMarkdown = useMemo(() => ( + markdown ? renderSimpleMarkdown(markdown, resolveMarkdownImageSrc) : null + ), [extensionContext.assetsPath, markdown]); const footerTitle = navigationTitle || extInfo.extensionDisplayName || extensionContext.extensionDisplayName || extensionContext.extensionName || 'Extension'; const footerIcon = extInfo.extensionIconDataUrl || extensionContext.extensionIconDataUrl; useEffect(() => { const handler = (event: KeyboardEvent) => { + const currentPrimaryAction = primaryActionRef.current; if (deps.isMetaK(event)) { event.preventDefault(); setShowActions((prev) => !prev); return; } - if (event.key === 'Enter' && event.metaKey && !event.repeat && primaryAction) { event.preventDefault(); primaryAction.execute(); return; } + if (event.key === 'Enter' && event.metaKey && !event.repeat && currentPrimaryAction) { event.preventDefault(); currentPrimaryAction.execute(); return; } if (!event.repeat) { - for (const action of detailActions) { + for (const action of detailActionsRef.current) { if (action.shortcut && deps.matchesShortcut(event, action.shortcut)) { event.preventDefault(); action.execute(); @@ -125,7 +135,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [detailActions, primaryAction]); + }, []); const handleActionExecute = useCallback((action: ExtractedActionLike) => { setShowActions(false); @@ -163,7 +173,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) {
{markdown ? (
- {renderSimpleMarkdown(markdown, resolveMarkdownImageSrc)} + {renderedMarkdown}
) : null} {detailChildren} @@ -176,7 +186,7 @@ export function createDetailRuntime(deps: CreateDetailRuntimeDeps) {
{markdown ? (
- {renderSimpleMarkdown(markdown, resolveMarkdownImageSrc)} + {renderedMarkdown}
) : null} {detailChildren} @@ -290,7 +300,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/form-runtime-state.ts b/src/renderer/src/raycast-api/form-runtime-state.ts new file mode 100644 index 00000000..1da503fc --- /dev/null +++ b/src/renderer/src/raycast-api/form-runtime-state.ts @@ -0,0 +1,19 @@ +export type FormErrorMap = Record; + +export function clearFormFieldError(previous: FormErrorMap, id: string): FormErrorMap { + if (!Object.prototype.hasOwnProperty.call(previous, id)) { + return previous; + } + + const next = { ...previous }; + delete next[id]; + return next; +} + +export function setFormFieldError(previous: FormErrorMap, id: string, error: string): FormErrorMap { + if (previous[id] === error) { + return previous; + } + + return { ...previous, [id]: error }; +} diff --git a/src/renderer/src/raycast-api/form-runtime.tsx b/src/renderer/src/raycast-api/form-runtime.tsx index 46ddbf0f..1690b366 100644 --- a/src/renderer/src/raycast-api/form-runtime.tsx +++ b/src/renderer/src/raycast-api/form-runtime.tsx @@ -5,8 +5,9 @@ * all field subcomponents. */ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { attachFormFields } from './form-runtime-fields'; +import { clearFormFieldError, setFormFieldError } from './form-runtime-state'; import { FormContext, setCurrentFormErrors, @@ -65,8 +66,8 @@ export function createFormRuntime(deps: FormRuntimeDeps) { return next; }); setErrors((previous) => { - const next = { ...previous }; - delete next[id]; + const next = clearFormFieldError(previous, id); + if (next === previous) return previous; setCurrentFormErrors(next); return next; }); @@ -74,7 +75,8 @@ export function createFormRuntime(deps: FormRuntimeDeps) { const setError = useCallback((id: string, error: string) => { setErrors((previous) => { - const next = { ...previous, [id]: error }; + const next = setFormFieldError(previous, id, error); + if (next === previous) return previous; setCurrentFormErrors(next); return next; }); @@ -97,6 +99,12 @@ export function createFormRuntime(deps: FormRuntimeDeps) { const { collectedActions: formActions, registryAPI: formActionRegistry } = useCollectedActions(); const primaryAction = formActions[0]; + const formActionsRef = useRef(formActions); + const primaryActionRef = useRef(primaryAction); + useEffect(() => { + formActionsRef.current = formActions; + primaryActionRef.current = primaryAction; + }, [formActions, primaryAction]); const extensionContext = getExtensionContext(); const footerTitle = @@ -114,13 +122,14 @@ export function createFormRuntime(deps: FormRuntimeDeps) { setShowActions((value) => !value); return; } - if (event.key === 'Enter' && event.metaKey && !event.repeat && primaryAction) { + const currentPrimaryAction = primaryActionRef.current; + if (event.key === 'Enter' && event.metaKey && !event.repeat && currentPrimaryAction) { event.preventDefault(); - primaryAction.execute(); + currentPrimaryAction.execute(); return; } if (event.repeat) return; - for (const action of formActions) { + for (const action of formActionsRef.current) { if (!action.shortcut || !matchesShortcut(event, action.shortcut)) continue; event.preventDefault(); action.execute(); @@ -130,7 +139,7 @@ export function createFormRuntime(deps: FormRuntimeDeps) { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [formActions, isMetaK, matchesShortcut, primaryAction]); + }, [isMetaK, matchesShortcut]); const contextValue = useMemo( () => ({ values, setValue, errors, setError, placeholders, setPlaceholder }), diff --git a/src/renderer/src/raycast-api/grid-runtime-hooks.ts b/src/renderer/src/raycast-api/grid-runtime-hooks.ts index aada480b..3d732ab1 100644 --- a/src/renderer/src/raycast-api/grid-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/grid-runtime-hooks.ts @@ -4,31 +4,172 @@ * Extracted registry/grouping logic for the grid runtime container. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import React, { 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 }[]; +} + +function getReactTypeName(type: any): string { + return String(type?.displayName || type?.name || type || ''); +} + +function buildValueSignature(value: unknown, seen = new WeakSet()): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value); + if (typeof value === 'function') return `fn:${value.name || 'anonymous'}`; + if (typeof value === 'symbol') return value.toString(); + + if (Array.isArray(value)) { + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; + } + + if (React.isValidElement(value)) { + return `element:${getReactTypeName(value.type)}:${buildValueSignature((value as any).props, seen)}`; + } + + if (typeof value === 'object') { + if (value instanceof Date) return `date:${value.getTime()}`; + if (seen.has(value as object)) return '[circular]'; + seen.add(value as object); + + const entries = Object.entries(value as Record) + .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); + + return `{${entries.join(',')}}`; + } + + return String(value); +} + +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function getActionType(actions: unknown): unknown { + if (!React.isValidElement(actions)) return actions; + return (actions as React.ReactElement).type; +} + +function buildImageLikeSignature(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value) || React.isValidElement(value)) { + return buildValueSignature(value); + } + const image = value as Record; + return [ + buildValueSignature(image.source), + buildValueSignature(image.value), + buildValueSignature(image.fileIcon), + buildValueSignature(image.light), + buildValueSignature(image.dark), + buildValueSignature(image.color), + buildValueSignature(image.tintColor), + buildValueSignature(image.mask), + buildValueSignature(image.fallback), + ].join('\u001e'); +} + +function buildGridContentSignature(content: unknown): string { + return buildImageLikeSignature(content); +} + +function buildGridAccessorySignature(accessory: unknown): string { + if (!accessory || typeof accessory !== 'object' || Array.isArray(accessory) || React.isValidElement(accessory)) { + return buildValueSignature(accessory); + } + const value = accessory as Record; + return [ + buildImageLikeSignature(value.icon), + buildValueSignature(value.tooltip), + buildValueSignature(value.text), + buildValueSignature(value.tag), + ].join('\u001e'); +} + +function buildQuickLookSignature(quickLook: GridItemRegistration['props']['quickLook']): string { + if (!quickLook) return ''; + return `${quickLook.name || ''}\u001e${quickLook.path || ''}`; +} + +export function buildGridItemVisibleSignature(entry: GridItemRegistration): string { + const props = entry.props; + const section = entry.section; + return [ + entry.id, + String(entry.order), + section?.id || '', + section?.title || '', + section?.subtitle || '', + section?.columns || '', + section?.aspectRatio || '', + section?.fit || '', + section?.inset || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildGridContentSignature(props.content), + buildGridAccessorySignature(props.accessory), + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildQuickLookSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + +function gridSectionVisibleInputsChanged( + previous: GridItemRegistration['section'], + next: GridItemRegistration['section'], +): boolean { + if (previous === next) return false; + return ( + previous?.id !== next?.id + || previous?.title !== next?.title + || previous?.subtitle !== next?.subtitle + || previous?.columns !== next?.columns + || previous?.aspectRatio !== next?.aspectRatio + || previous?.fit !== next?.fit + || previous?.inset !== next?.inset + ); +} + +export function gridItemVisibleInputsChanged(existing: GridItemRegistration, next: Omit): boolean { + if (existing.order !== next.order || gridSectionVisibleInputsChanged(existing.section, next.section)) return true; + const previousProps = existing.props; + const nextProps = next.props; + if (previousProps === nextProps) return false; + return ( + previousProps.id !== nextProps.id + || previousProps.title !== nextProps.title + || previousProps.subtitle !== nextProps.subtitle + || previousProps.content !== nextProps.content + || previousProps.accessory !== nextProps.accessory + || previousProps.keywords !== nextProps.keywords + || previousProps.quickLook !== nextProps.quickLook + || getActionType(previousProps.actions) !== getActionType(nextProps.actions) + ); +} + export function useGridRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()) - .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}`; - }) - .join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); - } + setRegistryVersion((value) => value + 1); }); }, []); @@ -37,17 +178,26 @@ export function useGridRegistry() { set(id, data) { const existing = registryRef.current.get(id); if (existing) { + const visibleInputsChanged = gridItemVisibleInputsChanged(existing, data); existing.props = data.props; - existing.sectionTitle = data.sectionTitle; + existing.section = data.section; existing.order = data.order; + if (!visibleInputsChanged) return; + + const nextSignature = buildGridItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const entry = { id, ...data }; + registryRef.current.set(id, entry); + visibleSignatureRef.current.set(id, buildGridItemVisibleSignature(entry)); } scheduleRegistryUpdate(); }, delete(id) { if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), @@ -63,14 +213,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..4c342832 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,9 @@ export function useCachedPromise( const [isPaginated, setIsPaginated] = useState(false); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - + const latestRunIdRef = useRef(0); + const abortControllerRef = useRef(null); + const abortableRefRef = useRef | undefined>(undefined); const fnRef = useRef(fn); const argsRef = useRef(args || []); const optionsRef = useRef(options); @@ -63,30 +59,69 @@ 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 runId = ++latestRunIdRef.current; + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => latestRunIdRef.current === runId && (!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 +131,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 +147,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 +158,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-fetch.ts b/src/renderer/src/raycast-api/hooks/use-fetch.ts index 95f882d0..710581c4 100644 --- a/src/renderer/src/raycast-api/hooks/use-fetch.ts +++ b/src/renderer/src/raycast-api/hooks/use-fetch.ts @@ -46,36 +46,71 @@ export function useFetch( const [error, setError] = useState(undefined); const mountedRef = useRef(true); + const runIdRef = useRef(0); + const lastInitialRequestKeyRef = useRef(undefined); + const activeRequestRef = useRef<{ requestId: string; controller: AbortController } | null>(null); + const requestSeqRef = useRef(0); + + const cancelActiveRequest = useCallback(() => { + const active = activeRequestRef.current; + if (!active) return; + active.controller.abort(); + activeRequestRef.current = null; + window.electron.cancelHttpRequest?.(active.requestId); + }, []); + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + runIdRef.current += 1; + cancelActiveRequest(); }; - }, []); + }, [cancelActiveRequest]); const urlRef = useRef(url); const optionsRef = useRef(options); urlRef.current = url; optionsRef.current = options; - const fetchData = useCallback(async (pageNum: number, currentCursor?: string) => { + const resolveUrl = useCallback(( + requestUrl: typeof url, + pageNum: number, + currentCursor?: string, + ) => typeof requestUrl === 'function' + ? requestUrl({ page: pageNum, cursor: currentCursor, lastItem: undefined }) + : requestUrl, []); + + const fetchData = useCallback(async (pageNum: number, currentCursor?: string, resolvedUrlOverride?: string) => { const opts = optionsRef.current; if (opts?.execute === false || !mountedRef.current) return; + const runId = ++runIdRef.current; + cancelActiveRequest(); + const requestId = `useFetch:${Date.now()}:${++requestSeqRef.current}`; + const controller = new AbortController(); + activeRequestRef.current = { requestId, controller }; + const isCurrentRun = () => ( + mountedRef.current + && runIdRef.current === runId + && activeRequestRef.current?.requestId === requestId + && !controller.signal.aborted + ); + setIsLoading(true); setError(undefined); try { - const resolvedUrl = typeof urlRef.current === 'function' - ? urlRef.current({ page: pageNum, cursor: currentCursor, lastItem: undefined }) - : urlRef.current; + const resolvedUrl = resolvedUrlOverride ?? resolveUrl(urlRef.current, pageNum, currentCursor); const ipcRes = await window.electron.httpRequest({ url: resolvedUrl, method: opts?.method, headers: opts?.headers, body: normalizeRequestBody(opts?.body) as string | undefined, + requestId, }); + if (!isCurrentRun()) return; const res = { ok: ipcRes.status >= 200 && ipcRes.status < 300, @@ -90,9 +125,10 @@ export function useFetch( if (!res.ok) throw new Error(`HTTP ${res.status}`); const parsed = opts?.parseResponse ? await opts.parseResponse(res) : await res.json(); - if (!mountedRef.current) return; + if (!isCurrentRun()) return; const mapped = opts?.mapResult ? opts.mapResult(parsed) : parsed; + if (!isCurrentRun()) return; if (mapped && typeof mapped === 'object' && 'data' in mapped) { const paginatedResult = mapped as { data: T; hasMore?: boolean; cursor?: string }; setHasMore(paginatedResult.hasMore ?? false); @@ -112,16 +148,19 @@ export function useFetch( opts?.onData?.(mapped as T); } } catch (err) { - if (!mountedRef.current) return; + if (!isCurrentRun() || controller.signal.aborted) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - if (mountedRef.current) setIsLoading(false); + const shouldFinishCurrentRun = isCurrentRun(); + if (activeRequestRef.current?.requestId === requestId) { + activeRequestRef.current = null; + } + if (shouldFinishCurrentRun) setIsLoading(false); } - }, []); + }, [cancelActiveRequest, resolveUrl]); - const urlString = typeof url === 'string' ? url : 'function'; const optionsKey = useMemo(() => { try { return JSON.stringify({ @@ -137,16 +176,33 @@ export function useFetch( useEffect(() => { if (options?.execute === false) { + runIdRef.current += 1; + cancelActiveRequest(); + lastInitialRequestKeyRef.current = undefined; setIsLoading(false); setError(undefined); setAllData(options?.initialData); return; } + + let initialResolvedUrl: string | undefined; + let initialUrlKey: string; + try { + initialResolvedUrl = resolveUrl(url, 0, undefined); + initialUrlKey = initialResolvedUrl; + } catch (err) { + initialUrlKey = `error:${err instanceof Error ? err.message : String(err)}`; + } + + const initialRequestKey = `${optionsKey}\n${initialUrlKey}`; + if (lastInitialRequestKeyRef.current === initialRequestKey) return; + lastInitialRequestKeyRef.current = initialRequestKey; + setPage(0); setCursor(undefined); setAllData(options?.initialData); - fetchData(0, undefined); - }, [fetchData, urlString, optionsKey]); + fetchData(0, undefined, initialResolvedUrl); + }, [cancelActiveRequest, fetchData, url, optionsKey]); const revalidate = useCallback(() => { setPage(0); diff --git a/src/renderer/src/raycast-api/hooks/use-form.ts b/src/renderer/src/raycast-api/hooks/use-form.ts index a1f8d88f..94a5dc8d 100644 --- a/src/renderer/src/raycast-api/hooks/use-form.ts +++ b/src/renderer/src/raycast-api/hooks/use-form.ts @@ -4,6 +4,7 @@ */ import { useCallback, useMemo, useState } from 'react'; +import { clearFormFieldError, setFormFieldError, type FormErrorMap } from '../form-runtime-state'; export enum FormValidation { Required = 'required', @@ -23,25 +24,21 @@ export function useForm = Record>(opt focus: (key: keyof T) => void; } { const [values, setValues] = useState((options.initialValues || {}) as T); - const [errors, setErrors] = useState>>({}); + const [errors, setErrors] = useState({}); const setValue = useCallback((key: keyof T, value: any) => { setValues((prev) => ({ ...prev, [key]: value })); - setErrors((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); + setErrors((prev) => clearFormFieldError(prev, key as string)); }, []); const setValidationError = useCallback((key: keyof T, error: string) => { - setErrors((prev) => ({ ...prev, [key]: error })); + setErrors((prev) => setFormFieldError(prev, key as string, error)); }, []); const validate = useCallback((): boolean => { if (!options.validation) return true; - const newErrors: Partial> = {}; + const newErrors: FormErrorMap = {}; let valid = true; for (const key of Object.keys(options.validation) as (keyof T)[]) { @@ -62,7 +59,7 @@ export function useForm = Record>(opt } if (error) { - newErrors[key] = error; + newErrors[key as string] = error; valid = false; } } @@ -99,7 +96,7 @@ export function useForm = Record>(opt id: key as string, value: values[key as keyof T], onChange: (v: any) => setValue(key as keyof T, v), - error: errors[key as keyof T], + error: errors[key as string], onBlur: () => { const rule = options.validation?.[key as keyof T]; if (!rule) return; @@ -121,7 +118,7 @@ export function useForm = Record>(opt } if (err) { - setErrors((prev) => ({ ...prev, [key]: err })); + setErrors((prev) => setFormFieldError(prev, key as string, err)); } }, }; 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..04f466a5 100644 --- a/src/renderer/src/raycast-api/hooks/use-promise.ts +++ b/src/renderer/src/raycast-api/hooks/use-promise.ts @@ -49,51 +49,100 @@ export function usePromise( const [error, setError] = useState(undefined); const mountedRef = useRef(true); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); + const latestRunIdRef = useRef(0); + 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 runId = ++latestRunIdRef.current; + const runCtx = runtimeCtxRef.current; + const isCurrentRun = () => latestRunIdRef.current === runId && (!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/hooks/use-stream-json.ts b/src/renderer/src/raycast-api/hooks/use-stream-json.ts index 6983704e..65a758c7 100644 --- a/src/renderer/src/raycast-api/hooks/use-stream-json.ts +++ b/src/renderer/src/raycast-api/hooks/use-stream-json.ts @@ -5,21 +5,62 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +type UseStreamJSONOptions = RequestInit & { + filter?: (item: T) => boolean; + transform?: (item: any) => T; + dataPath?: string | RegExp; + pageSize?: number; + initialData?: T[]; + keepPreviousData?: boolean; + execute?: boolean; + onError?: (error: Error) => void; + onData?: (data: T) => void; + onWillExecute?: (args: [string, RequestInit]) => void; + failureToastOptions?: any; +}; + +function abortWithSignalReason(controller: AbortController, signal: AbortSignal) { + if (controller.signal.aborted) return; + try { + controller.abort(signal.reason); + } catch { + controller.abort(); + } +} + +function composeAbortSignal(lifecycleSignal: AbortSignal, callerSignal?: AbortSignal | null) { + const controller = new AbortController(); + const cleanups: Array<() => void> = []; + const observedSignals = new Set(); + + const observe = (signal?: AbortSignal | null) => { + if (!signal || observedSignals.has(signal)) return; + observedSignals.add(signal); + + if (signal.aborted) { + abortWithSignalReason(controller, signal); + return; + } + + const abort = () => abortWithSignalReason(controller, signal); + signal.addEventListener('abort', abort, { once: true }); + cleanups.push(() => signal.removeEventListener('abort', abort)); + }; + + observe(lifecycleSignal); + observe(callerSignal); + + return { + signal: controller.signal, + cleanup: () => { + for (const cleanup of cleanups) cleanup(); + }, + }; +} + export function useStreamJSON( url: string | Request, - options?: RequestInit & { - filter?: (item: T) => boolean; - transform?: (item: any) => T; - dataPath?: string | RegExp; - pageSize?: number; - initialData?: T[]; - keepPreviousData?: boolean; - execute?: boolean; - onError?: (error: Error) => void; - onData?: (data: T) => void; - onWillExecute?: (args: [string, RequestInit]) => void; - failureToastOptions?: any; - } + options?: UseStreamJSONOptions ) { const pageSize = options?.pageSize ?? 20; const [allItems, setAllItems] = useState(options?.initialData || []); @@ -27,22 +68,59 @@ export function useStreamJSON( const [error, setError] = useState(undefined); const [displayCount, setDisplayCount] = useState(pageSize); + const mountedRef = useRef(true); + const runIdRef = useRef(0); + const abortControllerRef = useRef(null); const optionsRef = useRef(options); optionsRef.current = options; + const abortCurrentRun = useCallback(() => { + const controller = abortControllerRef.current; + if (!controller) return; + controller.abort(); + abortControllerRef.current = null; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + runIdRef.current += 1; + abortCurrentRun(); + }; + }, [abortCurrentRun]); + const fetchAndParse = useCallback(async () => { const opts = optionsRef.current; - if (opts?.execute === false) return; + const runId = runIdRef.current + 1; + runIdRef.current = runId; + abortCurrentRun(); + + if (opts?.execute === false || !mountedRef.current) return; + + const lifecycleController = new AbortController(); + const composedAbort = composeAbortSignal(lifecycleController.signal, opts?.signal); + const isCurrentRun = () => ( + mountedRef.current + && runIdRef.current === runId + && abortControllerRef.current === lifecycleController + ); + abortControllerRef.current = lifecycleController; setIsLoading(true); setError(undefined); try { const resolvedUrl = typeof url === 'string' ? url : url.url; - const res = await fetch(resolvedUrl, opts); + const fetchOptions: RequestInit = { + ...(opts || {}), + signal: composedAbort.signal, + }; + const res = await fetch(resolvedUrl, fetchOptions); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); + if (!isCurrentRun()) return; let items: any[]; if (opts?.dataPath) { @@ -59,17 +137,29 @@ export function useStreamJSON( if (!Array.isArray(items)) items = [items]; if (opts?.transform) items = items.map(opts.transform); if (opts?.filter) items = items.filter(opts.filter); + if (!isCurrentRun()) return; setAllItems(items as T[]); - items.forEach((item) => opts?.onData?.(item as T)); + for (const item of items) { + if (!isCurrentRun()) break; + opts?.onData?.(item as T); + } } catch (err) { + if (!isCurrentRun() || lifecycleController.signal.aborted) return; const e = err instanceof Error ? err : new Error(String(err)); setError(e); opts?.onError?.(e); } finally { - setIsLoading(false); + composedAbort.cleanup(); + const shouldFinishCurrentRun = isCurrentRun(); + if (abortControllerRef.current === lifecycleController) { + abortControllerRef.current = null; + } + if (shouldFinishCurrentRun) { + setIsLoading(false); + } } - }, [url]); + }, [url, abortCurrentRun]); useEffect(() => { fetchAndParse(); @@ -96,17 +186,23 @@ export function useStreamJSON( } }, [allItems, revalidate]); + const visibleData = useMemo(() => { + if (displayCount >= allItems.length) return allItems; + return allItems.slice(0, displayCount); + }, [allItems, displayCount]); + const hasMore = displayCount < allItems.length; + const onLoadMore = useCallback(() => { + if (hasMore) setDisplayCount((prev) => prev + pageSize); + }, [hasMore, pageSize]); const pagination = useMemo(() => ({ pageSize, hasMore, - onLoadMore: () => { - if (hasMore) setDisplayCount((prev) => prev + pageSize); - }, - }), [pageSize, hasMore]); + onLoadMore, + }), [pageSize, hasMore, onLoadMore]); return { - data: allItems.slice(0, displayCount), + data: visibleData, isLoading, error, revalidate, diff --git a/src/renderer/src/raycast-api/icon-runtime-assets.tsx b/src/renderer/src/raycast-api/icon-runtime-assets.tsx index 05731627..4bd8a02f 100644 --- a/src/renderer/src/raycast-api/icon-runtime-assets.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-assets.tsx @@ -6,12 +6,37 @@ import React from 'react'; import { getIconRuntimeContext } from './icon-runtime-config'; +const LOCAL_PATH_EXISTS_CACHE_MAX = 4096; +const positiveLocalPathExistsCache = new Set(); +const LOCAL_PATH_MISS_CACHE_MAX = 4096; +const LOCAL_PATH_MISS_CACHE_TTL_MS = 250; +const negativeLocalPathExistsCache = new Map(); + +const CSS_COLOR_CACHE_MAX = 1024; +const normalizedCssColorCache = new Map(); +const validCssColorCache = new Map(); +const parsedCssColorCache = new Map(); +const cssRgbVarCache = new Map(); +const readableTintColorCache = new Map(); +let observedThemeRoot: HTMLElement | null = null; +let themeMutationObserver: MutationObserver | null = null; +let themeCacheVersion = 0; +let lastThemeSignature = ''; + type RgbColor = { r: number; g: number; b: number; }; +function setBoundedCacheValue(cache: Map, key: K, value: V, max = CSS_COLOR_CACHE_MAX): V { + if (!cache.has(key) && cache.size >= max) { + cache.clear(); + } + cache.set(key, value); + return value; +} + export function isEmojiOrSymbol(input: unknown): boolean { const s = typeof input === 'string' ? input.trim() : ''; if (!s) return false; @@ -41,14 +66,47 @@ export function toScAssetUrl(filePath: string): string { function localPathExists(filePath: string): boolean { if (!filePath) return false; + if (positiveLocalPathExistsCache.has(filePath)) return true; + + const nowMs = getLocalPathCacheNowMs(); + const missExpiresAt = negativeLocalPathExistsCache.get(filePath); + if (typeof missExpiresAt === 'number') { + if (missExpiresAt > nowMs) return false; + negativeLocalPathExistsCache.delete(filePath); + } + try { const stat = (window as any).electron?.statSync?.(filePath); - return Boolean(stat?.exists); + const exists = Boolean(stat?.exists); + if (exists) { + if (positiveLocalPathExistsCache.size >= LOCAL_PATH_EXISTS_CACHE_MAX) { + positiveLocalPathExistsCache.clear(); + } + positiveLocalPathExistsCache.add(filePath); + negativeLocalPathExistsCache.delete(filePath); + } else { + cacheLocalPathMiss(filePath, nowMs); + } + return exists; } catch { + cacheLocalPathMiss(filePath, nowMs); return false; } } +function getLocalPathCacheNowMs(): number { + const performanceNow = (globalThis as any).performance?.now; + if (typeof performanceNow === 'function') return performanceNow.call((globalThis as any).performance); + return Date.now(); +} + +function cacheLocalPathMiss(filePath: string, nowMs: number): void { + if (negativeLocalPathExistsCache.size >= LOCAL_PATH_MISS_CACHE_MAX) { + negativeLocalPathExistsCache.clear(); + } + negativeLocalPathExistsCache.set(filePath, nowMs + LOCAL_PATH_MISS_CACHE_TTL_MS); +} + function localPathFromScAssetUrl(src: string): string | null { try { const parsed = new URL(src); @@ -101,69 +159,171 @@ export function resolveIconSrc(src: string, assetsPathOverride?: string): string return raw; } +function getDocumentElement(): HTMLElement | null { + if (typeof document === 'undefined') return null; + return document.documentElement || null; +} + +function readPrefersDark(): boolean { + return Boolean(getDocumentElement()?.classList?.contains('dark')); +} + +function invalidateThemeColorCaches(): void { + themeCacheVersion += 1; + parsedCssColorCache.clear(); + cssRgbVarCache.clear(); + readableTintColorCache.clear(); +} + +function getThemeSignature(root: HTMLElement, prefersDark: boolean): string { + const className = typeof root.className === 'string' + ? root.className + : (root.getAttribute?.('class') || ''); + const styleAttribute = root.getAttribute?.('style') || ''; + return `${prefersDark ? 'dark' : 'light'}|${className}|${styleAttribute}`; +} + +function ensureThemeObserver(root: HTMLElement): void { + if (observedThemeRoot === root) return; + try { + themeMutationObserver?.disconnect(); + } catch { + // Ignore observer cleanup failures. + } + + observedThemeRoot = root; + themeMutationObserver = null; + + if (typeof MutationObserver === 'undefined') return; + + try { + themeMutationObserver = new MutationObserver(() => { + invalidateThemeColorCaches(); + }); + themeMutationObserver.observe(root, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + } catch { + themeMutationObserver = null; + } +} + +function getThemeCacheKey(): string { + const root = getDocumentElement(); + if (!root) return `no-document:${themeCacheVersion}`; + + ensureThemeObserver(root); + const prefersDark = readPrefersDark(); + const signature = getThemeSignature(root, prefersDark); + if (signature !== lastThemeSignature) { + lastThemeSignature = signature; + invalidateThemeColorCaches(); + } + + return `${prefersDark ? 'dark' : 'light'}:${themeCacheVersion}`; +} + +function resolveTintColorString(raw: string): string | undefined { + const normalized = normalizeCssColor(raw); + return isValidCssColor(normalized) ? normalized : undefined; +} + +function canCacheResolvedCssColor(value: string): boolean { + return !/(var\(|env\(|light-dark\(|currentcolor|inherit|initial|revert|unset)/i.test(value); +} + export function resolveTintColor(tintColor: any): string | undefined { if (!tintColor) return undefined; if (typeof tintColor === 'string') { - const normalized = normalizeCssColor(tintColor); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(tintColor); } if (typeof tintColor === 'object') { - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const raw = prefersDark ? (tintColor.dark || tintColor.light) : (tintColor.light || tintColor.dark); if (typeof raw !== 'string') return undefined; - const normalized = normalizeCssColor(raw); - return isValidCssColor(normalized) ? normalized : undefined; + return resolveTintColorString(raw); } return undefined; } function isValidCssColor(value: string): boolean { + if (!value) return false; + const cached = validCssColorCache.get(value); + if (cached !== undefined) return cached; + if (typeof document === 'undefined') return false; + + let isValid = false; try { const el = document.createElement('span'); el.style.color = ''; el.style.color = value; - return Boolean(el.style.color); + isValid = Boolean(el.style.color); } catch { - return false; + isValid = false; } + return setBoundedCacheValue(validCssColorCache, value, isValid); } function normalizeCssColor(value: string): string { + const cached = normalizedCssColorCache.get(value); + if (cached !== undefined) return cached; + const v = value.trim(); - if (/^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v)) return `#${v}`; - return v; + const normalized = /^[0-9a-f]{3}$/i.test(v) || /^[0-9a-f]{6}$/i.test(v) || /^[0-9a-f]{8}$/i.test(v) + ? `#${v}` + : v; + return setBoundedCacheValue(normalizedCssColorCache, value, normalized); } -function parseCssColorToRgb(value: string): RgbColor | null { +function parseCssColorToRgb(value: string, themeKey: string): RgbColor | null { + const canCache = canCacheResolvedCssColor(value); + const cacheKey = `${themeKey}|${value}`; + if (canCache && parsedCssColorCache.has(cacheKey)) return parsedCssColorCache.get(cacheKey) || null; if (typeof document === 'undefined' || !document.body) return null; - const el = document.createElement('span'); - el.style.position = 'absolute'; - el.style.visibility = 'hidden'; - el.style.pointerEvents = 'none'; - el.style.color = value; - document.body.appendChild(el); - const computed = window.getComputedStyle(el).color; - el.remove(); - - const match = computed.match(/rgba?\(([^)]+)\)/i); - if (!match) return null; - const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); - if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; - return { - r: Math.max(0, Math.min(255, Math.round(parts[0]))), - g: Math.max(0, Math.min(255, Math.round(parts[1]))), - b: Math.max(0, Math.min(255, Math.round(parts[2]))), - }; + + let parsed: RgbColor | null = null; + try { + const el = document.createElement('span'); + el.style.position = 'absolute'; + el.style.visibility = 'hidden'; + el.style.pointerEvents = 'none'; + el.style.color = value; + document.body.appendChild(el); + const computed = window.getComputedStyle(el).color; + el.remove(); + + const match = computed.match(/rgba?\(([^)]+)\)/i); + if (match) { + const parts = match[1].split(',').map((part) => Number.parseFloat(part.trim())); + if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { + parsed = { + r: Math.max(0, Math.min(255, Math.round(parts[0]))), + g: Math.max(0, Math.min(255, Math.round(parts[1]))), + b: Math.max(0, Math.min(255, Math.round(parts[2]))), + }; + } + } + } catch { + parsed = null; + } + + return canCache ? setBoundedCacheValue(parsedCssColorCache, cacheKey, parsed) : parsed; } -function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { +function readCssRgbVar(variableName: string, fallback: RgbColor, themeKey: string): RgbColor { + const cacheKey = `${themeKey}|${variableName}|${fallback.r},${fallback.g},${fallback.b}`; + const cached = cssRgbVarCache.get(cacheKey); + if (cached) return cached; + + let resolved = fallback; try { const raw = window.getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); const parts = raw.split(',').map((part) => Number.parseFloat(part.trim())); if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) { - return { + resolved = { r: Math.max(0, Math.min(255, Math.round(parts[0]))), g: Math.max(0, Math.min(255, Math.round(parts[1]))), b: Math.max(0, Math.min(255, Math.round(parts[2]))), @@ -172,7 +332,7 @@ function readCssRgbVar(variableName: string, fallback: RgbColor): RgbColor { } catch { // fall through to fallback } - return fallback; + return setBoundedCacheValue(cssRgbVarCache, cacheKey, resolved); } function mixRgb(base: RgbColor, target: RgbColor, amount: number): RgbColor { @@ -208,16 +368,26 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast const resolved = resolveTintColor(tintColor); if (!resolved) return undefined; - const color = parseCssColorToRgb(resolved); - if (!color) return resolved; + const themeKey = getThemeCacheKey(); + const minContrast = options?.minContrast ?? 4.5; + const canCache = canCacheResolvedCssColor(resolved); + const readableCacheKey = `${themeKey}|${resolved}|${minContrast}`; + const cached = canCache ? readableTintColorCache.get(readableCacheKey) : undefined; + if (cached) return cached; + + const color = parseCssColorToRgb(resolved, themeKey); + if (!color) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } - const prefersDark = document.documentElement.classList.contains('dark'); + const prefersDark = readPrefersDark(); const background = readCssRgbVar('--surface-base-rgb', prefersDark ? { r: 30, g: 31, b: 36 } - : { r: 247, g: 248, b: 250 }); - const minContrast = options?.minContrast ?? 4.5; + : { r: 247, g: 248, b: 250 }, themeKey); - if (contrastRatio(color, background) >= minContrast) return resolved; + if (contrastRatio(color, background) >= minContrast) { + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, resolved) : resolved; + } const target = prefersDark ? { r: 255, g: 255, b: 255 } @@ -226,11 +396,13 @@ export function resolveReadableTintColor(tintColor: any, options?: { minContrast for (let step = 1; step <= 12; step += 1) { const adjusted = mixRgb(color, target, step / 12); if (contrastRatio(adjusted, background) >= minContrast) { - return formatRgb(adjusted); + const readable = formatRgb(adjusted); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } } - return formatRgb(mixRgb(color, target, 1)); + const readable = formatRgb(mixRgb(color, target, 1)); + return canCache ? setBoundedCacheValue(readableTintColorCache, readableCacheKey, readable) : readable; } export function addHexAlpha(color: string, alphaHex: string): string | undefined { 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..ae81ebdd 100644 --- a/src/renderer/src/raycast-api/icon-runtime-render.tsx +++ b/src/renderer/src/raycast-api/icon-runtime-render.tsx @@ -7,30 +7,128 @@ 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>(); +const fileIconKindCache = new Map(); +const inFlightFileIconKindRequests = 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 peekCachedFileIconKind(filePath: string): 'file' | 'directory' { + return fileIconKindCache.get(filePath) || 'file'; +} + +function writeCachedFileIconKind(filePath: string, kind: 'file' | 'directory') { + if (fileIconKindCache.has(filePath)) fileIconKindCache.delete(filePath); + fileIconKindCache.set(filePath, kind); + while (fileIconKindCache.size > FILE_ICON_CACHE_MAX_ENTRIES) { + const oldestKey = fileIconKindCache.keys().next().value; + if (oldestKey === undefined) break; + fileIconKindCache.delete(oldestKey); + } +} + +function loadFileIconKind(filePath: string): Promise<'file' | 'directory'> { + const cached = fileIconKindCache.get(filePath); + if (cached) return Promise.resolve(cached); + + const pending = inFlightFileIconKindRequests.get(filePath); + if (pending) return pending; + + const stat = typeof window !== 'undefined' + ? (window as any).electron?.stat + : undefined; + if (typeof stat !== 'function') return Promise.resolve('file'); + + const request: Promise<'file' | 'directory'> = Promise.resolve(stat(filePath)) + .then((payload: any) => { + const kind: 'file' | 'directory' = payload?.exists && payload?.isDirectory ? 'directory' : 'file'; + writeCachedFileIconKind(filePath, kind); + return kind; + }) + .catch((): 'file' => 'file') + .finally(() => { + inFlightFileIconKindRequests.delete(filePath); + }); + inFlightFileIconKindRequests.set(filePath, request); + return request; +} + +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); + const [fileKind, setFileKind] = useState<'file' | 'directory'>(() => peekCachedFileIconKind(filePath)); 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(() => { + } else { + loadFileIconDataUrl(filePath).then((iconSrc) => { if (cancelled) return; - fileIconCache.set(filePath, null); - setSrc(null); + setSrc(iconSrc); }); + } + + loadFileIconKind(filePath).then((kind) => { + if (cancelled) return; + setFileKind(kind); + }); return () => { cancelled = true; @@ -39,15 +137,7 @@ function FileIcon({ filePath, className }: { filePath: string; className: string if (src) return ; - let isDirectory = false; - try { - const stat = (window as any).electron?.statSync?.(filePath); - isDirectory = Boolean(stat?.exists && stat?.isDirectory); - } catch { - // best-effort - } - - return {isDirectory ? '📁' : '📄'}; + return {fileKind === 'directory' ? '📁' : '📄'}; } export const Icon: Record = new Proxy({} as Record, { diff --git a/src/renderer/src/raycast-api/index.tsx b/src/renderer/src/raycast-api/index.tsx index 95940389..930f61e9 100644 --- a/src/renderer/src/raycast-api/index.tsx +++ b/src/renderer/src/raycast-api/index.tsx @@ -54,6 +54,7 @@ import { useAI } from './hooks/use-ai'; import { useFrecencySorting } from './hooks/use-frecency-sorting'; import { useLocalStorage } from './hooks/use-local-storage'; import { configureStorageEvents, emitExtensionStorageChanged } from './storage-events'; +import { reportNoViewStatusIfChanged } from './no-view-status-reporting'; import { configureContextScopeRuntime, snapshotExtensionContext, @@ -434,8 +435,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 +486,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 +509,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; } @@ -672,8 +675,7 @@ export class Toast { this._style === ToastStyle.Failure ? 'error' as const : this._style === ToastStyle.Animated ? 'processing' as const : 'success' as const; - void (window as any).electron?.reportNoViewStatus?.(variant, String(this._title || '')); - (window as any).__scNoViewStatusReported = true; + reportNoViewStatusIfChanged(variant, String(this._title || '')); } this.updateActions(); @@ -697,8 +699,7 @@ export class Toast { this._style === ToastStyle.Failure ? 'error' as const : this._style === ToastStyle.Animated ? 'processing' as const : 'success' as const; - void (window as any).electron?.reportNoViewStatus?.(variant, String(this._title || '')); - (window as any).__scNoViewStatusReported = true; + reportNoViewStatusIfChanged(variant, String(this._title || '')); } this.hide(); // clear any existing instance of this toast @@ -894,16 +895,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 +921,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 +1384,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 +1411,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 +1558,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 +1575,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 +1591,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 +1627,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 +1647,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 +1663,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 +1676,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 +1706,8 @@ export class Cache { localStorage.removeItem(this.getItemKey(key)); } this.lruOrder = []; + this.sizeByKey.clear(); + this.totalSize = 0; this.saveToStorage(); // Notify subscribers @@ -1646,9 +1823,30 @@ class StreamingPromise implements PromiseLike { } // Global IPC listener registry — routes chunks to the right StreamingPromise -const _activeStreams = new Map(); +type ActiveAIStream = { + sp: StreamingPromise; + fullText: string; + abortSignal?: AbortSignal; + abortHandler?: () => void; +}; + +const _activeStreams = new Map(); let _aiListenersRegistered = false; +function finalizeAIStream(requestId: string): ActiveAIStream | undefined { + const entry = _activeStreams.get(requestId); + if (!entry) return undefined; + + if (entry.abortSignal && entry.abortHandler) { + entry.abortSignal.removeEventListener('abort', entry.abortHandler); + entry.abortSignal = undefined; + entry.abortHandler = undefined; + } + + _activeStreams.delete(requestId); + return entry; +} + function ensureAIListeners(): void { if (_aiListenersRegistered) return; _aiListenersRegistered = true; @@ -1665,18 +1863,16 @@ function ensureAIListeners(): void { }); electron.onAIStreamDone?.((data: { requestId: string }) => { - const entry = _activeStreams.get(data.requestId); + const entry = finalizeAIStream(data.requestId); if (entry) { entry.sp._complete(entry.fullText); - _activeStreams.delete(data.requestId); } }); electron.onAIStreamError?.((data: { requestId: string; error: string }) => { - const entry = _activeStreams.get(data.requestId); + const entry = finalizeAIStream(data.requestId); if (entry) { entry.sp._error(new Error(data.error)); - _activeStreams.delete(data.requestId); } }); } @@ -1729,10 +1925,9 @@ export const AI = { model: options?.model, creativity, }).catch((err: any) => { - const entry = _activeStreams.get(requestId); + const entry = finalizeAIStream(requestId); if (entry) { entry.sp._error(err); - _activeStreams.delete(requestId); } }); @@ -1740,17 +1935,22 @@ export const AI = { if (options?.signal) { if (options.signal.aborted) { electron.aiCancel?.(requestId); - setTimeout(() => sp._error(new Error('Request aborted')), 0); - _activeStreams.delete(requestId); + const entry = finalizeAIStream(requestId); + setTimeout(() => entry?.sp._error(new Error('Request aborted')), 0); } else { - options.signal.addEventListener('abort', () => { + const abortHandler = () => { electron.aiCancel?.(requestId); - const entry = _activeStreams.get(requestId); + const entry = finalizeAIStream(requestId); if (entry) { entry.sp._error(new Error('Request aborted')); - _activeStreams.delete(requestId); } - }, { once: true }); + }; + const entry = _activeStreams.get(requestId); + if (entry) { + entry.abortSignal = options.signal; + entry.abortHandler = abortHandler; + options.signal.addEventListener('abort', abortHandler, { once: true }); + } } } diff --git a/src/renderer/src/raycast-api/list-runtime-detail.tsx b/src/renderer/src/raycast-api/list-runtime-detail.tsx index 8c9da9ba..6f0bfb49 100644 --- a/src/renderer/src/raycast-api/list-runtime-detail.tsx +++ b/src/renderer/src/raycast-api/list-runtime-detail.tsx @@ -4,7 +4,7 @@ * Builds `List.Item.Detail` and handles markdown image source resolution. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { renderSimpleMarkdown } from './detail-markdown'; import { useI18n } from '../i18n'; @@ -36,6 +36,10 @@ export function createListDetailRuntime(deps: ListDetailDeps) { children?: React.ReactNode; }) => { const { t } = useI18n(); + const extensionContext = getExtensionContext(); + const renderedMarkdown = useMemo(() => ( + markdown ? renderSimpleMarkdown(markdown, resolveListDetailMarkdownImageSrc) : null + ), [extensionContext.assetsPath, markdown]); return (
@@ -43,7 +47,7 @@ export function createListDetailRuntime(deps: ListDetailDeps) {

{t('common.loading')}

) : ( <> - {markdown &&
{renderSimpleMarkdown(markdown, resolveListDetailMarkdownImageSrc)}
} + {markdown &&
{renderedMarkdown}
} {metadata} {children} diff --git a/src/renderer/src/raycast-api/list-runtime-hooks.ts b/src/renderer/src/raycast-api/list-runtime-hooks.ts index bae2b9cd..0be18769 100644 --- a/src/renderer/src/raycast-api/list-runtime-hooks.ts +++ b/src/renderer/src/raycast-api/list-runtime-hooks.ts @@ -7,11 +7,40 @@ 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 || ''); } -function buildSnapshotSignature(value: unknown, seen = new WeakSet()): string { +function buildValueSignature(value: unknown, seen = new WeakSet()): string { if (value === null) return 'null'; if (value === undefined) return 'undefined'; if (typeof value === 'string') return JSON.stringify(value); @@ -20,21 +49,22 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s if (typeof value === 'symbol') return value.toString(); if (Array.isArray(value)) { - return `[${value.map((item) => buildSnapshotSignature(item, seen)).join(',')}]`; + return `[${value.map((item) => buildValueSignature(item, seen)).join(',')}]`; } if (React.isValidElement(value)) { - return `element:${getReactTypeName(value.type)}:${buildSnapshotSignature((value as any).props, seen)}`; + return `element:${getReactTypeName(value.type)}:${buildValueSignature((value as any).props, seen)}`; } if (typeof value === 'object') { + if (value instanceof Date) return `date:${value.getTime()}`; if (seen.has(value as object)) return '[circular]'; seen.add(value as object); const entries = Object.entries(value as Record) .filter(([key]) => key !== '_owner' && key !== '_store' && key !== 'ref' && key !== 'key') .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entryValue]) => `${key}:${buildSnapshotSignature(entryValue, seen)}`); + .map(([key, entryValue]) => `${key}:${buildValueSignature(entryValue, seen)}`); return `{${entries.join(',')}}`; } @@ -42,37 +72,74 @@ function buildSnapshotSignature(value: unknown, seen = new WeakSet()): s return String(value); } +function buildActionTypeSignature(actions: unknown): string { + if (!React.isValidElement(actions)) return buildValueSignature(actions); + return getReactTypeName((actions as React.ReactElement).type); +} + +function getActionType(actions: unknown): unknown { + if (!React.isValidElement(actions)) return actions; + return (actions as React.ReactElement).type; +} + +function buildListAccessorySignature(accessory: NonNullable[number]): string { + return [ + buildValueSignature(accessory?.text), + buildValueSignature(accessory?.icon), + buildValueSignature(accessory?.tag), + buildValueSignature(accessory?.date), + buildValueSignature(accessory?.tooltip), + ].join('\u001e'); +} + +export function buildListItemVisibleSignature(item: ItemRegistration): string { + const props = item.props; + return [ + item.id, + String(item.order), + item.sectionTitle || '', + buildValueSignature(props.id), + buildValueSignature(props.title), + buildValueSignature(props.subtitle), + buildValueSignature(props.icon), + props.accessories?.map(buildListAccessorySignature).join('\u001d') || '', + props.keywords?.map((keyword) => JSON.stringify(keyword)).join('\u001d') || '', + buildValueSignature(props.detail), + buildValueSignature(props.quickLook), + buildActionTypeSignature(props.actions), + ].join('\u001f'); +} + +export function listItemVisibleInputsChanged(existing: ItemRegistration, next: Omit): boolean { + if (existing.sectionTitle !== next.sectionTitle || existing.order !== next.order) return true; + const previousProps = existing.props; + const nextProps = next.props; + if (previousProps === nextProps) return false; + return ( + previousProps.id !== nextProps.id + || previousProps.title !== nextProps.title + || previousProps.subtitle !== nextProps.subtitle + || previousProps.icon !== nextProps.icon + || previousProps.accessories !== nextProps.accessories + || previousProps.keywords !== nextProps.keywords + || previousProps.detail !== nextProps.detail + || previousProps.quickLook !== nextProps.quickLook + || getActionType(previousProps.actions) !== getActionType(nextProps.actions) + ); +} + export function useListRegistry() { const registryRef = useRef(new Map()); + const visibleSignatureRef = useRef(new Map()); const [registryVersion, setRegistryVersion] = useState(0); const pendingRef = useRef(false); - const lastSnapshotRef = useRef(''); const scheduleRegistryUpdate = useCallback(() => { if (pendingRef.current) return; pendingRef.current = true; queueMicrotask(() => { pendingRef.current = false; - const snapshot = Array.from(registryRef.current.values()).map((item) => { - const actionType = item.props.actions?.type as any; - const actionName = actionType?.name || actionType?.displayName || typeof actionType || ''; - return buildSnapshotSignature({ - actionName, - accessories: item.props.accessories, - detail: item.props.detail, - icon: item.props.icon, - id: item.id, - keywords: item.props.keywords, - order: item.order, - sectionTitle: item.sectionTitle || '', - subtitle: item.props.subtitle, - title: item.props.title, - }); - }).join('|'); - if (snapshot !== lastSnapshotRef.current) { - lastSnapshotRef.current = snapshot; - setRegistryVersion((value) => value + 1); - } + setRegistryVersion((value) => value + 1); }); }, []); @@ -80,26 +147,26 @@ export function useListRegistry() { set(id, data) { const existing = registryRef.current.get(id); if (existing) { - // Hot path: an unrelated re-render (e.g. hover changing selection) - // re-runs every render even though no content actually - // changed. The renderOrder counter shifts uniformly so relative - // order is preserved; just write it through and skip the costly - // snapshot recompute. Only schedule an update when props identity - // or section actually changed. - const propsChanged = existing.props !== data.props; - const sectionChanged = existing.sectionTitle !== data.sectionTitle; + const visibleInputsChanged = listItemVisibleInputsChanged(existing, data); existing.props = data.props; existing.sectionTitle = data.sectionTitle; existing.order = data.order; - if (!propsChanged && !sectionChanged) return; + if (!visibleInputsChanged) return; + + const nextSignature = buildListItemVisibleSignature(existing); + if (visibleSignatureRef.current.get(id) === nextSignature) return; + visibleSignatureRef.current.set(id, nextSignature); } else { - registryRef.current.set(id, { id, ...data }); + const item = { id, ...data }; + registryRef.current.set(id, item); + visibleSignatureRef.current.set(id, buildListItemVisibleSignature(item)); } scheduleRegistryUpdate(); }, delete(id) { if (!registryRef.current.has(id)) return; registryRef.current.delete(id); + visibleSignatureRef.current.delete(id); scheduleRegistryUpdate(); }, }), [scheduleRegistryUpdate]); @@ -155,8 +222,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 +237,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..efc11281 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; @@ -165,6 +177,10 @@ export function createListRuntime(deps: ListRuntimeDeps) { ActionRegistryContext, }), [selectedItem?.id, actionRegistry, ActionRegistryContext]); const primaryAction = selectedActions[0]; + const selectedActionsRef = useRef(selectedActions); + useEffect(() => { + selectedActionsRef.current = selectedActions; + }, [selectedActions]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (isMetaK(event)) { @@ -174,7 +190,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { } if ((event.metaKey || event.altKey || event.ctrlKey) && !event.repeat) { - for (const action of selectedActions) { + for (const action of selectedActionsRef.current) { if (!action.shortcut || !matchesShortcut(event, action.shortcut)) continue; event.preventDefault(); event.stopPropagation(); @@ -188,8 +204,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; @@ -218,7 +234,7 @@ export function createListRuntime(deps: ListRuntimeDeps) { }; window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); - }, [isMetaK, matchesShortcut, selectedActions]); + }, [isMetaK, matchesShortcut]); const prevFilteredItemsRef = useRef(filteredItems); useEffect(() => { @@ -255,39 +271,19 @@ 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(() => { + if (shouldUseEmojiGridValue) return []; + return buildListVirtualRows(groupedItems); + }, [groupedItems, shouldUseEmojiGridValue]); + const emojiGridRows = useMemo(() => { + if (!shouldUseEmojiGridValue) return []; + return buildEmojiGridVirtualRows(groupedItems); + }, [groupedItems, shouldUseEmojiGridValue]); + const virtualRows = shouldUseEmojiGridValue ? emojiGridRows : listRows; + const rowMetrics = useMemo(() => measureVirtualRows(virtualRows), [virtualRows]); const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); @@ -320,45 +316,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 +345,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 @@ -381,7 +355,10 @@ export function createListRuntime(deps: ListRuntimeDeps) { } else if (top + rowH > visBottom) { el.scrollTo({ top: top + rowH - el.clientHeight, behavior: 'auto' }); } - }, [selectedIdx]); + if (shouldUseEmojiGridValue) { + el.querySelector(`[data-idx="${selectedIdx}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'auto' }); + } + }, [selectedIdx, shouldUseEmojiGridValue]); const extensionContext = getExtensionContext(); const footerTitle = navigationTitle || extInfo.extensionDisplayName || extensionContext.extensionDisplayName || extensionContext.extensionName || 'Extension'; @@ -390,16 +367,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 +399,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 &&