11import { RefreshIcon } from "~/components/ui/refresh-icon" ;
22import { ExternalLinkIcon , PackagePlusIcon , PaletteIcon , SearchIcon } from "lucide-react" ;
3- import { useCallback , useEffect , useRef , useState } from "react" ;
3+ import { useEffect , useEffectEvent , useRef , useState } from "react" ;
44import {
55 importOpenVsxThemeExtension ,
66 searchOpenVsxThemes ,
@@ -43,14 +43,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> =
4343] ;
4444const SEARCH_DEBOUNCE_MS = 350 ;
4545
46- function SourceLinkIcon ( { url } : { url : string } ) {
46+ function sourceHost ( url : string ) : string | null {
4747 try {
48- const host = new URL ( url ) . hostname . toLowerCase ( ) ;
49- if ( host === "github.com" || host . endsWith ( ".github.com" ) )
50- return < GitHubIcon className = "size-3.5" /> ;
51- if ( host === "gitlab.com" || host . endsWith ( ".gitlab.com" ) )
52- return < GitLabIcon className = "size-3.5" monochrome /> ;
53- } catch { }
48+ return new URL ( url ) . hostname . toLowerCase ( ) ;
49+ } catch {
50+ return null ;
51+ }
52+ }
53+
54+ function SourceLinkIcon ( { url } : { url : string } ) {
55+ const host = sourceHost ( url ) ;
56+ if ( host === "github.com" || host ?. endsWith ( ".github.com" ) )
57+ return < GitHubIcon className = "size-3.5" /> ;
58+ if ( host === "gitlab.com" || host ?. endsWith ( ".gitlab.com" ) )
59+ return < GitLabIcon className = "size-3.5" monochrome /> ;
5460 return < ExternalLinkIcon className = "size-3.5" /> ;
5561}
5662
@@ -90,163 +96,184 @@ export function ThemeSearchSection({
9096 const [ installingId , setInstallingId ] = useState < string | null > ( null ) ;
9197 const [ pendingUpdate , setPendingUpdate ] = useState < OpenVsxThemeExtension | null > ( null ) ;
9298 const requestRef = useRef < AbortController | null > ( null ) ;
99+ // Recorded only once a search succeeds, so a finished install reruns the
100+ // search only when the query or sort changed while it was in flight, and the
101+ // install error the user needs to see survives.
102+ const [ lastSearchKey , setLastSearchKey ] = useState < string | null > ( null ) ;
103+ const [ handledSearch , setHandledSearch ] = useState < {
104+ key : string ;
105+ query : string ;
106+ fetch : boolean ;
107+ } | null > ( null ) ;
108+ // An install aborts any search, so once it ends the current key is checked
109+ // again even though it has not changed.
110+ const [ searchInterrupted , setSearchInterrupted ] = useState ( false ) ;
93111
94- const lastSearchKeyRef = useRef < string | null > ( null ) ;
95-
96- const prevSearchKeyRef = useRef < string | null > ( null ) ;
97-
98- useEffect ( ( ) => {
99- requestRef . current ?. abort ( ) ;
100- requestRef . current = null ;
112+ const [ wasOpen , setWasOpen ] = useState ( open ) ;
113+ if ( open !== wasOpen ) {
114+ setWasOpen ( open ) ;
101115 if ( open ) {
102- lastSearchKeyRef . current = null ;
103- prevSearchKeyRef . current = null ;
104116 setQuery ( "" ) ;
105117 setSortBy ( "downloadCount" ) ;
106118 setResults ( null ) ;
107119 setError ( null ) ;
108120 setIsSearching ( false ) ;
109121 setInstallingId ( null ) ;
110122 setPendingUpdate ( null ) ;
123+ setLastSearchKey ( null ) ;
124+ setHandledSearch ( null ) ;
125+ setSearchInterrupted ( false ) ;
111126 }
127+ }
128+
129+ useEffect ( ( ) => {
130+ if ( ! open ) return ;
112131 return ( ) => {
113132 requestRef . current ?. abort ( ) ;
114133 requestRef . current = null ;
115134 } ;
116135 } , [ open ] ) ;
117136
118- const runSearch = useCallback (
119- async ( searchText : string , nextSort = sortBy ) => {
120- const trimmed = searchText . trim ( ) ;
121- if ( ! trimmed ) return ;
122- requestRef . current ?. abort ( ) ;
123- const controller = new AbortController ( ) ;
124- requestRef . current = controller ;
125- setError ( null ) ;
126- setIsSearching ( true ) ;
127- try {
128- const nextResults = await searchOpenVsxThemes ( trimmed , {
129- signal : controller . signal ,
130- sortBy : nextSort ,
131- } ) ;
132- if ( ! controller . signal . aborted ) {
137+ const queryCleared = ! query . trim ( ) && installingId === null ;
138+ if (
139+ queryCleared &&
140+ ( results !== null ||
141+ error !== null ||
142+ isSearching ||
143+ lastSearchKey !== null ||
144+ handledSearch !== null )
145+ ) {
146+ setResults ( null ) ;
147+ setError ( null ) ;
148+ setIsSearching ( false ) ;
149+ setLastSearchKey ( null ) ;
150+ setHandledSearch ( null ) ;
151+ }
152+
153+ useEffect ( ( ) => {
154+ if ( ! queryCleared ) return ;
155+ requestRef . current ?. abort ( ) ;
156+ requestRef . current = null ;
157+ } , [ queryCleared ] ) ;
158+
159+ const startRequest = ( ) => {
160+ requestRef . current ?. abort ( ) ;
161+ const controller = new AbortController ( ) ;
162+ requestRef . current = controller ;
163+ return controller ;
164+ } ;
165+
166+ const fetchResults = ( trimmed : string , controller : AbortController ) => {
167+ const key = `${ trimmed } \u0000${ sortBy } ` ;
168+ void searchOpenVsxThemes ( trimmed , { signal : controller . signal , sortBy } )
169+ . then (
170+ ( nextResults ) => {
171+ if ( controller . signal . aborted ) return ;
133172 setResults ( nextResults ) ;
134- lastSearchKeyRef . current = ` ${ trimmed } \u0000 ${ nextSort } ` ;
135- }
136- } catch ( cause ) {
137- if ( ! controller . signal . aborted ) {
173+ setLastSearchKey ( key ) ;
174+ } ,
175+ ( cause : unknown ) => {
176+ if ( controller . signal . aborted ) return ;
138177 setResults ( null ) ;
139- lastSearchKeyRef . current = null ;
178+ setLastSearchKey ( null ) ;
140179 setError ( cause instanceof Error ? cause . message : "Open VSX search failed." ) ;
141- }
142- }
143- if ( requestRef . current === controller ) {
180+ } ,
181+ )
182+ . then ( ( ) => {
183+ if ( requestRef . current !== controller ) return ;
144184 requestRef . current = null ;
145185 setIsSearching ( false ) ;
146- }
147- } ,
148- [ sortBy ] ,
149- ) ;
186+ } ) ;
187+ } ;
188+
189+ const runSearch = ( searchText : string ) => {
190+ const trimmed = searchText . trim ( ) ;
191+ if ( ! trimmed ) return ;
192+ setError ( null ) ;
193+ setIsSearching ( true ) ;
194+ fetchResults ( trimmed , startRequest ( ) ) ;
195+ } ;
150196
151197 const debouncedQuery = useDebouncedValue ( query . trim ( ) , SEARCH_DEBOUNCE_MS ) ;
198+ const searchKey = `${ debouncedQuery } \u0000${ sortBy } ` ;
199+ // An unsettled query would hit Open VSX for text that is no longer visible;
200+ // wait for the debounce to catch up to the current input instead.
201+ const searchSettled =
202+ open && installingId === null && debouncedQuery !== "" && debouncedQuery === query . trim ( ) ;
152203
153- useEffect ( ( ) => {
154- if ( query . trim ( ) || installingId !== null ) return ;
155- requestRef . current ?. abort ( ) ;
156- requestRef . current = null ;
157- lastSearchKeyRef . current = null ;
158- setResults ( null ) ;
159- setError ( null ) ;
160- setIsSearching ( false ) ;
161- } , [ query , installingId ] ) ;
204+ const keyChanged = handledSearch ?. key !== searchKey ;
205+ if ( searchSettled && ( keyChanged || searchInterrupted ) ) {
206+ // Typing and then undoing lands back on the key already shown; keep those
207+ // results instead of searching again.
208+ const fetch = lastSearchKey !== searchKey ;
209+ setHandledSearch ( { key : searchKey , query : debouncedQuery , fetch } ) ;
210+ setSearchInterrupted ( false ) ;
211+ // An install error on an unchanged, already-shown query stays visible.
212+ if ( fetch || keyChanged ) setError ( null ) ;
213+ setIsSearching ( fetch ) ;
214+ }
215+
216+ const fetchHandledSearch = useEffectEvent ( ( searchText : string ) => {
217+ fetchResults ( searchText , startRequest ( ) ) ;
218+ } ) ;
162219
163220 useEffect ( ( ) => {
164- if ( ! open ) return ;
165- const searchKey = `${ debouncedQuery } \u0000${ sortBy } ` ;
166- const keyChanged = prevSearchKeyRef . current !== searchKey ;
167- prevSearchKeyRef . current = searchKey ;
168- if ( installingId !== null ) return ;
169- if ( ! debouncedQuery ) {
170- lastSearchKeyRef . current = null ;
171- requestRef . current ?. abort ( ) ;
172- requestRef . current = null ;
173- setResults ( null ) ;
174- setError ( null ) ;
175- setIsSearching ( false ) ;
176- return ;
177- }
178- if ( debouncedQuery !== query . trim ( ) ) {
179- // Searching it would hit Open VSX for a query that is no longer visible;
180- // wait for the debounce to catch up to the current input instead.
221+ if ( ! handledSearch ) return ;
222+ if ( handledSearch . fetch ) {
223+ fetchHandledSearch ( handledSearch . query ) ;
181224 return ;
182225 }
183- if ( lastSearchKeyRef . current === searchKey ) {
184- // A request for a newer key may still be in flight (typed and then
185- // undone); abort it so it cannot overwrite the results. Only a genuine
186- // key change makes a stale search error irrelevant, so an install error
187- // on an unchanged query survives.
188- requestRef . current ?. abort ( ) ;
189- requestRef . current = null ;
190- setIsSearching ( false ) ;
191- if ( keyChanged ) setError ( null ) ;
192- return ;
193- }
194- void runSearch ( debouncedQuery ) ;
195- // `sortBy` is deliberately not a direct dependency: the guards above read
196- // the current value from the fresh render closure. An install finishing
197- // reruns the search only when the query or sort changed while it was in
198- // flight (checked via lastSearchKeyRef, recorded only once a search
199- // succeeds), so the install error the user needs to see is preserved
200- // across that rerun.
201- } , [ open , query , debouncedQuery , installingId , runSearch ] ) ;
226+ // A request for a newer key may still be in flight; abort it so it cannot
227+ // overwrite the results being kept.
228+ requestRef . current ?. abort ( ) ;
229+ requestRef . current = null ;
230+ } , [ handledSearch ] ) ;
202231
203- const handleSortChange = useCallback ( ( value : OpenVsxThemeSort | null ) => {
232+ const handleSortChange = ( value : OpenVsxThemeSort | null ) => {
204233 const nextSort = SORT_OPTIONS . find ( ( option ) => option . value === value ) ?. value ;
205234 if ( ! nextSort ) return ;
206235 setSortBy ( nextSort ) ;
207- } , [ ] ) ;
236+ } ;
208237
209- const handleInstall = useCallback (
210- async ( extension : OpenVsxThemeExtension , allowUpdate : boolean ) => {
211- setError ( null ) ;
212- let installedCollection : ReadonlyArray < ThemeDefinition > ;
213- try {
214- installedCollection = getStoredCustomThemeCollection ( extension . collectionId ) ;
215- } catch ( cause ) {
216- setError ( cause instanceof Error ? cause . message : "Installed themes could not be read." ) ;
217- return ;
218- }
219- const updated = installedCollection . length > 0 ;
220- if ( updated && ! allowUpdate ) {
221- setPendingUpdate ( extension ) ;
222- return ;
223- }
238+ const handleInstall = async ( extension : OpenVsxThemeExtension , allowUpdate : boolean ) => {
239+ setError ( null ) ;
240+ let installedCollection : ReadonlyArray < ThemeDefinition > ;
241+ try {
242+ installedCollection = getStoredCustomThemeCollection ( extension . collectionId ) ;
243+ } catch ( cause ) {
244+ setError ( cause instanceof Error ? cause . message : "Installed themes could not be read." ) ;
245+ return ;
246+ }
247+ const updated = installedCollection . length > 0 ;
248+ if ( updated && ! allowUpdate ) {
249+ setPendingUpdate ( extension ) ;
250+ return ;
251+ }
224252
225- requestRef . current ?. abort ( ) ;
226- const controller = new AbortController ( ) ;
227- requestRef . current = controller ;
228- setIsSearching ( false ) ;
229- setInstallingId ( extension . id ) ;
230- try {
231- const themes = await importOpenVsxThemeExtension ( extension , controller . signal ) ;
232- if ( ! controller . signal . aborted ) {
233- const imported = replaceCustomThemeCollection ( extension . collectionId , themes , {
234- expectedCollection : installedCollection ,
235- } ) ;
236- onInstalled ( imported , { updated } ) ;
237- }
238- } catch ( cause ) {
239- if ( ! controller . signal . aborted ) {
240- setError ( cause instanceof Error ? cause . message : "That theme could not be added." ) ;
241- }
253+ requestRef . current ?. abort ( ) ;
254+ const controller = new AbortController ( ) ;
255+ requestRef . current = controller ;
256+ setIsSearching ( false ) ;
257+ setSearchInterrupted ( true ) ;
258+ setInstallingId ( extension . id ) ;
259+ try {
260+ const themes = await importOpenVsxThemeExtension ( extension , controller . signal ) ;
261+ if ( ! controller . signal . aborted ) {
262+ const imported = replaceCustomThemeCollection ( extension . collectionId , themes , {
263+ expectedCollection : installedCollection ,
264+ } ) ;
265+ onInstalled ( imported , { updated } ) ;
242266 }
243- if ( requestRef . current === controller ) {
244- requestRef . current = null ;
245- setInstallingId ( null ) ;
267+ } catch ( cause ) {
268+ if ( ! controller . signal . aborted ) {
269+ setError ( cause instanceof Error ? cause . message : "That theme could not be added." ) ;
246270 }
247- } ,
248- [ onInstalled ] ,
249- ) ;
271+ }
272+ if ( requestRef . current === controller ) {
273+ requestRef . current = null ;
274+ setInstallingId ( null ) ;
275+ }
276+ } ;
250277
251278 return (
252279 < section className = "space-y-3" aria-labelledby = "theme-search-heading" >
@@ -269,7 +296,7 @@ export function ThemeSearchSection({
269296 onKeyDown = { ( event ) => {
270297 if ( event . nativeEvent . isComposing || event . keyCode === 229 ) return ;
271298 if ( event . key === "Enter" && ! isSearching && installingId === null )
272- void runSearch ( query . trim ( ) ) ;
299+ runSearch ( query . trim ( ) ) ;
273300 } }
274301 placeholder = "Search themes..."
275302 size = "lg"
@@ -290,7 +317,7 @@ export function ThemeSearchSection({
290317 variant = "ghost"
291318 onClick = { ( ) => {
292319 if ( query . trim ( ) === suggestion ) {
293- void runSearch ( suggestion ) ;
320+ runSearch ( suggestion ) ;
294321 } else {
295322 setQuery ( suggestion ) ;
296323 }
0 commit comments