From 8ca7ec9f3b11715edf821327e549015ff80e1847 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:11:49 +0200 Subject: [PATCH 01/15] Remove root search ranking diagnostics --- .../src/hooks/useLauncherCommandModel.ts | 35 ------------------- src/renderer/src/utils/root-search-ranking.ts | 5 --- 2 files changed, 40 deletions(-) diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..bfc40cf3 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -857,41 +857,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/utils/root-search-ranking.ts b/src/renderer/src/utils/root-search-ranking.ts index 6eec2570..2637783f 100644 --- a/src/renderer/src/utils/root-search-ranking.ts +++ b/src/renderer/src/utils/root-search-ranking.ts @@ -94,11 +94,6 @@ const SEARCH_SEPARATOR_REGEX = /[^\p{L}\p{N}]+/gu; const COMBINING_MARK_REGEX = /\p{M}/gu; const DAY = 24 * 60 * 60 * 1000; -// Build liveness stamp — confirms the running renderer has the internal>browser -// precedence fix. Grep the bundle (dist/renderer/assets/*.js) for this string, -// or look for it in the DevTools console at startup. Remove once verified. -try { console.info('[SC-RANK build 2026-06-19c internal>browser precedence ACTIVE]'); } catch {} - export const ROOT_SEARCH_RESULTS_LIMIT = 8; export const ROOT_SEARCH_PROMOTION_SCORE = 700; From fb857f27a5b8951dcf8588addf07f4c30c8fe51f Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:16:59 +0200 Subject: [PATCH 02/15] perf: optimize file search delete batches --- .../benchmark-file-search-delete-batch.mjs | 130 ++++++++++++ scripts/test-file-search-delete-batch.mjs | 190 ++++++++++++++++++ src/main/file-search-index.ts | 73 ++++++- 3 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 scripts/benchmark-file-search-delete-batch.mjs create mode 100644 scripts/test-file-search-delete-batch.mjs diff --git a/scripts/benchmark-file-search-delete-batch.mjs b/scripts/benchmark-file-search-delete-batch.mjs new file mode 100644 index 00000000..d1c8072c --- /dev/null +++ b/scripts/benchmark-file-search-delete-batch.mjs @@ -0,0 +1,130 @@ +#!/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 buildScenario(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), + snapshot, + survivorPath: path.join(survivorRoot, 'dir-119', 'survivor-119-499.txt'), + }; +} + +const api = loadFileSearchIndexInternals(); +const scenario = buildScenario(api); +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); + +console.log(JSON.stringify({ + benchmark: 'file-search-delete-batch', + entries: scenario.snapshot.entries.length, + deletePaths: scenario.deletePaths.length, + deletedCount, + elapsedMs: Number(elapsedMs.toFixed(2)), +}, 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..684d3001 --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +function loadFileSearchIndexInternals() { + const filePath = path.resolve('src/main/file-search-index.ts'); + const source = `${fs.readFileSync(filePath, 'utf8')} + +exports.__test = { + collapseNestedDeletedPaths, + indexEntry, + searchIndexedFiles, + tombstoneDeletedPaths, + makeSnapshot() { + return { + entries: [], + prefixToEntryIds: new Map(), + pathToEntryId: new Map(), + builtAt: Date.now(), + }; + }, + setActiveIndex(snapshot, homeDir) { + activeIndex = snapshot; + configuredHomeDir = homeDir; + includeRoots = [homeDir]; + includeProtectedHomeRoots = true; + lastBuildStartedAt = 0; + lastIndexError = null; + }, +}; +`; + + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: filePath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + return module.exports.__test; +} + +const api = loadFileSearchIndexInternals(); +const homeDir = '/tmp/supercmd-file-search-delete-tests'; + +function addEntry(snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function entryForPath(snapshot, filePath) { + const id = snapshot.pathToEntryId.get(filePath); + assert.notEqual(id, undefined, `${filePath} should be indexed`); + return snapshot.entries[id]; +} + +function isDeleted(snapshot, filePath) { + return Boolean(entryForPath(snapshot, filePath).deleted); +} + +test('file delete tombstones only the exact indexed file', () => { + const snapshot = api.makeSnapshot(); + const deletedFile = path.join(homeDir, 'notes', 'alpha.txt'); + const prefixSibling = path.join(homeDir, 'notes', 'alpha.txt.backup'); + const survivor = path.join(homeDir, 'notes', 'beta.txt'); + + addEntry(snapshot, path.dirname(deletedFile), true); + addEntry(snapshot, deletedFile); + addEntry(snapshot, prefixSibling); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedFile]); + + assert.equal(isDeleted(snapshot, deletedFile), true); + assert.equal(isDeleted(snapshot, prefixSibling), false); + assert.equal(isDeleted(snapshot, survivor), false); + + api.indexEntry(snapshot, { + path: deletedFile, + name: path.basename(deletedFile), + parentPath: path.dirname(deletedFile), + isDirectory: false, + }); + assert.equal(isDeleted(snapshot, deletedFile), false); +}); + +test('directory delete tombstones the directory and all indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedDir = path.join(homeDir, 'project'); + const childDir = path.join(deletedDir, 'src'); + const childFile = path.join(childDir, 'index.ts'); + const survivor = path.join(homeDir, 'other', 'index.ts'); + + addEntry(snapshot, deletedDir, true); + addEntry(snapshot, childDir, true); + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(survivor), true); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedDir]); + + assert.equal(isDeleted(snapshot, deletedDir), true); + assert.equal(isDeleted(snapshot, childDir), true); + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, survivor), false); +}); + +test('nested directory deletes collapse to the outermost deleted root', () => { + const deletedRoot = path.join(homeDir, 'nested-root'); + const nestedDir = path.join(deletedRoot, 'child'); + const nestedFile = path.join(nestedDir, 'deep.txt'); + + assert.deepEqual( + Array.from(api.collapseNestedDeletedPaths([nestedFile, nestedDir, deletedRoot])), + [deletedRoot] + ); + + const snapshot = api.makeSnapshot(); + const similarSibling = path.join(homeDir, 'nested-root-copy', 'deep.txt'); + addEntry(snapshot, deletedRoot, true); + addEntry(snapshot, nestedDir, true); + addEntry(snapshot, nestedFile); + addEntry(snapshot, path.dirname(similarSibling), true); + addEntry(snapshot, similarSibling); + + api.tombstoneDeletedPaths(snapshot, [nestedFile, nestedDir, deletedRoot]); + + assert.equal(isDeleted(snapshot, deletedRoot), true); + assert.equal(isDeleted(snapshot, nestedDir), true); + assert.equal(isDeleted(snapshot, nestedFile), true); + assert.equal(isDeleted(snapshot, similarSibling), false); +}); + +test('non-matching delete paths do not tombstone prefix-like neighbors', async () => { + const snapshot = api.makeSnapshot(); + const prefixLikeDelete = path.join(homeDir, 'docs'); + const survivorDir = path.join(homeDir, 'docs-old'); + const survivor = path.join(survivorDir, 'report.txt'); + const searchable = path.join(homeDir, 'searchable', 'needle-survivor.txt'); + + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + addEntry(snapshot, path.dirname(searchable), true); + addEntry(snapshot, searchable); + + api.tombstoneDeletedPaths(snapshot, [ + prefixLikeDelete, + path.join(homeDir, 'missing', 'child'), + ]); + + assert.equal(isDeleted(snapshot, survivor), false); + assert.equal(isDeleted(snapshot, searchable), false); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('needle-survivor', { limit: 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, searchable); +}); diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..6db6ea2e 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -796,13 +796,75 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } +function hasDeletedPathAncestor(candidatePath: string, deletedPathSet: Set): boolean { + let currentPath = path.dirname(candidatePath); + while (currentPath && currentPath !== candidatePath) { + if (deletedPathSet.has(currentPath)) return true; + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; + } + return false; +} + +function normalizeDeletedPath(candidatePath: string): string | null { + const rawPath = String(candidatePath || ''); + if (!rawPath) return null; + return path.resolve(rawPath); +} + +function collapseNestedDeletedPaths(deletePaths: string[]): string[] { + const uniquePaths = new Set(); + for (const deletePath of deletePaths) { + const normalizedPath = normalizeDeletedPath(deletePath); + if (normalizedPath) uniquePaths.add(normalizedPath); + } + + const sortedPaths = [...uniquePaths].sort((a, b) => { + if (a.length !== b.length) return a.length - b.length; + return a.localeCompare(b); + }); + const collapsedPaths: string[] = []; + const collapsedPathSet = new Set(); + + for (const deletePath of sortedPaths) { + if (hasDeletedPathAncestor(deletePath, collapsedPathSet)) { + continue; + } + collapsedPaths.push(deletePath); + collapsedPathSet.add(deletePath); + } + + return collapsedPaths; +} + +function getDescendantPathPrefix(rootPath: string): string { + return rootPath.endsWith(path.sep) ? rootPath : `${rootPath}${path.sep}`; +} + function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + const directIds = new Set(); - for (const deletedPath of deletePaths) { + for (const deletedPath of collapsedDeletePaths) { const id = snapshot.pathToEntryId.get(deletedPath); if (id !== undefined) directIds.add(id); } - const prefixes = deletePaths.map((p) => p + path.sep); + + if (collapsedDeletePaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(collapsedDeletePaths[0]); + for (let i = 0; i < snapshot.entries.length; i += 1) { + const entry = snapshot.entries[i]; + if (entry.deleted) continue; + if (directIds.has(i) || entry.path.startsWith(descendantPrefix)) { + entry.deleted = true; + } + } + return; + } + + const deletedPathSet = new Set(collapsedDeletePaths); for (let i = 0; i < snapshot.entries.length; i += 1) { const entry = snapshot.entries[i]; @@ -811,11 +873,8 @@ function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): entry.deleted = true; continue; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { - entry.deleted = true; - break; - } + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + entry.deleted = true; } } } From dee7108c0626f15437cf07146356f44102b7cf47 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:17:25 +0200 Subject: [PATCH 03/15] perf: memoize frecency sorting defaults --- scripts/test-use-frecency-sorting.mjs | 244 ++++++++++++++++++ .../raycast-api/hooks/use-frecency-sorting.ts | 22 +- 2 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 scripts/test-use-frecency-sorting.mjs 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/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); From 68cb05049be3c1607534989abdb66fd2a56eaaf8 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:17:55 +0200 Subject: [PATCH 04/15] Avoid interval file-index rebuilds --- scripts/test-file-search-index.mjs | 245 +++++++++++++++++++++++++++++ src/main/file-search-index.ts | 48 +++++- 2 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 scripts/test-file-search-index.mjs diff --git a/scripts/test-file-search-index.mjs b/scripts/test-file-search-index.mjs new file mode 100644 index 00000000..f8e7a86c --- /dev/null +++ b/scripts/test-file-search-index.mjs @@ -0,0 +1,245 @@ +#!/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; + +function createFakeDate(clock) { + return class FakeDate extends Date { + constructor(...args) { + super(...(args.length > 0 ? args : [clock.now])); + } + + static now() { + return clock.now; + } + }; +} + +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 transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: SOURCE_PATH, + }); + + 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, + Date: createFakeDate(clock), + setTimeout, + clearTimeout, + setInterval, + clearInterval, + Promise, + process: sandboxProcess, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: SOURCE_PATH }); + 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 runScenario() { + 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 }); + } +} + +test('file search index uses watcher batches and avoids healthy interval rebuilds', async () => { + const metrics = await runScenario(); + 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'); + } +}); diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 6db6ea2e..88ababf2 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -63,6 +63,7 @@ const MAX_QUERY_RESULTS = 5_000; const MAX_FILE_METADATA_STAT_RESULTS = 240; const MIN_REBUILD_GAP_MS = 45_000; const DEFAULT_REFRESH_INTERVAL_MS = 8 * 60_000; +const SAFETY_REBUILD_INTERVAL_MS = 6 * 60 * 60_000; const WATCH_EVENT_DEBOUNCE_MS = 500; const MAX_SPOTLIGHT_CANDIDATES = 10_000; const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400; @@ -143,10 +144,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; @@ -632,7 +635,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 +644,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 +665,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 +720,7 @@ function startFileSearchWatcher(): void { if (!configuredHomeDir) return; try { - activeWatcher = fs.watch( + const watcher = fs.watch( configuredHomeDir, { recursive: true, persistent: false }, (_eventType, filename) => { @@ -705,13 +733,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 = ''; } From 9f246ce30e8324cbabd036ef35cb9e21da420e14 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:14 +0200 Subject: [PATCH 05/15] Avoid full script reads for command headers --- scripts/bench-script-command-discovery.mjs | 102 ++++++++++ scripts/lib/script-command-runner-harness.mjs | 178 ++++++++++++++++++ scripts/test-script-command-runner.mjs | 168 +++++++++++++++++ src/main/script-command-runner.ts | 71 +++++-- 4 files changed, 507 insertions(+), 12 deletions(-) create mode 100644 scripts/bench-script-command-discovery.mjs create mode 100644 scripts/lib/script-command-runner-harness.mjs create mode 100644 scripts/test-script-command-runner.mjs diff --git a/scripts/bench-script-command-discovery.mjs b/scripts/bench-script-command-discovery.mjs new file mode 100644 index 00000000..91223f5a --- /dev/null +++ b/scripts/bench-script-command-discovery.mjs @@ -0,0 +1,102 @@ +#!/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); + +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) { + const header = `#!/bin/bash +# Required parameters: +# @raycast.schemaVersion 1 +# @raycast.title ${title} +# @raycast.mode fullOutput + +# Optional parameters: +# @raycast.packageName Benchmark +# @raycast.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)}`); +} + +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 { + for (let i = 0; i < fileCount; i += 1) { + const scriptPath = path.join(scriptsDir, `large-command-${String(i + 1).padStart(3, '0')}.sh`); + fs.writeFileSync(scriptPath, makeLargeScript(`Large Command ${i + 1}`, bodyBytesPerFile), { + 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); + + console.log('Script command discovery benchmark'); + console.log(`files: ${fileCount}`); + console.log(`body bytes per file: ${formatBytes(bodyBytesPerFile)}`); + printMetric('discovery', discoveryMs, discoveryMetrics); + printMetric('cached execution shebang lookup', executeMs, executeMetrics); +} finally { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + diff --git a/scripts/lib/script-command-runner-harness.mjs b/scripts/lib/script-command-runner-harness.mjs new file mode 100644 index 00000000..f6ee9b67 --- /dev/null +++ b/scripts/lib/script-command-runner-harness.mjs @@ -0,0 +1,178 @@ +import { build } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const runnerPath = path.join(root, 'src/main/script-command-runner.ts'); + +function makeElectronMockSource(appPaths) { + return ` + const paths = ${JSON.stringify(appPaths)}; + export const app = { + getPath(name) { + return paths[name] || paths.userData; + }, + }; + `; +} + +function makeSettingsMockSource(scriptCommandFolders) { + return ` + export function loadSettings() { + return { scriptCommandFolders: ${JSON.stringify(scriptCommandFolders)} }; + } + `; +} + +function makeInstrumentedFsSource(metricsKey) { + return ` + import realFs from 'node:fs'; + + const metrics = globalThis[${JSON.stringify(metricsKey)}]; + + function countReadFile(value, options) { + if (Buffer.isBuffer(value)) return value.byteLength; + const encoding = typeof options === 'string' + ? options + : options && typeof options === 'object' && options.encoding + ? options.encoding + : 'utf8'; + return Buffer.byteLength(String(value), encoding); + } + + export const constants = realFs.constants; + export const existsSync = realFs.existsSync.bind(realFs); + export const mkdirSync = realFs.mkdirSync.bind(realFs); + export const readdirSync = realFs.readdirSync.bind(realFs); + export const writeFileSync = realFs.writeFileSync.bind(realFs); + export const chmodSync = realFs.chmodSync.bind(realFs); + export const unlinkSync = realFs.unlinkSync.bind(realFs); + export const openSync = (...args) => { + metrics.openSyncCalls += 1; + return realFs.openSync(...args); + }; + export const closeSync = realFs.closeSync.bind(realFs); + export const accessSync = realFs.accessSync.bind(realFs); + export const readFileSync = (filePath, options) => { + const value = realFs.readFileSync(filePath, options); + metrics.readFileSyncCalls += 1; + metrics.readFileSyncBytes += countReadFile(value, options); + return value; + }; + export const readSync = (...args) => { + const bytesRead = realFs.readSync(...args); + metrics.readSyncCalls += 1; + metrics.readSyncBytes += Math.max(0, Number(bytesRead) || 0); + return bytesRead; + }; + + export default { + ...realFs, + constants, + existsSync, + mkdirSync, + readdirSync, + writeFileSync, + chmodSync, + unlinkSync, + openSync, + closeSync, + accessSync, + readFileSync, + readSync, + }; + `; +} + +function resetMetrics(metrics) { + for (const key of Object.keys(metrics)) { + metrics[key] = 0; + } +} + +export async function loadScriptCommandRunner({ + homeDir = os.homedir(), + tempDir = os.tmpdir(), + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-user-data-')), + scriptCommandFolders = [], + instrumentFs = false, +} = {}) { + const metricsKey = `__supercmdScriptCommandFsMetrics_${Date.now()}_${Math.random() + .toString(36) + .slice(2)}`; + const metrics = { + readFileSyncCalls: 0, + readFileSyncBytes: 0, + readSyncCalls: 0, + readSyncBytes: 0, + openSyncCalls: 0, + }; + globalThis[metricsKey] = metrics; + + const plugins = [ + { + name: 'supercmd-script-command-runner-mocks', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ + path: 'electron-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^electron-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeElectronMockSource({ home: homeDir, temp: tempDir, userData: userDataDir }), + loader: 'js', + })); + + pluginBuild.onResolve({ filter: /^\.\/settings-store$/ }, () => ({ + path: 'settings-store-mock', + namespace: 'supercmd-mocks', + })); + pluginBuild.onLoad({ filter: /^settings-store-mock$/, namespace: 'supercmd-mocks' }, () => ({ + contents: makeSettingsMockSource(scriptCommandFolders), + loader: 'js', + })); + }, + }, + ]; + + if (instrumentFs) { + plugins.push({ + name: 'supercmd-script-command-fs-instrumentation', + setup(pluginBuild) { + pluginBuild.onResolve({ filter: /^fs$/ }, () => ({ + path: 'fs-instrumented', + namespace: 'supercmd-fs', + })); + pluginBuild.onLoad({ filter: /^fs-instrumented$/, namespace: 'supercmd-fs' }, () => ({ + contents: makeInstrumentedFsSource(metricsKey), + loader: 'js', + })); + }, + }); + } + + const result = await build({ + entryPoints: [runnerPath], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + plugins, + }); + + const output = result.outputFiles[0]?.text; + if (!output) { + throw new Error('Failed to bundle script-command-runner.ts'); + } + + const moduleUrl = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}#${metricsKey}`; + const module = await import(moduleUrl); + + return { + module, + metrics, + resetMetrics: () => resetMetrics(metrics), + root, + }; +} diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs new file mode 100644 index 00000000..b805dae4 --- /dev/null +++ b/scripts/test-script-command-runner.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadScriptCommandRunner } from './lib/script-command-runner-harness.mjs'; + +async function withScriptCommandRunner(t, files, { instrumentFs = false } = {}) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-script-runner-test-')); + const scriptsDir = path.join(tempRoot, 'script-commands'); + const userDataDir = path.join(tempRoot, 'user-data'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(userDataDir, { recursive: true }); + + for (const [name, contents] of Object.entries(files)) { + const filePath = path.join(scriptsDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, { mode: 0o755 }); + } + + const previousPaths = process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = scriptsDir; + t.after(() => { + if (previousPaths === undefined) { + delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; + } else { + process.env.SUPERCMD_SCRIPT_COMMAND_PATHS = previousPaths; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const loaded = await loadScriptCommandRunner({ + userDataDir, + scriptCommandFolders: [], + instrumentFs, + }); + + return { ...loaded, scriptsDir, tempRoot }; +} + +function scriptHeader({ + title = 'Test Command', + mode = 'fullOutput', + prefix = '#', + extra = '', +} = {}) { + return `${prefix} @raycast.schemaVersion 1 +${prefix} @raycast.title ${title} +${prefix} @raycast.mode ${mode} +${extra}`; +} + +test('Script command runner', async (t) => { + await t.test('parses Raycast metadata and argument definitions from command headers', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'metadata.js': `#!/usr/bin/env node --no-warnings +${scriptHeader({ + title: 'Deploy Helper', + prefix: '//', + extra: `// @raycast.packageName Ops Tools +// @raycast.icon 🚀 +// @raycast.description Deploys a selected environment +// @raycast.needsConfirmation yes +// @raycast.currentDirectoryPath ./workdir +// @raycast.argument1 {"type":"text","placeholder":"Service","required":true} +// @raycast.argument2 {"type":"dropdown","placeholder":"Environment","optional":true,"data":[{"title":"Production","value":"prod"},{"title":"Staging","value":"staging"}]} +`, +})} +console.log('ok'); +`, + }); + fs.mkdirSync(path.join(scriptsDir, 'workdir'), { recursive: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command.title, 'Deploy Helper'); + assert.equal(command.mode, 'fullOutput'); + assert.equal(command.packageName, 'Ops Tools'); + assert.equal(command.iconEmoji, '🚀'); + assert.equal(command.description, 'Deploys a selected environment'); + assert.equal(command.needsConfirmation, true); + assert.equal(command.currentDirectoryPath, path.join(scriptsDir, 'workdir')); + assert.equal(command.interpreter, '/usr/bin/env'); + assert.deepEqual(command.interpreterArgs, ['node', '--no-warnings']); + assert.deepEqual(command.arguments, [ + { + name: 'argument1', + index: 1, + type: 'text', + placeholder: 'Service', + required: true, + percentEncoded: undefined, + data: undefined, + }, + { + name: 'argument2', + index: 2, + type: 'dropdown', + placeholder: 'Environment', + required: false, + percentEncoded: undefined, + data: [ + { title: 'Production', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + }, + ]); + }); + + await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { + const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { + 'with-shebang.sh': `#!/bin/bash +${scriptHeader({ title: 'Shebang Command' })} +echo "shebang:$RAYCAST_TITLE" +`, + }, { instrumentFs: true }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/bash'); + assert.deepEqual(command.interpreterArgs, []); + + resetMetrics(); + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'shebang:Shebang Command'); + assert.equal(metrics.readFileSyncBytes + metrics.readSyncBytes, 0); + }); + + await t.test('executes no-shebang scripts with the bash fallback', async (t) => { + const { module: runner } = await withScriptCommandRunner(t, { + 'no-shebang.sh': `${scriptHeader({ title: 'No Shebang Command' })} +echo "fallback:$RAYCAST_MODE" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, undefined); + assert.deepEqual(command.interpreterArgs, []); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fallback:fullOutput'); + }); + + await t.test('discovers metadata in large scripts with bounded prefix reads', async (t) => { + const body = `# ${'x'.repeat(1022)}\n`.repeat(2048); + const { module: runner, metrics } = await withScriptCommandRunner(t, { + 'large.sh': `#!/bin/bash +${scriptHeader({ title: 'Large Command' })} +exit 0 +${body} +`, + }, { instrumentFs: true }); + + const commands = runner.discoverScriptCommands(); + assert.equal(commands.length, 1); + assert.equal(commands[0].title, 'Large Command'); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok( + metrics.readSyncBytes < 512 * 1024, + `expected a bounded prefix read, got ${metrics.readSyncBytes} bytes`, + ); + }); +}); + diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index ccbac2ae..cb1f5157 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -33,6 +33,8 @@ export interface ScriptCommandInfo { refreshTime?: string; interval?: string; currentDirectoryPath?: string; + interpreter?: string; + interpreterArgs?: string[]; needsConfirmation: boolean; arguments: ScriptArgumentDefinition[]; keywords: string[]; @@ -55,6 +57,9 @@ export interface ScriptExecutionResult { const CACHE_TTL_MS = 12_000; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; // 2MB const DEFAULT_TIMEOUT_MS = 60_000; +const SCRIPT_COMMAND_HEADER_MAX_LINES = 120; +const SCRIPT_COMMAND_HEADER_MAX_BYTES = 256 * 1024; +const SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES = 8 * 1024; let cache: { fetchedAt: number; commands: ScriptCommandInfo[] } | null = null; @@ -302,24 +307,66 @@ function discoverScriptFiles(rootDir: string): string[] { return out; } -function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { - const scriptPath = path.resolve(filePath); - const scriptDir = path.dirname(scriptPath); +function countLineBreaks(buffer: Buffer, length: number): number { + let count = 0; + for (let i = 0; i < length; i += 1) { + if (buffer[i] === 10) count += 1; + } + return count; +} - let raw = ''; +function readScriptCommandHeader(filePath: string): string | null { + let fd: number | null = null; try { - raw = fs.readFileSync(scriptPath, 'utf-8'); + fd = fs.openSync(filePath, 'r'); + const chunks: Buffer[] = []; + let totalBytes = 0; + let lineBreaks = 0; + + while ( + totalBytes < SCRIPT_COMMAND_HEADER_MAX_BYTES && + lineBreaks < SCRIPT_COMMAND_HEADER_MAX_LINES + ) { + const bytesToRead = Math.min( + SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES, + SCRIPT_COMMAND_HEADER_MAX_BYTES - totalBytes + ); + if (bytesToRead <= 0) break; + + const chunk = Buffer.allocUnsafe(bytesToRead); + const bytesRead = fs.readSync(fd, chunk, 0, bytesToRead, totalBytes); + if (bytesRead <= 0) break; + + chunks.push(bytesRead === chunk.length ? chunk : chunk.subarray(0, bytesRead)); + totalBytes += bytesRead; + lineBreaks += countLineBreaks(chunk, bytesRead); + } + + return Buffer.concat(chunks, totalBytes).toString('utf8'); } catch { return null; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch {} + } } +} + +function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { + const scriptPath = path.resolve(filePath); + const scriptDir = path.dirname(scriptPath); + + const raw = readScriptCommandHeader(scriptPath); + if (raw === null) return null; if (!raw.trim()) return null; const lines = raw.split(/\r?\n/); + const shebang = shebangArgs(lines[0] || ''); const metadata: Record = {}; const argumentDefs = new Map(); - for (const line of lines.slice(0, 120)) { + for (const line of lines.slice(0, SCRIPT_COMMAND_HEADER_MAX_LINES)) { const match = line.match(/^\s*(#|\/\/|--)\s*@raycast\.([A-Za-z0-9]+)\s*(.*)$/); if (!match) continue; const key = String(match[2] || '').trim(); @@ -393,6 +440,8 @@ function parseScriptCommandFile(filePath: string): ScriptCommandInfo | null { refreshTime, interval: mode === 'inline' ? refreshTime : undefined, currentDirectoryPath, + interpreter: shebang[0], + interpreterArgs: shebang.slice(1), needsConfirmation, arguments: argumentsList, keywords: Array.from(new Set(keywords)), @@ -497,9 +546,7 @@ export async function executeScriptCommand( return { missingArguments: missing, command: cmd }; } - const source = fs.readFileSync(cmd.scriptPath, 'utf-8'); - const firstLine = source.split(/\r?\n/)[0] || ''; - const shebang = shebangArgs(firstLine); + fs.accessSync(cmd.scriptPath, fs.constants.R_OK); const env = { ...process.env, @@ -514,10 +561,10 @@ export async function executeScriptCommand( const cwd = cmd.currentDirectoryPath || cmd.scriptDir; const spawnCommand = - shebang.length > 0 ? shebang[0] : '/bin/bash'; + cmd.interpreter ? cmd.interpreter : '/bin/bash'; const spawnArgs = - shebang.length > 0 - ? [...shebang.slice(1), cmd.scriptPath, ...args] + cmd.interpreter + ? [...(cmd.interpreterArgs || []), cmd.scriptPath, ...args] : [cmd.scriptPath, ...args]; const run = await new Promise<{ From 12f951e330a21bd2e588f47b184fd9f36b2ecc75 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:11 +0200 Subject: [PATCH 06/15] Optimize root search command scoring --- scripts/test-root-search-perf.mjs | 324 ++++++++++++++++++ .../src/hooks/useLauncherCommandModel.ts | 23 +- src/renderer/src/utils/command-helpers.tsx | 38 +- 3 files changed, 362 insertions(+), 23 deletions(-) create mode 100644 scripts/test-root-search-perf.mjs diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..8911fab1 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') { + return { renderQuickLinkIconGlyph: () => null }; + } + if (request.startsWith('../icons/')) { + return { __esModule: true, default: makeComponent(path.basename(request)) }; + } + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); +const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); + +const { filterCommands, rankCommands } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 6000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 6); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 1); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +const WORDS = [ + 'Notes', + 'Clipboard', + 'Window', + 'Browser', + 'Settings', + 'Canvas', + 'Timer', + 'Schedule', + 'Project', + 'Deploy', + 'Debug', + 'GitHub', + 'Snippet', + 'Search', + 'Memory', + 'Automation', +]; + +function normalizeSearchText(value) { + return String(value || '') + .normalize('NFKD') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +function makeCommands(count) { + const commands = []; + const aliases = {}; + + for (let i = 0; i < count; i += 1) { + const primary = WORDS[i % WORDS.length]; + const secondary = WORDS[(i * 7 + 3) % WORDS.length]; + const group = i % 4 === 0 ? 'extension' : i % 4 === 1 ? 'script' : i % 4 === 2 ? 'app' : 'system'; + const command = { + id: `synthetic-command-${i}`, + title: `${primary} ${secondary} Command ${i}`, + subtitle: `${secondary} tools for Project Alpha workspace ${i % 97}`, + category: group, + path: group === 'extension' ? `extension-${i % 80}/command-${i}` : undefined, + keywords: [ + primary, + secondary, + `cmd ${i}`, + i % 9 === 0 ? 'project alpha' : 'workspace', + i % 11 === 0 ? 'gh github repo' : 'local action', + ], + alwaysOnTop: i % 997 === 0, + }; + commands.push(command); + + if (i % 5 === 0) aliases[command.id] = `${primary.toLowerCase()}-${i}`; + if (i % 37 === 0) aliases[command.id] = 'gh'; + } + + return { commands, aliases }; +} + +function rootCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.08 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.68 })), + ]; +} + +function legacyRankCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.7 })), + ]; +} + +function legacyRootCommandMatches(commands, query, aliases) { + const normalizedQuery = normalizeSearchText(query); + const ranked = commands + .map((command) => { + const alias = aliases[command.id] || ''; + const firstPass = scoreRootSearchFields(query, legacyRankCommandFields(command, alias)); + if (!firstPass.matched) return null; + return { + command, + score: firstPass.matchScore, + hasExactAliasMatch: Boolean(alias) && normalizeSearchText(alias) === normalizedQuery, + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } + if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.command.title.localeCompare(b.command.title); + }); + + return ranked + .map(({ command }) => { + const scored = scoreRootSearchFields(query, rootCommandFields(command, aliases[command.id] || '')); + assert.equal(scored.matched, true, `${command.id} lost its second-pass match for ${query}`); + return { + id: command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function optimizedRootCommandMatches(commands, query, aliases) { + return rankCommands(commands, query, aliases) + .map((entry) => { + if (typeof entry.matchKind === 'string' && typeof entry.matchScore === 'number') { + return { + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + }; + } + + const scored = scoreRootSearchFields(query, rootCommandFields(entry.command, aliases[entry.command.id] || '')); + assert.equal(scored.matched, true, `${entry.command.id} lost its optimized fallback match for ${query}`); + return { + id: entry.command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function signature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + .sort(); +} + +function assertSameRootCommandMatches(commands, aliases) { + for (const query of QUERIES) { + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + signature(legacyRootCommandMatches(commands, query, aliases)), + `root command matches changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +const { commands, aliases } = makeCommands(COMMAND_COUNT); + +assertSameRootCommandMatches(commands, aliases); + +const legacy = measure('legacy filter + two-pass command scoring', (query) => { + const filtered = filterCommands(commands, query, aliases); + const matches = legacyRootCommandMatches(commands, query, aliases); + return filtered.length + matches.length; +}); + +const optimized = measure('optimized command scoring path', (query) => { + const matches = optimizedRootCommandMatches(commands, query, aliases); + return matches.length; +}); + +const speedup = legacy.median > 0 ? legacy.median / optimized.median : 0; + +console.log('Root search perf harness'); +console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); +console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); +console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); +console.log(`speedup=${speedup.toFixed(2)}x`); diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index bfc40cf3..66308ca1 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -339,9 +339,13 @@ export function useLauncherCommandModel({ const calcResult = syncCalcResult ?? asyncCalcResult; const calcOffset = calcResult ? 1 : 0; const contextualCommands = commands; + const hasSearchQuery = searchQuery.trim().length > 0; + const shouldComputeLegacyCommandList = !hasSearchQuery || Boolean(calcResult); const filteredCommands = useMemo( - () => filterCommands(contextualCommands, searchQuery, commandAliases), - [contextualCommands, searchQuery, commandAliases] + () => shouldComputeLegacyCommandList + ? filterCommands(contextualCommands, searchQuery, commandAliases) + : contextualCommands, + [contextualCommands, searchQuery, commandAliases, shouldComputeLegacyCommandList] ); // When calculator is showing but no commands match, show unfiltered list below. @@ -355,7 +359,6 @@ export function useLauncherCommandModel({ () => new Set(['system-add-to-memory', 'system-cursor-prompt', 'system-emoji-picker']), [] ); - const hasSearchQuery = searchQuery.trim().length > 0; const visibleSourceCommands = useMemo( () => sourceCommands .filter((cmd) => !hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) @@ -527,15 +530,7 @@ export function useLauncherCommandModel({ (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) ); return rankCommands(searchableCommands, searchQuery, commandAliases) - .map(({ command }) => { - const alias = commandAliases[command.id] || ''; - const scored = scoreRootSearchFields(searchQuery, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), - ]); - if (!scored.matched) return null; + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; return scoreRootSearchCandidate({ @@ -551,8 +546,8 @@ export function useLauncherCommandModel({ label: command.title, description: command.subtitle, pathOrUrl: command.path, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + matchKind, + matchScore, sourceQualityBoost: command.alwaysOnTop ? 80 : 0, freshnessBoost: 0, pathLocationBoost: 0, diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index dfa5bcbc..31a70a3d 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -27,7 +27,11 @@ import IconPen from '../icons/Pen'; import IconMagicWand from '../icons/MagicWand'; import { formatShortcutForDisplay } from './hyper-key'; import { renderQuickLinkIconGlyph } from './quicklink-icons'; -import { scoreRootSearchFields } from './root-search-ranking'; +import { + scoreRootSearchFields, + type MatchKind, + type RootSearchScoringField, +} from './root-search-ranking'; import { getTranslitVariant } from './transliterate'; export interface LauncherAction { @@ -192,8 +196,19 @@ type SearchCandidate = { export type RankedCommand = { command: CommandInfo; score: number; + matchKind: MatchKind; + matchScore: number; }; +function getRootSearchCommandScoringFields(command: CommandInfo, alias: string): RootSearchScoringField[] { + 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' as const, weight: 0.68 })), + ]; +} + function bestTermScore(term: string, candidates: SearchCandidate[]): number { let best = 0; for (const candidate of candidates) { @@ -346,20 +361,25 @@ export function rankCommands( ): RankedCommand[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { - return commands.map((command) => ({ command, score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0 })); + return commands.map((command) => ({ + command, + score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0, + matchKind: 'exact', + matchScore: 0, + })); } return commands .map((command): RankedCommand | null => { const alias = aliasLookup[command.id] || ''; - const scored = scoreRootSearchFields(query, [ - { 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' as const, weight: 0.7 })), - ]); + const scored = scoreRootSearchFields(query, getRootSearchCommandScoringFields(command, alias)); if (!scored.matched) return null; - return { command, score: scored.matchScore }; + return { + command, + score: scored.matchScore, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; }) .filter((entry): entry is RankedCommand => entry !== null) .sort((a, b) => { From 7ed6c2a0542d7998c3577d5c2dbe1ff5fb1e19a8 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:21:16 +0200 Subject: [PATCH 07/15] Speed up path-like file search queries --- scripts/test-file-search-index.mjs | 263 +++++++++++++++++++++++++++-- src/main/file-search-index.ts | 72 +++++++- 2 files changed, 316 insertions(+), 19 deletions(-) diff --git a/scripts/test-file-search-index.mjs b/scripts/test-file-search-index.mjs index f8e7a86c..03dee3a3 100644 --- a/scripts/test-file-search-index.mjs +++ b/scripts/test-file-search-index.mjs @@ -16,6 +16,7 @@ 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 { @@ -29,6 +30,18 @@ function createFakeDate(clock) { }; } +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')} @@ -56,16 +69,6 @@ export const __fileSearchIndexTestInternals = { }; `; - const transpiled = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2022, - esModuleInterop: true, - importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, - }, - fileName: SOURCE_PATH, - }); - const module = { exports: {} }; const localRequire = (request) => require(request); const quietConsole = { @@ -88,16 +91,81 @@ export const __fileSearchIndexTestInternals = { 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, - Promise, - process: sandboxProcess, }; + 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; - vm.runInNewContext(transpiled.outputText, sandbox, { filename: SOURCE_PATH }); + 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; } @@ -147,7 +215,7 @@ async function runRequestedRefresh(indexModule, reason, logs) { }; } -async function runScenario() { +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 }; @@ -231,8 +299,113 @@ async function runScenario() { } } +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 runScenario(); + 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'); @@ -243,3 +416,63 @@ test('file search index uses watcher batches and avoids healthy interval rebuild 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/src/main/file-search-index.ts b/src/main/file-search-index.ts index 88ababf2..04077004 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[]; @@ -133,6 +134,7 @@ const PROTECTED_TOP_LEVEL_SET = new Set( FILE_SEARCH_INDEX_PROTECTED_HOME_TOP_LEVEL_DIRECTORIES.map((name) => name.toLowerCase()) ); const EXCLUDED_FILE_EXTENSIONS = new Set(['.tmp', '.temp', '.log', '.cache', '.crdownload', '.download']); +const PATH_CANDIDATE_TERM_REGEX = /[a-z0-9]/; let activeIndex: IndexSnapshot | null = null; let rebuildPromise: Promise | null = null; @@ -266,12 +268,13 @@ function addPrefixIndexValue(prefixToEntryIds: Map, key: strin function indexEntry( snapshot: IndexSnapshot, - entry: Omit + entry: Omit ): void { const normalizedName = normalizeSearchText(entry.name); if (!normalizedName) return; const normalizedPath = normalizePathSearchText(entry.path); if (!normalizedPath) return; + const normalizedTildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); const existingId = snapshot.pathToEntryId.get(entry.path); if (existingId !== undefined) { @@ -295,6 +298,7 @@ function indexEntry( ...entry, normalizedName, normalizedPath, + normalizedTildePath, compactName, tokens, pathTokens, @@ -592,6 +596,62 @@ function resolveCandidateIds(snapshot: IndexSnapshot, terms: string[]): number[] return intersectCandidates(indexedLists); } +function getPathLikeBoundaryTerms(needle: string): string[] { + const normalizedNeedle = normalizePathSearchText(needle); + if (!normalizedNeedle) return []; + + const terms: string[] = []; + let termStart = -1; + + const addTerm = (endIndex: number) => { + if (termStart <= 0) return; + const previousChar = normalizedNeedle[termStart - 1] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(previousChar)) return; + const term = normalizedNeedle.slice(termStart, endIndex); + if (term.length >= 2) terms.push(term); + }; + + for (let index = 0; index <= normalizedNeedle.length; index += 1) { + const char = normalizedNeedle[index] || ''; + if (PATH_CANDIDATE_TERM_REGEX.test(char)) { + if (termStart < 0) termStart = index; + continue; + } + + if (termStart >= 0) { + addTerm(index); + termStart = -1; + } + } + + return [...new Set(terms)]; +} + +function resolvePathLikeCandidateIds( + snapshot: IndexSnapshot, + rawNeedle: string, + expandedNeedle: string, + trimmedQuery: string +): number[] | null { + const isHomeTildeQuery = trimmedQuery === '~' || trimmedQuery.startsWith('~/') || trimmedQuery.startsWith('~\\'); + const terms = getPathLikeBoundaryTerms(rawNeedle); + if (terms.length === 0 && isHomeTildeQuery) { + terms.push(...getPathLikeBoundaryTerms(expandedNeedle)); + } + if (terms.length === 0) return null; + + let smallestBucket: number[] | null = null; + for (const term of terms) { + const key = term.slice(0, Math.min(MAX_PREFIX_LENGTH, term.length)); + const matches = snapshot.prefixToEntryIds.get(key); + if (!matches || matches.length === 0) return []; + if (!smallestBucket || matches.length < smallestBucket.length) { + smallestBucket = matches; + } + } + return smallestBucket ? [...smallestBucket] : null; +} + function resolveHomeDir(inputHomeDir?: string): string { const candidate = String(inputHomeDir || '').trim(); if (candidate) return path.resolve(candidate); @@ -1008,13 +1068,16 @@ export async function searchIndexedFiles( const expandedNeedle = trimmedQuery.startsWith('~') && configuredHomeDir ? normalizePathSearchText(`${configuredHomeDir}${trimmedQuery.slice(1)}`) : rawNeedle; + const candidateIds = resolvePathLikeCandidateIds(snapshot, rawNeedle, expandedNeedle, trimmedQuery); + const candidateEntries = candidateIds === null + ? snapshot.entries + : candidateIds.map((entryId) => snapshot.entries[entryId]).filter(Boolean); const scored: Array<{ entry: IndexedEntry; score: number }> = []; - for (const entry of snapshot.entries) { + for (const entry of candidateEntries) { if (entry.deleted) continue; const pathIndex = entry.normalizedPath.indexOf(expandedNeedle); - const tildePath = normalizePathSearchText(asTildePath(entry.path, configuredHomeDir)); - const tildeIndex = tildePath.indexOf(rawNeedle); + const tildeIndex = entry.normalizedTildePath.indexOf(rawNeedle); const matchIndex = pathIndex >= 0 ? pathIndex : tildeIndex; if (matchIndex < 0) continue; @@ -1160,6 +1223,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), From dff85390076db78075a919051a18062a2ae39675 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:21:59 +0200 Subject: [PATCH 08/15] Add file search perf harness --- package.json | 1 + scripts/file-search-perf-harness.mjs | 478 ++++++++++++++++++++++ scripts/test-file-search-perf-harness.mjs | 52 +++ src/main/file-search-index.ts | 54 +++ 4 files changed, 585 insertions(+) create mode 100644 scripts/file-search-perf-harness.mjs create mode 100644 scripts/test-file-search-perf-harness.mjs diff --git a/package.json b/package.json index f262a8c1..11a1701d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", "build:renderer": "vite build", "check:i18n": "node scripts/check-i18n.mjs", + "perf:file-search": "node scripts/file-search-perf-harness.mjs", "test": "node --test 'scripts/test-*.mjs'", "build:native": "node scripts/build-native.mjs", "ensure:cross-arch-esbuild": "node scripts/ensure-cross-arch-esbuild.mjs", diff --git a/scripts/file-search-perf-harness.mjs b/scripts/file-search-perf-harness.mjs new file mode 100644 index 00000000..904e5564 --- /dev/null +++ b/scripts/file-search-perf-harness.mjs @@ -0,0 +1,478 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import { importTs } from './lib/ts-import.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); +const FILE_SEARCH_INDEX_TS = path.join(REPO_ROOT, 'src/main/file-search-index.ts'); + +const DEFAULT_CONFIG = { + projects: 24, + modulesPerProject: 4, + filesPerModule: 8, + queryRuns: 5, + updateCount: 32, + deleteCount: 32, + limit: 12, + keep: false, + json: false, + output: '', + includeProtectedHomeRoots: true, + thresholds: {}, +}; + +const FILE_NAME_PATTERNS = [ + 'command-palette-{project}-{module}-{file}.ts', + 'launch-plan-alpha-{project}-{module}-{file}.md', + 'invoice-report-{project}-{module}-{file}.json', + 'notes-search-index-{project}-{module}-{file}.txt', + 'shortcut-resolver-{project}-{module}-{file}.tsx', + 'settings-migration-{project}-{module}-{file}.ts', + 'quick-action-workflow-{project}-{module}-{file}.md', + 'browser-history-cache-{project}-{module}-{file}.json', +]; + +function pad(value, width = 3) { + return String(value).padStart(width, '0'); +} + +function formatPattern(pattern, values) { + return pattern + .replaceAll('{project}', values.project) + .replaceAll('{module}', values.module) + .replaceAll('{file}', values.file); +} + +function readNumber(value, fallback, min = 1) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} + +function readOptionalNumber(value) { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function getArgValue(args, name) { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + const index = args.indexOf(name); + if (index >= 0 && index + 1 < args.length) return args[index + 1]; + return undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function getUsage() { + return [ + 'Usage: node scripts/file-search-perf-harness.mjs [options]', + '', + 'Options:', + ' --projects Number of synthetic projects', + ' --modules Modules per project', + ' --files-per-module Files per module', + ' --query-runs Query repetitions per query', + ' --updates Watcher-style added paths', + ' --deletes Deleted paths in the tombstone batch', + ' --limit Search result limit', + ' --json Print JSON output', + ' --output Write the same output to a file', + ' --keep Keep the temp fixture for inspection', + ' --threshold-index-ms Fail if initial indexing exceeds n ms', + ' --threshold-normal-query-p95-ms Fail if normal query p95 exceeds n ms', + ' --threshold-path-query-p95-ms Fail if path-like query p95 exceeds n ms', + ' --threshold-watch-update-ms Fail if watcher-style batch exceeds n ms', + ' --threshold-delete-ms Fail if delete batch exceeds n ms', + ].join('\n'); +} + +export function parseFileSearchPerfHarnessArgs(args = process.argv.slice(2)) { + return { + projects: readNumber(getArgValue(args, '--projects'), DEFAULT_CONFIG.projects), + modulesPerProject: readNumber(getArgValue(args, '--modules'), DEFAULT_CONFIG.modulesPerProject), + filesPerModule: readNumber(getArgValue(args, '--files-per-module'), DEFAULT_CONFIG.filesPerModule), + queryRuns: readNumber(getArgValue(args, '--query-runs'), DEFAULT_CONFIG.queryRuns), + updateCount: readNumber(getArgValue(args, '--updates'), DEFAULT_CONFIG.updateCount), + deleteCount: readNumber(getArgValue(args, '--deletes'), DEFAULT_CONFIG.deleteCount), + limit: readNumber(getArgValue(args, '--limit'), DEFAULT_CONFIG.limit), + keep: hasFlag(args, '--keep'), + json: hasFlag(args, '--json'), + output: getArgValue(args, '--output') || '', + includeProtectedHomeRoots: !hasFlag(args, '--exclude-protected-home-roots'), + thresholds: { + initialIndexMs: readOptionalNumber(getArgValue(args, '--threshold-index-ms')), + normalQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-normal-query-p95-ms')), + pathQueryP95Ms: readOptionalNumber(getArgValue(args, '--threshold-path-query-p95-ms')), + watchUpdateBatchMs: readOptionalNumber(getArgValue(args, '--threshold-watch-update-ms')), + deleteBatchMs: readOptionalNumber(getArgValue(args, '--threshold-delete-ms')), + }, + }; +} + +function mergeConfig(overrides = {}) { + return { + ...DEFAULT_CONFIG, + ...overrides, + thresholds: { + ...DEFAULT_CONFIG.thresholds, + ...(overrides.thresholds || {}), + }, + }; +} + +async function writeFile(filePath, contents) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents); +} + +async function createSyntheticHome(config) { + const tempRootRaw = await fs.mkdtemp(path.join(os.tmpdir(), 'supercmd-file-search-perf-')); + const tempRoot = await fs.realpath(tempRootRaw); + const homeDir = path.join(tempRoot, 'home'); + const benchRoot = path.join(homeDir, 'BenchRoot'); + const projectsRoot = path.join(benchRoot, 'Projects'); + const archiveRoot = path.join(benchRoot, 'Archive'); + const referenceRoot = path.join(benchRoot, 'References'); + const filePaths = []; + const deleteCandidates = []; + + await fs.mkdir(projectsRoot, { recursive: true }); + await fs.mkdir(archiveRoot, { recursive: true }); + await fs.mkdir(referenceRoot, { recursive: true }); + + for (let projectIndex = 0; projectIndex < config.projects; projectIndex += 1) { + const project = `project-${pad(projectIndex)}`; + for (let moduleIndex = 0; moduleIndex < config.modulesPerProject; moduleIndex += 1) { + const moduleName = `module-${pad(moduleIndex, 2)}`; + const srcDir = path.join(projectsRoot, project, moduleName, 'src'); + const docsDir = path.join(projectsRoot, project, moduleName, 'docs'); + + for (let fileIndex = 0; fileIndex < config.filesPerModule; fileIndex += 1) { + const file = pad(fileIndex, 2); + const pattern = FILE_NAME_PATTERNS[fileIndex % FILE_NAME_PATTERNS.length]; + const targetDir = fileIndex % 2 === 0 ? srcDir : docsDir; + const fileName = formatPattern(pattern, { project, module: moduleName, file }); + const filePath = path.join(targetDir, fileName); + await writeFile( + filePath, + [ + `project=${project}`, + `module=${moduleName}`, + `file=${file}`, + `intent=${fileName}`, + '', + ].join('\n') + ); + filePaths.push(filePath); + if (fileName.startsWith('launch-plan-alpha') || fileName.startsWith('invoice-report')) { + deleteCandidates.push(filePath); + } + } + } + + await writeFile( + path.join(archiveRoot, `release-notes-command-palette-${project}.md`), + `release notes for ${project}\n` + ); + await writeFile( + path.join(referenceRoot, `path-query-reference-${project}.txt`), + `path reference for ${project}\n` + ); + } + + return { + tempRoot, + homeDir, + benchRoot, + projectsRoot, + filePaths, + deleteCandidates, + }; +} + +function percentile(values, percentileValue) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((percentileValue / 100) * sorted.length) - 1)); + return sorted[index]; +} + +function summarizeDurations(samples) { + const totalMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0); + const durations = samples.map((sample) => sample.durationMs); + return { + runs: samples.length, + minMs: Number(Math.min(...durations).toFixed(3)), + meanMs: Number((totalMs / Math.max(1, samples.length)).toFixed(3)), + medianMs: Number(percentile(durations, 50).toFixed(3)), + p95Ms: Number(percentile(durations, 95).toFixed(3)), + maxMs: Number(Math.max(...durations).toFixed(3)), + totalResults: samples.reduce((sum, sample) => sum + sample.resultCount, 0), + }; +} + +async function measureDuration(fn) { + const startedAt = performance.now(); + const result = await fn(); + return { + durationMs: performance.now() - startedAt, + result, + }; +} + +async function measureQueries(searchIndexedFiles, queries, config) { + const samples = []; + for (let run = 0; run < config.queryRuns; run += 1) { + for (const query of queries) { + const measured = await measureDuration(() => searchIndexedFiles(query, { limit: config.limit })); + samples.push({ + query, + run, + durationMs: measured.durationMs, + resultCount: measured.result.length, + }); + } + } + return { + ...summarizeDurations(samples), + samples: samples.map((sample) => ({ + ...sample, + durationMs: Number(sample.durationMs.toFixed(3)), + })), + }; +} + +function buildQueries(homeDir) { + return { + normal: [ + 'command palette', + 'launch plan alpha', + 'invoice report', + 'notes search index', + ], + pathLike: [ + '~/BenchRoot/Projects/project-', + '~/BenchRoot/Archive/release-notes-command', + `${homeDir}/BenchRoot/References/path-query-reference`, + ], + }; +} + +async function createUpdateFiles(fixture, config) { + const updatePaths = []; + for (let index = 0; index < config.updateCount; index += 1) { + const project = `project-${pad(index % config.projects)}`; + const moduleName = `module-${pad(index % config.modulesPerProject, 2)}`; + const filePath = path.join( + fixture.projectsRoot, + project, + moduleName, + 'src', + `watcher-added-command-${pad(index)}.ts` + ); + await writeFile(filePath, `watcher added command ${index}\n`); + updatePaths.push(filePath); + } + return updatePaths; +} + +async function deleteFixtureFiles(fixture, config) { + const deletePaths = fixture.deleteCandidates.slice(0, config.deleteCount); + for (const filePath of deletePaths) { + await fs.rm(filePath, { force: true }); + } + return deletePaths; +} + +function evaluateThresholds(metrics, thresholds = {}) { + const checks = [ + ['initialIndexMs', metrics.initialIndexMs, thresholds.initialIndexMs], + ['normalQueryP95Ms', metrics.normalQueries.p95Ms, thresholds.normalQueryP95Ms], + ['pathQueryP95Ms', metrics.pathLikeQueries.p95Ms, thresholds.pathQueryP95Ms], + ['watchUpdateBatchMs', metrics.watchUpdateBatchMs, thresholds.watchUpdateBatchMs], + ['deleteBatchMs', metrics.deleteBatchMs, thresholds.deleteBatchMs], + ]; + const failures = checks + .filter(([, actual, threshold]) => typeof threshold === 'number' && actual > threshold) + .map(([name, actual, threshold]) => `${name} ${actual.toFixed(3)}ms exceeded ${threshold}ms`); + + return { + passed: failures.length === 0, + failures, + applied: Object.fromEntries( + checks + .filter(([, , threshold]) => typeof threshold === 'number') + .map(([name, , threshold]) => [name, threshold]) + ), + }; +} + +function roundMetric(value) { + return Number(value.toFixed(3)); +} + +function toDisplayLines(summary) { + const thresholdLine = summary.thresholds.applied && Object.keys(summary.thresholds.applied).length > 0 + ? summary.thresholds.passed + ? 'Thresholds: passed' + : `Thresholds: failed (${summary.thresholds.failures.join('; ')})` + : 'Thresholds: not applied'; + + return [ + 'File search perf harness', + `Home fixture: ${summary.fixture.homeDir}`, + `Indexed entries: ${summary.fixture.initialEntryCount}`, + `Initial indexing: ${summary.metrics.initialIndexMs.toFixed(3)}ms`, + `Normal queries: mean ${summary.metrics.normalQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.normalQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.normalQueries.totalResults}`, + `Path-like queries: mean ${summary.metrics.pathLikeQueries.meanMs.toFixed(3)}ms, p95 ${summary.metrics.pathLikeQueries.p95Ms.toFixed(3)}ms, results ${summary.metrics.pathLikeQueries.totalResults}`, + `Watcher-style updates: ${summary.metrics.watchUpdateBatchMs.toFixed(3)}ms for ${summary.fixture.updateCount} paths`, + `Delete batch: ${summary.metrics.deleteBatchMs.toFixed(3)}ms for ${summary.fixture.deleteCount} paths`, + `Post-update query: ${summary.metrics.postUpdateQueryMs.toFixed(3)}ms (${summary.verification.postUpdateResultCount} results)`, + `Deleted exact matches after tombstone: ${summary.verification.deletedExactMatches}`, + thresholdLine, + ]; +} + +export async function runFileSearchPerfHarness(overrides = {}) { + const config = mergeConfig(overrides); + const fixture = await createSyntheticHome(config); + let fileSearchIndexPerfHarness = null; + let summary = null; + let cleanupCompleted = false; + try { + const fileSearchIndex = await importTs(FILE_SEARCH_INDEX_TS); + const { + __fileSearchIndexPerfHarness, + getFileSearchIndexStatus, + searchIndexedFiles, + } = fileSearchIndex; + fileSearchIndexPerfHarness = __fileSearchIndexPerfHarness; + + if (!fileSearchIndexPerfHarness) { + throw new Error('Missing __fileSearchIndexPerfHarness export from file-search-index.ts'); + } + + const queries = buildQueries(fixture.homeDir); + const initialIndex = await measureDuration(() => + fileSearchIndexPerfHarness.rebuild({ + homeDir: fixture.homeDir, + includeProtectedHomeRoots: config.includeProtectedHomeRoots, + }) + ); + const statusAfterIndex = getFileSearchIndexStatus(); + + const normalQueries = await measureQueries(searchIndexedFiles, queries.normal, config); + const pathLikeQueries = await measureQueries(searchIndexedFiles, queries.pathLike, config); + + const updatePaths = await createUpdateFiles(fixture, config); + const watchUpdate = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(updatePaths) + ); + const postUpdateQuery = await measureDuration(() => + searchIndexedFiles('watcher added command', { limit: config.limit }) + ); + + const deletePaths = await deleteFixtureFiles(fixture, config); + const deleteBatch = await measureDuration(() => + fileSearchIndexPerfHarness.applyWatchEventBatch(deletePaths) + ); + const deletedNeedle = deletePaths[0] ? path.basename(deletePaths[0]) : ''; + const postDeleteQuery = await measureDuration(() => + deletedNeedle ? searchIndexedFiles(deletedNeedle, { limit: config.limit }) : Promise.resolve([]) + ); + const deletedExactMatches = deletedNeedle + ? postDeleteQuery.result.filter((result) => result.name === deletedNeedle).length + : 0; + + const metrics = { + initialIndexMs: roundMetric(initialIndex.durationMs), + normalQueries, + pathLikeQueries, + watchUpdateBatchMs: roundMetric(watchUpdate.durationMs), + postUpdateQueryMs: roundMetric(postUpdateQuery.durationMs), + deleteBatchMs: roundMetric(deleteBatch.durationMs), + postDeleteQueryMs: roundMetric(postDeleteQuery.durationMs), + }; + const realTempDir = await fs.realpath(os.tmpdir()); + + summary = { + config: { + projects: config.projects, + modulesPerProject: config.modulesPerProject, + filesPerModule: config.filesPerModule, + queryRuns: config.queryRuns, + updateCount: config.updateCount, + deleteCount: config.deleteCount, + limit: config.limit, + }, + fixture: { + tempRoot: fixture.tempRoot, + homeDir: fixture.homeDir, + initialFileCount: fixture.filePaths.length + config.projects * 2, + initialEntryCount: statusAfterIndex.indexedEntryCount, + updateCount: updatePaths.length, + deleteCount: deletePaths.length, + }, + metrics, + verification: { + postUpdateResultCount: postUpdateQuery.result.length, + postDeleteResultCount: postDeleteQuery.result.length, + deletedExactMatches, + homeDirectoryUsed: statusAfterIndex.homeDirectory, + homeIsTempDirectory: fixture.homeDir.startsWith(realTempDir), + }, + thresholds: evaluateThresholds(metrics, config.thresholds), + cleanup: { + keptFixture: Boolean(config.keep), + completed: false, + }, + }; + + return summary; + } finally { + fileSearchIndexPerfHarness?.reset(); + if (!config.keep) { + await fs.rm(fixture.tempRoot, { recursive: true, force: true }); + cleanupCompleted = true; + } + if (summary) { + summary.cleanup.completed = cleanupCompleted; + } + } +} + +async function runCli() { + if (hasFlag(process.argv, '--help')) { + process.stdout.write(`${getUsage()}\n`); + return; + } + + const config = parseFileSearchPerfHarnessArgs(); + const summary = await runFileSearchPerfHarness(config); + const output = config.json ? `${JSON.stringify(summary, null, 2)}\n` : `${toDisplayLines(summary).join('\n')}\n`; + + if (config.output) { + await fs.writeFile(path.resolve(config.output), output); + } + process.stdout.write(output); + + if (!summary.thresholds.passed) { + process.exitCode = 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runCli().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/test-file-search-perf-harness.mjs b/scripts/test-file-search-perf-harness.mjs new file mode 100644 index 00000000..5ff1ddde --- /dev/null +++ b/scripts/test-file-search-perf-harness.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { runFileSearchPerfHarness } from './file-search-perf-harness.mjs'; + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +test('file search perf harness covers deterministic temp-home scenarios', async () => { + const summary = await runFileSearchPerfHarness({ + projects: 8, + modulesPerProject: 3, + filesPerModule: 6, + queryRuns: 2, + updateCount: 10, + deleteCount: 10, + limit: 8, + thresholds: { + initialIndexMs: 15_000, + normalQueryP95Ms: 2_000, + pathQueryP95Ms: 2_000, + watchUpdateBatchMs: 5_000, + deleteBatchMs: 5_000, + }, + }); + + assert.equal(summary.thresholds.passed, true, summary.thresholds.failures.join('\n')); + assert.equal(summary.verification.homeDirectoryUsed, summary.fixture.homeDir); + assert.equal(summary.verification.homeIsTempDirectory, true); + assert.ok( + summary.fixture.homeDir.startsWith(await fs.realpath(os.tmpdir())), + 'harness must use an OS temp home' + ); + assert.equal(summary.cleanup.completed, true); + assert.equal(await pathExists(summary.fixture.tempRoot), false, 'temp fixture should be cleaned up'); + + assert.ok(summary.fixture.initialEntryCount >= summary.fixture.initialFileCount); + assert.ok(summary.metrics.initialIndexMs >= 0); + assert.ok(summary.metrics.normalQueries.totalResults > 0); + assert.ok(summary.metrics.pathLikeQueries.totalResults > 0); + assert.ok(summary.verification.postUpdateResultCount > 0); + assert.equal(summary.verification.deletedExactMatches, 0); +}); diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 04077004..7f5b0c20 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -1043,6 +1043,60 @@ export function stopFileSearchIndexing(): void { stopFileSearchWatcher(); } +function resetFileSearchIndexForPerfHarness(options?: { + homeDir?: string; + includeProtectedHomeRoots?: boolean; +}): void { + stopFileSearchIndexing(); + activeIndex = null; + rebuildPromise = null; + configuredHomeDir = ''; + includeRoots = []; + refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS; + includeProtectedHomeRoots = Boolean(options?.includeProtectedHomeRoots); + indexing = false; + lastIndexError = null; + lastBuildStartedAt = 0; + pendingWatchEvents.clear(); + if (options?.homeDir) { + ensureConfigured(options.homeDir); + } +} + +async function rebuildFileSearchIndexForPerfHarness(options: { + homeDir: string; + includeProtectedHomeRoots?: boolean; +}): Promise { + resetFileSearchIndexForPerfHarness(options); + if (includeRoots.length === 0) return; + + indexing = true; + try { + activeIndex = await buildIndexSnapshot(configuredHomeDir); + lastIndexError = null; + } catch (error) { + lastIndexError = error instanceof Error ? error.message : String(error || 'Unknown indexing error'); + throw error; + } finally { + indexing = false; + rebuildPromise = null; + lastBuildStartedAt = 0; + } +} + +async function applyFileSearchWatchEventBatchForPerfHarness(paths: string[]): Promise { + if (!activeIndex) { + throw new Error('File search perf harness requires an active index before applying watch events.'); + } + await applyWatchEventBatch(paths.map((candidatePath) => path.resolve(candidatePath))); +} + +export const __fileSearchIndexPerfHarness = { + reset: resetFileSearchIndexForPerfHarness, + rebuild: rebuildFileSearchIndexForPerfHarness, + applyWatchEventBatch: applyFileSearchWatchEventBatchForPerfHarness, +}; + export async function searchIndexedFiles( rawQuery: string, options?: { limit?: number } From caa78e1ae4051daf89cb063eff260d9423a5af53 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:34:22 +0200 Subject: [PATCH 09/15] perf(browser-search): index hot query path --- scripts/test-browser-search-lag-fix.mjs | 4 + scripts/test-browser-search-performance.mjs | 292 +++++++++++++++++++ src/renderer/src/hooks/useBrowserSearch.ts | 303 +++++++++++++++++++- 3 files changed, 589 insertions(+), 10 deletions(-) create mode 100644 scripts/test-browser-search-performance.mjs diff --git a/scripts/test-browser-search-lag-fix.mjs b/scripts/test-browser-search-lag-fix.mjs index bff0d368..c8e87af3 100644 --- a/scripts/test-browser-search-lag-fix.mjs +++ b/scripts/test-browser-search-lag-fix.mjs @@ -57,8 +57,12 @@ test('Browser search lag fix', async (t) => { assertIncludes(files.hook, 'refreshEntriesIfStale'); assertIncludes(files.hook, 'historyByTimeEntryIds'); assertIncludes(files.hook, 'bookmarksByBrowserOrderEntryIds'); + assertIncludes(files.hook, 'tokenPrefixEntryIds'); + assertIncludes(files.hook, 'tokenTrigramEntryIds'); + assertIncludes(files.hook, 'getIndexedEntryCandidateIds'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096'); + assertIncludes(files.hook, '__browserSearchTestAccess'); }); await t.test('local commands use refreshEntriesIfStale not refreshEntries', () => { diff --git a/scripts/test-browser-search-performance.mjs b/scripts/test-browser-search-performance.mjs new file mode 100644 index 00000000..8b2a345d --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const FIXED_NOW = Date.UTC(2026, 6, 3, 12, 0, 0); + +class FixedDate extends Date { + constructor(...args) { + super(...(args.length ? args : [FIXED_NOW])); + } + + static now() { + return FIXED_NOW; + } +} + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return /\.[cm]?[tj]sx?$/.test(nextPath) ? loadTsModule(nextPath) : require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Intl, + Date: FixedDate, + Math, + String, + Number, + Boolean, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function buildSyntheticBrowserData() { + const profiles = ['chrome:Default', 'chrome:Work', 'arc:Default', 'brave:Default']; + const topics = ['github', 'docs', 'linear', 'notion', 'calendar', 'supercmd', 'typescript', 'electron', 'raycast', 'browser']; + const entries = []; + const tabs = []; + + for (let index = 0; index < 20_000; index += 1) { + const topic = topics[index % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}${index % 700}.example.com`; + entries.push({ + id: `h-${index}`, + type: 'url', + query: `${topic} history page ${index}`, + url: `https://${host}/workspace/${index % 200}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 371_000, + useCount: (index % 23) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + }); + } + + for (let index = 0; index < 4_000; index += 1) { + const topic = topics[(index * 3) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-bookmark${index % 600}.example.com`; + entries.push({ + id: `b-${index}`, + type: 'bookmark', + query: `${topic} bookmark reference ${index}`, + url: `https://${host}/saved/${index % 300}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 997_000, + useCount: (index % 11) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + bookmarkFolder: `Folder ${index % 25}`, + bookmarkOrder: index, + }); + } + + const nicknameBookmark = { + id: 'b-nickname-github', + type: 'bookmark', + query: 'GitHub Pull Requests', + url: 'https://github.com/SuperCmdLabs/SuperCmd/pulls', + host: 'github.com', + lastUsedAt: FIXED_NOW, + useCount: 40, + source: 'chrome', + sourceProfileId: 'chrome:Default', + sourceProfileName: 'Default', + bookmarkFolder: 'Development', + bookmarkOrder: -1, + }; + entries.push(nicknameBookmark); + + for (let index = 0; index < 300; index += 1) { + const topic = topics[(index * 7) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-tab${index % 120}.example.com`; + tabs.push({ + id: `t-${index}`, + browserId: source, + browserName: source, + profileId: profile.split(':')[1], + profileSourceId: profile, + profileName: profile.split(':')[1], + windowId: String(index % 8), + windowOrdinal: index % 8, + tabId: String(index), + tabIndex: index % 40, + favIconUrl: '', + title: `${topic} active tab ${index}`, + url: `https://${host}/open/${index}`, + host, + active: index % 37 === 0, + windowLastFocusedAt: FIXED_NOW - (index % 120) * 60_000, + updatedAt: FIXED_NOW - index * 60_000, + }); + } + + const nicknames = [{ + source: nicknameBookmark.source, + sourceProfileId: nicknameBookmark.sourceProfileId, + url: nicknameBookmark.url, + nickname: 'gh', + }]; + + return { entries, tabs, nicknames }; +} + +function resultSignature(results) { + return results.map((result) => [ + result.id, + result.kind, + result.title, + result.url, + result.completion || '', + result.matchKind || '', + Boolean(result.nicknameMatch), + ]); +} + +function measureAverageMs(iterations, queries, fn) { + const byQuery = {}; + for (const query of queries) { + const start = performance.now(); + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const results = fn(query); + checksum += results.length + (results[0]?.id?.length || 0); + } + byQuery[query] = { + avgMs: (performance.now() - start) / iterations, + checksum, + }; + } + return byQuery; +} + +test('browser search indexed harness preserves full-scan result order', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; + + for (const query of queries) { + assert.deepEqual( + resultSignature(getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60)), + resultSignature(getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60)), + `ranked results should match the full scan for ${query}` + ); + assert.deepEqual( + resultSignature(getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true })), + resultSignature(getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true })), + `ordered results should match the full scan for ${query}` + ); + } +}); + +test('browser search indexed harness stays under generous query thresholds', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexStart = performance.now(); + const entryIndex = buildBrowserEntryIndex(entries); + const indexMs = performance.now() - indexStart; + const rankedFullScan = measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + ); + const orderedFullScan = measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedIndexed = measureAverageMs(15, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + ); + const orderedIndexed = measureAverageMs(15, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedMaxMs = Math.max(...Object.values(rankedIndexed).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedIndexed).map((item) => item.avgMs)); + + console.log(JSON.stringify({ + browserSearchPerf: { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMs.toFixed(2)), + rankedFullScanAvgMs: Object.fromEntries(Object.entries(rankedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedFullScanAvgMs: Object.fromEntries(Object.entries(orderedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + rankedIndexedAvgMs: Object.fromEntries(Object.entries(rankedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedIndexedAvgMs: Object.fromEntries(Object.entries(orderedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + }, + })); + + assert.ok(indexMs < 2_000, `index build should stay below 2000ms, got ${indexMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < 120, `ranked indexed search should stay below 120ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < 120, `ordered indexed search should stay below 120ms, got ${orderedMaxMs.toFixed(2)}ms`); +}); diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index e7f3bf2a..617db446 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -354,6 +354,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult const getBookmarkResults = useCallback((rawInput: string, limit = MAX_SCOPED_BOOKMARK_RESULTS): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('bookmark', decorateBrowserResults(getBrowserEntryCandidates('bookmark', rawInput, entriesRef.current, { + entryIndex: index, preserveBookmarkOrder: !rawInput.trim(), limit, nicknames: nicknamesRef.current, @@ -369,6 +370,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult ): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('history', decorateBrowserResults(getBrowserEntryCandidates('history', rawInput, entriesRef.current, { + entryIndex: index, preserveHistoryChronology: true, includeHistoryTimestamp: true, showHistoryProfileContext: showProfileContext, @@ -667,41 +669,73 @@ type BrowserEntrySearchIndex = { searchFields: TokenSearchField[]; }; +type BrowserEntryKindIndex = { + tokenPrefixEntryIds: Map; + tokenTrigramEntryIds: Map; +}; + type BrowserEntryIndex = { historyByTimeEntryIds: number[]; bookmarksByBrowserOrderEntryIds: number[]; + entrySearchIndexes: Array; + entriesByKind: { + history: BrowserEntryKindIndex; + bookmark: BrowserEntryKindIndex; + }; + bookmarkEntryIdsByNicknameKey: Map; profileCountsByKind: { history: Map; bookmark: Map; }; }; +const EMPTY_BROWSER_ENTRY_KIND_INDEX: BrowserEntryKindIndex = { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), +}; + const EMPTY_BROWSER_ENTRY_INDEX: BrowserEntryIndex = { historyByTimeEntryIds: [], bookmarksByBrowserOrderEntryIds: [], + entrySearchIndexes: [], + entriesByKind: { + history: EMPTY_BROWSER_ENTRY_KIND_INDEX, + bookmark: EMPTY_BROWSER_ENTRY_KIND_INDEX, + }, + bookmarkEntryIdsByNicknameKey: new Map(), profileCountsByKind: { history: new Map(), bookmark: new Map() }, }; const BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128; const BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096; +const BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH = 2; const BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX = 2_000; const browserEntrySearchIndexCache = new Map(); function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryIndex { const historyByTimeEntryIds: number[] = []; const bookmarksByBrowserOrderEntryIds: number[] = []; + const entrySearchIndexes: Array = new Array(entries.length); + const historyIndex = createBrowserEntryKindIndex(); + const bookmarkIndex = createBrowserEntryKindIndex(); + const bookmarkEntryIdsByNicknameKey = new Map(); const historyProfileCounts = new Map(); const bookmarkProfileCounts = new Map(); entries.forEach((entry, entryId) => { if (entry.type !== 'url' && entry.type !== 'bookmark') return; + const searchIndex = createBrowserEntrySearchIndex(entry); + entrySearchIndexes[entryId] = searchIndex; if (entry.type === 'url') { historyByTimeEntryIds.push(entryId); + addBrowserEntryToKindIndex(historyIndex, entryId, searchIndex); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); historyProfileCounts.set(key, (historyProfileCounts.get(key) || 0) + 1); } } else { bookmarksByBrowserOrderEntryIds.push(entryId); + addBrowserEntryToKindIndex(bookmarkIndex, entryId, searchIndex); + addBookmarkEntryToNicknameIndex(bookmarkEntryIdsByNicknameKey, entryId, entry); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); bookmarkProfileCounts.set(key, (bookmarkProfileCounts.get(key) || 0) + 1); @@ -713,6 +747,12 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde return { historyByTimeEntryIds, bookmarksByBrowserOrderEntryIds, + entrySearchIndexes, + entriesByKind: { + history: historyIndex, + bookmark: bookmarkIndex, + }, + bookmarkEntryIdsByNicknameKey, profileCountsByKind: { history: historyProfileCounts, bookmark: bookmarkProfileCounts, @@ -720,6 +760,77 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde }; } +function createBrowserEntryKindIndex(): BrowserEntryKindIndex { + return { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), + }; +} + +function addBrowserEntryToKindIndex( + kindIndex: BrowserEntryKindIndex, + entryId: number, + searchIndex: BrowserEntrySearchIndex +): void { + const tokens = getEntryIndexTokens(searchIndex); + const prefixes = new Set(); + const trigrams = new Set(); + for (const token of tokens) { + prefixes.add(token.slice(0, BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH)); + if (token.length >= 3) { + for (let index = 0; index <= token.length - 3; index += 1) { + trigrams.add(token.slice(index, index + 3)); + } + } + } + for (const prefix of prefixes) pushEntryId(kindIndex.tokenPrefixEntryIds, prefix, entryId); + for (const trigram of trigrams) pushEntryId(kindIndex.tokenTrigramEntryIds, trigram, entryId); +} + +function getEntryIndexTokens(searchIndex: BrowserEntrySearchIndex): string[] { + const seen = new Set(); + const tokens: string[] = []; + for (const field of searchIndex.searchFields) { + const value = field.value || ''; + if (!value) continue; + for (const token of value.split(' ')) { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH || seen.has(token)) continue; + seen.add(token); + tokens.push(token); + } + } + return tokens; +} + +function pushEntryId(index: Map, key: string, entryId: number): void { + const existing = index.get(key); + if (existing) { + existing.push(entryId); + return; + } + index.set(key, [entryId]); +} + +function addBookmarkEntryToNicknameIndex( + index: Map, + entryId: number, + entry: BrowserSearchEntry +): void { + const url = normalizeNicknameUrl(entry.url); + if (!url) return; + const source = String(entry.source || ''); + const profileId = String(entry.sourceProfileId || ''); + const fullProfileId = getEntryProfileKey(entry); + pushEntryId(index, getBookmarkNicknameLookupKey(source, profileId, url), entryId); + if (fullProfileId !== profileId) { + pushEntryId(index, getBookmarkNicknameLookupKey(source, fullProfileId, url), entryId); + } +} + +function getBookmarkNicknameLookupKey(source: string, sourceProfileId: string, url: string): string { + return [source, sourceProfileId, url].join('\0'); +} + function compareHistoryEntriesByTime(a: BrowserSearchEntry, b: BrowserSearchEntry): number { const aTime = Number.isFinite(Number(a?.lastUsedAt)) ? Number(a.lastUsedAt) : 0; const bTime = Number.isFinite(Number(b?.lastUsedAt)) ? Number(b.lastUsedAt) : 0; @@ -868,14 +979,15 @@ function buildBrowserCandidates( options: { limitPerKind?: number } = {} ): Record { const openTabs = getOpenTabCandidates(input, tabs); - void entryIndex; return { 'open-tab': openTabs, bookmark: getBrowserEntryCandidates('bookmark', input, entries, { + entryIndex, nicknames, limit: options.limitPerKind, }), history: getBrowserEntryCandidates('history', input, entries, { + entryIndex, limit: options.limitPerKind, }), }; @@ -886,6 +998,7 @@ function getBrowserEntryCandidates( input: string, entries: BrowserSearchEntry[], options: { + entryIndex?: BrowserEntryIndex | null; preserveBookmarkOrder?: boolean; preserveHistoryChronology?: boolean; includeHistoryTimestamp?: boolean; @@ -912,7 +1025,6 @@ function getBrowserEntryCandidates( if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -948,11 +1060,22 @@ function getBrowserEntryCandidates( } return results; } - const candidateEntries = entries; - for (const entry of candidateEntries) { + const indexedEntryIds = getIndexedEntryCandidateIds(kind, queryTokens, options.entryIndex || null); + const nicknameEntryIds = indexedEntryIds !== null + ? getBookmarkNicknameCandidateIds(kind, trimmed, options.nicknames || [], options.entryIndex || null) + : null; + const candidateEntryIds = nicknameEntryIds + ? unionSortedEntryIds(indexedEntryIds, nicknameEntryIds) + : indexedEntryIds; + const scanEntryIds = candidateEntryIds || null; + const scanLength = scanEntryIds ? scanEntryIds.length : entries.length; + for (let scanIndex = 0; scanIndex < scanLength; scanIndex += 1) { + const entryId = scanEntryIds ? scanEntryIds[scanIndex] : scanIndex; + const entry = entries[entryId]; + if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); + const index = getBrowserEntrySearchIndexForEntry(entry, entryId, options.entryIndex || null); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -1035,6 +1158,155 @@ function getBrowserEntryCandidates( return options.limit && options.limit > 0 ? sorted.slice(0, options.limit) : sorted; } +function getIndexedEntryCandidateIds( + kind: 'bookmark' | 'history', + queryTokens: string[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (!entryIndex || queryTokens.length === 0) return null; + const kindIndex = entryIndex.entriesByKind[kind]; + if (!kindIndex) return null; + + const tokenLists: number[][] = []; + for (const token of queryTokens) { + const tokenEntryIds = getIndexedEntryIdsForToken(kindIndex, token); + if (!tokenEntryIds) return null; + if (tokenEntryIds.length === 0) return []; + tokenLists.push(tokenEntryIds); + } + + tokenLists.sort((a, b) => a.length - b.length); + let candidateIds = tokenLists[0] || []; + for (let index = 1; index < tokenLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, tokenLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getBookmarkNicknameCandidateIds( + kind: 'bookmark' | 'history', + input: string, + nicknames: BrowserSearchNicknameSetting[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (kind !== 'bookmark' || !entryIndex || nicknames.length === 0 || /\s/.test(input)) return null; + const parsed = parseNicknameQuery(input); + const normalizedToken = normalizeNicknameToken(parsed.firstToken); + if (!normalizedToken) return null; + let candidateIds: number[] = []; + for (const item of nicknames) { + const nickname = normalizeNicknameToken(item.nickname); + if (!nickname || !nickname.startsWith(normalizedToken)) continue; + const key = getBookmarkNicknameLookupKey( + String(item.source || ''), + String(item.sourceProfileId || ''), + normalizeNicknameUrl(item.url) + ); + const entryIds = entryIndex.bookmarkEntryIdsByNicknameKey.get(key); + if (entryIds?.length) candidateIds = unionSortedEntryIds(candidateIds, entryIds); + } + return candidateIds; +} + +function getIndexedEntryIdsForToken( + kindIndex: BrowserEntryKindIndex, + token: string +): number[] | null { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) return null; + if (token.length === BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) { + return kindIndex.tokenPrefixEntryIds.get(token) || []; + } + const trigrams = getTokenTrigrams(token); + if (trigrams.length === 0) return null; + const gramLists: number[][] = []; + for (const trigram of trigrams) { + const ids = kindIndex.tokenTrigramEntryIds.get(trigram) || []; + if (ids.length === 0) return []; + gramLists.push(ids); + } + gramLists.sort((a, b) => a.length - b.length); + let candidateIds = gramLists[0] || []; + for (let index = 1; index < gramLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, gramLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getTokenTrigrams(token: string): string[] { + if (token.length < 3) return []; + const seen = new Set(); + const trigrams: string[] = []; + for (let index = 0; index <= token.length - 3; index += 1) { + const trigram = token.slice(index, index + 3); + if (seen.has(trigram)) continue; + seen.add(trigram); + trigrams.push(trigram); + } + return trigrams; +} + +function intersectSortedEntryIds(a: number[], b: number[]): number[] { + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + aIndex += 1; + } else { + bIndex += 1; + } + } + return out; +} + +function unionSortedEntryIds(a: number[], b: number[]): number[] { + if (a.length === 0) return b; + if (b.length === 0) return a; + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + out.push(aValue); + aIndex += 1; + } else { + out.push(bValue); + bIndex += 1; + } + } + while (aIndex < a.length) { + out.push(a[aIndex]); + aIndex += 1; + } + while (bIndex < b.length) { + out.push(b[bIndex]); + bIndex += 1; + } + return out; +} + +function getBrowserEntrySearchIndexForEntry( + entry: BrowserSearchEntry, + entryId: number, + entryIndex: BrowserEntryIndex | null +): BrowserEntrySearchIndex { + return entryIndex?.entrySearchIndexes[entryId] || getBrowserEntrySearchIndex(entry); +} + function compareBookmarksByBrowserOrder(a: BrowserSearchResult, b: BrowserSearchResult): number { const aOrder = Number.isFinite(Number(a.bookmarkOrder)) ? Number(a.bookmarkOrder) : Number.MAX_SAFE_INTEGER; const bOrder = Number.isFinite(Number(b.bookmarkOrder)) ? Number(b.bookmarkOrder) : Number.MAX_SAFE_INTEGER; @@ -1338,6 +1610,16 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear browserEntrySearchIndexCache.set(cacheKey, cached); return cached.index; } + const index = createBrowserEntrySearchIndex(entry); + browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); + if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { + const oldestKey = browserEntrySearchIndexCache.keys().next().value; + if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); + } + return index; +} + +function createBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySearchIndex { const searchFields: TokenSearchField[] = [ { value: normalizeForTokenSearch(entry.query), weight: 1.15 }, { value: normalizeForTokenSearch(entry.url, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), weight: 1 }, @@ -1351,11 +1633,6 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear normalizedUrl: normalizeUrlForCompletion(entry.url || entry.host, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), searchFields, }; - browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); - if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { - const oldestKey = browserEntrySearchIndexCache.keys().next().value; - if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); - } return index; } @@ -1572,3 +1849,9 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + getOrderedBrowserResults, + getRankedBrowserResults, +}; From 321d1038e21ef61ba4b46374b87801912b1f1c57 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:47:11 +0200 Subject: [PATCH 10/15] fix(browser-search): narrow indexed nickname candidates --- src/renderer/src/hooks/useBrowserSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index 617db446..0ba44d51 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -1064,7 +1064,7 @@ function getBrowserEntryCandidates( const nicknameEntryIds = indexedEntryIds !== null ? getBookmarkNicknameCandidateIds(kind, trimmed, options.nicknames || [], options.entryIndex || null) : null; - const candidateEntryIds = nicknameEntryIds + const candidateEntryIds = indexedEntryIds && nicknameEntryIds ? unionSortedEntryIds(indexedEntryIds, nicknameEntryIds) : indexedEntryIds; const scanEntryIds = candidateEntryIds || null; From 644e72dbe35308b698d2f44432f8df15c5e95ae8 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:30:19 +0200 Subject: [PATCH 11/15] test: trim benchmark script eof whitespace --- scripts/bench-script-command-discovery.mjs | 1 - scripts/test-script-command-runner.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/scripts/bench-script-command-discovery.mjs b/scripts/bench-script-command-discovery.mjs index 91223f5a..275a875e 100644 --- a/scripts/bench-script-command-discovery.mjs +++ b/scripts/bench-script-command-discovery.mjs @@ -99,4 +99,3 @@ try { delete process.env.SUPERCMD_SCRIPT_COMMAND_PATHS; fs.rmSync(tempRoot, { recursive: true, force: true }); } - diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs index b805dae4..dcc3a0e4 100644 --- a/scripts/test-script-command-runner.mjs +++ b/scripts/test-script-command-runner.mjs @@ -165,4 +165,3 @@ ${body} ); }); }); - From d1589f4575429e9e578c848573fe65d47c93b015 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:48:10 +0200 Subject: [PATCH 12/15] perf(root-search): precompile command scoring fields --- scripts/test-root-search-perf.mjs | 59 +++++++- .../src/hooks/useLauncherCommandModel.ts | 30 ++-- src/renderer/src/utils/command-helpers.tsx | 106 ++++++++++---- src/renderer/src/utils/root-search-ranking.ts | 136 +++++++++++++----- 4 files changed, 259 insertions(+), 72 deletions(-) diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs index 8911fab1..22fd6798 100644 --- a/scripts/test-root-search-perf.mjs +++ b/scripts/test-root-search-perf.mjs @@ -105,7 +105,7 @@ function loadTsModule(filePath) { const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); -const { filterCommands, rankCommands } = commandHelpers; +const { createRootCommandScoreIndex, filterCommands, rankCommands, rankCommandsWithIndex } = commandHelpers; const { scoreRootSearchFields } = ranking; const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 6000); @@ -256,6 +256,15 @@ function optimizedRootCommandMatches(commands, query, aliases) { }); } +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}`) @@ -263,12 +272,19 @@ function signature(matches) { } function assertSameRootCommandMatches(commands, aliases) { + const index = createRootCommandScoreIndex(commands, aliases); for (const query of QUERIES) { + const legacySignature = signature(legacyRootCommandMatches(commands, query, aliases)); assert.deepEqual( signature(optimizedRootCommandMatches(commands, query, aliases)), - signature(legacyRootCommandMatches(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}"` + ); } } @@ -300,9 +316,29 @@ function measure(label, fn) { 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 commandScoreIndex = createRootCommandScoreIndex(commands, aliases); const legacy = measure('legacy filter + two-pass command scoring', (query) => { const filtered = filterCommands(commands, query, aliases); @@ -315,10 +351,25 @@ const optimized = measure('optimized command scoring path', (query) => { return matches.length; }); -const speedup = legacy.median > 0 ? legacy.median / optimized.median : 0; +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 indexedSpeedup = legacy.median > 0 ? legacy.median / indexed.median : 0; +const indexedVsOptimizedSpeedup = optimized.median > 0 ? optimized.median / indexed.median : 0; console.log('Root search perf harness'); console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); -console.log(`speedup=${speedup.toFixed(2)}x`); +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-speedup=${indexedSpeedup.toFixed(2)}x`); +console.log(`optimized-vs-indexed-speedup=${indexedVsOptimizedSpeedup.toFixed(2)}x`); diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 66308ca1..f8221c3d 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -9,7 +9,11 @@ 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 { + createRootCommandScoreIndex, + filterCommands, + rankCommandsWithIndex, +} from '../utils/command-helpers'; import { asTildePath, buildFileResultCommandId, @@ -359,6 +363,20 @@ export function useLauncherCommandModel({ () => new Set(['system-add-to-memory', 'system-cursor-prompt', 'system-emoji-picker']), [] ); + 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) @@ -524,12 +542,8 @@ 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) + if (!shouldIndexRootCommands) return []; + return rankCommandsWithIndex(rootCommandScoreIndex, searchQuery) .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; @@ -556,7 +570,7 @@ export function useLauncherCommandModel({ }, searchQuery, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, contextualCommands, hiddenListOnlyCommandIds, searchQuery, commandAliases, rootSearchRanking]); + }, [shouldIndexRootCommands, rootCommandScoreIndex, searchQuery, rootSearchRanking]); const fileCandidates = useMemo(() => { if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index 31a70a3d..bb13bb7d 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -28,8 +28,11 @@ import IconMagicWand from '../icons/MagicWand'; import { formatShortcutForDisplay } from './hyper-key'; import { renderQuickLinkIconGlyph } from './quicklink-icons'; import { - scoreRootSearchFields, + precompileRootSearchQuery, + precompileRootSearchScoringFields, + scorePrecompiledRootSearchFields, type MatchKind, + type PrecompiledRootSearchScoringField, type RootSearchScoringField, } from './root-search-ranking'; import { getTranslitVariant } from './transliterate'; @@ -200,15 +203,53 @@ export type RankedCommand = { matchScore: number; }; -function getRootSearchCommandScoringFields(command: CommandInfo, alias: string): RootSearchScoringField[] { +export type IndexedRankedCommand = RankedCommand & { + matchKind: MatchKind; + matchScore: number; +}; + +export type RootCommandScoreIndexEntry = { + command: CommandInfo; + rankingFields: PrecompiledRootSearchScoringField[]; + scoringFields: PrecompiledRootSearchScoringField[]; + normalizedAlias: string; +}; + +export type RootCommandScoreIndex = { + entries: RootCommandScoreIndexEntry[]; +}; + +function getRootSearchCommandRankingFields(command: CommandInfo, alias: string): RootSearchScoringField[] { return [ { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, + { value: alias, kind: 'alias', weight: 1.06 }, { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.7 })), ]; } +export function createRootCommandScoreIndex( + commands: CommandInfo[], + aliasLookup: Record = {} +): RootCommandScoreIndex { + return { + entries: commands.map((command) => { + const alias = aliasLookup[command.id] || ''; + const rankingFields = precompileRootSearchScoringFields(getRootSearchCommandRankingFields(command, alias)); + const scoringFields = rankingFields.map((field, index) => { + const scoringWeight = index === 1 ? 1.08 : index >= 3 ? 0.68 : field.weight; + return scoringWeight === field.weight ? field : { ...field, weight: scoringWeight }; + }); + return { + command, + rankingFields, + scoringFields, + normalizedAlias: normalizeSearchText(alias), + }; + }), + }; +} + function bestTermScore(term: string, candidates: SearchCandidate[]): number { let best = 0; for (const candidate of candidates) { @@ -354,14 +395,14 @@ export function filterCommands( return [...matchedTop, ...matchedRest]; } -export function rankCommands( - commands: CommandInfo[], - query: string, - aliasLookup: Record = {} -): RankedCommand[] { +type RankedCommandSortEntry = IndexedRankedCommand & { + hasExactAliasMatch: boolean; +}; + +export function rankCommandsWithIndex(index: RootCommandScoreIndex, query: string): IndexedRankedCommand[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { - return commands.map((command) => ({ + return index.entries.map(({ command }) => ({ command, score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0, matchKind: 'exact', @@ -369,27 +410,44 @@ export function rankCommands( })); } - return commands - .map((command): RankedCommand | null => { - const alias = aliasLookup[command.id] || ''; - const scored = scoreRootSearchFields(query, getRootSearchCommandScoringFields(command, alias)); - if (!scored.matched) return null; + const compiledQuery = precompileRootSearchQuery(query); + + return index.entries + .map((entry): RankedCommandSortEntry | null => { + const ranked = scorePrecompiledRootSearchFields(compiledQuery, entry.rankingFields); + if (!ranked.matched) return null; + const scored = scorePrecompiledRootSearchFields(compiledQuery, entry.scoringFields); return { - command, - score: scored.matchScore, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + command: entry.command, + score: ranked.matchScore, + matchKind: scored.matched ? scored.matchKind : ranked.matchKind, + matchScore: scored.matched ? scored.matchScore : ranked.matchScore, + hasExactAliasMatch: entry.normalizedAlias === normalizedQuery, }; }) - .filter((entry): entry is RankedCommand => entry !== null) + .filter((entry): entry is RankedCommandSortEntry => entry !== null) .sort((a, b) => { - const aAlias = normalizeSearchText(aliasLookup[a.command.id] || '') === normalizedQuery; - const bAlias = normalizeSearchText(aliasLookup[b.command.id] || '') === normalizedQuery; - if (aAlias !== bAlias) return Number(bAlias) - Number(aAlias); + 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); - }); + }) + .map(({ command, score, matchKind, matchScore }) => ({ + command, + score, + matchKind, + matchScore, + })); +} + +export function rankCommands( + commands: CommandInfo[], + query: string, + aliasLookup: Record = {} +): RankedCommand[] { + return rankCommandsWithIndex(createRootCommandScoreIndex(commands, aliasLookup), query); } /** diff --git a/src/renderer/src/utils/root-search-ranking.ts b/src/renderer/src/utils/root-search-ranking.ts index 2637783f..2d4bacfe 100644 --- a/src/renderer/src/utils/root-search-ranking.ts +++ b/src/renderer/src/utils/root-search-ranking.ts @@ -90,6 +90,28 @@ export type RootSearchScoreResult = { matchScore: number; }; +export type PrecompiledRootSearchQueryTerm = { + normalized: string; + compact: string; +}; + +export type PrecompiledRootSearchQuery = { + fullQuery: string; + terms: PrecompiledRootSearchQueryTerm[]; +}; + +export type PrecompiledRootSearchScoringField = { + raw: string; + normalized: string; + compact: string; + tokens: string[]; + boundaryInitials: string; + kind: RootSearchFieldKind; + weight: number; + isSecondaryField: boolean; + secondaryKind: MatchKind; +}; + const SEARCH_SEPARATOR_REGEX = /[^\p{L}\p{N}]+/gu; const COMBINING_MARK_REGEX = /\p{M}/gu; const DAY = 24 * 60 * 60 * 1000; @@ -142,9 +164,13 @@ export function compactRootSearchText(value: string): string { return normalizeRootSearchText(value).replace(SEARCH_SEPARATOR_REGEX, '').replace(/\s+/g, ''); } +function tokenizeNormalizedRootSearchText(normalized: string): string[] { + return normalized ? normalized.split(/\s+/).filter(Boolean) : []; +} + export function tokenizeRootSearchQuery(value: string): string[] { const normalized = normalizeRootSearchText(value); - return normalized ? normalized.split(/\s+/).filter(Boolean) : []; + return tokenizeNormalizedRootSearchText(normalized); } export function normalizeRootSearchStableValue(value: string): string { @@ -178,86 +204,117 @@ function isSubsequenceMatch(needle: string, haystack: string): boolean { return needleIndex === needle.length; } -function hasBoundaryFuzzyMatch(term: string, value: string): boolean { - if (!term || !value) return false; - const normalized = normalizeRootSearchText(value); - if (normalized.split(/\s+/).some((token) => token.startsWith(term))) return true; - const compactTerm = compactRootSearchText(term); +function getRootSearchBoundaryInitials(value: string): string { const boundaryChars = String(value || '').match(/[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+/g) || []; const camelInitials = boundaryChars.map((part) => part[0]).join(''); - return Boolean(compactTerm.length >= 2 && compactRootSearchText(camelInitials).startsWith(compactTerm)); + return compactRootSearchText(camelInitials); +} + +export function precompileRootSearchQuery(query: string): PrecompiledRootSearchQuery { + const fullQuery = normalizeRootSearchText(query); + return { + fullQuery, + terms: tokenizeNormalizedRootSearchText(fullQuery).map((term) => ({ + normalized: normalizeRootSearchText(term), + compact: compactRootSearchText(term), + })), + }; } -function scoreSingleField(term: string, fullQuery: string, field: RootSearchScoringField): RootSearchScoreResult { +function getRootSearchFieldWeight(field: RootSearchScoringField): number { + return field.weight ?? (field.kind === 'label' ? 1 : field.kind === 'alias' || field.kind === 'nickname' ? 1.04 : 0.72); +} + +function getSecondaryRootSearchMatchKind(field: RootSearchScoringField): MatchKind { + return field.kind === 'url' ? 'url' : field.kind === 'path' ? 'path' : 'description'; +} + +export function precompileRootSearchScoringField(field: RootSearchScoringField): PrecompiledRootSearchScoringField { const raw = String(field.value || ''); const normalized = normalizeRootSearchText(raw); - if (!normalized) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; - - const normalizedTerm = normalizeRootSearchText(term); - const compactField = compactRootSearchText(raw); - const compactTerm = compactRootSearchText(term); - const tokens = normalized.split(/\s+/).filter(Boolean); const isSecondaryField = field.kind === 'description' || field.kind === 'path' || field.kind === 'url'; - const secondaryKind: MatchKind = field.kind === 'url' ? 'url' : field.kind === 'path' ? 'path' : 'description'; - const weight = field.weight ?? (field.kind === 'label' ? 1 : field.kind === 'alias' || field.kind === 'nickname' ? 1.04 : 0.72); + return { + raw, + normalized, + compact: normalized ? compactRootSearchText(raw) : '', + tokens: tokenizeNormalizedRootSearchText(normalized), + boundaryInitials: raw ? getRootSearchBoundaryInitials(raw) : '', + kind: field.kind, + weight: getRootSearchFieldWeight(field), + isSecondaryField, + secondaryKind: getSecondaryRootSearchMatchKind(field), + }; +} + +export function precompileRootSearchScoringFields(fields: RootSearchScoringField[]): PrecompiledRootSearchScoringField[] { + return fields.map(precompileRootSearchScoringField); +} + +function scorePrecompiledSingleField( + term: PrecompiledRootSearchQueryTerm, + fullQuery: string, + field: PrecompiledRootSearchScoringField +): RootSearchScoreResult { + if (!field.normalized) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; let kind: MatchKind | null = null; let baseScore = 0; - if (normalized === fullQuery || normalized === normalizedTerm) { + if (field.normalized === fullQuery || field.normalized === term.normalized) { kind = field.kind === 'alias' ? 'alias-exact' : field.kind === 'nickname' ? 'nickname-exact' : 'exact'; baseScore = 1000; - } else if (normalized.startsWith(normalizedTerm)) { + } else if (field.normalized.startsWith(term.normalized)) { kind = 'prefix'; baseScore = 900; - } else if (tokens.some((token) => token.startsWith(normalizedTerm))) { + } else if (field.tokens.some((token) => token.startsWith(term.normalized))) { kind = 'token-prefix'; baseScore = 780; - } else if (compactTerm && compactField.startsWith(compactTerm)) { + } else if (term.compact && field.compact.startsWith(term.compact)) { kind = 'compact-prefix'; baseScore = 740; - } else if (hasBoundaryFuzzyMatch(normalizedTerm, raw)) { + } else if (term.compact.length >= 2 && field.boundaryInitials.startsWith(term.compact)) { kind = 'word-boundary-fuzzy'; - const compactness = Math.max(0, 1 - Math.max(0, compactField.length - compactTerm.length) / 24); + const compactness = Math.max(0, 1 - Math.max(0, field.compact.length - term.compact.length) / 24); baseScore = 620 + Math.round(compactness * 110); - } else if (normalizedTerm.length >= 2 && normalized.includes(normalizedTerm)) { + } else if (term.normalized.length >= 2 && field.normalized.includes(term.normalized)) { kind = 'contains'; baseScore = 560; - } else if (compactTerm.length >= 2 && isSubsequenceMatch(compactTerm, compactField)) { + } else if (term.compact.length >= 2 && isSubsequenceMatch(term.compact, field.compact)) { kind = 'subsequence'; - const density = compactTerm.length / Math.max(compactField.length, compactTerm.length); + const density = term.compact.length / Math.max(field.compact.length, term.compact.length); baseScore = 380 + Math.round(Math.min(140, density * 170)); } if (!kind || baseScore <= 0) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; - if (isSecondaryField && baseScore < 900) { - kind = secondaryKind; + if (field.isSecondaryField && baseScore < 900) { + kind = field.secondaryKind; baseScore = Math.min(500, Math.max(260, Math.round(baseScore * 0.72))); } const compactnessBoost = kind === 'exact' || kind === 'alias-exact' || kind === 'nickname-exact' || kind === 'prefix' - ? Math.max(0, 36 - Math.max(0, compactField.length - compactTerm.length) * 2) + ? Math.max(0, 36 - Math.max(0, field.compact.length - term.compact.length) * 2) : 0; - const weightedScore = Math.round((baseScore + compactnessBoost) * weight); + const weightedScore = Math.round((baseScore + compactnessBoost) * field.weight); return { matched: true, matchKind: kind, matchScore: weightedScore }; } -export function scoreRootSearchFields(query: string, fields: RootSearchScoringField[]): RootSearchScoreResult { - const fullQuery = normalizeRootSearchText(query); - const queryTerms = tokenizeRootSearchQuery(query); - if (!fullQuery || queryTerms.length === 0) { +export function scorePrecompiledRootSearchFields( + query: PrecompiledRootSearchQuery, + fields: PrecompiledRootSearchScoringField[] +): RootSearchScoreResult { + if (!query.fullQuery || query.terms.length === 0) { return { matched: false, matchKind: 'subsequence', matchScore: 0 }; } let total = 0; let bestKind: MatchKind = 'subsequence'; - for (const term of queryTerms) { + for (const term of query.terms) { let bestForTerm: RootSearchScoreResult | null = null; for (const field of fields) { - const scored = scoreSingleField(term, fullQuery, field); + const scored = scorePrecompiledSingleField(term, query.fullQuery, field); if (!scored.matched) continue; if (!bestForTerm || scored.matchScore > bestForTerm.matchScore) { bestForTerm = scored; @@ -275,10 +332,17 @@ export function scoreRootSearchFields(query: string, fields: RootSearchScoringFi return { matched: true, matchKind: bestKind, - matchScore: Math.round(total / queryTerms.length), + matchScore: Math.round(total / query.terms.length), }; } +export function scoreRootSearchFields(query: string, fields: RootSearchScoringField[]): RootSearchScoreResult { + return scorePrecompiledRootSearchFields( + precompileRootSearchQuery(query), + precompileRootSearchScoringFields(fields) + ); +} + function getFrecencyBoost(stableKey: string, ranking: RootSearchRankingState | undefined, now: number): number { const entry = ranking?.[stableKey]; if (!entry) return 0; From cb0f2ea2e5c52678a819870d3e7f73e00ff36e7b Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:35:02 +0200 Subject: [PATCH 13/15] perf(files): map launcher file result commands by path --- .../test-launcher-file-result-command-map.mjs | 215 ++++++++++++++++++ .../src/hooks/useLauncherCommandModel.ts | 103 ++------- .../src/utils/launcher-file-candidates.ts | 124 ++++++++++ 3 files changed, 355 insertions(+), 87 deletions(-) create mode 100644 scripts/test-launcher-file-result-command-map.mjs create mode 100644 src/renderer/src/utils/launcher-file-candidates.ts 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/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index f8221c3d..d2a4f148 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -47,7 +47,6 @@ import { rankRootSearchCandidates, scoreRootSearchCandidate, scoreRootSearchFields, - type MatchKind, type RootSearchCandidate, type RootSearchRankingState, type RootSearchSubtype, @@ -56,6 +55,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 = { @@ -142,53 +146,6 @@ 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; @@ -412,6 +369,10 @@ export function useLauncherCommandModel({ }), [launcherFileResults, launcherFileIcons, homeDir] ); + const fileResultCommandByPath = useMemo( + () => buildLauncherFileResultCommandByPath(fileResultCommands), + [fileResultCommands] + ); const pinnedFileCommands = useMemo( () => @@ -574,45 +535,13 @@ export function useLauncherCommandModel({ 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]); + return buildLauncherFileCandidates({ + launcherFileResults, + fileResultCommandByPath, + searchQuery, + rootSearchRanking, + }); + }, [hasSearchQuery, aiMode, rootBangState, launcherFileResults, fileResultCommandByPath, searchQuery, rootSearchRanking]); const browserCandidates = useMemo(() => { if (!browserSearch.enabled || !browserSearch.alphaChromiumRootSearchEnabled || !hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; diff --git a/src/renderer/src/utils/launcher-file-candidates.ts b/src/renderer/src/utils/launcher-file-candidates.ts new file mode 100644 index 00000000..c64cebe3 --- /dev/null +++ b/src/renderer/src/utils/launcher-file-candidates.ts @@ -0,0 +1,124 @@ +import type { + CommandInfo, + IndexedFileSearchResult, +} from '../../types/electron'; +import { + normalizeRootSearchStableValue, + scoreRootSearchCandidate, + scoreRootSearchFields, + type MatchKind, + type RootSearchCandidate, + type RootSearchRankingState, + type RootSearchSubtype, +} from './root-search-ranking'; + +export function coerceRootSearchMatchKind(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); +} + +export function buildLauncherFileResultCommandByPath( + fileResultCommands: readonly CommandInfo[] +): Map { + const commandByPath = new Map(); + for (const command of fileResultCommands) { + if (typeof command.path !== 'string') continue; + if (!commandByPath.has(command.path)) { + commandByPath.set(command.path, command); + } + } + return commandByPath; +} + +export function buildLauncherFileCandidates({ + launcherFileResults, + fileResultCommandByPath, + searchQuery, + rootSearchRanking, +}: { + launcherFileResults: readonly IndexedFileSearchResult[]; + fileResultCommandByPath: ReadonlyMap; + searchQuery: string; + rootSearchRanking: RootSearchRankingState; +}): RootSearchCandidate[] { + return launcherFileResults + .map((result) => { + const command = fileResultCommandByPath.get(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 = coerceRootSearchMatchKind(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)); +} From f51c3f32910929e00025cb98b892a0a392d47d70 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:16:22 +0200 Subject: [PATCH 14/15] fix(search): guard normalized-empty command queries --- scripts/test-root-search-perf.mjs | 5 ++- scripts/test-script-command-runner.mjs | 49 +++++++++++++++++++++- src/main/script-command-runner.ts | 30 +++++++++++-- src/renderer/src/utils/command-helpers.tsx | 1 + 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs index 22fd6798..786903e9 100644 --- a/scripts/test-root-search-perf.mjs +++ b/scripts/test-root-search-perf.mjs @@ -121,6 +121,8 @@ const QUERIES = [ 'timer', 'gh', 'cmd 42', + '...', + '@@', ]; const WORDS = [ @@ -266,8 +268,7 @@ function indexedRootCommandMatches(index, query) { } function signature(matches) { - return matches - .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + return Array.from(matches, (match) => `${match.id}:${match.matchKind}:${match.matchScore}`) .sort(); } diff --git a/scripts/test-script-command-runner.mjs b/scripts/test-script-command-runner.mjs index dcc3a0e4..4dcb9172 100644 --- a/scripts/test-script-command-runner.mjs +++ b/scripts/test-script-command-runner.mjs @@ -110,7 +110,7 @@ console.log('ok'); ]); }); - await t.test('executes shebang scripts without rereading the full script for interpreter lookup', async (t) => { + await t.test('executes shebang scripts with a bounded first-line interpreter lookup', async (t) => { const { module: runner, metrics, resetMetrics } = await withScriptCommandRunner(t, { 'with-shebang.sh': `#!/bin/bash ${scriptHeader({ title: 'Shebang Command' })} @@ -126,7 +126,52 @@ echo "shebang:$RAYCAST_TITLE" 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); + assert.equal(metrics.readFileSyncBytes, 0); + assert.ok( + metrics.readSyncBytes <= 8 * 1024, + `expected only a bounded first-line read, got ${metrics.readSyncBytes} bytes`, + ); + }); + + await t.test('uses updated shebangs within the discovery cache window', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'fresh-shebang.sh': `#!/bin/sh +${scriptHeader({ title: 'Fresh Shebang' })} +[[ "$RAYCAST_TITLE" == "Fresh Shebang" ]] && echo "fresh" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/sh'); + + fs.writeFileSync(path.join(scriptsDir, 'fresh-shebang.sh'), `#!/bin/bash +${scriptHeader({ title: 'Fresh Shebang' })} +[[ "$RAYCAST_TITLE" == "Fresh Shebang" ]] && echo "fresh" +`, { mode: 0o755 }); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'fresh'); + }); + + await t.test('uses the bash fallback when a cached shebang is removed', async (t) => { + const { module: runner, scriptsDir } = await withScriptCommandRunner(t, { + 'removed-shebang.sh': `#!/bin/false +${scriptHeader({ title: 'Removed Shebang' })} +echo "removed" +`, + }); + + const [command] = runner.discoverScriptCommands(); + assert.equal(command.interpreter, '/bin/false'); + + fs.writeFileSync(path.join(scriptsDir, 'removed-shebang.sh'), `${scriptHeader({ title: 'Removed Shebang' })} +echo "removed" +`, { mode: 0o755 }); + + const result = await runner.executeScriptCommand(command.id); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.trim(), 'removed'); }); await t.test('executes no-shebang scripts with the bash fallback', async (t) => { diff --git a/src/main/script-command-runner.ts b/src/main/script-command-runner.ts index cb1f5157..987cdd2d 100644 --- a/src/main/script-command-runner.ts +++ b/src/main/script-command-runner.ts @@ -352,6 +352,26 @@ function readScriptCommandHeader(filePath: string): string | null { } } +function readScriptCommandFirstLine(filePath: string): string | null { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, 'r'); + const chunk = Buffer.allocUnsafe(SCRIPT_COMMAND_HEADER_READ_CHUNK_BYTES); + const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, 0); + if (bytesRead <= 0) return ''; + + const raw = chunk.subarray(0, bytesRead).toString('utf8'); + const lineEnd = raw.search(/\r?\n/); + return lineEnd >= 0 ? raw.slice(0, lineEnd) : raw; + } 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); @@ -559,12 +579,16 @@ export async function executeScriptCommand( }; const cwd = cmd.currentDirectoryPath || cmd.scriptDir; + const firstLine = readScriptCommandFirstLine(cmd.scriptPath); + const freshShebang = firstLine !== null ? shebangArgs(firstLine) : []; + const interpreter = firstLine !== null ? freshShebang[0] : cmd.interpreter; + const interpreterArgs = firstLine !== null ? freshShebang.slice(1) : (cmd.interpreterArgs || []); const spawnCommand = - cmd.interpreter ? cmd.interpreter : '/bin/bash'; + interpreter ? interpreter : '/bin/bash'; const spawnArgs = - cmd.interpreter - ? [...(cmd.interpreterArgs || []), cmd.scriptPath, ...args] + interpreter + ? [...interpreterArgs, cmd.scriptPath, ...args] : [cmd.scriptPath, ...args]; const run = await new Promise<{ diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index bb13bb7d..5ad7c3c2 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -402,6 +402,7 @@ type RankedCommandSortEntry = IndexedRankedCommand & { export function rankCommandsWithIndex(index: RootCommandScoreIndex, query: string): IndexedRankedCommand[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { + if (query.trim()) return []; return index.entries.map(({ command }) => ({ command, score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0, From b14100e9d12b468746ac11423de1a80d8994f6cc Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:46:59 +0200 Subject: [PATCH 15/15] ci: make fork checks cross-platform --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test