@@ -4,21 +4,31 @@ import {
44} from '@sim/browser-extension-protocol'
55import { createLogger } from '@sim/logger'
66import { sleep } from '@sim/utils/helpers'
7+ import { LRUCache } from 'lru-cache'
78import { SIM_ORIGIN } from '@/config'
89
910const logger = createLogger ( 'BrowserExtensionOmnibox' )
1011
1112/** Long enough to skip the requests a fast typist's intermediate keystrokes would make. */
1213const 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 ( / ^ s i m \/ / 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 ( / ^ s i m \/ / 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 */
3443function 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 */
6478export 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 } )
0 commit comments