diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts index b889d53ce82..898b6189407 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts @@ -76,28 +76,30 @@ export function buildBrowserAddressBarSuggestions({ .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) } - const documentSuggestions = workspaceDocHistory.map(toWorkspaceDocSuggestion) - const historyRows: { entry: BrowserHistoryEntry; row: BrowserAddressBarSuggestion }[] = [ - ...browserUrlHistory.map((entry) => ({ - entry, - row: { ...entry, subtitle: entry.url, isSearch: false } - })), - ...documentSuggestions.map((row) => ({ - entry: { - url: row.url, - normalizedUrl: row.url, - title: row.title, - lastVisitedAt: row.lastVisitedAt, - visitCount: row.visitCount - }, - row - })) - ] - const rowByEntry = new Map(historyRows.map(({ entry, row }) => [entry, row])) + const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion) + const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({ + url: row.url, + normalizedUrl: row.url, + title: row.title, + lastVisitedAt: row.lastVisitedAt, + visitCount: row.visitCount + })) + const rowByEntry = new Map() + for (const entry of browserUrlHistory) { + rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false }) + } + documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index])) + // Why prepare the caller's array as-is: it is the stable `browserUrlHistory` + // identity, so the prepare cache hits instead of re-lowercasing every keystroke. + const preparedHistory = prepareBrowserHistoryEntries(browserUrlHistory) + const prepared = + documentEntries.length === 0 + ? preparedHistory + : [...preparedHistory, ...prepareBrowserHistoryEntries(documentEntries)] // Why url-tail is kept here: the address bar is a navigation surface, so a // path-only recall is still a destination — it just never outranks a real one. const historySuggestions: BrowserAddressBarSuggestion[] = matchBrowserHistory({ - prepared: prepareBrowserHistoryEntries(historyRows.map(({ entry }) => entry)), + prepared, query: trimmed, limit: MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1 }) diff --git a/src/renderer/src/lib/browser-history-match.test.ts b/src/renderer/src/lib/browser-history-match.test.ts index 74d82f8bb57..6901e2ab628 100644 --- a/src/renderer/src/lib/browser-history-match.test.ts +++ b/src/renderer/src/lib/browser-history-match.test.ts @@ -124,6 +124,17 @@ describe('browser history match', () => { expect(match(tied.toReversed(), 'acme')).toEqual(first) }) + it('breaks a full tie on the url so a reordered snapshot keeps the same order', () => { + const tied = [ + entry({ url: 'https://acme.dev/b', visitCount: 3, lastVisitedAt: NOW - 5 * HOUR }), + entry({ url: 'https://acme.dev/a', visitCount: 3, lastVisitedAt: NOW - 5 * HOUR }) + ] + + const expected = ['https://acme.dev/a', 'https://acme.dev/b'] + expect(match(tied, 'acme').map((row) => row.url)).toEqual(expected) + expect(match(tied.toReversed(), 'acme').map((row) => row.url)).toEqual(expected) + }) + it('honours the limit and returns the shared empty array when nothing matches', () => { expect(match(GIT_CORPUS, 'git', { limit: 2 })).toHaveLength(2) const prepared = prepareBrowserHistoryEntries(GIT_CORPUS) diff --git a/src/renderer/src/lib/browser-history-match.ts b/src/renderer/src/lib/browser-history-match.ts index 88b760f48f9..1f1825ec3fa 100644 --- a/src/renderer/src/lib/browser-history-match.ts +++ b/src/renderer/src/lib/browser-history-match.ts @@ -130,12 +130,14 @@ export function matchBrowserHistory({ if (matches.length === 0) { return NO_MATCHES } - // Why lastVisitedAt last: ordering must not wobble between renders on a tie. + // Why the url last: ordering must not wobble between renders when a snapshot + // reorders two entries that also tie on tier, score and recency. matches.sort( (a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier] || b.score - a.score || - b.entry.lastVisitedAt - a.entry.lastVisitedAt + b.entry.lastVisitedAt - a.entry.lastVisitedAt || + a.entry.normalizedUrl.localeCompare(b.entry.normalizedUrl) ) return matches.slice(0, limit) }