import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, RefreshCwIcon, SearchIcon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { importOpenVsxThemeExtension, searchOpenVsxThemes, type OpenVsxThemeExtension, type OpenVsxThemeSort, } from "../../openVsxThemes"; import { useDebouncedValue } from "../../state/queries"; import { getCustomThemes, getStoredCustomThemeCollection, replaceCustomThemeCollection, type ThemeDefinition, } from "../../themePalette"; import { GitHubIcon, GitLabIcon } from "../Icons"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1, }); const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"]; const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = [ { value: "downloadCount", label: "Most downloaded" }, { value: "rating", label: "Best rated" }, { value: "timestamp", label: "Newest" }, { value: "relevance", label: "Most relevant" }, ]; const SEARCH_DEBOUNCE_MS = 350; function SourceLinkIcon({ url }: { url: string }) { try { const host = new URL(url).hostname.toLowerCase(); if (host === "github.com" || host.endsWith(".github.com")) return ; if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return ; } catch { // Fall through to the generic external-link icon. } return ; } function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { const [failed, setFailed] = useState(false); return (
{extension.iconUrl && !failed ? ( setFailed(true)} /> ) : ( )}
); } export function ThemeSearchSection({ open, onInstalled, }: { open: boolean; onInstalled: (themes: ReadonlyArray, context: { updated: boolean }) => void; }) { const [query, setQuery] = useState(""); const [sortBy, setSortBy] = useState("downloadCount"); const [results, setResults] = useState | null>(null); const [error, setError] = useState(null); const [isSearching, setIsSearching] = useState(false); const [installingId, setInstallingId] = useState(null); const [pendingUpdate, setPendingUpdate] = useState(null); const requestRef = useRef(null); // The (query, sort) pair the last search actually ran, so an install // finishing can tell a same-key rerun (which must not wipe an install // error) from a query that changed mid-install (which must be searched). const lastSearchKeyRef = useRef(null); // The (query, sort) pair from the previous effect run, so a search error // that belongs to an older key can be cleared when the user returns to // already-shown results without clearing a fresh install error. const prevSearchKeyRef = useRef(null); useEffect(() => { requestRef.current?.abort(); requestRef.current = null; if (open) { lastSearchKeyRef.current = null; prevSearchKeyRef.current = null; setQuery(""); setSortBy("downloadCount"); setResults(null); setError(null); setIsSearching(false); setInstallingId(null); setPendingUpdate(null); } return () => { requestRef.current?.abort(); requestRef.current = null; }; }, [open]); const runSearch = useCallback( async (searchText: string, nextSort = sortBy) => { const trimmed = searchText.trim(); if (!trimmed) return; requestRef.current?.abort(); const controller = new AbortController(); requestRef.current = controller; setError(null); setIsSearching(true); try { const nextResults = await searchOpenVsxThemes(trimmed, { signal: controller.signal, sortBy: nextSort, }); if (!controller.signal.aborted) { setResults(nextResults); lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`; } } catch (cause) { if (!controller.signal.aborted) { setResults(null); lastSearchKeyRef.current = null; setError(cause instanceof Error ? cause.message : "Open VSX search failed."); } } if (requestRef.current === controller) { requestRef.current = null; setIsSearching(false); } }, [sortBy], ); const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); useEffect(() => { if (query.trim() || installingId !== null) return; requestRef.current?.abort(); requestRef.current = null; lastSearchKeyRef.current = null; setResults(null); setError(null); setIsSearching(false); }, [query, installingId]); useEffect(() => { if (!open) return; const searchKey = `${debouncedQuery}\u0000${sortBy}`; const keyChanged = prevSearchKeyRef.current !== searchKey; prevSearchKeyRef.current = searchKey; if (installingId !== null) return; if (!debouncedQuery) { lastSearchKeyRef.current = null; requestRef.current?.abort(); requestRef.current = null; setResults(null); setError(null); setIsSearching(false); return; } if (debouncedQuery !== query.trim()) { // The debounced value still trails the input (dialog reopened with the // box reset, or the user is mid-keystroke). Searching it would hit Open // VSX for a query that is no longer visible; wait for the debounce to // catch up to the current input instead. return; } if (lastSearchKeyRef.current === searchKey) { // The results already match this query. A request for a newer key may // still be in flight (typed and then undone); abort it so it cannot // overwrite the results. Only a genuine key change makes a stale search // error irrelevant, so an install error on an unchanged query survives. requestRef.current?.abort(); requestRef.current = null; setIsSearching(false); if (keyChanged) setError(null); return; } void runSearch(debouncedQuery); // `installingId` and `sortBy` are deliberately not dependencies: the // guards above read the current values from the fresh render closure. An // install finishing reruns the search only when the query or sort changed // while it was in flight (checked via lastSearchKeyRef, recorded only // once a search succeeds), so the install error the user needs to see is // preserved across that rerun. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, query, debouncedQuery, installingId, runSearch]); const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => { const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value; if (!nextSort) return; setSortBy(nextSort); }, []); const handleInstall = useCallback( async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => { setError(null); let installedCollection: ReadonlyArray; try { installedCollection = getStoredCustomThemeCollection(extension.collectionId); } catch (cause) { setError(cause instanceof Error ? cause.message : "Installed themes could not be read."); return; } const updated = installedCollection.length > 0; if (updated && !allowUpdate) { setPendingUpdate(extension); return; } requestRef.current?.abort(); const controller = new AbortController(); requestRef.current = controller; setIsSearching(false); setInstallingId(extension.id); try { const themes = await importOpenVsxThemeExtension(extension, controller.signal); if (!controller.signal.aborted) { const imported = replaceCustomThemeCollection(extension.collectionId, themes, { expectedCollection: installedCollection, }); onInstalled(imported, { updated }); } } catch (cause) { if (!controller.signal.aborted) { setError(cause instanceof Error ? cause.message : "That theme could not be added."); } } if (requestRef.current === controller) { requestRef.current = null; setInstallingId(null); } }, [onInstalled], ); return (

Search community themes

Find open-source themes from Open VSX.

{isSearching ? : } setQuery(event.currentTarget.value)} onKeyDown={(event) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter" && !isSearching && installingId === null) void runSearch(query.trim()); }} placeholder="Search themes..." size="lg" type="search" value={query} /> {!isSearching || results !== null ? (

Popular

{SUGGESTED_SEARCHES.map((suggestion) => ( ))}
{results && results.length > 0 ? (

Sort

) : null}
) : null}
{isSearching ? "Searching themes..." : results ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` : ""}
{error ? (
{error}
) : null} {isSearching && results === null ? (
Searching themes...
) : null} {results ? ( results.length === 0 ? (

No supported open-source themes found

Try a broader search.

) : (
{results.map((extension) => { const isInstalling = installingId === extension.id; const isInstalled = getCustomThemes().some( (theme) => theme.collection?.id === extension.collectionId, ); const action = isInstalled ? "Update" : "Install"; const progressAction = isInstalled ? "Updating" : "Installing"; return (

{extension.name}

{extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "} downloads

{extension.description || "A community color theme for your editor."}

{extension.sourceUrl ? ( ) : null}
); })}
) ) : null} { if (!nextOpen) setPendingUpdate(null); }} > Update “{pendingUpdate?.name}”? This replaces its installed variants, including any local edits. Variants no longer in the extension will be removed. }>Cancel
); }