diff --git a/scripts/test-browser-search-lag-fix.mjs b/scripts/test-browser-search-lag-fix.mjs index bff0d368..c8e87af3 100644 --- a/scripts/test-browser-search-lag-fix.mjs +++ b/scripts/test-browser-search-lag-fix.mjs @@ -57,8 +57,12 @@ test('Browser search lag fix', async (t) => { assertIncludes(files.hook, 'refreshEntriesIfStale'); assertIncludes(files.hook, 'historyByTimeEntryIds'); assertIncludes(files.hook, 'bookmarksByBrowserOrderEntryIds'); + assertIncludes(files.hook, 'tokenPrefixEntryIds'); + assertIncludes(files.hook, 'tokenTrigramEntryIds'); + assertIncludes(files.hook, 'getIndexedEntryCandidateIds'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128'); assertIncludes(files.hook, 'BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096'); + assertIncludes(files.hook, '__browserSearchTestAccess'); }); await t.test('local commands use refreshEntriesIfStale not refreshEntries', () => { diff --git a/scripts/test-browser-search-performance.mjs b/scripts/test-browser-search-performance.mjs new file mode 100644 index 00000000..8b2a345d --- /dev/null +++ b/scripts/test-browser-search-performance.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const FIXED_NOW = Date.UTC(2026, 6, 3, 12, 0, 0); + +class FixedDate extends Date { + constructor(...args) { + super(...(args.length ? args : [FIXED_NOW])); + } + + static now() { + return FIXED_NOW; + } +} + +const moduleCache = new Map(); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + const localRequire = (request) => { + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (fs.existsSync(nextPath) && fs.statSync(nextPath).isFile()) { + return /\.[cm]?[tj]sx?$/.test(nextPath) ? loadTsModule(nextPath) : require(nextPath); + } + } + } + return require(request); + }; + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Intl, + Date: FixedDate, + Math, + String, + Number, + Boolean, + Set, + Map, + Object, + Array, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function buildSyntheticBrowserData() { + const profiles = ['chrome:Default', 'chrome:Work', 'arc:Default', 'brave:Default']; + const topics = ['github', 'docs', 'linear', 'notion', 'calendar', 'supercmd', 'typescript', 'electron', 'raycast', 'browser']; + const entries = []; + const tabs = []; + + for (let index = 0; index < 20_000; index += 1) { + const topic = topics[index % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}${index % 700}.example.com`; + entries.push({ + id: `h-${index}`, + type: 'url', + query: `${topic} history page ${index}`, + url: `https://${host}/workspace/${index % 200}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 371_000, + useCount: (index % 23) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + }); + } + + for (let index = 0; index < 4_000; index += 1) { + const topic = topics[(index * 3) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-bookmark${index % 600}.example.com`; + entries.push({ + id: `b-${index}`, + type: 'bookmark', + query: `${topic} bookmark reference ${index}`, + url: `https://${host}/saved/${index % 300}/item-${index}`, + host, + lastUsedAt: FIXED_NOW - index * 997_000, + useCount: (index % 11) + 1, + source, + sourceProfileId: profile, + sourceProfileName: profile.split(':')[1], + bookmarkFolder: `Folder ${index % 25}`, + bookmarkOrder: index, + }); + } + + const nicknameBookmark = { + id: 'b-nickname-github', + type: 'bookmark', + query: 'GitHub Pull Requests', + url: 'https://github.com/SuperCmdLabs/SuperCmd/pulls', + host: 'github.com', + lastUsedAt: FIXED_NOW, + useCount: 40, + source: 'chrome', + sourceProfileId: 'chrome:Default', + sourceProfileName: 'Default', + bookmarkFolder: 'Development', + bookmarkOrder: -1, + }; + entries.push(nicknameBookmark); + + for (let index = 0; index < 300; index += 1) { + const topic = topics[(index * 7) % topics.length]; + const profile = profiles[index % profiles.length]; + const source = profile.split(':')[0]; + const host = `${topic}-tab${index % 120}.example.com`; + tabs.push({ + id: `t-${index}`, + browserId: source, + browserName: source, + profileId: profile.split(':')[1], + profileSourceId: profile, + profileName: profile.split(':')[1], + windowId: String(index % 8), + windowOrdinal: index % 8, + tabId: String(index), + tabIndex: index % 40, + favIconUrl: '', + title: `${topic} active tab ${index}`, + url: `https://${host}/open/${index}`, + host, + active: index % 37 === 0, + windowLastFocusedAt: FIXED_NOW - (index % 120) * 60_000, + updatedAt: FIXED_NOW - index * 60_000, + }); + } + + const nicknames = [{ + source: nicknameBookmark.source, + sourceProfileId: nicknameBookmark.sourceProfileId, + url: nicknameBookmark.url, + nickname: 'gh', + }]; + + return { entries, tabs, nicknames }; +} + +function resultSignature(results) { + return results.map((result) => [ + result.id, + result.kind, + result.title, + result.url, + result.completion || '', + result.matchKind || '', + Boolean(result.nicknameMatch), + ]); +} + +function measureAverageMs(iterations, queries, fn) { + const byQuery = {}; + for (const query of queries) { + const start = performance.now(); + let checksum = 0; + for (let index = 0; index < iterations; index += 1) { + const results = fn(query); + checksum += results.length + (results[0]?.id?.length || 0); + } + byQuery[query] = { + avgMs: (performance.now() - start) / iterations, + checksum, + }; + } + return byQuery; +} + +test('browser search indexed harness preserves full-scan result order', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const entryIndex = buildBrowserEntryIndex(entries); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'hub', + 'gi', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + 'gh', + ]; + + for (const query of queries) { + assert.deepEqual( + resultSignature(getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60)), + resultSignature(getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60)), + `ranked results should match the full scan for ${query}` + ); + assert.deepEqual( + resultSignature(getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true })), + resultSignature(getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true })), + `ordered results should match the full scan for ${query}` + ); + } +}); + +test('browser search indexed harness stays under generous query thresholds', () => { + const { __browserSearchTestAccess } = loadTsModule('src/renderer/src/hooks/useBrowserSearch.ts'); + const { buildBrowserEntryIndex, getOrderedBrowserResults, getRankedBrowserResults } = __browserSearchTestAccess; + const { entries, tabs, nicknames } = buildSyntheticBrowserData(); + const groups = [ + { kind: 'bookmark', limit: 2 }, + { kind: 'open-tab', limit: 2 }, + { kind: 'history', limit: 2 }, + ]; + const queries = [ + 'github', + 'supercmd', + 'electron workspace', + 'bookmark reference 42', + 'https://typescript', + 'tab 37', + ]; + + const indexStart = performance.now(); + const entryIndex = buildBrowserEntryIndex(entries); + const indexMs = performance.now() - indexStart; + const rankedFullScan = measureAverageMs(3, queries, (query) => + getRankedBrowserResults(query, groups, entries, null, tabs, nicknames, 60) + ); + const orderedFullScan = measureAverageMs(3, queries, (query) => + getOrderedBrowserResults(query, groups, entries, null, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedIndexed = measureAverageMs(15, queries, (query) => + getRankedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, 60) + ); + const orderedIndexed = measureAverageMs(15, queries, (query) => + getOrderedBrowserResults(query, groups, entries, entryIndex, tabs, nicknames, { useConfiguredLimits: true }) + ); + const rankedMaxMs = Math.max(...Object.values(rankedIndexed).map((item) => item.avgMs)); + const orderedMaxMs = Math.max(...Object.values(orderedIndexed).map((item) => item.avgMs)); + + console.log(JSON.stringify({ + browserSearchPerf: { + dataset: { entries: entries.length, tabs: tabs.length }, + indexMs: Number(indexMs.toFixed(2)), + rankedFullScanAvgMs: Object.fromEntries(Object.entries(rankedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedFullScanAvgMs: Object.fromEntries(Object.entries(orderedFullScan).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + rankedIndexedAvgMs: Object.fromEntries(Object.entries(rankedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + orderedIndexedAvgMs: Object.fromEntries(Object.entries(orderedIndexed).map(([query, item]) => [query, Number(item.avgMs.toFixed(2))])), + }, + })); + + assert.ok(indexMs < 2_000, `index build should stay below 2000ms, got ${indexMs.toFixed(2)}ms`); + assert.ok(rankedMaxMs < 120, `ranked indexed search should stay below 120ms, got ${rankedMaxMs.toFixed(2)}ms`); + assert.ok(orderedMaxMs < 120, `ordered indexed search should stay below 120ms, got ${orderedMaxMs.toFixed(2)}ms`); +}); diff --git a/src/renderer/src/hooks/useBrowserSearch.ts b/src/renderer/src/hooks/useBrowserSearch.ts index e7f3bf2a..0ba44d51 100644 --- a/src/renderer/src/hooks/useBrowserSearch.ts +++ b/src/renderer/src/hooks/useBrowserSearch.ts @@ -354,6 +354,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult const getBookmarkResults = useCallback((rawInput: string, limit = MAX_SCOPED_BOOKMARK_RESULTS): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('bookmark', decorateBrowserResults(getBrowserEntryCandidates('bookmark', rawInput, entriesRef.current, { + entryIndex: index, preserveBookmarkOrder: !rawInput.trim(), limit, nicknames: nicknamesRef.current, @@ -369,6 +370,7 @@ export function useBrowserSearch(_currentQuery: string): UseBrowserSearchResult ): BrowserSearchResult[] => { const index = entryIndexRef.current; return filterBrowserResultsForKind('history', decorateBrowserResults(getBrowserEntryCandidates('history', rawInput, entriesRef.current, { + entryIndex: index, preserveHistoryChronology: true, includeHistoryTimestamp: true, showHistoryProfileContext: showProfileContext, @@ -667,41 +669,73 @@ type BrowserEntrySearchIndex = { searchFields: TokenSearchField[]; }; +type BrowserEntryKindIndex = { + tokenPrefixEntryIds: Map; + tokenTrigramEntryIds: Map; +}; + type BrowserEntryIndex = { historyByTimeEntryIds: number[]; bookmarksByBrowserOrderEntryIds: number[]; + entrySearchIndexes: Array; + entriesByKind: { + history: BrowserEntryKindIndex; + bookmark: BrowserEntryKindIndex; + }; + bookmarkEntryIdsByNicknameKey: Map; profileCountsByKind: { history: Map; bookmark: Map; }; }; +const EMPTY_BROWSER_ENTRY_KIND_INDEX: BrowserEntryKindIndex = { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), +}; + const EMPTY_BROWSER_ENTRY_INDEX: BrowserEntryIndex = { historyByTimeEntryIds: [], bookmarksByBrowserOrderEntryIds: [], + entrySearchIndexes: [], + entriesByKind: { + history: EMPTY_BROWSER_ENTRY_KIND_INDEX, + bookmark: EMPTY_BROWSER_ENTRY_KIND_INDEX, + }, + bookmarkEntryIdsByNicknameKey: new Map(), profileCountsByKind: { history: new Map(), bookmark: new Map() }, }; const BROWSER_ENTRY_INDEX_MAX_TOKEN_LENGTH = 128; const BROWSER_ENTRY_INDEX_MAX_URL_CHARS = 4096; +const BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH = 2; const BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX = 2_000; const browserEntrySearchIndexCache = new Map(); function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryIndex { const historyByTimeEntryIds: number[] = []; const bookmarksByBrowserOrderEntryIds: number[] = []; + const entrySearchIndexes: Array = new Array(entries.length); + const historyIndex = createBrowserEntryKindIndex(); + const bookmarkIndex = createBrowserEntryKindIndex(); + const bookmarkEntryIdsByNicknameKey = new Map(); const historyProfileCounts = new Map(); const bookmarkProfileCounts = new Map(); entries.forEach((entry, entryId) => { if (entry.type !== 'url' && entry.type !== 'bookmark') return; + const searchIndex = createBrowserEntrySearchIndex(entry); + entrySearchIndexes[entryId] = searchIndex; if (entry.type === 'url') { historyByTimeEntryIds.push(entryId); + addBrowserEntryToKindIndex(historyIndex, entryId, searchIndex); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); historyProfileCounts.set(key, (historyProfileCounts.get(key) || 0) + 1); } } else { bookmarksByBrowserOrderEntryIds.push(entryId); + addBrowserEntryToKindIndex(bookmarkIndex, entryId, searchIndex); + addBookmarkEntryToNicknameIndex(bookmarkEntryIdsByNicknameKey, entryId, entry); if (entry.sourceProfileId) { const key = getEntryProfileKey(entry); bookmarkProfileCounts.set(key, (bookmarkProfileCounts.get(key) || 0) + 1); @@ -713,6 +747,12 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde return { historyByTimeEntryIds, bookmarksByBrowserOrderEntryIds, + entrySearchIndexes, + entriesByKind: { + history: historyIndex, + bookmark: bookmarkIndex, + }, + bookmarkEntryIdsByNicknameKey, profileCountsByKind: { history: historyProfileCounts, bookmark: bookmarkProfileCounts, @@ -720,6 +760,77 @@ function buildBrowserEntryIndex(entries: BrowserSearchEntry[]): BrowserEntryInde }; } +function createBrowserEntryKindIndex(): BrowserEntryKindIndex { + return { + tokenPrefixEntryIds: new Map(), + tokenTrigramEntryIds: new Map(), + }; +} + +function addBrowserEntryToKindIndex( + kindIndex: BrowserEntryKindIndex, + entryId: number, + searchIndex: BrowserEntrySearchIndex +): void { + const tokens = getEntryIndexTokens(searchIndex); + const prefixes = new Set(); + const trigrams = new Set(); + for (const token of tokens) { + prefixes.add(token.slice(0, BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH)); + if (token.length >= 3) { + for (let index = 0; index <= token.length - 3; index += 1) { + trigrams.add(token.slice(index, index + 3)); + } + } + } + for (const prefix of prefixes) pushEntryId(kindIndex.tokenPrefixEntryIds, prefix, entryId); + for (const trigram of trigrams) pushEntryId(kindIndex.tokenTrigramEntryIds, trigram, entryId); +} + +function getEntryIndexTokens(searchIndex: BrowserEntrySearchIndex): string[] { + const seen = new Set(); + const tokens: string[] = []; + for (const field of searchIndex.searchFields) { + const value = field.value || ''; + if (!value) continue; + for (const token of value.split(' ')) { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH || seen.has(token)) continue; + seen.add(token); + tokens.push(token); + } + } + return tokens; +} + +function pushEntryId(index: Map, key: string, entryId: number): void { + const existing = index.get(key); + if (existing) { + existing.push(entryId); + return; + } + index.set(key, [entryId]); +} + +function addBookmarkEntryToNicknameIndex( + index: Map, + entryId: number, + entry: BrowserSearchEntry +): void { + const url = normalizeNicknameUrl(entry.url); + if (!url) return; + const source = String(entry.source || ''); + const profileId = String(entry.sourceProfileId || ''); + const fullProfileId = getEntryProfileKey(entry); + pushEntryId(index, getBookmarkNicknameLookupKey(source, profileId, url), entryId); + if (fullProfileId !== profileId) { + pushEntryId(index, getBookmarkNicknameLookupKey(source, fullProfileId, url), entryId); + } +} + +function getBookmarkNicknameLookupKey(source: string, sourceProfileId: string, url: string): string { + return [source, sourceProfileId, url].join('\0'); +} + function compareHistoryEntriesByTime(a: BrowserSearchEntry, b: BrowserSearchEntry): number { const aTime = Number.isFinite(Number(a?.lastUsedAt)) ? Number(a.lastUsedAt) : 0; const bTime = Number.isFinite(Number(b?.lastUsedAt)) ? Number(b.lastUsedAt) : 0; @@ -868,14 +979,15 @@ function buildBrowserCandidates( options: { limitPerKind?: number } = {} ): Record { const openTabs = getOpenTabCandidates(input, tabs); - void entryIndex; return { 'open-tab': openTabs, bookmark: getBrowserEntryCandidates('bookmark', input, entries, { + entryIndex, nicknames, limit: options.limitPerKind, }), history: getBrowserEntryCandidates('history', input, entries, { + entryIndex, limit: options.limitPerKind, }), }; @@ -886,6 +998,7 @@ function getBrowserEntryCandidates( input: string, entries: BrowserSearchEntry[], options: { + entryIndex?: BrowserEntryIndex | null; preserveBookmarkOrder?: boolean; preserveHistoryChronology?: boolean; includeHistoryTimestamp?: boolean; @@ -912,7 +1025,6 @@ function getBrowserEntryCandidates( if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -948,11 +1060,22 @@ function getBrowserEntryCandidates( } return results; } - const candidateEntries = entries; - for (const entry of candidateEntries) { + const indexedEntryIds = getIndexedEntryCandidateIds(kind, queryTokens, options.entryIndex || null); + const nicknameEntryIds = indexedEntryIds !== null + ? getBookmarkNicknameCandidateIds(kind, trimmed, options.nicknames || [], options.entryIndex || null) + : null; + const candidateEntryIds = indexedEntryIds && nicknameEntryIds + ? unionSortedEntryIds(indexedEntryIds, nicknameEntryIds) + : indexedEntryIds; + const scanEntryIds = candidateEntryIds || null; + const scanLength = scanEntryIds ? scanEntryIds.length : entries.length; + for (let scanIndex = 0; scanIndex < scanLength; scanIndex += 1) { + const entryId = scanEntryIds ? scanEntryIds[scanIndex] : scanIndex; + const entry = entries[entryId]; + if (!entry) continue; if (entry.type !== entryType) continue; if (kind === 'history' && profileFilter && !profileFilter.has(getEntryProfileKey(entry))) continue; - const index = getBrowserEntrySearchIndex(entry); + const index = getBrowserEntrySearchIndexForEntry(entry, entryId, options.entryIndex || null); const savedNickname = kind === 'bookmark' ? findBookmarkNickname(entry, options.nicknames || []) : ''; @@ -1035,6 +1158,155 @@ function getBrowserEntryCandidates( return options.limit && options.limit > 0 ? sorted.slice(0, options.limit) : sorted; } +function getIndexedEntryCandidateIds( + kind: 'bookmark' | 'history', + queryTokens: string[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (!entryIndex || queryTokens.length === 0) return null; + const kindIndex = entryIndex.entriesByKind[kind]; + if (!kindIndex) return null; + + const tokenLists: number[][] = []; + for (const token of queryTokens) { + const tokenEntryIds = getIndexedEntryIdsForToken(kindIndex, token); + if (!tokenEntryIds) return null; + if (tokenEntryIds.length === 0) return []; + tokenLists.push(tokenEntryIds); + } + + tokenLists.sort((a, b) => a.length - b.length); + let candidateIds = tokenLists[0] || []; + for (let index = 1; index < tokenLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, tokenLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getBookmarkNicknameCandidateIds( + kind: 'bookmark' | 'history', + input: string, + nicknames: BrowserSearchNicknameSetting[], + entryIndex: BrowserEntryIndex | null +): number[] | null { + if (kind !== 'bookmark' || !entryIndex || nicknames.length === 0 || /\s/.test(input)) return null; + const parsed = parseNicknameQuery(input); + const normalizedToken = normalizeNicknameToken(parsed.firstToken); + if (!normalizedToken) return null; + let candidateIds: number[] = []; + for (const item of nicknames) { + const nickname = normalizeNicknameToken(item.nickname); + if (!nickname || !nickname.startsWith(normalizedToken)) continue; + const key = getBookmarkNicknameLookupKey( + String(item.source || ''), + String(item.sourceProfileId || ''), + normalizeNicknameUrl(item.url) + ); + const entryIds = entryIndex.bookmarkEntryIdsByNicknameKey.get(key); + if (entryIds?.length) candidateIds = unionSortedEntryIds(candidateIds, entryIds); + } + return candidateIds; +} + +function getIndexedEntryIdsForToken( + kindIndex: BrowserEntryKindIndex, + token: string +): number[] | null { + if (token.length < BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) return null; + if (token.length === BROWSER_ENTRY_INDEX_MIN_PREFIX_LENGTH) { + return kindIndex.tokenPrefixEntryIds.get(token) || []; + } + const trigrams = getTokenTrigrams(token); + if (trigrams.length === 0) return null; + const gramLists: number[][] = []; + for (const trigram of trigrams) { + const ids = kindIndex.tokenTrigramEntryIds.get(trigram) || []; + if (ids.length === 0) return []; + gramLists.push(ids); + } + gramLists.sort((a, b) => a.length - b.length); + let candidateIds = gramLists[0] || []; + for (let index = 1; index < gramLists.length; index += 1) { + candidateIds = intersectSortedEntryIds(candidateIds, gramLists[index]); + if (candidateIds.length === 0) return []; + } + return candidateIds; +} + +function getTokenTrigrams(token: string): string[] { + if (token.length < 3) return []; + const seen = new Set(); + const trigrams: string[] = []; + for (let index = 0; index <= token.length - 3; index += 1) { + const trigram = token.slice(index, index + 3); + if (seen.has(trigram)) continue; + seen.add(trigram); + trigrams.push(trigram); + } + return trigrams; +} + +function intersectSortedEntryIds(a: number[], b: number[]): number[] { + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + aIndex += 1; + } else { + bIndex += 1; + } + } + return out; +} + +function unionSortedEntryIds(a: number[], b: number[]): number[] { + if (a.length === 0) return b; + if (b.length === 0) return a; + const out: number[] = []; + let aIndex = 0; + let bIndex = 0; + while (aIndex < a.length && bIndex < b.length) { + const aValue = a[aIndex]; + const bValue = b[bIndex]; + if (aValue === bValue) { + out.push(aValue); + aIndex += 1; + bIndex += 1; + } else if (aValue < bValue) { + out.push(aValue); + aIndex += 1; + } else { + out.push(bValue); + bIndex += 1; + } + } + while (aIndex < a.length) { + out.push(a[aIndex]); + aIndex += 1; + } + while (bIndex < b.length) { + out.push(b[bIndex]); + bIndex += 1; + } + return out; +} + +function getBrowserEntrySearchIndexForEntry( + entry: BrowserSearchEntry, + entryId: number, + entryIndex: BrowserEntryIndex | null +): BrowserEntrySearchIndex { + return entryIndex?.entrySearchIndexes[entryId] || getBrowserEntrySearchIndex(entry); +} + function compareBookmarksByBrowserOrder(a: BrowserSearchResult, b: BrowserSearchResult): number { const aOrder = Number.isFinite(Number(a.bookmarkOrder)) ? Number(a.bookmarkOrder) : Number.MAX_SAFE_INTEGER; const bOrder = Number.isFinite(Number(b.bookmarkOrder)) ? Number(b.bookmarkOrder) : Number.MAX_SAFE_INTEGER; @@ -1338,6 +1610,16 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear browserEntrySearchIndexCache.set(cacheKey, cached); return cached.index; } + const index = createBrowserEntrySearchIndex(entry); + browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); + if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { + const oldestKey = browserEntrySearchIndexCache.keys().next().value; + if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); + } + return index; +} + +function createBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySearchIndex { const searchFields: TokenSearchField[] = [ { value: normalizeForTokenSearch(entry.query), weight: 1.15 }, { value: normalizeForTokenSearch(entry.url, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), weight: 1 }, @@ -1351,11 +1633,6 @@ function getBrowserEntrySearchIndex(entry: BrowserSearchEntry): BrowserEntrySear normalizedUrl: normalizeUrlForCompletion(entry.url || entry.host, BROWSER_ENTRY_INDEX_MAX_URL_CHARS), searchFields, }; - browserEntrySearchIndexCache.set(cacheKey, { fingerprint, index }); - if (browserEntrySearchIndexCache.size > BROWSER_ENTRY_SEARCH_INDEX_CACHE_MAX) { - const oldestKey = browserEntrySearchIndexCache.keys().next().value; - if (oldestKey !== undefined) browserEntrySearchIndexCache.delete(oldestKey); - } return index; } @@ -1572,3 +1849,9 @@ function tabToBrowserSearchEntry(tab: BrowserTabEntry): BrowserSearchEntry { sourceProfileName: tab.profileName, }; } + +export const __browserSearchTestAccess = { + buildBrowserEntryIndex, + getOrderedBrowserResults, + getRankedBrowserResults, +};