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 <GitHubIcon className="size-3.5" />; if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return <GitLabIcon className="size-3.5" monochrome />; } catch { // Fall through to the generic external-link icon. } return <ExternalLinkIcon className="size-3.5" />;} function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) { const [failed, setFailed] = useState(false); return ( <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground"> {extension.iconUrl && !failed ? ( <img alt="" className="size-full object-cover" loading="lazy" referrerPolicy="no-referrer" src={extension.iconUrl} onError={() => setFailed(true)} /> ) : ( <PaletteIcon className="size-4" /> )} </div> );} export function ThemeSearchSection({ open, onInstalled,}: { open: boolean; onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;}) { const [query, setQuery] = useState(""); const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount"); const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null); const [error, setError] = useState<string | null>(null); const [isSearching, setIsSearching] = useState(false); const [installingId, setInstallingId] = useState<string | null>(null); const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null); const requestRef = useRef<AbortController | null>(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<string | null>(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<string | null>(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<ThemeDefinition>; 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 ( <section className="space-y-3" aria-labelledby="theme-search-heading"> <div> <h3 className="text-sm font-medium" id="theme-search-heading"> Search community themes </h3> <p className="mt-0.5 text-muted-foreground text-xs"> Find open-source themes from Open VSX. </p> </div> <InputGroup> <InputGroupAddon> {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />} </InputGroupAddon> <InputGroupInput aria-label="Search Open VSX themes" autoFocus onChange={(event) => 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} /> </InputGroup> {!isSearching || results !== null ? ( <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex min-w-0 flex-wrap items-center gap-1.5"> <p className="text-muted-foreground text-xs">Popular</p> {SUGGESTED_SEARCHES.map((suggestion) => ( <Button key={suggestion} disabled={installingId !== null} size="xs" variant="ghost" onClick={() => { if (query.trim() === suggestion) { void runSearch(suggestion); } else { setQuery(suggestion); } }} > {suggestion} </Button> ))} </div> {results && results.length > 0 ? ( <div className="flex shrink-0 items-center justify-end gap-2"> <p className="text-muted-foreground text-xs">Sort</p> <Select disabled={installingId !== null} value={sortBy} onValueChange={handleSortChange} > <SelectTrigger size="sm" className="w-40" aria-label="Sort themes"> <SelectValue> {SORT_OPTIONS.find((option) => option.value === sortBy)?.label} </SelectValue> </SelectTrigger> <SelectPopup align="end" alignItemWithTrigger={false}> {SORT_OPTIONS.map((option) => ( <SelectItem key={option.value} hideIndicator value={option.value}> {option.label} </SelectItem> ))} </SelectPopup> </Select> </div> ) : null} </div> ) : null} <div className="sr-only" role="status"> {isSearching ? "Searching themes..." : results ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.` : ""} </div> {error ? ( <div aria-live="polite" className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm" > {error} </div> ) : null} {isSearching && results === null ? ( <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm"> <Spinner /> Searching themes... </div> ) : null} {results ? ( results.length === 0 ? ( <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center"> <p className="text-sm font-medium">No supported open-source themes found</p> <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p> </div> ) : ( <div className="grid gap-2 sm:grid-cols-2"> {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 ( <article 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" key={extension.id} > <div className="flex min-w-0 gap-3"> <ThemeExtensionIcon key={extension.iconUrl} extension={extension} /> <div className="min-w-0 flex-1"> <h4 className="truncate text-sm font-medium">{extension.name}</h4> <p className="truncate text-muted-foreground text-xs"> {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "} downloads </p> </div> </div> <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4"> {extension.description || "A community color theme for your editor."} </p> <div className="mt-auto flex items-center justify-between gap-2"> <div className="flex min-w-0 items-center gap-2"> {extension.sourceUrl ? ( <Button aria-label={`View source for ${extension.name}`} render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />} size="icon-micro" variant="ghost-muted" > <SourceLinkIcon url={extension.sourceUrl} /> </Button> ) : null} </div> <Button aria-label={`${isInstalling ? progressAction : action} ${extension.name}`} disabled={installingId !== null} size="xs" variant="outline" onClick={() => void handleInstall(extension, false)} > {isInstalling ? ( <Spinner /> ) : isInstalled ? ( <RefreshCwIcon /> ) : ( <PackagePlusIcon /> )} {isInstalling ? `${progressAction}...` : action} </Button> </div> </article> ); })} </div> ) ) : null} <AlertDialog open={pendingUpdate !== null} onOpenChange={(nextOpen) => { if (!nextOpen) setPendingUpdate(null); }} > <AlertDialogPopup> <AlertDialogHeader> <AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle> <AlertDialogDescription> This replaces its installed variants, including any local edits. Variants no longer in the extension will be removed. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose> <Button onClick={() => { const extension = pendingUpdate; setPendingUpdate(null); if (extension) void handleInstall(extension, true); }} > Update theme </Button> </AlertDialogFooter> </AlertDialogPopup> </AlertDialog> </section> );}
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 <GitHubIcon className="size-3.5" />;
if (host === "gitlab.com" || host.endsWith(".gitlab.com"))
return <GitLabIcon className="size-3.5" monochrome />;
} catch {
// Fall through to the generic external-link icon.
}
return <ExternalLinkIcon className="size-3.5" />;
}
function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
const [failed, setFailed] = useState(false);
return (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
{extension.iconUrl && !failed ? (
<img
alt=""
className="size-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
src={extension.iconUrl}
onError={() => setFailed(true)}
/>
) : (
<PaletteIcon className="size-4" />
)}
</div>
);
}
export function ThemeSearchSection({
open,
onInstalled,
}: {
open: boolean;
onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
}) {
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount");
const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [installingId, setInstallingId] = useState<string | null>(null);
const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
const requestRef = useRef<AbortController | null>(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<string | null>(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<string | null>(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<ThemeDefinition>;
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 (
<section className="space-y-3" aria-labelledby="theme-search-heading">
<div>
<h3 className="text-sm font-medium" id="theme-search-heading">
Search community themes
</h3>
<p className="mt-0.5 text-muted-foreground text-xs">
Find open-source themes from Open VSX.
</p>
</div>
<InputGroup>
<InputGroupAddon>
{isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
</InputGroupAddon>
<InputGroupInput
aria-label="Search Open VSX themes"
autoFocus
onChange={(event) => 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}
/>
</InputGroup>
{!isSearching || results !== null ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<p className="text-muted-foreground text-xs">Popular</p>
{SUGGESTED_SEARCHES.map((suggestion) => (
<Button
key={suggestion}
disabled={installingId !== null}
size="xs"
variant="ghost"
onClick={() => {
if (query.trim() === suggestion) {
void runSearch(suggestion);
} else {
setQuery(suggestion);
}
}}
>
{suggestion}
</Button>
))}
</div>
{results && results.length > 0 ? (
<div className="flex shrink-0 items-center justify-end gap-2">
<p className="text-muted-foreground text-xs">Sort</p>
<Select
disabled={installingId !== null}
value={sortBy}
onValueChange={handleSortChange}
>
<SelectTrigger size="sm" className="w-40" aria-label="Sort themes">
<SelectValue>
{SORT_OPTIONS.find((option) => option.value === sortBy)?.label}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} hideIndicator value={option.value}>
{option.label}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
) : null}
</div>
) : null}
<div className="sr-only" role="status">
{isSearching
? "Searching themes..."
: results
? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
: ""}
</div>
{error ? (
<div
aria-live="polite"
className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm"
>
{error}
</div>
) : null}
{isSearching && results === null ? (
<div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
<Spinner /> Searching themes...
</div>
) : null}
{results ? (
results.length === 0 ? (
<div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
<p className="text-sm font-medium">No supported open-source themes found</p>
<p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{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 (
<article
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"
key={extension.id}
>
<div className="flex min-w-0 gap-3">
<ThemeExtensionIcon key={extension.iconUrl} extension={extension} />
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium">{extension.name}</h4>
<p className="truncate text-muted-foreground text-xs">
{extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
downloads
</p>
</div>
</div>
<p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
{extension.description || "A community color theme for your editor."}
</p>
<div className="mt-auto flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{extension.sourceUrl ? (
<Button
aria-label={`View source for ${extension.name}`}
render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />}
size="icon-micro"
variant="ghost-muted"
>
<SourceLinkIcon url={extension.sourceUrl} />
</Button>
) : null}
</div>
<Button
aria-label={`${isInstalling ? progressAction : action} ${extension.name}`}
disabled={installingId !== null}
size="xs"
variant="outline"
onClick={() => void handleInstall(extension, false)}
>
{isInstalling ? (
<Spinner />
) : isInstalled ? (
<RefreshCwIcon />
) : (
<PackagePlusIcon />
)}
{isInstalling ? `${progressAction}...` : action}
</Button>
</div>
</article>
);
})}
</div>
)
) : null}
<AlertDialog
open={pendingUpdate !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) setPendingUpdate(null);
}}
>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle>
<AlertDialogDescription>
This replaces its installed variants, including any local edits. Variants no longer in
the extension will be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button
onClick={() => {
const extension = pendingUpdate;
setPendingUpdate(null);
if (extension) void handleInstall(extension, true);
}}
>
Update theme
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
</AlertDialog>
</section>
);
}