| @@ -1,12 +1,10 @@ |
| 1 | 1 | import { |
| 2 | 2 | ExternalLinkIcon, |
| 3 | + PackagePlusIcon, |
| 3 | 4 | PaletteIcon, |
| 4 | | − PlusIcon, |
| 5 | 5 | RefreshCwIcon, |
| 6 | 6 | SearchIcon, |
| 7 | | − ShieldCheckIcon, |
| 8 | 7 | } from "lucide-react"; |
| 9 | | −import type { FormEvent } from "react"; |
| 10 | 8 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 11 | 9 | import { |
| 12 | 10 | importOpenVsxThemeExtension, |
| @@ -14,12 +12,14 @@ import { · 1 unmodified line |
| 14 | 12 | type OpenVsxThemeExtension, |
| 15 | 13 | type OpenVsxThemeSort, |
| 16 | 14 | } from "../../openVsxThemes"; |
| 15 | +import { useDebouncedValue } from "../../state/queries"; |
| 17 | 16 | import { |
| 18 | 17 | getCustomThemes, |
| 19 | 18 | getStoredCustomThemeCollection, |
| 20 | 19 | replaceCustomThemeCollection, |
| 21 | 20 | type ThemeDefinition, |
| 22 | 21 | } from "../../themePalette"; |
| 22 | +import { GitHubIcon, GitLabIcon } from "../Icons"; |
| 23 | 23 | import { |
| 24 | 24 | AlertDialog, |
| 25 | 25 | AlertDialogClose, |
| @@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = · 19 unmodified lines |
| 45 | 45 | { value: "timestamp", label: "Newest" }, |
| 46 | 46 | { value: "relevance", label: "Most relevant" }, |
| 47 | 47 | ]; |
| 48 | +const SEARCH_DEBOUNCE_MS = 350; |
| 49 | + |
| 50 | +function SourceLinkIcon({ url }: { url: string }) { |
| 51 | + try { |
| 52 | + const host = new URL(url).hostname.toLowerCase(); |
| 53 | + if (host === "github.com" || host.endsWith(".github.com")) |
| 54 | + return <GitHubIcon className="size-3.5" />; |
| 55 | + if (host === "gitlab.com" || host.endsWith(".gitlab.com")) |
| 56 | + return <GitLabIcon className="size-3.5" monochrome />; |
| 57 | + } catch { |
| 58 | + // Fall through to the generic external-link icon. |
| 59 | + } |
| 60 | + return <ExternalLinkIcon className="size-3.5" />; |
| 61 | +} |
| 48 | 62 | |
| 49 | 63 | function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { |
| 50 | 64 | const [failed, setFailed] = useState(false); |
| @@ -82,11 +96,21 @@ export function ThemeSearchSection({ · 31 unmodified lines |
| 82 | 96 | const [installingId, setInstallingId] = useState<string | null>(null); |
| 83 | 97 | const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null); |
| 84 | 98 | const requestRef = useRef<AbortController | null>(null); |
| 99 | + // The (query, sort) pair the last search actually ran, so an install |
| 100 | + // finishing can tell a same-key rerun (which must not wipe an install |
| 101 | + // error) from a query that changed mid-install (which must be searched). |
| 102 | + const lastSearchKeyRef = useRef<string | null>(null); |
| 103 | + // The (query, sort) pair from the previous effect run, so a search error |
| 104 | + // that belongs to an older key can be cleared when the user returns to |
| 105 | + // already-shown results without clearing a fresh install error. |
| 106 | + const prevSearchKeyRef = useRef<string | null>(null); |
| 85 | 107 | |
| 86 | 108 | useEffect(() => { |
| 87 | 109 | requestRef.current?.abort(); |
| 88 | 110 | requestRef.current = null; |
| 89 | 111 | if (open) { |
| 112 | + lastSearchKeyRef.current = null; |
| 113 | + prevSearchKeyRef.current = null; |
| 90 | 114 | setQuery(""); |
| 91 | 115 | setSortBy("downloadCount"); |
| 92 | 116 | setResults(null); |
| @@ -108,18 +132,21 @@ export function ThemeSearchSection({ · 15 unmodified lines |
| 108 | 132 | requestRef.current?.abort(); |
| 109 | 133 | const controller = new AbortController(); |
| 110 | 134 | requestRef.current = controller; |
| 111 | | − setQuery(trimmed); |
| 112 | | − setIsSearching(true); |
| 113 | 135 | setError(null); |
| 114 | | − setResults(null); |
| 136 | + setIsSearching(true); |
| 115 | 137 | try { |
| 116 | 138 | const nextResults = await searchOpenVsxThemes(trimmed, { |
| 117 | 139 | signal: controller.signal, |
| 118 | 140 | sortBy: nextSort, |
| 119 | 141 | }); |
| 120 | | − if (!controller.signal.aborted) setResults(nextResults); |
| 142 | + if (!controller.signal.aborted) { |
| 143 | + setResults(nextResults); |
| 144 | + lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`; |
| 145 | + } |
| 121 | 146 | } catch (cause) { |
| 122 | 147 | if (!controller.signal.aborted) { |
| 148 | + setResults(null); |
| 149 | + lastSearchKeyRef.current = null; |
| 123 | 150 | setError(cause instanceof Error ? cause.message : "Open VSX search failed."); |
| 124 | 151 | } |
| 125 | 152 | } |
| @@ -131,25 +158,66 @@ export function ThemeSearchSection({ · 5 unmodified lines |
| 131 | 158 | [sortBy], |
| 132 | 159 | ); |
| 133 | 160 | |
| 134 | | − const handleSearch = useCallback( |
| 135 | | − (event: FormEvent<HTMLFormElement>) => { |
| 136 | | − event.preventDefault(); |
| 137 | | − void runSearch(query); |
| 138 | | − }, |
| 139 | | − [query, runSearch], |
| 140 | | − ); |
| 161 | + const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); |
| 141 | 162 | |
| 142 | | − const handleSortChange = useCallback( |
| 143 | | − (value: OpenVsxThemeSort | null) => { |
| 144 | | − const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value; |
| 145 | | − if (!nextSort) return; |
| 146 | | − setSortBy(nextSort); |
| 147 | | − if ((results !== null || isSearching) && query.trim()) { |
| 148 | | − void runSearch(query, nextSort); |
| 149 | | − } |
| 150 | | − }, |
| 151 | | − [isSearching, query, results, runSearch], |
| 152 | | − ); |
| 163 | + useEffect(() => { |
| 164 | + if (query.trim() || installingId !== null) return; |
| 165 | + requestRef.current?.abort(); |
| 166 | + requestRef.current = null; |
| 167 | + lastSearchKeyRef.current = null; |
| 168 | + setResults(null); |
| 169 | + setError(null); |
| 170 | + setIsSearching(false); |
| 171 | + }, [query, installingId]); |
| 172 | + |
| 173 | + useEffect(() => { |
| 174 | + if (!open) return; |
| 175 | + const searchKey = `${debouncedQuery}\u0000${sortBy}`; |
| 176 | + const keyChanged = prevSearchKeyRef.current !== searchKey; |
| 177 | + prevSearchKeyRef.current = searchKey; |
| 178 | + if (installingId !== null) return; |
| 179 | + if (!debouncedQuery) { |
| 180 | + lastSearchKeyRef.current = null; |
| 181 | + requestRef.current?.abort(); |
| 182 | + requestRef.current = null; |
| 183 | + setResults(null); |
| 184 | + setError(null); |
| 185 | + setIsSearching(false); |
| 186 | + return; |
| 187 | + } |
| 188 | + if (debouncedQuery !== query.trim()) { |
| 189 | + // The debounced value still trails the input (dialog reopened with the |
| 190 | + // box reset, or the user is mid-keystroke). Searching it would hit Open |
| 191 | + // VSX for a query that is no longer visible; wait for the debounce to |
| 192 | + // catch up to the current input instead. |
| 193 | + return; |
| 194 | + } |
| 195 | + if (lastSearchKeyRef.current === searchKey) { |
| 196 | + // The results already match this query. A request for a newer key may |
| 197 | + // still be in flight (typed and then undone); abort it so it cannot |
| 198 | + // overwrite the results. Only a genuine key change makes a stale search |
| 199 | + // error irrelevant, so an install error on an unchanged query survives. |
| 200 | + requestRef.current?.abort(); |
| 201 | + requestRef.current = null; |
| 202 | + setIsSearching(false); |
| 203 | + if (keyChanged) setError(null); |
| 204 | + return; |
| 205 | + } |
| 206 | + void runSearch(debouncedQuery); |
| 207 | + // `installingId` and `sortBy` are deliberately not dependencies: the |
| 208 | + // guards above read the current values from the fresh render closure. An |
| 209 | + // install finishing reruns the search only when the query or sort changed |
| 210 | + // while it was in flight (checked via lastSearchKeyRef, recorded only |
| 211 | + // once a search succeeds), so the install error the user needs to see is |
| 212 | + // preserved across that rerun. |
| 213 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 214 | + }, [open, query, debouncedQuery, installingId, runSearch]); |
| 215 | + |
| 216 | + const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => { |
| 217 | + const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value; |
| 218 | + if (!nextSort) return; |
| 219 | + setSortBy(nextSort); |
| 220 | + }, []); |
| 153 | 221 | |
| 154 | 222 | const handleInstall = useCallback( |
| 155 | 223 | async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => { |
| @@ -170,6 +238,7 @@ export function ThemeSearchSection({ · 14 unmodified lines |
| 170 | 238 | requestRef.current?.abort(); |
| 171 | 239 | const controller = new AbortController(); |
| 172 | 240 | requestRef.current = controller; |
| 241 | + setIsSearching(false); |
| 173 | 242 | setInstallingId(extension.id); |
| 174 | 243 | try { |
| 175 | 244 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); |
| @@ -202,42 +271,43 @@ export function ThemeSearchSection({ · 26 unmodified lines |
| 202 | 271 | Find open-source themes from Open VSX. |
| 203 | 272 | </p> |
| 204 | 273 | </div> |
| 205 | | − <form className="flex gap-2" onSubmit={handleSearch}> |
| 206 | | − <InputGroup> |
| 207 | | − <InputGroupAddon> |
| 208 | | − <SearchIcon /> |
| 209 | | − </InputGroupAddon> |
| 210 | | − <InputGroupInput |
| 211 | | − aria-label="Search Open VSX themes" |
| 212 | | − autoFocus |
| 213 | | − onChange={(event) => setQuery(event.currentTarget.value)} |
| 214 | | − placeholder="try dracula, nord, catppuccin..." |
| 215 | | − size="lg" |
| 216 | | − type="search" |
| 217 | | − value={query} |
| 218 | | − /> |
| 219 | | − </InputGroup> |
| 220 | | − <Button |
| 221 | | − disabled={!query.trim() || isSearching || installingId !== null} |
| 274 | + <InputGroup> |
| 275 | + <InputGroupAddon> |
| 276 | + {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />} |
| 277 | + </InputGroupAddon> |
| 278 | + <InputGroupInput |
| 279 | + aria-label="Search Open VSX themes" |
| 280 | + autoFocus |
| 281 | + onChange={(event) => setQuery(event.currentTarget.value)} |
| 282 | + onKeyDown={(event) => { |
| 283 | + if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 284 | + if (event.key === "Enter" && !isSearching && installingId === null) |
| 285 | + void runSearch(query.trim()); |
| 286 | + }} |
| 287 | + placeholder="Search themes..." |
| 222 | 288 | size="lg" |
| 223 | | − type="submit" |
| 224 | | − variant="outline" |
| 225 | | − > |
| 226 | | − {isSearching ? <Spinner /> : <SearchIcon />} |
| 227 | | − Search |
| 228 | | − </Button> |
| 229 | | − </form> |
| 289 | + type="search" |
| 290 | + value={query} |
| 291 | + /> |
| 292 | + </InputGroup> |
| 230 | 293 | |
| 231 | | − {!isSearching ? ( |
| 294 | + {!isSearching || results !== null ? ( |
| 232 | 295 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| 233 | 296 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 234 | 297 | <p className="text-muted-foreground text-xs">Popular</p> |
| 235 | 298 | {SUGGESTED_SEARCHES.map((suggestion) => ( |
| 236 | 299 | <Button |
| 237 | 300 | key={suggestion} |
| 301 | + disabled={installingId !== null} |
| 238 | 302 | size="xs" |
| 239 | 303 | variant="ghost" |
| 240 | | − onClick={() => void runSearch(suggestion)} |
| 304 | + onClick={() => { |
| 305 | + if (query.trim() === suggestion) { |
| 306 | + void runSearch(suggestion); |
| 307 | + } else { |
| 308 | + setQuery(suggestion); |
| 309 | + } |
| 310 | + }} |
| 241 | 311 | > |
| 242 | 312 | {suggestion} |
| 243 | 313 | </Button> |
| @@ -271,7 +341,7 @@ export function ThemeSearchSection({ · 27 unmodified lines |
| 271 | 341 | |
| 272 | 342 | <div className="sr-only" role="status"> |
| 273 | 343 | {isSearching |
| 274 | | − ? "Finding themes..." |
| 344 | + ? "Searching themes..." |
| 275 | 345 | : results |
| 276 | 346 | ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` |
| 277 | 347 | : ""} |
| @@ -286,9 +356,9 @@ export function ThemeSearchSection({ · 8 unmodified lines |
| 286 | 356 | </div> |
| 287 | 357 | ) : null} |
| 288 | 358 | |
| 289 | | − {isSearching ? ( |
| 359 | + {isSearching && results === null ? ( |
| 290 | 360 | <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm"> |
| 291 | | − <Spinner /> Finding themes... |
| 361 | + <Spinner /> Searching themes... |
| 292 | 362 | </div> |
| 293 | 363 | ) : null} |
| 294 | 364 | |
| @@ -305,8 +375,8 @@ export function ThemeSearchSection({ · 10 unmodified lines |
| 305 | 375 | const isInstalled = getCustomThemes().some( |
| 306 | 376 | (theme) => theme.collection?.id === extension.collectionId, |
| 307 | 377 | ); |
| 308 | | − const action = isInstalled ? "Update" : "Add"; |
| 309 | | − const progressAction = isInstalled ? "Updating" : "Adding"; |
| 378 | + const action = isInstalled ? "Update" : "Install"; |
| 379 | + const progressAction = isInstalled ? "Updating" : "Installing"; |
| 310 | 380 | return ( |
| 311 | 381 | <article |
| 312 | 382 | className="group flex min-w-0 flex-col gap-3 rounded-xl border border-border/70 bg-card/60 p-3 transition-colors hover:bg-accent/20" |
| @@ -327,19 +397,15 @@ export function ThemeSearchSection({ · 14 unmodified lines |
| 327 | 397 | </p> |
| 328 | 398 | <div className="mt-auto flex items-center justify-between gap-2"> |
| 329 | 399 | <div className="flex min-w-0 items-center gap-2"> |
| 330 | | − <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]"> |
| 331 | | − <ShieldCheckIcon className="size-3" /> {extension.license} |
| 332 | | − </span> |
| 333 | 400 | {extension.sourceUrl ? ( |
| 334 | | − <a |
| 401 | + <Button |
| 335 | 402 | aria-label={`View source for ${extension.name}`} |
| 336 | | − className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground" |
| 337 | | − href={extension.sourceUrl} |
| 338 | | − rel="noreferrer" |
| 339 | | − target="_blank" |
| 403 | + render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />} |
| 404 | + size="icon-micro" |
| 405 | + variant="ghost-muted" |
| 340 | 406 | > |
| 341 | | − Source <ExternalLinkIcon className="size-3" /> |
| 342 | | − </a> |
| 407 | + <SourceLinkIcon url={extension.sourceUrl} /> |
| 408 | + </Button> |
| 343 | 409 | ) : null} |
| 344 | 410 | </div> |
| 345 | 411 | <Button |
| @@ -349,7 +415,13 @@ export function ThemeSearchSection({ · 3 unmodified lines |
| 349 | 415 | variant="outline" |
| 350 | 416 | onClick={() => void handleInstall(extension, false)} |
| 351 | 417 | > |
| 352 | | − {isInstalling ? <Spinner /> : isInstalled ? <RefreshCwIcon /> : <PlusIcon />} |
| 418 | + {isInstalling ? ( |
| 419 | + <Spinner /> |
| 420 | + ) : isInstalled ? ( |
| 421 | + <RefreshCwIcon /> |
| 422 | + ) : ( |
| 423 | + <PackagePlusIcon /> |
| 424 | + )} |
| 353 | 425 | {isInstalling ? `${progressAction}...` : action} |
| 354 | 426 | </Button> |
| 355 | 427 | </div> |