diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..51f66a3d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ on: jobs: claude-review: + if: github.event.pull_request.head.repo.full_name == github.repository # Optional: Filter by PR author # if: | # github.event.pull_request.user.login == 'external-contributor' || @@ -41,4 +42,3 @@ jobs: prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecfdadf2..04e21a91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --force - name: Run node test suite run: npm test diff --git a/scripts/benchmark-file-search-delete-batch.mjs b/scripts/benchmark-file-search-delete-batch.mjs new file mode 100644 index 00000000..1b6ef709 --- /dev/null +++ b/scripts/benchmark-file-search-delete-batch.mjs @@ -0,0 +1,197 @@ +#!/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, + 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; +} + +function addEntry(api, snapshot, filePath, isDirectory = false) { + api.indexEntry(snapshot, { + path: filePath, + name: path.basename(filePath), + parentPath: path.dirname(filePath), + isDirectory, + }); +} + +function buildDirectoryScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench'; + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'deleted-root'); + const survivorRoot = path.join(homeDir, 'survivors'); + const deletePaths = [deletedRoot]; + + addEntry(api, snapshot, deletedRoot, true); + for (let group = 0; group < 40; group += 1) { + const groupDir = path.join(deletedRoot, `group-${group}`); + addEntry(api, snapshot, groupDir, true); + deletePaths.push(groupDir); + for (let item = 0; item < 100; item += 1) { + const nestedDir = path.join(groupDir, `nested-${item}`); + addEntry(api, snapshot, nestedDir, true); + addEntry(api, snapshot, path.join(nestedDir, `deleted-${group}-${item}.txt`)); + deletePaths.push(nestedDir); + } + } + + addEntry(api, snapshot, survivorRoot, true); + for (let dir = 0; dir < 120; dir += 1) { + const dirPath = path.join(survivorRoot, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + addEntry(api, snapshot, path.join(dirPath, `survivor-${dir}-${file}.txt`)); + } + } + + return { + deletePaths, + deletedRoot, + expectedDeletedCount: 1 + (40 * 100) + 40 + (40 * 100), + homeDir, + name: 'directory-tree-batch', + postDeleteQuery: 'deleted 39 99', + postDeleteQueryBucket: 'deleted', + snapshot, + survivorPath: path.join(survivorRoot, 'dir-119', 'survivor-119-499.txt'), + }; +} + +function buildDirectFileScenario(api) { + const homeDir = '/tmp/supercmd-file-index-bench-direct'; + const snapshot = api.makeSnapshot(); + const rootDir = path.join(homeDir, 'projects'); + const deletePaths = []; + + addEntry(api, snapshot, rootDir, true); + for (let dir = 0; dir < 160; dir += 1) { + const dirPath = path.join(rootDir, `dir-${dir}`); + addEntry(api, snapshot, dirPath, true); + for (let file = 0; file < 500; file += 1) { + const filePath = path.join(dirPath, `file-${dir}-${file}.txt`); + addEntry(api, snapshot, filePath); + if (deletePaths.length < 500 && file % 8 === 0) { + deletePaths.push(filePath); + } + } + } + + return { + deletePaths, + expectedDeletedCount: deletePaths.length, + homeDir, + name: 'direct-file-batch', + postDeleteQuery: path.basename(deletePaths[0] || ''), + postDeleteQueryBucket: 'file', + snapshot, + survivorPath: path.join(rootDir, 'dir-159', 'file-159-499.txt'), + }; +} + +async function measureScenario(api, scenario) { + const bucketBefore = scenario.snapshot.prefixToEntryIds.get(scenario.postDeleteQueryBucket)?.length || 0; + const started = performance.now(); + api.tombstoneDeletedPaths(scenario.snapshot, scenario.deletePaths); + const elapsedMs = performance.now() - started; + const bucketAfter = scenario.snapshot.prefixToEntryIds.get(scenario.postDeleteQueryBucket)?.length || 0; + + 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); + + api.setActiveIndex(scenario.snapshot, scenario.homeDir); + const queryStarted = performance.now(); + const postDeleteResults = await api.searchIndexedFiles(scenario.postDeleteQuery, { limit: 20 }); + const postDeleteQueryMs = performance.now() - queryStarted; + assert.equal(postDeleteResults.some((result) => result.path && result.path.includes('deleted-root')), false); + + return { + name: scenario.name, + entries: scenario.snapshot.entries.length, + deletePaths: scenario.deletePaths.length, + deletedCount, + elapsedMs: Number(elapsedMs.toFixed(3)), + postDeleteQueryMs: Number(postDeleteQueryMs.toFixed(3)), + postDeleteResultCount: postDeleteResults.length, + touchedBucketSizeBefore: bucketBefore, + touchedBucketSizeAfter: bucketAfter, + }; +} + +const api = loadFileSearchIndexInternals(); +const scenarios = [ + await measureScenario(api, buildDirectFileScenario(api)), + await measureScenario(api, buildDirectoryScenario(api)), +]; + +console.log(JSON.stringify({ + benchmark: 'file-search-delete-batch', + scenarios, +}, null, 2)); diff --git a/scripts/lib/ts-import.mjs b/scripts/lib/ts-import.mjs index 49df6079..89523936 100644 --- a/scripts/lib/ts-import.mjs +++ b/scripts/lib/ts-import.mjs @@ -1,16 +1,293 @@ -// Import a self-contained TypeScript module from a test by transpiling it with -// esbuild on the fly. This lets the recovery tests run the *real* production -// source (renderer-recovery.ts, reload-budget.ts) instead of grepping it. -// -// Only works for modules with no relative imports of their own — the recovery -// helpers are deliberately dependency-free for exactly this reason. - -import { transform } from 'esbuild'; -import fs from 'node:fs'; - -export async function importTs(absPath) { - const src = fs.readFileSync(absPath, 'utf8'); - const { code } = await transform(src, { loader: 'ts', format: 'esm' }); - const dataUrl = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64'); +// Import a TypeScript/TSX module from a test by bundling it with esbuild on the +// fly. Bundling lets tests execute selected production modules that have local +// imports instead of falling back to grep-only assertions. + +import { build } from 'esbuild'; +import path from 'node:path'; + +let importNonce = 0; + +const DEFAULT_STUB_MODULES = new Set([ + '@phosphor-icons/react', + '@raycast/api', + 'electron', + 'electron-liquid-glass', + 'lucide-react', + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-dev-runtime', + 'react/jsx-runtime', +]); + +const ASSET_RE = /\.(?:css|less|sass|scss|svg|png|jpe?g|gif|webp|ico|icns|woff2?|ttf|otf|eot)$/i; + +export async function importTs(absPath, options = {}) { + const resolvedPath = path.resolve(absPath); + const { code } = await bundleTs(resolvedPath, options); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(code).toString('base64'), + `#${encodeURIComponent(resolvedPath)}-${importNonce++}`, + ].join(''); return import(dataUrl); } + +export async function bundleTs(absPath, options = {}) { + const result = await build({ + absWorkingDir: options.root || process.cwd(), + entryPoints: [path.resolve(absPath)], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: options.target || 'node20', + sourcemap: options.sourcemap || false, + logLevel: 'silent', + banner: options.browserGlobals === false ? undefined : { js: BROWSER_GLOBALS_BANNER }, + define: { + 'process.env.NODE_ENV': '"test"', + ...(options.define || {}), + }, + loader: { + '.js': 'js', + '.jsx': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + '.json': 'json', + ...(options.loader || {}), + }, + external: options.external || [], + plugins: [ + tsImportStubPlugin(options), + ...(options.plugins || []), + ], + }); + + return { code: result.outputFiles[0].text }; +} + +function tsImportStubPlugin(options) { + const stubModules = new Set([...DEFAULT_STUB_MODULES, ...Object.keys(options.stubs || {})]); + for (const specifier of options.stubModules || []) stubModules.add(specifier); + + return { + name: 'ts-import-test-stubs', + setup(esbuild) { + esbuild.onResolve({ filter: /.*/ }, (args) => { + if (stubModules.has(args.path) || ASSET_RE.test(args.path)) { + return { path: args.path, namespace: 'ts-import-stub' }; + } + return null; + }); + + esbuild.onLoad({ filter: /.*/, namespace: 'ts-import-stub' }, (args) => ({ + contents: options.stubs?.[args.path] || defaultStubFor(args.path), + loader: 'js', + })); + }, + }; +} + +function defaultStubFor(specifier) { + if (specifier === 'electron') return ELECTRON_STUB; + if (specifier === 'react') return REACT_STUB; + if (specifier === 'react/jsx-runtime' || specifier === 'react/jsx-dev-runtime') return JSX_RUNTIME_STUB; + if (specifier === 'react-dom' || specifier === 'react-dom/client') return REACT_DOM_STUB; + if (ASSET_RE.test(specifier)) return ASSET_STUB; + return GENERIC_PROXY_STUB; +} + +const BROWSER_GLOBALS_BANNER = ` +const __scNoop = () => {}; +const __scMakeStorage = () => { + const map = new Map(); + return { + getItem: (key) => map.has(String(key)) ? map.get(String(key)) : null, + setItem: (key, value) => { map.set(String(key), String(value)); }, + removeItem: (key) => { map.delete(String(key)); }, + clear: () => { map.clear(); }, + }; +}; +var document = globalThis.document || { + addEventListener: __scNoop, + removeEventListener: __scNoop, + createElement: () => ({ style: {}, setAttribute: __scNoop, appendChild: __scNoop, remove: __scNoop }), + body: { appendChild: __scNoop, removeChild: __scNoop }, +}; +var navigator = globalThis.navigator || { platform: 'test', userAgent: 'node' }; +var localStorage = globalThis.localStorage || __scMakeStorage(); +var sessionStorage = globalThis.sessionStorage || __scMakeStorage(); +var window = globalThis.window || { + document, + navigator, + localStorage, + sessionStorage, + addEventListener: __scNoop, + removeEventListener: __scNoop, + dispatchEvent: () => true, + electron: {}, + location: { href: 'about:blank', reload: __scNoop }, +}; +var requestAnimationFrame = globalThis.requestAnimationFrame || ((callback) => setTimeout(() => callback(Date.now()), 0)); +var cancelAnimationFrame = globalThis.cancelAnimationFrame || ((id) => clearTimeout(id)); +`; + +const ELECTRON_STUB = ` +const noop = () => {}; +const asyncNoop = async () => undefined; +const home = process.env.HOME || process.cwd(); +const paths = { + appData: process.cwd(), + desktop: home + '/Desktop', + documents: home + '/Documents', + downloads: home + '/Downloads', + home, + temp: process.env.TMPDIR || process.cwd(), + userData: process.env.SUPERCMD_TEST_USER_DATA || process.cwd(), +}; +const app = { + isPackaged: false, + getAppPath: () => process.cwd(), + getName: () => 'SuperCmd Test', + getPath: (name) => paths[name] || process.cwd(), + getVersion: () => '0.0.0-test', + isReady: () => true, + whenReady: asyncNoop, + on: noop, + once: noop, + quit: noop, + relaunch: noop, + requestSingleInstanceLock: () => true, + setAsDefaultProtocolClient: () => true, +}; +class BrowserWindow { + constructor() { + this.webContents = { + executeJavaScript: asyncNoop, + on: noop, + once: noop, + send: noop, + setWindowOpenHandler: noop, + }; + } + loadFile() { return Promise.resolve(); } + loadURL() { return Promise.resolve(); } + on() {} + once() {} + show() {} + hide() {} + close() {} + destroy() {} + isDestroyed() { return false; } +} +const ipcMain = { handle: noop, on: noop, once: noop, removeAllListeners: noop, removeHandler: noop }; +const ipcRenderer = { invoke: asyncNoop, send: noop, on: noop, once: noop, removeAllListeners: noop, removeListener: noop }; +const shell = { openExternal: asyncNoop, openPath: async () => '', showItemInFolder: noop }; +const dialog = { + showErrorBox: noop, + showMessageBox: async () => ({ response: 0 }), + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showSaveDialog: async () => ({ canceled: true, filePath: '' }), +}; +const clipboard = { + readText: () => '', + writeText: noop, + readImage: () => ({ isEmpty: () => true, toDataURL: () => '' }), + writeImage: noop, +}; +const nativeTheme = { shouldUseDarkColors: false, on: noop, removeListener: noop }; +const screen = { + getAllDisplays: () => [], + getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1024, height: 768 }, workArea: { x: 0, y: 0, width: 1024, height: 768 }, scaleFactor: 1 }), +}; +const safeStorage = { + decryptString: (value) => Buffer.from(value).toString('utf8'), + encryptString: (value) => Buffer.from(String(value)), + isEncryptionAvailable: () => false, +}; +module.exports = { app, BrowserWindow, clipboard, dialog, ipcMain, ipcRenderer, nativeTheme, safeStorage, screen, shell }; +`; + +const REACT_STUB = ` +const noop = () => {}; +const createElement = (type, props, ...children) => ({ type, props: { ...(props || {}), children } }); +class Component { + constructor(props) { + this.props = props || {}; + this.state = {}; + } + setState(update) { + this.state = { ...this.state, ...(typeof update === 'function' ? update(this.state, this.props) : update) }; + } +} +const createContext = (value) => ({ + Consumer: ({ children }) => typeof children === 'function' ? children(value) : children, + Provider: ({ children }) => children, + _currentValue: value, +}); +const React = { + Component, + PureComponent: Component, + Fragment: Symbol.for('react.fragment'), + createContext, + createElement, + createRef: () => ({ current: null }), + forwardRef: (render) => render, + lazy: (loader) => loader, + memo: (component) => component, + startTransition: (callback) => callback(), + useCallback: (callback) => callback, + useContext: (context) => context?._currentValue, + useEffect: noop, + useId: () => 'test-id', + useLayoutEffect: noop, + useMemo: (factory) => factory(), + useReducer: (reducer, initialArg, init) => [init ? init(initialArg) : initialArg, noop], + useRef: (value) => ({ current: value }), + useState: (initial) => [typeof initial === 'function' ? initial() : initial, noop], +}; +module.exports = React; +module.exports.default = React; +module.exports.__esModule = true; +`; + +const JSX_RUNTIME_STUB = ` +const Fragment = Symbol.for('react.fragment'); +const jsx = (type, props, key) => ({ type, key, props: props || {} }); +module.exports = { Fragment, jsx, jsxs: jsx }; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const REACT_DOM_STUB = ` +const root = { render() {}, unmount() {} }; +module.exports = { + createPortal: (children) => children, + createRoot: () => root, + hydrateRoot: () => root, + render() {}, + unmountComponentAtNode() {}, +}; +module.exports.default = module.exports; +module.exports.__esModule = true; +`; + +const ASSET_STUB = ` +module.exports = ''; +module.exports.default = ''; +`; + +const GENERIC_PROXY_STUB = ` +const makeStub = (name = 'stub') => new Proxy(function stub() {}, { + apply: () => undefined, + construct: () => ({}), + get: (_target, prop) => { + if (prop === '__esModule') return true; + if (prop === Symbol.toStringTag) return 'Module'; + return makeStub(String(prop)); + }, +}); +module.exports = makeStub(); +module.exports.default = module.exports; +`; diff --git a/scripts/test-command-discovery-metadata-cache.mjs b/scripts/test-command-discovery-metadata-cache.mjs new file mode 100644 index 00000000..924d4afd --- /dev/null +++ b/scripts/test-command-discovery-metadata-cache.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node + +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-command-discovery-metadata-')); +const userDataRoot = path.join(tempRoot, 'user-data'); +const appsRoot = path.join(tempRoot, 'Applications'); +const extraAppsRoot = path.join(tempRoot, 'ExtraApplications'); +const extensionsRoot = path.join(tempRoot, 'ExtensionKit', 'Extensions'); +const prefPanesRoot = path.join(tempRoot, 'PreferencePanes'); +const mdfindAppPaths = []; + +process.env.SUPERCMD_TEST_USER_DATA = userDataRoot; + +const counters = { + mdfind: 0, + osascript: 0, + plutil: 0, + plistReads: 0, + sips: 0, +}; + +const systemDisplayNames = new Map(); + +function resetCounters() { + counters.mdfind = 0; + counters.osascript = 0; + counters.plutil = 0; + counters.plistReads = 0; + counters.sips = 0; +} + +function callbackAsync(callback, error, result) { + queueMicrotask(() => callback(error, result)); +} + +function extractQuotedPath(command) { + const match = command.match(/"([^"]+)"/); + return match ? match[1].replace(/\\"/g, '"') : ''; +} + +function extractSipsOutputPath(command) { + const match = command.match(/--out\s+"([^"]+)"/); + return match ? match[1].replace(/\\"/g, '"') : ''; +} + +function delayMs(ms) { + const start = performance.now(); + while (performance.now() - start < ms) {} +} + +const childProcessStub = ` + export function exec(command, callback) { + const counters = globalThis.__superCmdDiscoveryCounters; + if (command.includes('/usr/bin/mdfind')) { + counters.mdfind += 1; + globalThis.__superCmdDiscoveryDelay(0.2); + return globalThis.__superCmdDiscoveryCallback(callback, null, { + stdout: globalThis.__superCmdMdfindAppPaths.join('\\n'), + stderr: '', + }); + } + + if (command.includes('/usr/bin/plutil')) { + counters.plutil += 1; + counters.plistReads += 1; + globalThis.__superCmdDiscoveryDelay(0.35); + const filePath = globalThis.__superCmdExtractQuotedPath(command); + try { + const stdout = globalThis.__superCmdFs.readFileSync(filePath, 'utf-8'); + return globalThis.__superCmdDiscoveryCallback(callback, null, { stdout, stderr: '' }); + } catch (error) { + return globalThis.__superCmdDiscoveryCallback(callback, error); + } + } + + if (command.includes('/usr/bin/sips')) { + counters.sips += 1; + globalThis.__superCmdDiscoveryDelay(0.1); + const outputPath = globalThis.__superCmdExtractSipsOutputPath(command); + if (outputPath) { + globalThis.__superCmdFs.mkdirSync(globalThis.__superCmdPath.dirname(outputPath), { recursive: true }); + globalThis.__superCmdFs.writeFileSync(outputPath, Buffer.alloc(256, 7)); + } + return globalThis.__superCmdDiscoveryCallback(callback, null, { stdout: '', stderr: '' }); + } + + return globalThis.__superCmdDiscoveryCallback(callback, new Error('Unexpected exec: ' + command)); + } + + export function execFile(file, args, callback) { + const counters = globalThis.__superCmdDiscoveryCounters; + if (file === '/usr/bin/osascript') { + counters.osascript += 1; + globalThis.__superCmdDiscoveryDelay(0.9); + const script = args.join('\\n'); + const match = script.match(/const bundlePath = "([^"]+)"/); + const bundlePath = match ? JSON.parse('"' + match[1] + '"') : ''; + const stdout = globalThis.__superCmdSystemDisplayNames.get(bundlePath) || ''; + return globalThis.__superCmdDiscoveryCallback(callback, null, { stdout, stderr: '' }); + } + return globalThis.__superCmdDiscoveryCallback(callback, new Error('Unexpected execFile: ' + file)); + } + + export function spawn() { + return { + on() {}, + unref() {}, + }; + } +`; + +globalThis.__superCmdDiscoveryCounters = counters; +globalThis.__superCmdMdfindAppPaths = mdfindAppPaths; +globalThis.__superCmdSystemDisplayNames = systemDisplayNames; +globalThis.__superCmdDiscoveryCallback = callbackAsync; +globalThis.__superCmdExtractQuotedPath = extractQuotedPath; +globalThis.__superCmdExtractSipsOutputPath = extractSipsOutputPath; +globalThis.__superCmdDiscoveryDelay = delayMs; +globalThis.__superCmdFs = fs; +globalThis.__superCmdPath = path; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + child_process: childProcessStub, + electron: ` + export const app = { + getPath(name) { + if (name === 'temp') return ${JSON.stringify(tempRoot)}; + return ${JSON.stringify(userDataRoot)}; + }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { return []; } + export function getQuickLinkCommandId(link) { return 'quicklink-' + String(link?.id || link?.url || 'unknown'); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return globalThis.__superCmdApplicationScope || []; } + export function loadSettings() { return { appLanguage: 'en_US' }; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data)); +} + +function writeIcon(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, Buffer.alloc(256, 3)); +} + +function makeBundle(relativePath, info, resources = {}) { + const bundlePath = path.join(tempRoot, relativePath); + writeJson(path.join(bundlePath, 'Contents', 'Info.plist'), info); + for (const [resourcePath, value] of Object.entries(resources)) { + const fullPath = path.join(bundlePath, 'Contents', 'Resources', resourcePath); + if (resourcePath.endsWith('.icns')) { + writeIcon(fullPath); + } else { + writeJson(fullPath, value); + } + } + return bundlePath; +} + +function setupFixtures() { + fs.mkdirSync(appsRoot, { recursive: true }); + fs.mkdirSync(extraAppsRoot, { recursive: true }); + fs.mkdirSync(extensionsRoot, { recursive: true }); + fs.mkdirSync(prefPanesRoot, { recursive: true }); + + const alphaApp = makeBundle('Applications/Alpha.app', { + CFBundlePackageType: 'APPL', + CFBundleIdentifier: 'com.example.alpha', + CFBundleDisplayName: 'Alpha', + CFBundleIconFile: 'AppIcon', + }, { + 'AppIcon.icns': true, + 'InfoPlist.loctable': { en_US: { CFBundleDisplayName: 'Alpha Localized' } }, + }); + + const betaApp = makeBundle('Applications/Beta.app', { + CFBundlePackageType: 'APPL', + CFBundleIdentifier: 'com.example.beta', + CFBundleDisplayName: 'Beta', + CFBundleIconFile: 'AppIcon.icns', + }, { + 'AppIcon.icns': true, + 'en_US.lproj/InfoPlist.strings': { CFBundleDisplayName: 'Beta Localized' }, + }); + + const hiddenApp = makeBundle('Applications/Hidden.app', { + CFBundlePackageType: 'APPL', + CFBundleIdentifier: 'com.example.hidden', + CFBundleDisplayName: 'Hidden', + LSBackgroundOnly: true, + }, { + 'AppIcon.icns': true, + }); + + const gammaApp = makeBundle('ExtraApplications/Gamma.app', { + CFBundlePackageType: 'APPL', + CFBundleIdentifier: 'com.example.gamma', + CFBundleDisplayName: 'Gamma', + CFBundleIconFile: 'AppIcon', + }, { + 'AppIcon.icns': true, + }); + + const batteryExtension = makeBundle('ExtensionKit/Extensions/Battery.appex', { + CFBundleIdentifier: 'com.apple.Battery-Settings.extension', + CFBundleDisplayName: 'Battery', + CFBundleIconFile: 'AppIcon', + EXAppExtensionAttributes: { + EXExtensionPointIdentifier: 'com.apple.Settings.extension.ui', + SettingsExtensionAttributes: { + legacyBundleIdentifier: 'com.apple.preference.battery', + searchTermsFileName: 'BatteryTerms', + }, + }, + }, { + 'AppIcon.icns': true, + 'en_US.lproj/BatteryTerms.searchTerms': { + Power: { + localizableStrings: [ + { title: 'Low Power Mode', index: 'battery, energy, saver' }, + { title: 'Battery Health', index: 'condition, service' }, + ], + }, + }, + }); + + const fallbackPane = makeBundle('PreferencePanes/Fallback.prefPane', { + CFBundleIdentifier: 'com.example.fallback', + CFBundleDisplayName: 'Raw Fallback', + CFBundleIconFile: 'AppIcon', + }, { + 'AppIcon.icns': true, + 'InfoPlist.loctable': { en_US: { CFBundleDisplayName: 'Loctable Winner' } }, + 'InfoPlist.strings': { CFBundleDisplayName: 'Root Strings Loser' }, + 'en_US.lproj/InfoPlist.strings': { CFBundleDisplayName: 'Locale Strings Loser' }, + 'Localizable.strings': { CFBundleDisplayName: 'Localizable Loser' }, + }); + + const systemPane = makeBundle('PreferencePanes/System.prefPane', { + CFBundleIdentifier: 'com.example.system', + CFBundleDisplayName: 'System Raw', + CFBundleIconFile: 'AppIcon', + }, { + 'AppIcon.icns': true, + 'InfoPlist.loctable': { en_US: { CFBundleDisplayName: 'Local Should Not Win' } }, + }); + + systemDisplayNames.set(systemPane, 'System Winner'); + mdfindAppPaths.splice(0, mdfindAppPaths.length, alphaApp, betaApp, hiddenApp, gammaApp); + globalThis.__superCmdApplicationScope = [appsRoot]; + commandsModule.__setApplicationSearchScopeForTesting([appsRoot]); + commandsModule.__setSettingsDiscoveryRootsForTesting({ + extensionDir: extensionsRoot, + prefDirs: [prefPanesRoot], + }); + + return { + alphaApp, + betaApp, + gammaApp, + batteryExtension, + fallbackPane, + systemPane, + }; +} + +const fixtures = setupFixtures(); + +function commandFingerprint(commands) { + return commands + .map((command) => ({ + id: command.id, + title: command.title, + subtitle: command.subtitle, + keywords: command.keywords, + category: command.category, + path: command.path, + hasBundlePath: Boolean(command._bundlePath), + })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +function summarize(commands) { + return { + total: commands.length, + apps: commands.filter((command) => command.category === 'app').length, + settings: commands.filter((command) => command.category === 'settings').length, + ids: commands.map((command) => command.id).sort(), + }; +} + +async function discoverAppAndSettings() { + const apps = await commandsModule.__discoverApplicationCommandsForTesting(); + const settings = await commandsModule.__discoverSystemSettingsCommandsForTesting(); + return [...apps, ...settings]; +} + +async function runDiscoveryScenario(label) { + resetCounters(); + const startedAt = performance.now(); + const commands = await discoverAppAndSettings(); + const elapsedMs = performance.now() - startedAt; + return { + label, + elapsedMs: Number(elapsedMs.toFixed(3)), + subprocessCalls: { + mdfind: counters.mdfind, + osascript: counters.osascript, + plutil: counters.plutil, + sips: counters.sips, + total: counters.mdfind + counters.osascript + counters.plutil + counters.sips, + }, + plistReads: counters.plistReads, + commandSummary: summarize(commands), + fingerprint: commandFingerprint(commands), + }; +} + +function configureDiscovery({ metadataCacheEnabled, appScope = [appsRoot] }) { + commandsModule.__resetCommandCacheForTesting(); + commandsModule.__setCommandDiscoveryMetadataCacheEnabledForTesting(metadataCacheEnabled); + commandsModule.__setApplicationSearchScopeForTesting(appScope); + commandsModule.__setSettingsDiscoveryRootsForTesting({ + extensionDir: extensionsRoot, + prefDirs: [prefPanesRoot], + }); + globalThis.__superCmdApplicationScope = appScope; +} + +function resetIconCacheFiles() { + const iconCacheDir = path.join(userDataRoot, 'icon-cache'); + fs.rmSync(iconCacheDir, { recursive: true, force: true }); + fs.mkdirSync(iconCacheDir, { recursive: true }); +} + +async function runFreshDiscoveryScenario(label, metadataCacheEnabled, appScope = [appsRoot]) { + configureDiscovery({ metadataCacheEnabled, appScope }); + resetIconCacheFiles(); + return runDiscoveryScenario(label); +} + +test('localized display-name fallback order is stable', async () => { + resetCounters(); + const settings = await commandsModule.__discoverSystemSettingsCommandsForTesting(); + const titles = settings.map((command) => command.title).sort(); + + assert.ok(titles.includes('Battery')); + assert.ok(titles.includes('Loctable Winner')); + assert.ok(titles.includes('System Winner')); + assert.ok(!titles.includes('Root Strings Loser')); + assert.ok(!titles.includes('Local Should Not Win')); +}); + +test('settings search terms produce child commands and keywords', async () => { + resetCounters(); + const pane = { + id: 'settings-battery', + title: 'Battery', + keywords: ['battery'], + category: 'settings', + path: 'com.apple.preference.battery', + _bundlePath: fixtures.batteryExtension, + }; + const commands = await commandsModule.__discoverSettingsSearchTermCommandsForTesting( + fixtures.batteryExtension, + pane, + 'com.apple.Battery-Settings.extension', + 'com.apple.preference.battery', + 'BatteryTerms' + ); + + assert.equal(commands.length, 3); + assert.deepEqual(commands.map((command) => command.title).sort(), [ + 'Battery Health', + 'Low Power Mode', + 'Power', + ]); + assert.ok(commands.find((command) => command.title === 'Low Power Mode')?.keywords?.includes('energy')); +}); + +test('app/settings discovery metadata benchmark reports parity and subprocess counts', async () => { + const legacyCold = await runFreshDiscoveryScenario('legacy/no metadata cache cold full refresh', false); + const optimizedCold = await runFreshDiscoveryScenario('optimized cold/no disk cache', true); + const staleRefresh = await runDiscoveryScenario('optimized stale-cache background refresh'); + const quickInvalidation = await runDiscoveryScenario('optimized quick structural invalidation'); + commandsModule.__setApplicationSearchScopeForTesting([appsRoot, extraAppsRoot]); + globalThis.__superCmdApplicationScope = [appsRoot, extraAppsRoot]; + const scopeChange = await runDiscoveryScenario('optimized app-search-scope change'); + + assert.deepEqual(optimizedCold.fingerprint, legacyCold.fingerprint); + assert.deepEqual(staleRefresh.fingerprint, optimizedCold.fingerprint); + assert.deepEqual(quickInvalidation.fingerprint, optimizedCold.fingerprint); + assert.equal(optimizedCold.commandSummary.apps, 2); + assert.equal(optimizedCold.commandSummary.settings, 3); + assert.equal(optimizedCold.commandSummary.total, 5); + assert.equal(scopeChange.commandSummary.apps, 3); + assert.equal(scopeChange.fingerprint.some((command) => command.id === 'app-hidden'), false); + assert.equal(scopeChange.fingerprint.some((command) => command.id === 'app-gamma'), true); + assert.equal(optimizedCold.fingerprint.every((command) => command.hasBundlePath), true); + + const report = { + fixtureBundles: { + apps: 4, + settingsExtensions: 1, + prefPanes: 2, + }, + legacyCold, + optimizedCold, + staleRefresh, + quickInvalidation, + scopeChange, + discoveredCommandParity: JSON.stringify(legacyCold.fingerprint) === JSON.stringify(optimizedCold.fingerprint), + }; + + console.log('Command discovery metadata benchmark', JSON.stringify(report)); +}); diff --git a/scripts/test-file-search-delete-batch.mjs b/scripts/test-file-search-delete-batch.mjs new file mode 100644 index 00000000..bdac93b6 --- /dev/null +++ b/scripts/test-file-search-delete-batch.mjs @@ -0,0 +1,333 @@ +#!/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 testState = { + spotlightCalls: [], + spotlightStdout: '', + }; + 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 testProcess = Object.create(process); + Object.defineProperty(testProcess, 'platform', { value: 'darwin' }); + const localRequire = (request) => { + if (request === 'child_process') { + return { + execFile(file, args, options, callback) { + testState.spotlightCalls.push({ file, args, options }); + callback(null, { stdout: testState.spotlightStdout, stderr: '' }); + }, + }; + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + Date, + Map, + Promise, + Set, + clearInterval, + clearTimeout, + process: testProcess, + setInterval, + setTimeout, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: filePath }); + module.exports.__test.setSpotlightStdout = (stdout) => { + testState.spotlightStdout = stdout; + testState.spotlightCalls = []; + }; + module.exports.__test.getSpotlightCalls = () => [...testState.spotlightCalls]; + 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(path.resolve(filePath)); + assert.notEqual(id, undefined, `${filePath} should be indexed`); + return snapshot.entries[id]; +} + +function isDeleted(snapshot, filePath) { + return Boolean(entryForPath(snapshot, filePath).deleted); +} + +test('file delete tombstones only the exact indexed file', () => { + const snapshot = api.makeSnapshot(); + const deletedFile = path.join(homeDir, 'notes', 'alpha.txt'); + const prefixSibling = path.join(homeDir, 'notes', 'alpha.txt.backup'); + const survivor = path.join(homeDir, 'notes', 'beta.txt'); + + addEntry(snapshot, path.dirname(deletedFile), true); + addEntry(snapshot, deletedFile); + addEntry(snapshot, prefixSibling); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedFile]); + + assert.equal(isDeleted(snapshot, deletedFile), true); + assert.equal(isDeleted(snapshot, prefixSibling), false); + assert.equal(isDeleted(snapshot, survivor), false); + + api.indexEntry(snapshot, { + path: deletedFile, + name: path.basename(deletedFile), + parentPath: path.dirname(deletedFile), + isDirectory: false, + }); + assert.equal(isDeleted(snapshot, deletedFile), false); +}); + +test('batched indexed file deletes do not scan unrelated entries', () => { + const snapshot = api.makeSnapshot(); + const deletedFiles = []; + const bulkDir = path.join(homeDir, 'bulk-files'); + + addEntry(snapshot, bulkDir, true); + for (let index = 0; index < 64; index += 1) { + const filePath = path.join(bulkDir, `deleted-${index}.txt`); + deletedFiles.push(filePath); + addEntry(snapshot, filePath); + } + + snapshot.entries.push({ + get path() { + throw new Error('direct indexed file deletes should not scan unrelated entry paths'); + }, + name: 'sentinel.txt', + parentPath: bulkDir, + normalizedName: 'sentinel txt', + normalizedPath: 'sentinel', + normalizedTildePath: 'sentinel', + compactName: 'sentineltxt', + tokens: ['sentinel', 'txt'], + pathTokens: ['sentinel'], + isDirectory: false, + }); + + api.tombstoneDeletedPaths(snapshot, deletedFiles); + + for (const filePath of deletedFiles) { + assert.equal(isDeleted(snapshot, filePath), true); + } +}); + +test('directory delete tombstones the directory and all indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedDir = path.join(homeDir, 'project'); + const childDir = path.join(deletedDir, 'src'); + const childFile = path.join(childDir, 'index.ts'); + const survivor = path.join(homeDir, 'other', 'index.ts'); + + addEntry(snapshot, deletedDir, true); + addEntry(snapshot, childDir, true); + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(survivor), true); + addEntry(snapshot, survivor); + + api.tombstoneDeletedPaths(snapshot, [deletedDir]); + + assert.equal(isDeleted(snapshot, deletedDir), true); + assert.equal(isDeleted(snapshot, childDir), true); + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, survivor), false); +}); + +test('unknown deleted paths still tombstone indexed descendants', () => { + const snapshot = api.makeSnapshot(); + const deletedRoot = path.join(homeDir, 'unknown-root'); + const childFile = path.join(deletedRoot, 'child.txt'); + const siblingFile = path.join(homeDir, 'unknown-root-copy', 'child.txt'); + + addEntry(snapshot, childFile); + addEntry(snapshot, path.dirname(siblingFile), true); + addEntry(snapshot, siblingFile); + + api.tombstoneDeletedPaths(snapshot, [deletedRoot]); + + assert.equal(isDeleted(snapshot, childFile), true); + assert.equal(isDeleted(snapshot, siblingFile), false); +}); + +test('nested directory deletes collapse to the outermost deleted root', () => { + const deletedRoot = path.join(homeDir, 'nested-root'); + const nestedDir = path.join(deletedRoot, 'child'); + const nestedFile = path.join(nestedDir, 'deep.txt'); + + assert.deepEqual( + Array.from(api.collapseNestedDeletedPaths([nestedFile, nestedDir, deletedRoot])), + [deletedRoot] + ); + + const snapshot = api.makeSnapshot(); + const similarSibling = path.join(homeDir, 'nested-root-copy', 'deep.txt'); + addEntry(snapshot, deletedRoot, true); + addEntry(snapshot, nestedDir, true); + addEntry(snapshot, nestedFile); + addEntry(snapshot, path.dirname(similarSibling), true); + addEntry(snapshot, similarSibling); + + api.tombstoneDeletedPaths(snapshot, [nestedFile, nestedDir, deletedRoot]); + + assert.equal(isDeleted(snapshot, deletedRoot), true); + assert.equal(isDeleted(snapshot, nestedDir), true); + assert.equal(isDeleted(snapshot, nestedFile), true); + assert.equal(isDeleted(snapshot, similarSibling), false); +}); + +test('non-matching delete paths do not tombstone prefix-like neighbors', async () => { + const snapshot = api.makeSnapshot(); + const prefixLikeDelete = path.join(homeDir, 'docs'); + const survivorDir = path.join(homeDir, 'docs-old'); + const survivor = path.join(survivorDir, 'report.txt'); + const searchable = path.join(homeDir, 'searchable', 'needle-survivor.txt'); + + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + addEntry(snapshot, path.dirname(searchable), true); + addEntry(snapshot, searchable); + + api.tombstoneDeletedPaths(snapshot, [ + prefixLikeDelete, + path.join(homeDir, 'missing', 'child'), + ]); + + assert.equal(isDeleted(snapshot, survivor), false); + assert.equal(isDeleted(snapshot, searchable), false); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('needle-survivor', { limit: 1 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, searchable); +}); + +test('delete path matching uses the same resolved form as indexed entries', () => { + const snapshot = api.makeSnapshot(); + const rawIndexedPath = `${homeDir}/normalized/../normalized/same-name.txt`; + const resolvedPath = path.resolve(rawIndexedPath); + + addEntry(snapshot, rawIndexedPath); + api.tombstoneDeletedPaths(snapshot, [resolvedPath]); + + assert.equal(isDeleted(snapshot, resolvedPath), true); +}); + +test('deleted-name tombstones do not suppress Spotlight fallback for live same-name files', async () => { + const snapshot = api.makeSnapshot(); + const deletedPath = path.join(homeDir, 'old', 'shared-name.txt'); + const livePath = path.join(homeDir, 'new', 'shared-name.txt'); + + addEntry(snapshot, deletedPath); + api.tombstoneDeletedPaths(snapshot, [deletedPath]); + api.setActiveIndex(snapshot, homeDir); + api.setSpotlightStdout(`${deletedPath}\n${livePath}\n`); + + const results = await api.searchIndexedFiles('shared-name.txt', { limit: 3 }); + + assert.equal(api.getSpotlightCalls().length, 1); + assert.equal(results.length, 1); + assert.equal(results[0].path, livePath); +}); + +test('large tombstone batches compact deleted-heavy prefix buckets before post-delete queries', async () => { + const snapshot = api.makeSnapshot(); + const deletedFiles = []; + const deletedDir = path.join(homeDir, 'deleted-heavy'); + const survivorDir = path.join(homeDir, 'survivors'); + const survivor = path.join(survivorDir, 'launch-plan-alpha-survivor.md'); + + addEntry(snapshot, deletedDir, true); + for (let index = 0; index < 5000; index += 1) { + const filePath = path.join(deletedDir, `launch-plan-alpha-deleted-${index}.md`); + deletedFiles.push(filePath); + addEntry(snapshot, filePath); + } + addEntry(snapshot, survivorDir, true); + addEntry(snapshot, survivor); + + const bucketBefore = snapshot.prefixToEntryIds.get('launch') || []; + assert.ok(bucketBefore.length > 5000, 'fixture should create a deleted-heavy launch bucket'); + + api.tombstoneDeletedPaths(snapshot, deletedFiles); + + const bucketAfter = snapshot.prefixToEntryIds.get('launch') || []; + assert.equal(bucketAfter.length, 1); + assert.equal(snapshot.entries[bucketAfter[0]].path, survivor); + + api.setActiveIndex(snapshot, homeDir); + const results = await api.searchIndexedFiles('launch plan alpha survivor', { limit: 3 }); + assert.equal(results.length, 1); + assert.equal(results[0].path, survivor); + + const restoredFile = deletedFiles[42]; + api.indexEntry(snapshot, { + path: restoredFile, + name: path.basename(restoredFile), + parentPath: path.dirname(restoredFile), + isDirectory: false, + }); + + const restoredResults = await api.searchIndexedFiles('launch plan alpha deleted 42', { limit: 3 }); + assert.equal(restoredResults.length, 1); + assert.equal(restoredResults[0].path, restoredFile); +}); diff --git a/scripts/test-quicklink-command-refresh-cache.mjs b/scripts/test-quicklink-command-refresh-cache.mjs new file mode 100644 index 00000000..de3f1d30 --- /dev/null +++ b/scripts/test-quicklink-command-refresh-cache.mjs @@ -0,0 +1,418 @@ +#!/usr/bin/env node + +import test, { after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { importTs } from './lib/ts-import.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-quicklink-command-refresh-')); +process.env.SUPERCMD_TEST_USER_DATA = tempRoot; + +globalThis.__superCmdQuickLinks = []; +globalThis.__superCmdQuickLinkDiscoveryRuns = 0; +globalThis.__superCmdQuickLinkRefreshSettings = {}; +globalThis.__superCmdQuickLinksShouldThrow = false; + +const commandsModule = await importTs(path.join(root, 'src/main/commands.ts'), { + root, + stubs: { + electron: ` + export const app = { + getPath(name) { + if (name === 'temp') return ${JSON.stringify(tempRoot)}; + return ${JSON.stringify(tempRoot)}; + }, + quit() {}, + }; + `, + './extension-runner': ` + export function discoverInstalledExtensionCommands() { return []; } + `, + './quicklink-store': ` + export function getAllQuickLinks() { + globalThis.__superCmdQuickLinkDiscoveryRuns = (globalThis.__superCmdQuickLinkDiscoveryRuns || 0) + 1; + if (globalThis.__superCmdQuickLinksShouldThrow) { + throw new Error('quicklink discovery failed'); + } + return [...(globalThis.__superCmdQuickLinks || [])]; + } + export function getQuickLinkCommandId(quickLinkId) { return 'quicklink-' + String(quickLinkId || '').trim(); } + export function isQuickLinkCommandId(commandId) { return String(commandId || '').trim().startsWith('quicklink-'); } + `, + './script-command-runner': ` + export function discoverScriptCommands() { return []; } + `, + './settings-store': ` + export function getSearchApplicationsScope() { return []; } + export function loadSettings() { return globalThis.__superCmdQuickLinkRefreshSettings || {}; } + `, + }, +}); + +after(() => { + commandsModule.__resetCommandCacheForTesting(); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +const QUICKLINK_ICON_DATA_URL = 'data:image/png;base64,aWNvbg=='; + +function setQuickLinks(quickLinks) { + globalThis.__superCmdQuickLinks = quickLinks; + globalThis.__superCmdQuickLinkDiscoveryRuns = 0; + globalThis.__superCmdQuickLinksShouldThrow = false; +} + +function makeQuickLinks(count = 2) { + return Array.from({ length: count }, (_, index) => { + const id = index === 0 ? 'docs' : `search-${index}`; + return { + id, + name: index === 0 ? 'Docs' : `Search ${index}`, + urlTemplate: index === 0 ? 'https://docs.example.com/{query}' : `https://search${index}.example.com/?q={query}`, + applicationName: index === 0 ? 'Browser' : undefined, + applicationPath: undefined, + applicationBundleId: undefined, + appIconDataUrl: index === 0 ? QUICKLINK_ICON_DATA_URL : undefined, + icon: index === 0 ? 'default' : 'Link', + createdAt: 1_700_000_000_000 + index, + updatedAt: 1_700_000_000_000 + index, + }; + }); +} + +function makeBaseCommands(extraAppCount = 0, includeQuickLinks = true) { + const commands = [ + { + id: 'app-safari', + title: 'Safari', + keywords: ['browser'], + category: 'app', + path: '/Applications/Safari.app', + }, + { + id: 'settings-network', + title: 'Network', + keywords: ['wifi'], + category: 'settings', + path: 'com.apple.Network-Settings.extension', + }, + { + id: 'ext-existing-search', + title: 'Existing Extension Search', + subtitle: 'Existing Extension', + keywords: ['extension'], + category: 'extension', + path: 'existing/search', + mode: 'view', + deeplink: 'supercmd://extensions/existing/search', + }, + { + id: 'script-cleanup', + title: 'Cleanup', + subtitle: 'Scripts', + keywords: ['cleanup'], + category: 'script', + path: '/tmp/cleanup.sh', + mode: 'inline', + }, + { + id: 'system-search-quicklinks', + title: 'Search Quick Links', + keywords: ['quicklink'], + category: 'system', + }, + { + id: 'system-open-settings', + title: 'SuperCmd Settings', + keywords: ['settings'], + category: 'system', + }, + ]; + + if (includeQuickLinks) { + commands.splice( + 4, + 0, + { + id: 'quicklink-old-docs', + title: 'Old Docs', + subtitle: 'Old Quick Link', + keywords: ['old'], + iconName: 'Globe', + category: 'system', + }, + { + id: 'quicklink-old-search', + title: 'Old Search', + subtitle: 'Old Quick Link', + keywords: ['old'], + iconName: 'Search', + category: 'system', + } + ); + } + + for (let index = 0; index < extraAppCount; index += 1) { + commands.unshift({ + id: `app-synthetic-${index}`, + title: `Synthetic App ${index}`, + keywords: [`synthetic-${index}`], + category: 'app', + path: `/Applications/Synthetic ${index}.app`, + }); + } + + return commands; +} + +function commandIds(commands) { + return commands.map((command) => command.id); +} + +function stats(values) { + const sorted = [...values].sort((a, b) => a - b); + return { + min: Number(sorted[0].toFixed(3)), + median: Number(sorted[Math.floor(sorted.length / 2)].toFixed(3)), + max: Number(sorted[sorted.length - 1].toFixed(3)), + }; +} + +test('quicklink command refresh swaps quicklinks without structural rediscovery', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = { + commandMetadata: { + 'quicklink-docs': { subtitle: 'Live Docs' }, + }, + commandAliases: { + 'quicklink-docs': 'qd', + }, + }; + setQuickLinks(makeQuickLinks(2)); + + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForQuickLinkChange(); + + assert.equal(structuralDiscoveryRuns, 0); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 0); + assert.equal(globalThis.__superCmdQuickLinkDiscoveryRuns, 1); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-existing-search', + 'script-cleanup', + 'quicklink-docs', + 'quicklink-search-1', + 'system-search-quicklinks', + 'system-open-settings', + ]); + + const docsCommand = refreshed.find((command) => command.id === 'quicklink-docs'); + assert.equal(docsCommand?.deeplink, 'supercmd://commands/quicklink-docs'); + assert.equal(docsCommand?.subtitle, 'Live Docs'); + assert.equal(docsCommand?.iconDataUrl, QUICKLINK_ICON_DATA_URL); + assert.equal(docsCommand?.iconName, undefined); + assert.equal(docsCommand?.keywords?.includes('qd'), true); + assert.equal(docsCommand?.keywords?.includes('docs.example.com'), true); + + const searchCommand = refreshed.find((command) => command.id === 'quicklink-search-1'); + assert.equal(searchCommand?.subtitle, 'Quick Link'); + assert.equal(searchCommand?.iconName, 'Link'); + assert.equal(searchCommand?.iconDataUrl, undefined); + assert.equal(searchCommand?.deeplink, 'supercmd://commands/quicklink-search-1'); + + assert.equal(refreshed.some((command) => command.id === 'quicklink-old-docs'), false); + assert.equal(await commandsModule.getAvailableCommands(), refreshed); + + const diskCache = JSON.parse(fs.readFileSync(path.join(tempRoot, 'commands-disk-cache.json'), 'utf8')); + const diskDocsCommand = diskCache.commands.find((command) => command.id === 'quicklink-docs'); + assert.equal(diskDocsCommand.subtitle, 'Browser'); + assert.equal(diskDocsCommand.iconDataUrl, undefined); +}); + +test('quicklink command refresh keeps runtime subtitle overlays out of the cached base', async () => { + commandsModule.__resetCommandCacheForTesting(); + setQuickLinks(makeQuickLinks(1)); + + globalThis.__superCmdQuickLinkRefreshSettings = { + commandMetadata: { + 'ext-existing-search': { subtitle: 'Live Extension Subtitle' }, + }, + }; + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const overlaid = await commandsModule.refreshCommandsForQuickLinkChange(); + assert.equal( + overlaid.find((command) => command.id === 'ext-existing-search')?.subtitle, + 'Live Extension Subtitle' + ); + + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks([ + { + ...makeQuickLinks(1)[0], + name: 'Docs Updated', + updatedAt: 1_700_000_010_000, + }, + ]); + + const restored = await commandsModule.refreshCommandsForQuickLinkChange(); + const extensionCommand = restored.find((command) => command.id === 'ext-existing-search'); + assert.equal(extensionCommand?.subtitle, 'Existing Extension'); + + const diskCache = JSON.parse(fs.readFileSync(path.join(tempRoot, 'commands-disk-cache.json'), 'utf8')); + const diskExtensionCommand = diskCache.commands.find((command) => command.id === 'ext-existing-search'); + assert.equal(diskExtensionCommand.subtitle, 'Existing Extension'); +}); + +test('quicklink command refresh inserts newly created quicklinks before built-in system commands', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks(makeQuickLinks(1)); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(0, false), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForQuickLinkChange(); + + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-existing-search', + 'script-cleanup', + 'quicklink-docs', + 'system-search-quicklinks', + 'system-open-settings', + ]); +}); + +test('quicklink command refresh removes deleted quicklinks from cached command list', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks([]); + + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(), { cacheTimestamp: Date.now() }); + const refreshed = await commandsModule.refreshCommandsForQuickLinkChange(); + + assert.equal(globalThis.__superCmdQuickLinkDiscoveryRuns, 1); + assert.equal(refreshed.some((command) => command.id.startsWith('quicklink-')), false); + assert.deepEqual(commandIds(refreshed), [ + 'app-safari', + 'settings-network', + 'ext-existing-search', + 'script-cleanup', + 'system-search-quicklinks', + 'system-open-settings', + ]); +}); + +test('quicklink command refresh falls back to full discovery without a command cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + + let structuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + structuralDiscoveryRuns += 1; + return makeBaseCommands(); + }); + + const refreshed = await commandsModule.refreshCommandsForQuickLinkChange(); + + assert.equal(structuralDiscoveryRuns, 1); + assert.equal(commandsModule.__getCommandDiscoveryStartCountForTesting(), 1); + assert.equal(refreshed.some((command) => command.id === 'app-safari'), true); +}); + +test('quicklink command refresh rejects discovery errors without mutating the warm cache', async () => { + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks(makeQuickLinks(1)); + + const originalCommands = makeBaseCommands(); + commandsModule.__seedCommandCacheForTesting(originalCommands, { cacheTimestamp: Date.now() }); + globalThis.__superCmdQuickLinksShouldThrow = true; + + await assert.rejects( + commandsModule.refreshCommandsForQuickLinkChange(), + /quicklink discovery failed/ + ); + assert.equal(await commandsModule.getAvailableCommands(), originalCommands); +}); + +test('quicklink command refresh benchmark avoids full discovery starts with a warm cache', async () => { + const iterations = 40; + const baseCommandCount = 2_000; + const quickLinkCount = 80; + const simulatedStructuralDiscoveryMs = 8; + + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks(makeQuickLinks(quickLinkCount)); + + let legacyStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + legacyStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + + const legacyTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + commandsModule.invalidateCache(); + await commandsModule.refreshCommandsNow(); + legacyTimes.push(performance.now() - startedAt); + } + + commandsModule.__resetCommandCacheForTesting(); + globalThis.__superCmdQuickLinkRefreshSettings = {}; + setQuickLinks(makeQuickLinks(quickLinkCount)); + let targetedStructuralDiscoveryRuns = 0; + commandsModule.__setCommandDiscoveryRunnerForTesting(async () => { + targetedStructuralDiscoveryRuns += 1; + await delay(simulatedStructuralDiscoveryMs); + return makeBaseCommands(baseCommandCount); + }); + + const targetedTimes = []; + for (let index = 0; index < iterations; index += 1) { + commandsModule.__seedCommandCacheForTesting(makeBaseCommands(baseCommandCount), { cacheTimestamp: Date.now() }); + const startedAt = performance.now(); + await commandsModule.refreshCommandsForQuickLinkChange(); + targetedTimes.push(performance.now() - startedAt); + } + + const report = { + iterations, + baseCommandCount: makeBaseCommands(baseCommandCount).length, + quickLinkCount, + simulatedStructuralDiscoveryMs, + legacy: { + structuralDiscoveryStarts: legacyStructuralDiscoveryRuns, + refreshMs: stats(legacyTimes), + }, + targeted: { + structuralDiscoveryStarts: targetedStructuralDiscoveryRuns, + quickLinkDiscoveryStarts: globalThis.__superCmdQuickLinkDiscoveryRuns, + refreshMs: stats(targetedTimes), + }, + avoidedStructuralDiscoveryStarts: legacyStructuralDiscoveryRuns - targetedStructuralDiscoveryRuns, + }; + + console.log('Quicklink command refresh benchmark', JSON.stringify(report)); + assert.equal(legacyStructuralDiscoveryRuns, iterations); + assert.equal(targetedStructuralDiscoveryRuns, 0); + assert.equal(globalThis.__superCmdQuickLinkDiscoveryRuns, iterations); + assert.equal(report.avoidedStructuralDiscoveryStarts, iterations); +}); diff --git a/scripts/test-root-search-perf-budgets.mjs b/scripts/test-root-search-perf-budgets.mjs new file mode 100644 index 00000000..b4d69448 --- /dev/null +++ b/scripts/test-root-search-perf-budgets.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const BASE_ENV = { + ...process.env, + SUPERCMD_ROOT_SEARCH_PERF_COMMANDS: '50', + SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS: '1', + SUPERCMD_ROOT_SEARCH_PERF_WARMUPS: '0', +}; + +function runRootSearchPerf(extraEnv) { + return spawnSync(process.execPath, ['scripts/test-root-search-perf.mjs'], { + cwd: process.cwd(), + env: { ...BASE_ENV, ...extraEnv }, + encoding: 'utf8', + }); +} + +test('root search perf harness passes when configured budgets are met', () => { + const result = runRootSearchPerf({ + SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS: '100000', + SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS: '100000', + SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS: '100000', + SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN: '0', + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Root search perf harness/); + assert.match(result.stdout, /optimized command scoring path: median=/); + assert.match(result.stdout, /optimized-vs-indexed-speedup=/); + assert.doesNotMatch(result.stderr, /Root search perf budget failures/); +}); + +test('root search perf harness exits nonzero when configured budgets fail', () => { + const result = runRootSearchPerf({ + SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS: '0.001', + SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS: '0.001', + SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS: '0.001', + SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN: '999999', + }); + + assert.equal(result.status, 1, result.stderr || result.stdout); + assert.match(result.stdout, /Root search perf harness/); + assert.match(result.stdout, /optimized command scoring path: median=/); + assert.match(result.stderr, /Root search perf budget failures:/); + assert.match(result.stderr, /SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS/); + assert.match(result.stderr, /SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS/); + assert.match(result.stderr, /SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS/); + assert.match(result.stderr, /SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN/); +}); diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..05b29894 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,457 @@ +#!/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 { createRootCommandScoreIndex, filterCommands, rankCommands, rankCommandsWithIndex } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 2000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 1); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 0); + +function readNonNegativeBudget(envName) { + const rawValue = process.env[envName]; + if (rawValue === undefined || rawValue === '') return null; + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + console.error(`Root search perf budget configuration error: ${envName} must be a finite non-negative number, got "${rawValue}".`); + process.exit(1); + } + + return value; +} + +const PERF_BUDGETS = { + optimizedMedianMs: readNonNegativeBudget('SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS'), + indexedMedianMs: readNonNegativeBudget('SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS'), + compileMedianMs: readNonNegativeBudget('SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS'), + indexedSpeedupMin: readNonNegativeBudget('SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN'), +}; + +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 indexedRootCommandMatches(index, query, aliases) { + return rankCommandsWithIndex(index, query) + .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 indexed 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) { + const index = createRootCommandScoreIndex(commands, aliases); + for (const query of QUERIES) { + const legacySignature = signature(legacyRootCommandMatches(commands, query, aliases)); + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + legacySignature, + `root command matches changed for query "${query}"` + ); + assert.deepEqual( + signature(indexedRootCommandMatches(index, query, aliases)), + legacySignature, + `indexed root command matches changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +function measureSingle(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) fn(); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const start = performance.now(); + total = fn(); + samples.push(performance.now() - start); + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +function formatMs(value) { + return `${value.toFixed(2)}ms`; +} + +function formatSpeedup(value) { + return `${value.toFixed(2)}x`; +} + +function collectBudgetFailures({ optimized, indexed, compile, indexedVsOptimizedSpeedup }) { + const failures = []; + + if (PERF_BUDGETS.optimizedMedianMs !== null && optimized.median > PERF_BUDGETS.optimizedMedianMs) { + failures.push( + `${optimized.label} median ${formatMs(optimized.median)} exceeded SUPERCMD_ROOT_SEARCH_PERF_OPTIMIZED_MEDIAN_MS=${formatMs(PERF_BUDGETS.optimizedMedianMs)}` + ); + } + + if (PERF_BUDGETS.indexedMedianMs !== null && indexed.median > PERF_BUDGETS.indexedMedianMs) { + failures.push( + `${indexed.label} median ${formatMs(indexed.median)} exceeded SUPERCMD_ROOT_SEARCH_PERF_INDEXED_MEDIAN_MS=${formatMs(PERF_BUDGETS.indexedMedianMs)}` + ); + } + + if (PERF_BUDGETS.compileMedianMs !== null && compile.median > PERF_BUDGETS.compileMedianMs) { + failures.push( + `${compile.label} median ${formatMs(compile.median)} exceeded SUPERCMD_ROOT_SEARCH_PERF_COMPILE_MEDIAN_MS=${formatMs(PERF_BUDGETS.compileMedianMs)}` + ); + } + + if ( + PERF_BUDGETS.indexedSpeedupMin !== null + && indexedVsOptimizedSpeedup < PERF_BUDGETS.indexedSpeedupMin + ) { + failures.push( + `optimized-vs-indexed-speedup ${formatSpeedup(indexedVsOptimizedSpeedup)} fell below SUPERCMD_ROOT_SEARCH_PERF_INDEXED_SPEEDUP_MIN=${formatSpeedup(PERF_BUDGETS.indexedSpeedupMin)}` + ); + } + + return failures; +} + +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); + 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 indexed = measure('indexed command scoring path', (query) => { + const matches = indexedRootCommandMatches(commandScoreIndex, query, aliases); + 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(`${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`); + +const budgetFailures = collectBudgetFailures({ optimized, indexed, compile, indexedVsOptimizedSpeedup }); +if (budgetFailures.length > 0) { + console.error('Root search perf budget failures:'); + for (const failure of budgetFailures) { + console.error(`- ${failure}`); + } + process.exitCode = 1; +} diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..07607f25 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -18,7 +18,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { discoverInstalledExtensionCommands } from './extension-runner'; import { discoverScriptCommands } from './script-command-runner'; -import { getAllQuickLinks, getQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; +import { getAllQuickLinks, getQuickLinkCommandId, isQuickLinkCommandId, type QuickLink, type QuickLinkIcon } from './quicklink-store'; import { loadSettings,getSearchApplicationsScope} from './settings-store'; const execAsync = promisify(exec); @@ -112,6 +112,32 @@ let inflightDiscovery: Promise | null = null; let lastStaleRefreshRequestAt = 0; const CACHE_TTL = 30 * 60_000; // 30 min const STALE_REFRESH_COOLDOWN_MS = 15_000; +let commandDiscoveryStartCount = 0; +let commandDiscoveryRunnerForTesting: (() => Promise) | null = null; +let applicationSearchScopeForTesting: string[] | null = null; +let settingsDiscoveryRootsForTesting: { extensionDir?: string; prefDirs?: string[] } | null = null; + +type CommandRuntimeMetadata = { subtitle?: string | null | undefined }; +type CommandRuntimeMetadataStore = Record; + +const runtimeMetadataBaseSubtitleByKey = new Map(); +const runtimeMetadataCommandsByKey = new Map>(); +let commandDiscoveryMetadataCacheEnabled = true; + +type PathSignature = { + exists: boolean; + size?: number; + mtimeMs?: number; +}; + +type CachedValue = { + signature: string; + value: T; +}; + +const plistJsonByPath = new Map | null>>(); +const bundleDisplayNameBySignature = new Map>(); +const searchTermsFileBySignature = new Map>(); // ─── Commands Disk Cache ───────────────────────────────────────────────────── // Persists the discovered commands list across restarts so the launcher is @@ -151,7 +177,18 @@ function loadCommandsDiskCache(): CommandInfo[] | null { function saveCommandsDiskCache(commands: CommandInfo[]): void { try { // Strip icon data — icons are persisted separately in icon-cache/. - const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => rest); + const stripped = commands.map(({ iconDataUrl: _drop, ...rest }) => { + const commandForDisk = { ...rest }; + const baseSubtitle = getRuntimeMetadataBaseSubtitle(commandForDisk); + if (baseSubtitle.known) { + if (baseSubtitle.subtitle) { + commandForDisk.subtitle = baseSubtitle.subtitle; + } else { + delete commandForDisk.subtitle; + } + } + return commandForDisk; + }); fs.writeFileSync( getCommandsDiskCachePath(), JSON.stringify({ version: COMMANDS_DISK_CACHE_VERSION, commands: stripped }), @@ -166,6 +203,11 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void { export function initCommandsCache(): void { const cmds = loadCommandsDiskCache(); if (cmds) { + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(cmds); + try { + applyStoredRuntimeCommandMetadata(cmds, loadSettings().commandMetadata || {}); + } catch {} cachedCommands = cmds; staleCommandsFallback = cmds; cacheTimestamp = 0; // mark stale so the next getAvailableCommands() triggers a background refresh @@ -178,6 +220,162 @@ export function getInflightDiscovery(): Promise | null { return inflightDiscovery; } +function normalizeCommandMetadataKey(value: string): string { + return String(value || '').trim(); +} + +function normalizeRuntimeSubtitle(value: string | null | undefined): string { + return String(value ?? '').trim(); +} + +function isRuntimeMetadataSubtitleEligible(command: CommandInfo): boolean { + return !(command.category === 'script' && command.mode !== 'inline'); +} + +function getRuntimeMetadataKeys(command: CommandInfo): string[] { + const keys = [normalizeCommandMetadataKey(command.id)]; + if (command.category === 'extension') { + keys.push(normalizeCommandMetadataKey(command.path || '')); + } + return Array.from(new Set(keys.filter(Boolean))); +} + +function resetRuntimeMetadataTracking(): void { + runtimeMetadataBaseSubtitleByKey.clear(); + runtimeMetadataCommandsByKey.clear(); +} + +function rememberRuntimeMetadataBaseSubtitles(commands: CommandInfo[]): void { + for (const command of commands) { + if (!isRuntimeMetadataSubtitleEligible(command)) continue; + for (const key of getRuntimeMetadataKeys(command)) { + runtimeMetadataBaseSubtitleByKey.set(key, command.subtitle); + let commandsForKey = runtimeMetadataCommandsByKey.get(key); + if (!commandsForKey) { + commandsForKey = new Set(); + runtimeMetadataCommandsByKey.set(key, commandsForKey); + } + commandsForKey.add(command); + } + } +} + +function getRuntimeMetadataBaseSubtitle(command: CommandInfo): { known: boolean; subtitle?: string } { + if (!isRuntimeMetadataSubtitleEligible(command)) return { known: false }; + for (const key of getRuntimeMetadataKeys(command)) { + if (runtimeMetadataBaseSubtitleByKey.has(key)) { + return { known: true, subtitle: runtimeMetadataBaseSubtitleByKey.get(key) }; + } + } + return { known: false }; +} + +function restoreRuntimeMetadataBaseSubtitle(command: CommandInfo): void { + const baseSubtitle = getRuntimeMetadataBaseSubtitle(command); + if (!baseSubtitle.known) return; + if (baseSubtitle.subtitle) { + command.subtitle = baseSubtitle.subtitle; + } else { + delete command.subtitle; + } +} + +function getStoredRuntimeSubtitle( + command: CommandInfo, + commandMetadata: CommandRuntimeMetadataStore +): string { + if (!isRuntimeMetadataSubtitleEligible(command)) return ''; + for (const key of getRuntimeMetadataKeys(command)) { + const subtitle = normalizeRuntimeSubtitle(commandMetadata[key]?.subtitle); + if (subtitle) return subtitle; + } + return ''; +} + +function applyStoredRuntimeCommandMetadata( + commands: CommandInfo[], + commandMetadata: CommandRuntimeMetadataStore +): void { + for (const command of commands) { + const subtitle = getStoredRuntimeSubtitle(command, commandMetadata); + if (subtitle) { + command.subtitle = subtitle; + } + } +} + +function startCommandDiscovery(): Promise { + commandDiscoveryStartCount += 1; + return (commandDiscoveryRunnerForTesting || discoverAndBuildCommands)(); +} + +export function __seedCommandCacheForTesting( + commands: CommandInfo[], + options: { cacheTimestamp?: number; staleCommandsFallback?: CommandInfo[] | null } = {} +): void { + cachedCommands = commands; + staleCommandsFallback = options.staleCommandsFallback === undefined + ? commands + : options.staleCommandsFallback; + cacheTimestamp = options.cacheTimestamp ?? Date.now(); + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + if (staleCommandsFallback && staleCommandsFallback !== commands) { + rememberRuntimeMetadataBaseSubtitles(staleCommandsFallback); + } +} + +export function __resetCommandCacheForTesting(): void { + cachedCommands = null; + staleCommandsFallback = null; + cacheTimestamp = 0; + inflightDiscovery = null; + lastStaleRefreshRequestAt = 0; + commandDiscoveryStartCount = 0; + commandDiscoveryRunnerForTesting = null; + applicationSearchScopeForTesting = null; + settingsDiscoveryRootsForTesting = null; + commandDiscoveryMetadataCacheEnabled = true; + resetRuntimeMetadataTracking(); + commandsDiskCachePath = null; + clearCommandDiscoveryMetadataCaches(); +} + +export function __setCommandDiscoveryRunnerForTesting( + runner: (() => Promise) | null +): void { + commandDiscoveryRunnerForTesting = runner; +} + +export function __getCommandDiscoveryStartCountForTesting(): number { + return commandDiscoveryStartCount; +} + +export function __setApplicationSearchScopeForTesting(scope: string[] | null): void { + applicationSearchScopeForTesting = scope; +} + +export function __setSettingsDiscoveryRootsForTesting( + roots: { extensionDir?: string; prefDirs?: string[] } | null +): void { + settingsDiscoveryRootsForTesting = roots; +} + +export function __setCommandDiscoveryMetadataCacheEnabledForTesting(enabled: boolean): void { + commandDiscoveryMetadataCacheEnabled = enabled; + clearCommandDiscoveryMetadataCaches(); +} + +export async function __discoverApplicationCommandsForTesting(): Promise { + return discoverApplications(); +} + +export async function __discoverSystemSettingsCommandsForTesting(): Promise { + return discoverSystemSettings(); +} + // ─── Icon Disk Cache ──────────────────────────────────────────────── let iconCacheDir: string | null = null; @@ -248,12 +446,8 @@ async function getIconFromIcns(bundlePath: string): Promise // Try CFBundleIconFile / CFBundleIconName from Info.plist try { - const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); - if (fs.existsSync(plistPath)) { - const { stdout } = await execAsync( - `/usr/bin/plutil -convert json -o - "${plistPath}" 2>/dev/null` - ); - const info = JSON.parse(stdout); + const info = await readPlistJson(bundlePath); + if (info) { const iconFileName: string | undefined = info.CFBundleIconFile || info.CFBundleIconName; @@ -404,22 +598,66 @@ async function getIconDataUrl(bundlePath: string): Promise { // ─── Plist / Name Helpers ─────────────────────────────────────────── +function clearCommandDiscoveryMetadataCaches(): void { + plistJsonByPath.clear(); + bundleDisplayNameBySignature.clear(); + searchTermsFileBySignature.clear(); +} + +function getPathSignature(targetPath: string): PathSignature { + try { + const stat = fs.statSync(targetPath); + return { + exists: true, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; + } catch { + return { exists: false }; + } +} + +function stringifyPathSignature(signature: PathSignature): string { + return signature.exists + ? `${signature.size || 0}:${signature.mtimeMs || 0}` + : 'missing'; +} + +function getCombinedPathSignature(paths: string[]): string { + return paths + .map((targetPath) => `${targetPath}:${stringifyPathSignature(getPathSignature(targetPath))}`) + .join('|'); +} + +function getLocalizedMetadataFilePaths(resourcesDir: string, localeCandidates: string[]): string[] { + const paths = [ + path.join(resourcesDir, 'InfoPlist.loctable'), + path.join(resourcesDir, 'InfoPlist.strings'), + ]; + + for (const locale of localeCandidates) { + paths.push(path.join(resourcesDir, `${locale}.lproj`, 'InfoPlist.strings')); + } + + paths.push( + path.join(resourcesDir, 'Localizable.loctable'), + path.join(resourcesDir, 'Localizable.strings') + ); + + for (const locale of localeCandidates) { + paths.push(path.join(resourcesDir, `${locale}.lproj`, 'Localizable.strings')); + } + + return paths; +} + /** * Read a JSON-converted Info.plist and return the whole object. */ async function readPlistJson( bundlePath: string ): Promise | null> { - try { - const plistPath = path.join(bundlePath, 'Contents', 'Info.plist'); - if (!fs.existsSync(plistPath)) return null; - const { stdout } = await execAsync( - `/usr/bin/plutil -convert json -o - "${plistPath}" 2>/dev/null` - ); - return JSON.parse(stdout); - } catch { - return null; - } + return readPlistFileJson(path.join(bundlePath, 'Contents', 'Info.plist')); } /** @@ -657,6 +895,41 @@ function buildQuickLinkKeywords(quickLink: QuickLink): string[] { return Array.from(set); } +async function discoverQuickLinkCommandInfos(options: { throwOnError?: boolean } = {}): Promise { + try { + const quickLinks = getAllQuickLinks(); + return await Promise.all( + quickLinks.map(async (quickLink) => { + const resolvedIconName = resolveQuickLinkIconName(quickLink.icon); + let iconDataUrl = resolveQuickLinkIconDataUrl(quickLink, resolvedIconName); + + if (!resolvedIconName && quickLink.applicationPath) { + const resolvedAppIconDataUrl = await getIconDataUrl(quickLink.applicationPath); + if (resolvedAppIconDataUrl) { + iconDataUrl = resolvedAppIconDataUrl; + } + } + + return { + id: getQuickLinkCommandId(quickLink.id), + title: quickLink.name, + subtitle: quickLink.applicationName || 'Quick Link', + keywords: buildQuickLinkKeywords(quickLink), + iconDataUrl, + iconName: iconDataUrl ? undefined : resolvedIconName, + category: 'system' as const, + }; + }) + ); + } catch (e) { + if (options.throwOnError) { + throw e; + } + console.error('Failed to discover quick links:', e); + return []; + } +} + function getLocaleCandidates(): string[] { const set = new Set(); const preferredAppLanguage = loadSettings().appLanguage; @@ -686,36 +959,77 @@ function resolveSearchTermsFile(bundlePath: string, searchTermsFileName?: string const fileStem = String(searchTermsFileName || '').trim(); const localeCandidates = getLocaleCandidates(); + const lprojDirs = localeCandidates.map((locale) => path.join(resourcesDir, `${locale}.lproj`)); + const cacheKey = `${bundlePath}\0${fileStem}\0${localeCandidates.join('\0')}`; + const signature = getCombinedPathSignature([resourcesDir, ...lprojDirs]); + if (commandDiscoveryMetadataCacheEnabled) { + const cached = searchTermsFileBySignature.get(cacheKey); + if (cached && cached.signature === signature) { + return cached.value; + } + } + + let resolved: string | undefined; if (fileStem) { for (const locale of localeCandidates) { const candidate = path.join(resourcesDir, `${locale}.lproj`, `${fileStem}.searchTerms`); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + resolved = candidate; + break; + } } } - for (const locale of localeCandidates) { - const lprojDir = path.join(resourcesDir, `${locale}.lproj`); - if (!fs.existsSync(lprojDir)) continue; - try { - const files = fs.readdirSync(lprojDir).filter((f) => f.endsWith('.searchTerms')); - if (files.length > 0) return path.join(lprojDir, files[0]); - } catch {} + if (!resolved) { + for (const locale of localeCandidates) { + const lprojDir = path.join(resourcesDir, `${locale}.lproj`); + if (!fs.existsSync(lprojDir)) continue; + try { + const files = fs.readdirSync(lprojDir).filter((f) => f.endsWith('.searchTerms')); + if (files.length > 0) { + resolved = path.join(lprojDir, files[0]); + break; + } + } catch {} + } } - return undefined; + if (commandDiscoveryMetadataCacheEnabled) { + searchTermsFileBySignature.set(cacheKey, { signature, value: resolved }); + } + + return resolved; } async function readPlistFileJson(plistPath: string): Promise | null> { + const signature = stringifyPathSignature(getPathSignature(plistPath)); + if (commandDiscoveryMetadataCacheEnabled) { + const cached = plistJsonByPath.get(plistPath); + if (cached && cached.signature === signature) { + return cached.value; + } + } + + let result: Record | null = null; try { - if (!fs.existsSync(plistPath)) return null; + if (!fs.existsSync(plistPath)) { + if (commandDiscoveryMetadataCacheEnabled) { + plistJsonByPath.set(plistPath, { signature, value: null }); + } + return null; + } const safePath = plistPath.replace(/"/g, '\\"'); const { stdout } = await execAsync( `/usr/bin/plutil -convert json -o - "${safePath}" 2>/dev/null` ); - return JSON.parse(stdout); + result = JSON.parse(stdout); } catch { - return null; + result = null; + } + if (commandDiscoveryMetadataCacheEnabled) { + plistJsonByPath.set(plistPath, { signature, value: result }); } + return result; } async function resolveBundleDisplayNameViaSystem( @@ -724,6 +1038,20 @@ async function resolveBundleDisplayNameViaSystem( ): Promise { if (!fs.existsSync(bundlePath) || keys.length === 0) return undefined; + const resourcesDir = path.join(bundlePath, 'Contents', 'Resources'); + const localeCandidates = getLocaleCandidates(); + const cacheKey = `${bundlePath}\0${keys.join('\0')}`; + const signature = getCombinedPathSignature([ + path.join(bundlePath, 'Contents', 'Info.plist'), + ...getLocalizedMetadataFilePaths(resourcesDir, localeCandidates), + ]); + if (commandDiscoveryMetadataCacheEnabled) { + const cached = bundleDisplayNameBySignature.get(cacheKey); + if (cached && cached.signature === signature) { + return cached.value; + } + } + const script = ` ObjC.import("Foundation"); @@ -784,8 +1112,15 @@ resolved; try { const { stdout } = await execFileAsync('/usr/bin/osascript', ['-l', 'JavaScript', '-e', script]); const resolved = String(stdout || '').trim(); - return resolved || undefined; + const result = resolved || undefined; + if (commandDiscoveryMetadataCacheEnabled) { + bundleDisplayNameBySignature.set(cacheKey, { signature, value: result }); + } + return result; } catch { + if (commandDiscoveryMetadataCacheEnabled) { + bundleDisplayNameBySignature.set(cacheKey, { signature, value: undefined }); + } return undefined; } } @@ -954,6 +1289,16 @@ async function discoverSettingsSearchTermCommands( return commands; } +export async function __discoverSettingsSearchTermCommandsForTesting( + bundlePath: string, + pane: CommandInfo, + bundleId?: string, + legacyBundleId?: string, + searchTermsFileName?: string +): Promise { + return discoverSettingsSearchTermCommands(bundlePath, pane, bundleId, legacyBundleId, searchTermsFileName); +} + function buildSettingsKeywords( title: string, bundleId?: string, @@ -983,7 +1328,7 @@ function buildSettingsKeywords( async function discoverApplications(): Promise { const results: CommandInfo[] = []; const usedIds = new Set(); - const appDirs = getSearchApplicationsScope(); + const appDirs = applicationSearchScopeForTesting || getSearchApplicationsScope(); const appPathsSet = new Set(); const spotlightPaths = await discoverAppBundlesViaSpotlight(appDirs); @@ -997,7 +1342,7 @@ async function discoverApplications(): Promise { } } const finderPath = '/System/Library/CoreServices/Finder.app'; - if (fs.existsSync(finderPath)) { + if (!applicationSearchScopeForTesting && fs.existsSync(finderPath)) { appPathsSet.add(finderPath); } @@ -1076,7 +1421,7 @@ async function discoverSystemSettings(): Promise { const seen = new Set(); // ── Source 1: .appex extensions (macOS Ventura+) ── - const extDir = '/System/Library/ExtensionKit/Extensions'; + const extDir = settingsDiscoveryRootsForTesting?.extensionDir || '/System/Library/ExtensionKit/Extensions'; if (fs.existsSync(extDir)) { let files: string[]; try { @@ -1162,7 +1507,7 @@ async function discoverSystemSettings(): Promise { } // ── Source 2: .prefPane bundles ── - const prefDirs = [ + const prefDirs = settingsDiscoveryRootsForTesting?.prefDirs || [ '/System/Library/PreferencePanes', '/Library/PreferencePanes', path.join(process.env.HOME || '', 'Library', 'PreferencePanes'), @@ -1270,6 +1615,92 @@ async function openSettingsPane(identifier: string): Promise { // ─── Public API ───────────────────────────────────────────────────── +function assignUniversalDeeplinks(commands: CommandInfo[]): void { + for (const cmd of commands) { + if (!cmd.deeplink && cmd.id) { + cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; + } + } +} + +function applyRuntimeMetadataAndAliases(commands: CommandInfo[]): void { + try { + const loadedSettings = loadSettings(); + const commandMetadata = loadedSettings.commandMetadata || {}; + const commandAliases = loadedSettings.commandAliases || {}; + resetRuntimeMetadataTracking(); + rememberRuntimeMetadataBaseSubtitles(commands); + applyStoredRuntimeCommandMetadata(commands, commandMetadata); + for (const cmd of commands) { + const alias = String(commandAliases[cmd.id] || '').trim(); + if (alias) { + cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); + } + } + } catch {} +} + +function publishCommandCache(commands: CommandInfo[]): CommandInfo[] { + assignUniversalDeeplinks(commands); + applyRuntimeMetadataAndAliases(commands); + + cachedCommands = commands; + cacheTimestamp = Date.now(); + staleCommandsFallback = commands; + saveCommandsDiskCache(commands); + + return commands; +} + +function cloneCommandForTargetedRefresh(command: CommandInfo): CommandInfo { + return { + ...command, + keywords: command.keywords ? [...command.keywords] : undefined, + commandArgumentDefinitions: command.commandArgumentDefinitions + ? command.commandArgumentDefinitions.map((arg) => ({ + ...arg, + data: arg.data ? arg.data.map((item) => ({ ...item })) : arg.data, + })) + : undefined, + }; +} + +function getQuickLinkInsertionIndex(commands: CommandInfo[]): number { + const existingQuickLinkIndex = commands.findIndex((command) => isQuickLinkCommandId(command.id)); + if (existingQuickLinkIndex >= 0) return existingQuickLinkIndex; + + const systemIndex = commands.findIndex((command) => command.category === 'system'); + if (systemIndex >= 0) return systemIndex; + + return commands.length; +} + +function rebuildCommandsWithFreshQuickLinks( + baseCommands: CommandInfo[], + quickLinkCommands: CommandInfo[] +): CommandInfo[] { + const insertionIndex = getQuickLinkInsertionIndex(baseCommands); + const beforeQuickLinks: CommandInfo[] = []; + const afterQuickLinks: CommandInfo[] = []; + + baseCommands.forEach((command, index) => { + if (isQuickLinkCommandId(command.id)) return; + const cloned = cloneCommandForTargetedRefresh(command); + restoreRuntimeMetadataBaseSubtitle(cloned); + if (index < insertionIndex) { + beforeQuickLinks.push(cloned); + } else { + afterQuickLinks.push(cloned); + } + }); + + return [ + ...beforeQuickLinks, + ...quickLinkCommands.map(cloneCommandForTargetedRefresh), + ...afterQuickLinks, + ]; +} + async function discoverAndBuildCommands(): Promise { const t0 = Date.now(); console.log('Discovering applications and settings…'); @@ -1974,47 +2405,13 @@ async function discoverAndBuildCommands(): Promise { delete cmd._bundlePath; } - // Assign a universal deeplink to any launcher command that doesn't already - // have one (extensions + scripts keep their owner/slug-based schemes above). - // This lets apps, settings, system, and quick-link commands be copied and - // re-invoked via `supercmd://commands/`. - for (const cmd of allCommands) { - if (!cmd.deeplink && cmd.id) { - cmd.deeplink = `supercmd://commands/${encodeURIComponent(cmd.id)}`; - } - } - - // Runtime metadata overlays (used by updateCommandMetadata and inline scripts). - try { - const loadedSettings = loadSettings(); - const commandMetadata = loadedSettings.commandMetadata || {}; - const commandAliases = loadedSettings.commandAliases || {}; - for (const cmd of allCommands) { - if (!(cmd.category === 'script' && cmd.mode !== 'inline')) { - const subtitle = String(commandMetadata[cmd.id]?.subtitle || '').trim(); - if (subtitle) { - cmd.subtitle = subtitle; - } - } - const alias = String(commandAliases[cmd.id] || '').trim(); - if (alias) { - cmd.keywords = Array.from(new Set([...(cmd.keywords || []), alias])); - } - } - } catch {} - - cachedCommands = allCommands; - cacheTimestamp = Date.now(); - staleCommandsFallback = allCommands; + const publishedCommands = publishCommandCache(allCommands); console.log( `Discovered ${apps.length} apps, ${settings.length} settings panes, ${extensionCommands.length} extension commands, ${scriptCommands.length} script commands, ${quickLinkCommands.length} quick links in ${Date.now() - t0}ms` ); - // Persist to disk so the next startup can serve commands instantly. - saveCommandsDiskCache(allCommands); - - return cachedCommands; + return publishedCommands; } function ensureBackgroundRefreshForStaleCache(): void { @@ -2023,7 +2420,7 @@ function ensureBackgroundRefreshForStaleCache(): void { const now = Date.now(); if (now - lastStaleRefreshRequestAt < STALE_REFRESH_COOLDOWN_MS) return; lastStaleRefreshRequestAt = now; - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return cachedCommands || []; @@ -2038,12 +2435,40 @@ export async function refreshCommandsNow(): Promise { return inflightDiscovery; } - inflightDiscovery = discoverAndBuildCommands().finally(() => { + inflightDiscovery = startCommandDiscovery().finally(() => { inflightDiscovery = null; }); return inflightDiscovery; } +export async function refreshCommandsForQuickLinkChange(): Promise { + if (!cachedCommands && !staleCommandsFallback) { + return refreshCommandsNow(); + } + + if (inflightDiscovery) { + try { + await inflightDiscovery; + } catch (error) { + console.warn('[Commands] Inflight refresh failed before quick link refresh:', error); + } + } + + const baseCommands = cachedCommands || staleCommandsFallback; + if (!baseCommands) { + return refreshCommandsNow(); + } + + const t0 = Date.now(); + const quickLinkCommands = await discoverQuickLinkCommandInfos({ throwOnError: true }); + const nextCommands = rebuildCommandsWithFreshQuickLinks(baseCommands, quickLinkCommands); + const refreshed = publishCommandCache(nextCommands); + console.log( + `[Commands] Refreshed ${quickLinkCommands.length} quick link commands from cached app/settings base in ${Date.now() - t0}ms` + ); + return refreshed; +} + export async function getAvailableCommands(): Promise { const now = Date.now(); if (cachedCommands && now - cacheTimestamp < CACHE_TTL) { @@ -2062,7 +2487,7 @@ export async function getAvailableCommands(): Promise { // so the launcher never blocks on discovery after an invalidation event. if (staleCommandsFallback) { if (!inflightDiscovery) { - inflightDiscovery = discoverAndBuildCommands() + inflightDiscovery = startCommandDiscovery() .catch((error) => { console.warn('[Commands] Background refresh failed:', error); return staleCommandsFallback || []; diff --git a/src/main/file-search-index.ts b/src/main/file-search-index.ts index 9065be73..db62304d 100644 --- a/src/main/file-search-index.ts +++ b/src/main/file-search-index.ts @@ -68,6 +68,8 @@ const MAX_SPOTLIGHT_CANDIDATES = 10_000; const SPOTLIGHT_SEARCH_TIMEOUT_MS = 2_400; const INDEX_SCAN_YIELD_EVERY_DIRECTORIES = 80; const INDEX_SCAN_PAUSE_MS = 6; +const MIN_PREFIX_BUCKET_COMPACT_DELETED_IDS = 16; +const MIN_PREFIX_BUCKET_COMPACT_DELETED_RATIO = 0.08; const execFileAsync = promisify(execFile); @@ -261,22 +263,112 @@ function addPrefixIndexValue(prefixToEntryIds: Map, key: strin bucket.push(entryId); } +function ensurePrefixIndexValue(prefixToEntryIds: Map, key: string, entryId: number): void { + if (!key) return; + const bucket = prefixToEntryIds.get(key); + if (!bucket) { + prefixToEntryIds.set(key, [entryId]); + return; + } + if (!bucket.includes(entryId)) { + bucket.push(entryId); + } +} + +function getEntryPrefixIndexKeys(entry: Pick): Set { + const indexKeys = new Set(); + for (const token of entry.tokens) { + if (!token) continue; + const maxLen = Math.min(MAX_PREFIX_LENGTH, token.length); + for (let length = 1; length <= maxLen; length += 1) { + indexKeys.add(token.slice(0, length)); + } + } + for (const token of entry.pathTokens) { + if (!token) continue; + const maxLen = Math.min(MAX_PREFIX_LENGTH, token.length); + for (let length = 2; length <= maxLen; length += 1) { + indexKeys.add(token.slice(0, length)); + } + } + const compactPrefix = entry.compactName.slice(0, Math.min(MAX_PREFIX_LENGTH, entry.compactName.length)); + if (compactPrefix) indexKeys.add(compactPrefix); + return indexKeys; +} + +function getEntryPrimaryPrefixIndexKeys(entry: Pick): Set { + const indexKeys = new Set(); + const primaryToken = entry.tokens[0] || ''; + if (primaryToken) { + indexKeys.add(primaryToken.slice(0, Math.min(MAX_PREFIX_LENGTH, primaryToken.length))); + } + const compactPrefix = entry.compactName.slice(0, Math.min(MAX_PREFIX_LENGTH, entry.compactName.length)); + if (compactPrefix) indexKeys.add(compactPrefix); + return indexKeys; +} + +function compactPrefixBucketsForEntries(snapshot: IndexSnapshot, entries: IndexedEntry[]): void { + if (entries.length === 0) return; + + const deletedIdsByKey = new Map(); + for (const entry of entries) { + for (const key of getEntryPrimaryPrefixIndexKeys(entry)) { + deletedIdsByKey.set(key, (deletedIdsByKey.get(key) || 0) + 1); + } + } + + for (const [key, deletedIds] of deletedIdsByKey) { + const bucket = snapshot.prefixToEntryIds.get(key); + if (!bucket || bucket.length === 0) continue; + if ( + deletedIds < MIN_PREFIX_BUCKET_COMPACT_DELETED_IDS || + deletedIds / bucket.length < MIN_PREFIX_BUCKET_COMPACT_DELETED_RATIO + ) { + continue; + } + + let writeIndex = 0; + for (let readIndex = 0; readIndex < bucket.length; readIndex += 1) { + const entryId = bucket[readIndex]; + const entry = snapshot.entries[entryId]; + if (!entry || entry.deleted) continue; + bucket[writeIndex] = entryId; + writeIndex += 1; + } + + if (writeIndex === 0) { + snapshot.prefixToEntryIds.delete(key); + } else { + bucket.length = writeIndex; + } + } +} + function indexEntry( snapshot: IndexSnapshot, entry: Omit ): void { + const entryPath = normalizeIndexedPath(entry.path); + if (!entryPath) return; + const parentPath = normalizeIndexedPath(entry.parentPath) || path.dirname(entryPath); const normalizedName = normalizeSearchText(entry.name); if (!normalizedName) return; - const normalizedPath = normalizePathSearchText(entry.path); + const normalizedPath = normalizePathSearchText(entryPath); if (!normalizedPath) return; - const existingId = snapshot.pathToEntryId.get(entry.path); + const existingId = snapshot.pathToEntryId.get(entryPath); if (existingId !== undefined) { const existing = snapshot.entries[existingId]; if (existing) { + const wasDeleted = Boolean(existing.deleted); existing.deleted = false; existing.isDirectory = entry.isDirectory; - existing.parentPath = entry.parentPath; + existing.parentPath = parentPath; + if (wasDeleted) { + for (const key of getEntryPrefixIndexKeys(existing)) { + ensurePrefixIndexValue(snapshot.prefixToEntryIds, key, existingId); + } + } return; } } @@ -284,12 +376,14 @@ function indexEntry( if (snapshot.entries.length >= MAX_INDEX_ENTRIES) return; const tokens = tokenizeSearchText(entry.name); - const pathTokens = tokenizeSearchText(entry.path); + const pathTokens = tokenizeSearchText(entryPath); const compactName = normalizedName.replace(/\s+/g, ''); const entryId = snapshot.entries.length; const nextEntry: IndexedEntry = { ...entry, + path: entryPath, + parentPath, normalizedName, normalizedPath, compactName, @@ -297,26 +391,9 @@ function indexEntry( pathTokens, }; snapshot.entries.push(nextEntry); - snapshot.pathToEntryId.set(entry.path, entryId); - - const seenIndexKeys = new Set(); - for (const token of tokens) { - if (!token) continue; - const maxLen = Math.min(MAX_PREFIX_LENGTH, token.length); - for (let length = 1; length <= maxLen; length += 1) { - seenIndexKeys.add(token.slice(0, length)); - } - } - for (const token of pathTokens) { - if (!token) continue; - const maxLen = Math.min(MAX_PREFIX_LENGTH, token.length); - for (let length = 2; length <= maxLen; length += 1) { - seenIndexKeys.add(token.slice(0, length)); - } - } - seenIndexKeys.add(compactName.slice(0, Math.min(MAX_PREFIX_LENGTH, compactName.length))); + snapshot.pathToEntryId.set(entryPath, entryId); - for (const key of seenIndexKeys) { + for (const key of getEntryPrefixIndexKeys(nextEntry)) { addPrefixIndexValue(snapshot.prefixToEntryIds, key, entryId); } } @@ -796,28 +873,136 @@ async function applyWatchEventBatch(paths: string[]): Promise { } } -function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { - const directIds = new Set(); - for (const deletedPath of deletePaths) { - const id = snapshot.pathToEntryId.get(deletedPath); - if (id !== undefined) directIds.add(id); +function hasDeletedPathAncestor(candidatePath: string, deletedPathSet: Set): boolean { + let currentPath = path.dirname(candidatePath); + while (currentPath && currentPath !== candidatePath) { + if (deletedPathSet.has(currentPath)) return true; + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; } - const prefixes = deletePaths.map((p) => p + path.sep); + return false; +} - for (let i = 0; i < snapshot.entries.length; i += 1) { - const entry = snapshot.entries[i]; +function normalizeIndexedPath(candidatePath: string): string | null { + const rawPath = String(candidatePath || ''); + if (!rawPath) return null; + return path.resolve(rawPath); +} + +function normalizeDeletedPath(candidatePath: string): string | null { + return normalizeIndexedPath(candidatePath); +} + +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 markEntryDeleted(snapshot: IndexSnapshot, entry: IndexedEntry, deletedEntries: IndexedEntry[]): void { + if (entry.deleted) return; + entry.deleted = true; + deletedEntries.push(entry); +} + +function markDescendantEntriesDeleted( + snapshot: IndexSnapshot, + deletedRootPaths: string[], + deletedEntries: IndexedEntry[] +): void { + if (deletedRootPaths.length === 0) return; + + if (deletedRootPaths.length === 1) { + const descendantPrefix = getDescendantPathPrefix(deletedRootPaths[0]); + for (const entry of snapshot.entries) { + if (entry.deleted) continue; + if (entry.path.startsWith(descendantPrefix)) { + markEntryDeleted(snapshot, entry, deletedEntries); + } + } + return; + } + + const deletedPathSet = new Set(deletedRootPaths); + for (const entry of snapshot.entries) { if (entry.deleted) continue; - if (directIds.has(i)) { - entry.deleted = true; + if (hasDeletedPathAncestor(entry.path, deletedPathSet)) { + markEntryDeleted(snapshot, entry, deletedEntries); + } + } +} + +function tombstoneDeletedPaths(snapshot: IndexSnapshot, deletePaths: string[]): void { + const collapsedDeletePaths = collapseNestedDeletedPaths(deletePaths); + if (collapsedDeletePaths.length === 0) return; + + const deletedRootPaths: string[] = []; + const deletedEntries: IndexedEntry[] = []; + for (const deletedPath of collapsedDeletePaths) { + const id = snapshot.pathToEntryId.get(deletedPath); + if (id === undefined) { + deletedRootPaths.push(deletedPath); continue; } - for (const prefix of prefixes) { - if (entry.path.startsWith(prefix)) { - entry.deleted = true; - break; + + const entry = snapshot.entries[id]; + if (entry) { + markEntryDeleted(snapshot, entry, deletedEntries); + } + if (entry?.isDirectory) { + deletedRootPaths.push(deletedPath); + } + } + + markDescendantEntriesDeleted(snapshot, deletedRootPaths, deletedEntries); + compactPrefixBucketsForEntries(snapshot, deletedEntries); +} + +function isDeletedIndexedPathOrDescendant(snapshot: IndexSnapshot | null, candidatePath: string): boolean { + if (!snapshot) return false; + const normalizedPath = normalizeIndexedPath(candidatePath); + if (!normalizedPath) return false; + + let currentPath = normalizedPath; + while (currentPath) { + const entryId = snapshot.pathToEntryId.get(currentPath); + if (entryId !== undefined) { + const entry = snapshot.entries[entryId]; + if (entry?.deleted && (currentPath === normalizedPath || entry.isDirectory)) { + return true; } } + + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) break; + currentPath = parentPath; } + + return false; } async function walkAddedDirectory(snapshot: IndexSnapshot, dirPath: string): Promise { @@ -950,7 +1135,8 @@ export async function searchIndexedFiles( const scored: Array<{ entry: IndexedEntry; score: number }> = []; for (const entryId of candidateIds) { const entry = snapshot.entries[entryId]; - if (!entry || entry.deleted) continue; + if (!entry) continue; + if (entry.deleted) continue; const score = scoreEntryMatch(entry, normalizedQuery, terms); if (score <= 0) continue; scored.push({ entry, score }); @@ -1030,6 +1216,7 @@ export async function searchIndexedFiles( const candidatePath = path.resolve(candidateRawPath); if (existingPaths.has(candidatePath)) continue; + if (isDeletedIndexedPathOrDescendant(snapshot, candidatePath)) continue; if (shouldSkipPathForSearch(candidatePath, configuredHomeDir)) continue; const candidateName = path.basename(candidatePath); diff --git a/src/main/main.ts b/src/main/main.ts index 94985e40..bc33b820 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { fork, execFileSync, type ChildProcess } from 'child_process'; import { getNativeBinaryPath, resolvePackagedUnpackedPath } from './native-binary'; -import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow } from './commands'; +import { getAvailableCommands, executeCommand, invalidateCache, initCommandsCache, getInflightDiscovery, refreshCommandsNow, refreshCommandsForQuickLinkChange } from './commands'; import { loadSettings, saveSettings, @@ -17337,36 +17337,43 @@ if let tiff = image?.tiffRepresentation { return getQuickLinkDynamicFieldsById(id); }); - ipcMain.handle('quicklink-create', (_event: any, data: any) => { - const created = createQuickLink(data || {}); - invalidateCache(); + const refreshQuickLinkCommandsBeforeBroadcast = async (reason: string) => { + try { + await refreshCommandsForQuickLinkChange(); + } catch (error) { + console.warn(`[Commands] Quick link targeted refresh failed (${reason}); falling back to full refresh:`, error); + invalidateCache(); + await refreshCommandsNow(); + } broadcastCommandsUpdated(); + }; + + ipcMain.handle('quicklink-create', async (_event: any, data: any) => { + const created = createQuickLink(data || {}); + await refreshQuickLinkCommandsBeforeBroadcast('create'); return created; }); - ipcMain.handle('quicklink-update', (_event: any, id: string, data: any) => { + ipcMain.handle('quicklink-update', async (_event: any, id: string, data: any) => { const updated = updateQuickLink(id, data || {}); if (updated) { - invalidateCache(); - broadcastCommandsUpdated(); + await refreshQuickLinkCommandsBeforeBroadcast('update'); } return updated; }); - ipcMain.handle('quicklink-delete', (_event: any, id: string) => { + ipcMain.handle('quicklink-delete', async (_event: any, id: string) => { const removed = deleteQuickLink(id); if (removed) { - invalidateCache(); - broadcastCommandsUpdated(); + await refreshQuickLinkCommandsBeforeBroadcast('delete'); } return removed; }); - ipcMain.handle('quicklink-duplicate', (_event: any, id: string) => { + ipcMain.handle('quicklink-duplicate', async (_event: any, id: string) => { const duplicated = duplicateQuickLink(id); if (duplicated) { - invalidateCache(); - broadcastCommandsUpdated(); + await refreshQuickLinkCommandsBeforeBroadcast('duplicate'); } return duplicated; }); diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index dfa5bcbc..a1735398 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -27,7 +27,7 @@ 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 +192,48 @@ type SearchCandidate = { export type RankedCommand = { command: CommandInfo; score: number; + matchKind?: MatchKind; + matchScore?: number; }; +export type IndexedRankedCommand = RankedCommand; + +export type RootCommandScoreIndexEntry = { + command: CommandInfo; + rankingFields: RootSearchScoringField[]; + 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.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(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 = getRootSearchCommandRankingFields(command, alias); + return { + command, + rankingFields, + normalizedAlias: normalizeSearchText(alias), + }; + }), + }; +} + function bestTermScore(term: string, candidates: SearchCandidate[]): number { let best = 0; for (const candidate of candidates) { @@ -344,32 +384,47 @@ export function rankCommands( query: string, aliasLookup: Record = {} ): RankedCommand[] { + return rankCommandsWithIndex(createRootCommandScoreIndex(commands, aliasLookup), query); +} + +type RankedCommandSortEntry = IndexedRankedCommand & { + hasExactAliasMatch: boolean; +}; + +export function rankCommandsWithIndex(index: RootCommandScoreIndex, query: string): IndexedRankedCommand[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { - return commands.map((command) => ({ command, score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 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 })), - ]); - if (!scored.matched) return null; - return { command, score: scored.matchScore }; + return index.entries.map(({ command }) => ({ + command, + score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0, + matchKind: 'exact', + matchScore: 0, + })); + } + + return index.entries + .map((entry): RankedCommandSortEntry | null => { + const ranked = scoreRootSearchFields(query, entry.rankingFields); + if (!ranked.matched) return null; + return { + command: entry.command, + score: 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 }) => ({ + command, + score, + })); } /**