| ... | @@ -1,12 +1,10 @@ | ... | @@ -1,12 +1,10 @@ |
| 1 | import { | 1 | import { |
| 2 | ExternalLinkIcon, | 2 | ExternalLinkIcon, |
| | 3 | PackagePlusIcon, |
| 3 | PaletteIcon, | 4 | PaletteIcon, |
| 4 | PlusIcon, | | |
| 5 | RefreshCwIcon, | 5 | RefreshCwIcon, |
| 6 | SearchIcon, | 6 | SearchIcon, |
| 7 | ShieldCheckIcon, | | |
| 8 | } from "lucide-react"; | 7 | } from "lucide-react"; |
| 9 | import type { FormEvent } from "react"; | | |
| 10 | import { useCallback, useEffect, useRef, useState } from "react"; | 8 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 11 | import { | 9 | import { |
| 12 | importOpenVsxThemeExtension, | 10 | importOpenVsxThemeExtension, |
| ... | @@ -14,12 +12,14 @@ import { | ... | @@ -14,12 +12,14 @@ import { |
| 14 | type OpenVsxThemeExtension, | 12 | type OpenVsxThemeExtension, |
| 15 | type OpenVsxThemeSort, | 13 | type OpenVsxThemeSort, |
| 16 | } from "../../openVsxThemes"; | 14 | } from "../../openVsxThemes"; |
| | 15 | import { useDebouncedValue } from "../../state/queries"; |
| 17 | import { | 16 | import { |
| 18 | getCustomThemes, | 17 | getCustomThemes, |
| 19 | getStoredCustomThemeCollection, | 18 | getStoredCustomThemeCollection, |
| 20 | replaceCustomThemeCollection, | 19 | replaceCustomThemeCollection, |
| 21 | type ThemeDefinition, | 20 | type ThemeDefinition, |
| 22 | } from "../../themePalette"; | 21 | } from "../../themePalette"; |
| | 22 | import { GitHubIcon, GitLabIcon } from "../Icons"; |
| 23 | import { | 23 | import { |
| 24 | AlertDialog, | 24 | AlertDialog, |
| 25 | AlertDialogClose, | 25 | AlertDialogClose, |
| ... | @@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = | ... | @@ -45,6 +45,20 @@ const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = |
| 45 | { value: "timestamp", label: "Newest" }, | 45 | { value: "timestamp", label: "Newest" }, |
| 46 | { value: "relevance", label: "Most relevant" }, | 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 | function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { | 63 | function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { |
| 50 | const [failed, setFailed] = useState(false); | 64 | const [failed, setFailed] = useState(false); |
| ... | @@ -82,11 +96,21 @@ export function ThemeSearchSection({ | ... | @@ -82,11 +96,21 @@ export function ThemeSearchSection({ |
| 82 | const [installingId, setInstallingId] = useState<string | null>(null); | 96 | const [installingId, setInstallingId] = useState<string | null>(null); |
| 83 | const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null); | 97 | const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null); |
| 84 | const requestRef = useRef<AbortController | null>(null); | 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 | useEffect(() => { | 108 | useEffect(() => { |
| 87 | requestRef.current?.abort(); | 109 | requestRef.current?.abort(); |
| 88 | requestRef.current = null; | 110 | requestRef.current = null; |
| 89 | if (open) { | 111 | if (open) { |
| | 112 | lastSearchKeyRef.current = null; |
| | 113 | prevSearchKeyRef.current = null; |
| 90 | setQuery(""); | 114 | setQuery(""); |
| 91 | setSortBy("downloadCount"); | 115 | setSortBy("downloadCount"); |
| 92 | setResults(null); | 116 | setResults(null); |
| ... | @@ -108,18 +132,21 @@ export function ThemeSearchSection({ | ... | @@ -108,18 +132,21 @@ export function ThemeSearchSection({ |
| 108 | requestRef.current?.abort(); | 132 | requestRef.current?.abort(); |
| 109 | const controller = new AbortController(); | 133 | const controller = new AbortController(); |
| 110 | requestRef.current = controller; | 134 | requestRef.current = controller; |
| 111 | setQuery(trimmed); | | |
| 112 | setIsSearching(true); | | |
| 113 | setError(null); | 135 | setError(null); |
| 114 | setResults(null); | 136 | setIsSearching(true); |
| 115 | try { | 137 | try { |
| 116 | const nextResults = await searchOpenVsxThemes(trimmed, { | 138 | const nextResults = await searchOpenVsxThemes(trimmed, { |
| 117 | signal: controller.signal, | 139 | signal: controller.signal, |
| 118 | sortBy: nextSort, | 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 | } catch (cause) { | 146 | } catch (cause) { |
| 122 | if (!controller.signal.aborted) { | 147 | if (!controller.signal.aborted) { |
| | 148 | setResults(null); |
| | 149 | lastSearchKeyRef.current = null; |
| 123 | setError(cause instanceof Error ? cause.message : "Open VSX search failed."); | 150 | setError(cause instanceof Error ? cause.message : "Open VSX search failed."); |
| 124 | } | 151 | } |
| 125 | } | 152 | } |
| ... | @@ -131,25 +158,66 @@ export function ThemeSearchSection({ | ... | @@ -131,25 +158,66 @@ export function ThemeSearchSection({ |
| 131 | [sortBy], | 158 | [sortBy], |
| 132 | ); | 159 | ); |
| 133 | | 160 | |
| 134 | const handleSearch = useCallback( | 161 | const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); |
| 135 | (event: FormEvent<HTMLFormElement>) => { | | |
| 136 | event.preventDefault(); | | |
| 137 | void runSearch(query); | | |
| 138 | }, | | |
| 139 | [query, runSearch], | | |
| 140 | ); | | |
| 141 | | 162 | |
| 142 | const handleSortChange = useCallback( | 163 | useEffect(() => { |
| 143 | (value: OpenVsxThemeSort | null) => { | 164 | if (query.trim() || installingId !== null) return; |
| 144 | const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value; | 165 | requestRef.current?.abort(); |
| 145 | if (!nextSort) return; | 166 | requestRef.current = null; |
| 146 | setSortBy(nextSort); | 167 | lastSearchKeyRef.current = null; |
| 147 | if ((results !== null || isSearching) && query.trim()) { | 168 | setResults(null); |
| 148 | void runSearch(query, nextSort); | 169 | setError(null); |
| 149 | } | 170 | setIsSearching(false); |
| 150 | }, | 171 | }, [query, installingId]); |
| 151 | [isSearching, query, results, runSearch], | 172 | |
| 152 | ); | 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 | const handleInstall = useCallback( | 222 | const handleInstall = useCallback( |
| 155 | async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => { | 223 | async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => { |
| ... | @@ -170,6 +238,7 @@ export function ThemeSearchSection({ | ... | @@ -170,6 +238,7 @@ export function ThemeSearchSection({ |
| 170 | requestRef.current?.abort(); | 238 | requestRef.current?.abort(); |
| 171 | const controller = new AbortController(); | 239 | const controller = new AbortController(); |
| 172 | requestRef.current = controller; | 240 | requestRef.current = controller; |
| | 241 | setIsSearching(false); |
| 173 | setInstallingId(extension.id); | 242 | setInstallingId(extension.id); |
| 174 | try { | 243 | try { |
| 175 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); | 244 | const themes = await importOpenVsxThemeExtension(extension, controller.signal); |
| ... | @@ -202,42 +271,43 @@ export function ThemeSearchSection({ | ... | @@ -202,42 +271,43 @@ export function ThemeSearchSection({ |
| 202 | Find open-source themes from Open VSX. | 271 | Find open-source themes from Open VSX. |
| 203 | </p> | 272 | </p> |
| 204 | </div> | 273 | </div> |
| 205 | <form className="flex gap-2" onSubmit={handleSearch}> | 274 | <InputGroup> |
| 206 | <InputGroup> | 275 | <InputGroupAddon> |
| 207 | <InputGroupAddon> | 276 | {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />} |
| 208 | <SearchIcon /> | 277 | </InputGroupAddon> |
| 209 | </InputGroupAddon> | 278 | <InputGroupInput |
| 210 | <InputGroupInput | 279 | aria-label="Search Open VSX themes" |
| 211 | aria-label="Search Open VSX themes" | 280 | autoFocus |
| 212 | autoFocus | 281 | onChange={(event) => setQuery(event.currentTarget.value)} |
| 213 | onChange={(event) => setQuery(event.currentTarget.value)} | 282 | onKeyDown={(event) => { |
| 214 | placeholder="try dracula, nord, catppuccin..." | 283 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 215 | size="lg" | 284 | if (event.key === "Enter" && !isSearching && installingId === null) |
| 216 | type="search" | 285 | void runSearch(query.trim()); |
| 217 | value={query} | 286 | }} |
| 218 | /> | 287 | placeholder="Search themes..." |
| 219 | </InputGroup> | | |
| 220 | <Button | | |
| 221 | disabled={!query.trim() || isSearching || installingId !== null} | | |
| 222 | size="lg" | 288 | size="lg" |
| 223 | type="submit" | 289 | type="search" |
| 224 | variant="outline" | 290 | value={query} |
| 225 | > | 291 | /> |
| 226 | {isSearching ? <Spinner /> : <SearchIcon />} | 292 | </InputGroup> |
| 227 | Search | | |
| 228 | </Button> | | |
| 229 | </form> | | |
| 230 | | 293 | |
| 231 | {!isSearching ? ( | 294 | {!isSearching || results !== null ? ( |
| 232 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> | 295 | <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> |
| 233 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> | 296 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 234 | <p className="text-muted-foreground text-xs">Popular</p> | 297 | <p className="text-muted-foreground text-xs">Popular</p> |
| 235 | {SUGGESTED_SEARCHES.map((suggestion) => ( | 298 | {SUGGESTED_SEARCHES.map((suggestion) => ( |
| 236 | <Button | 299 | <Button |
| 237 | key={suggestion} | 300 | key={suggestion} |
| | 301 | disabled={installingId !== null} |
| 238 | size="xs" | 302 | size="xs" |
| 239 | variant="ghost" | 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 | {suggestion} | 312 | {suggestion} |
| 243 | </Button> | 313 | </Button> |
| ... | @@ -271,7 +341,7 @@ export function ThemeSearchSection({ | ... | @@ -271,7 +341,7 @@ export function ThemeSearchSection({ |
| 271 | | 341 | |
| 272 | <div className="sr-only" role="status"> | 342 | <div className="sr-only" role="status"> |
| 273 | {isSearching | 343 | {isSearching |
| 274 | ? "Finding themes..." | 344 | ? "Searching themes..." |
| 275 | : results | 345 | : results |
| 276 | ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` | 346 | ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` |
| 277 | : ""} | 347 | : ""} |
| ... | @@ -286,9 +356,9 @@ export function ThemeSearchSection({ | ... | @@ -286,9 +356,9 @@ export function ThemeSearchSection({ |
| 286 | </div> | 356 | </div> |
| 287 | ) : null} | 357 | ) : null} |
| 288 | | 358 | |
| 289 | {isSearching ? ( | 359 | {isSearching && results === null ? ( |
| 290 | <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm"> | 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 | </div> | 362 | </div> |
| 293 | ) : null} | 363 | ) : null} |
| 294 | | 364 | |
| ... | @@ -305,8 +375,8 @@ export function ThemeSearchSection({ | ... | @@ -305,8 +375,8 @@ export function ThemeSearchSection({ |
| 305 | const isInstalled = getCustomThemes().some( | 375 | const isInstalled = getCustomThemes().some( |
| 306 | (theme) => theme.collection?.id === extension.collectionId, | 376 | (theme) => theme.collection?.id === extension.collectionId, |
| 307 | ); | 377 | ); |
| 308 | const action = isInstalled ? "Update" : "Add"; | 378 | const action = isInstalled ? "Update" : "Install"; |
| 309 | const progressAction = isInstalled ? "Updating" : "Adding"; | 379 | const progressAction = isInstalled ? "Updating" : "Installing"; |
| 310 | return ( | 380 | return ( |
| 311 | <article | 381 | <article |
| 312 | 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" | 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,19 +397,15 @@ export function ThemeSearchSection({ |
| 327 | </p> | 397 | </p> |
| 328 | <div className="mt-auto flex items-center justify-between gap-2"> | 398 | <div className="mt-auto flex items-center justify-between gap-2"> |
| 329 | <div className="flex min-w-0 items-center gap-2"> | 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 | {extension.sourceUrl ? ( | 400 | {extension.sourceUrl ? ( |
| 334 | <a | 401 | <Button |
| 335 | aria-label={`View source for ${extension.name}`} | 402 | aria-label={`View source for ${extension.name}`} |
| 336 | className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground" | 403 | render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />} |
| 337 | href={extension.sourceUrl} | 404 | size="icon-micro" |
| 338 | rel="noreferrer" | 405 | variant="ghost-muted" |
| 339 | target="_blank" | | |
| 340 | > | 406 | > |
| 341 | Source <ExternalLinkIcon className="size-3" /> | 407 | <SourceLinkIcon url={extension.sourceUrl} /> |
| 342 | </a> | 408 | </Button> |
| 343 | ) : null} | 409 | ) : null} |
| 344 | </div> | 410 | </div> |
| 345 | <Button | 411 | <Button |
| ... | @@ -349,7 +415,13 @@ export function ThemeSearchSection({ | ... | @@ -349,7 +415,13 @@ export function ThemeSearchSection({ |
| 349 | variant="outline" | 415 | variant="outline" |
| 350 | onClick={() => void handleInstall(extension, false)} | 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 | {isInstalling ? `${progressAction}...` : action} | 425 | {isInstalling ? `${progressAction}...` : action} |
| 354 | </Button> | 426 | </Button> |
| 355 | </div> | 427 | </div> |