| ... | ... | @@ -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 { |
| 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 }> = |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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> |