1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react";
3import { useCallback, useEffect, useRef, useState } from "react";
4import {
5 importOpenVsxThemeExtension,
6 searchOpenVsxThemes,
7 type OpenVsxThemeExtension,
8 type OpenVsxThemeSort,
9} from "../../openVsxThemes";
10import { useDebouncedValue } from "../../state/queries";
11import {
12 getCustomThemes,
13 getStoredCustomThemeCollection,
14 replaceCustomThemeCollection,
15 type ThemeDefinition,
16} from "../../themePalette";
17import { GitHubIcon, GitLabIcon } from "../Icons";
18import {
19 AlertDialog,
20 AlertDialogClose,
21 AlertDialogDescription,
22 AlertDialogFooter,
23 AlertDialogHeader,
24 AlertDialogPopup,
25 AlertDialogTitle,
26} from "../ui/alert-dialog";
27import { Alert, AlertDescription } from "../ui/alert";
28import { Button } from "../ui/button";
29import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
30import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
31import { Spinner } from "../ui/spinner";
32
33const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
34 notation: "compact",
35 maximumFractionDigits: 1,
36});
37const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
38const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = [
39 { value: "downloadCount", label: "Most downloaded" },
40 { value: "rating", label: "Best rated" },
41 { value: "timestamp", label: "Newest" },
42 { value: "relevance", label: "Most relevant" },
43];
44const SEARCH_DEBOUNCE_MS = 350;
45
46function SourceLinkIcon({ url }: { url: string }) {
47 try {
48 const host = new URL(url).hostname.toLowerCase();
49 if (host === "github.com" || host.endsWith(".github.com"))
50 return <GitHubIcon className="size-3.5" />;
51 if (host === "gitlab.com" || host.endsWith(".gitlab.com"))
52 return <GitLabIcon className="size-3.5" monochrome />;
53 } catch {
54 // Fall through to the generic external-link icon.
55 }
56 return <ExternalLinkIcon className="size-3.5" />;
57}
58
59function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
60 const [failed, setFailed] = useState(false);
61
62 return (
63 <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
64 {extension.iconUrl && !failed ? (
65 <img
66 alt=""
67 className="size-full object-cover"
68 loading="lazy"
69 referrerPolicy="no-referrer"
70 src={extension.iconUrl}
71 onError={() => setFailed(true)}
72 />
73 ) : (
74 <PaletteIcon className="size-4" />
75 )}
76 </div>
77 );
78}
79
80export function ThemeSearchSection({
81 open,
82 onInstalled,
83}: {
84 open: boolean;
85 onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
86}) {
87 const [query, setQuery] = useState("");
88 const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount");
89 const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
90 const [error, setError] = useState<string | null>(null);
91 const [isSearching, setIsSearching] = useState(false);
92 const [installingId, setInstallingId] = useState<string | null>(null);
93 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
94 const requestRef = useRef<AbortController | null>(null);
95 // The (query, sort) pair the last search actually ran, so an install
96 // finishing can tell a same-key rerun (which must not wipe an install
97 // error) from a query that changed mid-install (which must be searched).
98 const lastSearchKeyRef = useRef<string | null>(null);
99 // The (query, sort) pair from the previous effect run, so a search error
100 // that belongs to an older key can be cleared when the user returns to
101 // already-shown results without clearing a fresh install error.
102 const prevSearchKeyRef = useRef<string | null>(null);
103
104 useEffect(() => {
105 requestRef.current?.abort();
106 requestRef.current = null;
107 if (open) {
108 lastSearchKeyRef.current = null;
109 prevSearchKeyRef.current = null;
110 setQuery("");
111 setSortBy("downloadCount");
112 setResults(null);
113 setError(null);
114 setIsSearching(false);
115 setInstallingId(null);
116 setPendingUpdate(null);
117 }
118 return () => {
119 requestRef.current?.abort();
120 requestRef.current = null;
121 };
122 }, [open]);
123
124 const runSearch = useCallback(
125 async (searchText: string, nextSort = sortBy) => {
126 const trimmed = searchText.trim();
127 if (!trimmed) return;
128 requestRef.current?.abort();
129 const controller = new AbortController();
130 requestRef.current = controller;
131 setError(null);
132 setIsSearching(true);
133 try {
134 const nextResults = await searchOpenVsxThemes(trimmed, {
135 signal: controller.signal,
136 sortBy: nextSort,
137 });
138 if (!controller.signal.aborted) {
139 setResults(nextResults);
140 lastSearchKeyRef.current = `${trimmed}\u0000${nextSort}`;
141 }
142 } catch (cause) {
143 if (!controller.signal.aborted) {
144 setResults(null);
145 lastSearchKeyRef.current = null;
146 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
147 }
148 }
149 if (requestRef.current === controller) {
150 requestRef.current = null;
151 setIsSearching(false);
152 }
153 },
154 [sortBy],
155 );
156
157 const debouncedQuery = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
158
159 useEffect(() => {
160 if (query.trim() || installingId !== null) return;
161 requestRef.current?.abort();
162 requestRef.current = null;
163 lastSearchKeyRef.current = null;
164 setResults(null);
165 setError(null);
166 setIsSearching(false);
167 }, [query, installingId]);
168
169 useEffect(() => {
170 if (!open) return;
171 const searchKey = `${debouncedQuery}\u0000${sortBy}`;
172 const keyChanged = prevSearchKeyRef.current !== searchKey;
173 prevSearchKeyRef.current = searchKey;
174 if (installingId !== null) return;
175 if (!debouncedQuery) {
176 lastSearchKeyRef.current = null;
177 requestRef.current?.abort();
178 requestRef.current = null;
179 setResults(null);
180 setError(null);
181 setIsSearching(false);
182 return;
183 }
184 if (debouncedQuery !== query.trim()) {
185 // The debounced value still trails the input (dialog reopened with the
186 // box reset, or the user is mid-keystroke). Searching it would hit Open
187 // VSX for a query that is no longer visible; wait for the debounce to
188 // catch up to the current input instead.
189 return;
190 }
191 if (lastSearchKeyRef.current === searchKey) {
192 // The results already match this query. A request for a newer key may
193 // still be in flight (typed and then undone); abort it so it cannot
194 // overwrite the results. Only a genuine key change makes a stale search
195 // error irrelevant, so an install error on an unchanged query survives.
196 requestRef.current?.abort();
197 requestRef.current = null;
198 setIsSearching(false);
199 if (keyChanged) setError(null);
200 return;
201 }
202 void runSearch(debouncedQuery);
203 // `sortBy` is deliberately not a direct dependency: the guards above read
204 // the current value from the fresh render closure. An install finishing
205 // reruns the search only when the query or sort changed while it was in
206 // flight (checked via lastSearchKeyRef, recorded only once a search
207 // succeeds), so the install error the user needs to see is preserved
208 // across that rerun.
209 }, [open, query, debouncedQuery, installingId, runSearch]);
210
211 const handleSortChange = useCallback((value: OpenVsxThemeSort | null) => {
212 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
213 if (!nextSort) return;
214 setSortBy(nextSort);
215 }, []);
216
217 const handleInstall = useCallback(
218 async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
219 setError(null);
220 let installedCollection: ReadonlyArray<ThemeDefinition>;
221 try {
222 installedCollection = getStoredCustomThemeCollection(extension.collectionId);
223 } catch (cause) {
224 setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
225 return;
226 }
227 const updated = installedCollection.length > 0;
228 if (updated && !allowUpdate) {
229 setPendingUpdate(extension);
230 return;
231 }
232
233 requestRef.current?.abort();
234 const controller = new AbortController();
235 requestRef.current = controller;
236 setIsSearching(false);
237 setInstallingId(extension.id);
238 try {
239 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
240 if (!controller.signal.aborted) {
241 const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
242 expectedCollection: installedCollection,
243 });
244 onInstalled(imported, { updated });
245 }
246 } catch (cause) {
247 if (!controller.signal.aborted) {
248 setError(cause instanceof Error ? cause.message : "That theme could not be added.");
249 }
250 }
251 if (requestRef.current === controller) {
252 requestRef.current = null;
253 setInstallingId(null);
254 }
255 },
256 [onInstalled],
257 );
258
259 return (
260 <section className="space-y-3" aria-labelledby="theme-search-heading">
261 <div>
262 <h3 className="text-sm font-medium" id="theme-search-heading">
263 Search community themes
264 </h3>
265 <p className="mt-0.5 text-muted-foreground text-xs">
266 Find open-source themes from Open VSX.
267 </p>
268 </div>
269 <InputGroup>
270 <InputGroupAddon>
271 {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
272 </InputGroupAddon>
273 <InputGroupInput
274 aria-label="Search Open VSX themes"
275 autoFocus
276 onChange={(event) => setQuery(event.currentTarget.value)}
277 onKeyDown={(event) => {
278 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
279 if (event.key === "Enter" && !isSearching && installingId === null)
280 void runSearch(query.trim());
281 }}
282 placeholder="Search themes..."
283 size="lg"
284 type="search"
285 value={query}
286 />
287 </InputGroup>
288
289 {!isSearching || results !== null ? (
290 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
291 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
292 <p className="text-muted-foreground text-xs">Popular</p>
293 {SUGGESTED_SEARCHES.map((suggestion) => (
294 <Button
295 key={suggestion}
296 disabled={installingId !== null}
297 size="xs"
298 variant="ghost"
299 onClick={() => {
300 if (query.trim() === suggestion) {
301 void runSearch(suggestion);
302 } else {
303 setQuery(suggestion);
304 }
305 }}
306 >
307 {suggestion}
308 </Button>
309 ))}
310 </div>
311 {results && results.length > 0 ? (
312 <div className="flex shrink-0 items-center justify-end gap-2">
313 <p className="text-muted-foreground text-xs">Sort</p>
314 <Select
315 disabled={installingId !== null}
316 value={sortBy}
317 onValueChange={handleSortChange}
318 >
319 <SelectTrigger size="sm" className="w-40" aria-label="Sort themes">
320 <SelectValue>
321 {SORT_OPTIONS.find((option) => option.value === sortBy)?.label}
322 </SelectValue>
323 </SelectTrigger>
324 <SelectPopup align="end" alignItemWithTrigger={false}>
325 {SORT_OPTIONS.map((option) => (
326 <SelectItem key={option.value} hideIndicator value={option.value}>
327 {option.label}
328 </SelectItem>
329 ))}
330 </SelectPopup>
331 </Select>
332 </div>
333 ) : null}
334 </div>
335 ) : null}
336
337 <div className="sr-only" role="status">
338 {isSearching
339 ? "Searching themes..."
340 : results
341 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
342 : ""}
343 </div>
344
345 {error ? (
346 <Alert variant="error" role="status" aria-live="polite">
347 <AlertDescription>{error}</AlertDescription>
348 </Alert>
349 ) : null}
350
351 {isSearching && results === null ? (
352 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
353 <Spinner /> Searching themes...
354 </div>
355 ) : null}
356
357 {results ? (
358 results.length === 0 ? (
359 <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
360 <p className="text-sm font-medium">No supported open-source themes found</p>
361 <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
362 </div>
363 ) : (
364 <div className="grid gap-2 sm:grid-cols-2">
365 {results.map((extension) => {
366 const isInstalling = installingId === extension.id;
367 const isInstalled = getCustomThemes().some(
368 (theme) => theme.collection?.id === extension.collectionId,
369 );
370 const action = isInstalled ? "Update" : "Install";
371 const progressAction = isInstalled ? "Updating" : "Installing";
372 return (
373 <article
374 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"
375 key={extension.id}
376 >
377 <div className="flex min-w-0 gap-3">
378 <ThemeExtensionIcon key={extension.iconUrl} extension={extension} />
379 <div className="min-w-0 flex-1">
380 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
381 <p className="truncate text-muted-foreground text-xs">
382 {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
383 downloads
384 </p>
385 </div>
386 </div>
387 <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
388 {extension.description || "A community color theme for your editor."}
389 </p>
390 <div className="mt-auto flex items-center justify-between gap-2">
391 <div className="flex min-w-0 items-center gap-2">
392 {extension.sourceUrl ? (
393 <Button
394 aria-label={`View source for ${extension.name}`}
395 render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />}
396 size="icon-micro"
397 variant="ghost-muted"
398 >
399 <SourceLinkIcon url={extension.sourceUrl} />
400 </Button>
401 ) : null}
402 </div>
403 <Button
404 aria-label={`${isInstalling ? progressAction : action} ${extension.name}`}
405 disabled={installingId !== null}
406 size="xs"
407 variant="outline"
408 onClick={() => void handleInstall(extension, false)}
409 >
410 {isInstalling ? (
411 <Spinner />
412 ) : isInstalled ? (
413 <RefreshIcon />
414 ) : (
415 <PackagePlusIcon />
416 )}
417 {isInstalling ? `${progressAction}...` : action}
418 </Button>
419 </div>
420 </article>
421 );
422 })}
423 </div>
424 )
425 ) : null}
426
427 <AlertDialog
428 open={pendingUpdate !== null}
429 onOpenChange={(nextOpen) => {
430 if (!nextOpen) setPendingUpdate(null);
431 }}
432 >
433 <AlertDialogPopup>
434 <AlertDialogHeader>
435 <AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle>
436 <AlertDialogDescription>
437 This replaces its installed variants, including any local edits. Variants no longer in
438 the extension will be removed.
439 </AlertDialogDescription>
440 </AlertDialogHeader>
441 <AlertDialogFooter>
442 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
443 <Button
444 onClick={() => {
445 const extension = pendingUpdate;
446 setPendingUpdate(null);
447 if (extension) void handleInstall(extension, true);
448 }}
449 >
450 Update theme
451 </Button>
452 </AlertDialogFooter>
453 </AlertDialogPopup>
454 </AlertDialog>
455 </section>
456 );
457}