Skip to content

Commit 5ef061a

Browse files
committed
fix(extension): keep typed values when a suggestion is chosen, bound the suggestion cache
1 parent 2f5a99d commit 5ef061a

3 files changed

Lines changed: 50 additions & 30 deletions

File tree

‎apps/extension/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@sim/emcn": "workspace:*",
1919
"@sim/logger": "workspace:*",
2020
"@sim/utils": "workspace:*",
21+
"lru-cache": "11.5.3",
2122
"react": "19.2.4",
2223
"react-dom": "19.2.4",
2324
"zod": "4.3.6"

‎apps/extension/src/omnibox.ts‎

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,31 @@ import {
44
} from '@sim/browser-extension-protocol'
55
import { createLogger } from '@sim/logger'
66
import { sleep } from '@sim/utils/helpers'
7+
import { LRUCache } from 'lru-cache'
78
import { SIM_ORIGIN } from '@/config'
89

910
const logger = createLogger('BrowserExtensionOmnibox')
1011

1112
/** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */
1213
const SUGGEST_DELAY_MS = 120
1314

14-
/** What was typed after the `sim` keyword, whether or not `sim/` was typed too. */
15-
function typedSimLink(text: string): string {
16-
return text.trim().replace(/^sim\//i, '')
15+
/** A Sim link as suggested: its name, and what it shows beside the name. */
16+
interface SuggestedLink {
17+
name: string
18+
detail: string
1719
}
1820

19-
/** The name part, which alone decides the suggestions: `jira/ABC-1?x` suggests from `jira`. */
20-
function typedName(text: string): string {
21-
return typedSimLink(text).split(/[/?]/)[0].toLowerCase()
21+
/**
22+
* What was typed after the `sim` keyword, split where the name ends: `sim/jira/ABC-1?tab=all`
23+
* (the `sim/` is optional) has the name `jira` and the rest `/ABC-1?tab=all`. Only the space
24+
* before the name and the prefix are dropped; values and the query stay exactly as typed.
25+
*/
26+
function readTyped(text: string): { name: string; rest: string } {
27+
const typed = text.trimStart().replace(/^sim\//i, '')
28+
const nameEnd = typed.search(/[/?]/)
29+
return nameEnd === -1
30+
? { name: typed, rest: '' }
31+
: { name: typed.slice(0, nameEnd), rest: typed.slice(nameEnd) }
2232
}
2333

2434
/** Suggestion text is XML, where these five characters are markup. */
@@ -27,33 +37,37 @@ function escapeXml(text: string): string {
2737
}
2838

2939
/**
30-
* The resolver address for what was typed after the `sim` keyword, written the way it is typed
31-
* in the address bar: `jira/ABC-1?tab=all` (with or without a leading `sim/`) opens
32-
* `sim/jira/ABC-1?tab=all`. Each `/` separates a value, and a value may contain spaces.
40+
* The resolver address for what was typed, as the address bar would send `sim/<typed>`: each
41+
* `/` starts a value, kept in position even when empty, and a trailing `/` adds none.
3342
*/
3443
function simLinkUrl(text: string): string {
35-
const typed = typedSimLink(text)
36-
const queryStart = typed.indexOf('?')
37-
const path = queryStart === -1 ? typed : typed.slice(0, queryStart)
38-
const query = queryStart === -1 ? '' : new URLSearchParams(typed.slice(queryStart + 1)).toString()
39-
const segments = path.split('/').filter(Boolean).map(encodeURIComponent)
44+
const { name, rest } = readTyped(text)
45+
const queryStart = rest.indexOf('?')
46+
const path = `${name}${queryStart === -1 ? rest : rest.slice(0, queryStart)}`.replace(/\/$/, '')
47+
const query = queryStart === -1 ? '' : new URLSearchParams(rest.slice(queryStart + 1)).toString()
48+
const segments = path ? path.split('/').map(encodeURIComponent) : []
4049
return `${SIM_ORIGIN}/${['sim', ...segments].join('/')}${query ? `?${query}` : ''}`
4150
}
4251

43-
async function fetchSuggestions(
44-
name: string,
45-
signal: AbortSignal
46-
): Promise<chrome.omnibox.SuggestResult[]> {
52+
async function fetchSuggestedLinks(name: string, signal: AbortSignal): Promise<SuggestedLink[]> {
4753
const response = await fetch(
4854
`${SIM_ORIGIN}${SIM_LINK_SUGGESTIONS_PATH}?${new URLSearchParams({ q: name })}`,
4955
{ credentials: 'include', signal }
5056
)
5157
// Signed out, or Sim links are unavailable: the typed link still opens through the resolver.
5258
if (!response.ok) return []
5359
const { links } = simLinkSuggestionsSchema.parse(await response.json())
60+
return links.map((link) => ({ name: link.name, detail: link.description ?? link.url }))
61+
}
62+
63+
/** Each link as a suggestion that keeps whatever was typed after the name. */
64+
function toSuggestions(
65+
links: readonly SuggestedLink[],
66+
rest: string
67+
): chrome.omnibox.SuggestResult[] {
5468
return links.map((link) => ({
55-
content: link.name,
56-
description: `<match>sim/${escapeXml(link.name)}</match> <dim>${escapeXml(link.description ?? link.url)}</dim>`,
69+
content: `${link.name}${rest}`,
70+
description: `<match>sim/${escapeXml(`${link.name}${rest}`)}</match> <dim>${escapeXml(link.detail)}</dim>`,
5771
}))
5872
}
5973

@@ -63,30 +77,34 @@ async function fetchSuggestions(
6377
*/
6478
export function registerSimLinkOmnibox() {
6579
let pending: AbortController | null = null
66-
/** Suggestions by typed name for the current keyword session, so retyping and editing values reuse them. */
67-
const suggestionsByName = new Map<string, chrome.omnibox.SuggestResult[]>()
80+
/**
81+
* Links by typed name, emptied as each keyword session starts, so editing values or retyping
82+
* a name in one session asks Sim once per name.
83+
*/
84+
const linksByName = new LRUCache<string, SuggestedLink[]>({ max: 100 })
6885

69-
chrome.omnibox.onInputStarted.addListener(() => suggestionsByName.clear())
86+
chrome.omnibox.onInputStarted.addListener(() => linksByName.clear())
7087

7188
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
7289
pending?.abort()
90+
const { name, rest } = readTyped(text)
7391
chrome.omnibox.setDefaultSuggestion({
74-
description: `Open <match>sim/${escapeXml(typedSimLink(text))}</match>`,
92+
description: `Open <match>sim/${escapeXml(`${name}${rest}`)}</match>`,
7593
})
76-
const name = typedName(text)
77-
const known = suggestionsByName.get(name)
94+
const key = name.toLowerCase()
95+
const known = linksByName.get(key)
7896
if (known) {
79-
suggest(known)
97+
suggest(toSuggestions(known, rest))
8098
return
8199
}
82100
const controller = new AbortController()
83101
pending = controller
84102
void (async () => {
85103
await sleep(SUGGEST_DELAY_MS)
86104
if (controller.signal.aborted) return
87-
const suggestions = await fetchSuggestions(name, controller.signal)
88-
suggestionsByName.set(name, suggestions)
89-
if (!controller.signal.aborted) suggest(suggestions)
105+
const links = await fetchSuggestedLinks(key, controller.signal)
106+
linksByName.set(key, links)
107+
if (!controller.signal.aborted) suggest(toSuggestions(links, rest))
90108
})().catch(() => {
91109
if (!controller.signal.aborted) logger.warn('Could not load Sim link suggestions')
92110
})

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)