import {
ExternalLinkIcon,
PaletteIcon,
PlusIcon,
RefreshCwIcon,
SearchIcon,
ShieldCheckIcon,
} from "lucide-react";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
importOpenVsxThemeExtension,
searchOpenVsxThemes,
type OpenVsxThemeExtension,
type OpenVsxThemeSort,
} from "../../openVsxThemes";
import {
getCustomThemes,
getStoredCustomThemeCollection,
replaceCustomThemeCollection,
type ThemeDefinition,
} from "../../themePalette";
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" },
];
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);
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (open) {
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;
setQuery(trimmed);
setIsSearching(true);
setError(null);
setResults(null);
try {
const nextResults = await searchOpenVsxThemes(trimmed, {
signal: controller.signal,
sortBy: nextSort,
});
if (!controller.signal.aborted) setResults(nextResults);
} catch (cause) {
if (!controller.signal.aborted) {
setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
}
}
if (requestRef.current === controller) {
requestRef.current = null;
setIsSearching(false);
}
},
[sortBy],
);
const handleSearch = useCallback(
(event: FormEvent) => {
event.preventDefault();
void runSearch(query);
},
[query, runSearch],
);
const handleSortChange = useCallback(
(value: OpenVsxThemeSort | null) => {
const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
if (!nextSort) return;
setSortBy(nextSort);
if ((results !== null || isSearching) && query.trim()) {
void runSearch(query, nextSort);
}
},
[isSearching, query, results, runSearch],
);
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;
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 ? (
Popular
{SUGGESTED_SEARCHES.map((suggestion) => (
void runSearch(suggestion)}
>
{suggestion}
))}
{results && results.length > 0 ? (
Sort
{SORT_OPTIONS.find((option) => option.value === sortBy)?.label}
{SORT_OPTIONS.map((option) => (
{option.label}
))}
) : null}
) : null}
{isSearching
? "Finding themes..."
: results
? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
: ""}
{error ? (
{error}
) : null}
{isSearching ? (
Finding 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" : "Add";
const progressAction = isInstalled ? "Updating" : "Adding";
return (
{extension.name}
{extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
downloads
{extension.description || "A community color theme for your editor."}
{extension.license}
{extension.sourceUrl ? (
Source
) : null}
void handleInstall(extension, false)}
>
{isInstalling ? : isInstalled ? : }
{isInstalling ? `${progressAction}...` : action}
);
})}
)
) : 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
{
const extension = pendingUpdate;
setPendingUpdate(null);
if (extension) void handleInstall(extension, true);
}}
>
Update theme
);
}