Skip to content
Source
Sign inSign up
Open History to see the last commit

ThemeSearchSection.tsx

491 lines17.1 KB

1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2import { RefreshIcon } from "~/components/ui/refresh-icon";
3import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react";
4import { type ReactNode, useState } from "react";
5import {
6 importOpenVsxThemeExtension,
7 searchOpenVsxThemes,
8 type OpenVsxThemeExtension,
9 type OpenVsxThemeSort,
10} from "../../openVsxThemes";
11import {
12 getCustomThemes,
13 getStoredCustomThemeCollection,
14 replaceCustomThemeCollection,
15 type ThemeDefinition,
16} from "../../themePalette";
17import { sourceControlIcon } from "../../sourceControlPresentation";
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 sourceHost(url: string): string | null {
47 try {
48 return new URL(url).hostname.toLowerCase();
49 } catch {
50 return null;
51 }
53​
54function sourceLinkIcon(url: string): ReactNode {
55 const host = sourceHost(url);
56 const definition = host === null ? undefined : sourceControlClients.findByPublicDomain(host);
57 const Icon = definition ? sourceControlIcon(definition) : ExternalLinkIcon;
58 return <Icon className="size-3.5" />;
60​
61function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
62 const [failed, setFailed] = useState(false);
63​
64 return (
65 <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
66 {extension.iconUrl && !failed ? (
67 <img
68 alt=""
69 className="size-full object-cover"
70 loading="lazy"
71 referrerPolicy="no-referrer"
72 src={extension.iconUrl}
73 onError={() => setFailed(true)}
74 />
75 ) : (
76 <PaletteIcon className="size-4" />
77 )}
78 </div>
79 );
81​
82export interface ThemeSearchView {
83 readonly setResults: (results: ReadonlyArray<OpenVsxThemeExtension> | null) => void;
84 readonly setError: (error: string | null) => void;
85 readonly setIsSearching: (isSearching: boolean) => void;
87​
88type SearchThemes = typeof searchOpenVsxThemes;
89​
90/**
91 * Owns one dialog session's Open VSX traffic: the input debounce, the request in
92 * flight, and which key the shown results belong to. Aborting `signal` (the
93 * dialog closing) cancels all of it, including an install.
94 */
95export function createThemeSearch(
96 view: ThemeSearchView,
97 signal: AbortSignal,
98 search: SearchThemes = searchOpenVsxThemes,
99) {
100 let query = "";
101 let sortBy: OpenVsxThemeSort = "downloadCount";
102 let debounce: ReturnType<typeof setTimeout> | null = null;
103 let request: AbortController | null = null;
104 let install: AbortController | null = null;
105 // Record successful searches only; rerun after install only for changed query/sort to preserve install
106 // errors.
107 let shownKey: string | null = null;
108 let handledKey: string | null = null;
109 let interrupted = false;
110​
111 const keyOf = () => `${query}\u0000${sortBy}`;
112 const abortRequest = () => {
113 request?.abort();
114 request = null;
115 };
116 const cancelDebounce = () => {
117 if (debounce !== null) clearTimeout(debounce);
118 debounce = null;
119 };
120​
121 const fetchResults = () => {
122 abortRequest();
123 const controller = new AbortController();
124 request = controller;
125 const key = keyOf();
126 void search(query, { signal: controller.signal, sortBy })
127 .then(
128 (results) => {
129 if (controller.signal.aborted) return;
130 view.setResults(results);
131 shownKey = key;
132 },
133 (cause: unknown) => {
134 if (controller.signal.aborted) return;
135 view.setResults(null);
136 shownKey = null;
137 view.setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
138 },
139 )
140 .then(() => {
141 if (request !== controller) return;
142 request = null;
143 view.setIsSearching(false);
144 });
145 };
146​
147 const clear = () => {
148 cancelDebounce();
149 abortRequest();
150 shownKey = null;
151 handledKey = null;
152 view.setResults(null);
153 view.setError(null);
154 view.setIsSearching(false);
155 };
156​
157 const settle = () => {
158 if (install !== null || signal.aborted || query === "") return;
159 const key = keyOf();
160 const keyChanged = handledKey !== key;
161 if (!keyChanged && !interrupted) return;
162 handledKey = key;
163 interrupted = false;
164 const shouldFetch = shownKey !== key;
165 if (shouldFetch || keyChanged) view.setError(null);
166 view.setIsSearching(shouldFetch);
167 if (shouldFetch) fetchResults();
168 else abortRequest();
169 };
170​
171 signal.addEventListener(
172 "abort",
173 () => {
174 cancelDebounce();
175 abortRequest();
176 install?.abort();
177 },
178 { once: true },
179 );
180​
181 return {
182 /** An unsettled query would hit Open VSX for text that is no longer visible, so typing waits. */
183 setQuery(nextQuery: string) {
184 const trimmed = nextQuery.trim();
185 if (trimmed === query) return;
186 query = trimmed;
187 cancelDebounce();
188 if (trimmed === "") {
189 if (install === null) clear();
190 return;
191 }
192 debounce = setTimeout(() => {
193 debounce = null;
194 settle();
195 }, SEARCH_DEBOUNCE_MS);
196 },
197 setSortBy(nextSort: OpenVsxThemeSort) {
198 sortBy = nextSort;
199 if (debounce === null) settle();
200 },
201 /** Enter or re-picking the current suggestion searches again even for the shown key. */
202 searchNow() {
203 if (install !== null || signal.aborted || query === "") return;
204 // A pending debounce for the same key would otherwise restart this search.
205 cancelDebounce();
206 handledKey = keyOf();
207 interrupted = false;
208 view.setError(null);
209 view.setIsSearching(true);
210 fetchResults();
211 },
212 beginInstall(): AbortSignal {
213 abortRequest();
214 install?.abort();
215 install = new AbortController();
216 interrupted = true;
217 view.setIsSearching(false);
218 return install.signal;
219 },
220 endInstall(installSignal: AbortSignal): boolean {
221 if (install?.signal !== installSignal) return false;
222 install = null;
223 if (query === "") clear();
224 else if (debounce === null) settle();
225 return true;
226 },
227 };
229​
230export function ThemeSearchSection({
231 signal,
232 onInstalled,
233}: {
234 signal: AbortSignal;
235 onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
236}) {
237 const [query, setQuery] = useState("");
238 const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount");
239 const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
240 const [error, setError] = useState<string | null>(null);
241 const [isSearching, setIsSearching] = useState(false);
242 const [installingId, setInstallingId] = useState<string | null>(null);
243 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
244 const [themeSearch] = useState(() =>
245 createThemeSearch({ setResults, setError, setIsSearching }, signal),
246 );
247​
248 const changeQuery = (nextQuery: string) => {
249 setQuery(nextQuery);
250 themeSearch.setQuery(nextQuery);
251 };
252​
253 const handleSortChange = (value: OpenVsxThemeSort | null) => {
254 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
255 if (!nextSort) return;
256 setSortBy(nextSort);
257 themeSearch.setSortBy(nextSort);
258 };
259​
260 const handleInstall = async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
261 setError(null);
262 let installedCollection: ReadonlyArray<ThemeDefinition>;
263 try {
264 installedCollection = getStoredCustomThemeCollection(extension.collectionId);
265 } catch (cause) {
266 setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
267 return;
268 }
269 const updated = installedCollection.length > 0;
270 if (updated && !allowUpdate) {
271 setPendingUpdate(extension);
272 return;
273 }
274​
275 const installSignal = themeSearch.beginInstall();
276 setInstallingId(extension.id);
277 try {
278 const themes = await importOpenVsxThemeExtension(extension, installSignal);
279 if (!installSignal.aborted) {
280 const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
281 expectedCollection: installedCollection,
282 });
283 onInstalled(imported, { updated });
284 }
285 } catch (cause) {
286 if (!installSignal.aborted) {
287 setError(cause instanceof Error ? cause.message : "That theme could not be added.");
288 }
289 }
290 if (themeSearch.endInstall(installSignal)) setInstallingId(null);
291 };
292​
293 return (
294 <section className="space-y-3" aria-labelledby="theme-search-heading">
295 <div>
296 <h3 className="text-sm font-medium" id="theme-search-heading">
297 Search community themes
298 </h3>
299 <p className="mt-0.5 text-muted-foreground text-xs">
300 Find open-source themes from Open VSX.
301 </p>
302 </div>
303 <InputGroup>
304 <InputGroupAddon>
305 {isSearching ? <Spinner aria-hidden /> : <SearchIcon aria-hidden />}
306 </InputGroupAddon>
307 <InputGroupInput
308 aria-label="Search Open VSX themes"
309 autoFocus
310 onChange={(event) => changeQuery(event.currentTarget.value)}
311 onKeyDown={(event) => {
312 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
313 if (event.key === "Enter" && !isSearching && installingId === null)
314 themeSearch.searchNow();
315 }}
316 placeholder="Search themes..."
317 size="lg"
318 type="search"
319 value={query}
320 />
321 </InputGroup>
322​
323 {!isSearching || results !== null ? (
324 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
325 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
326 <p className="text-muted-foreground text-xs">Popular</p>
327 {SUGGESTED_SEARCHES.map((suggestion) => (
328 <Button
329 key={suggestion}
330 disabled={installingId !== null}
331 size="xs"
332 variant="ghost"
333 onClick={() => {
334 if (query.trim() === suggestion) {
335 themeSearch.searchNow();
336 } else {
337 changeQuery(suggestion);
338 }
339 }}
340 >
341 {suggestion}
342 </Button>
343 ))}
344 </div>
345 {results && results.length > 0 ? (
346 <div className="flex shrink-0 items-center justify-end gap-2">
347 <p className="text-muted-foreground text-xs">Sort</p>
348 <Select
349 disabled={installingId !== null}
350 value={sortBy}
351 onValueChange={handleSortChange}
352 >
353 <SelectTrigger size="sm" className="w-40" aria-label="Sort themes">
354 <SelectValue>
355 {SORT_OPTIONS.find((option) => option.value === sortBy)?.label}
356 </SelectValue>
357 </SelectTrigger>
358 <SelectPopup align="end" alignItemWithTrigger={false}>
359 {SORT_OPTIONS.map((option) => (
360 <SelectItem key={option.value} hideIndicator value={option.value}>
361 {option.label}
362 </SelectItem>
363 ))}
364 </SelectPopup>
365 </Select>
366 </div>
367 ) : null}
368 </div>
369 ) : null}
370​
371 <div className="sr-only" role="status">
372 {isSearching
373 ? "Searching themes..."
374 : results
375 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
376 : ""}
377 </div>
378​
379 {error ? (
380 <Alert variant="error" role="status" aria-live="polite">
381 <AlertDescription>{error}</AlertDescription>
382 </Alert>
383 ) : null}
384​
385 {isSearching && results === null ? (
386 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
387 <Spinner /> Searching themes...
388 </div>
389 ) : null}
390​
391 {results ? (
392 results.length === 0 ? (
393 <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
394 <p className="text-sm font-medium">No supported open-source themes found</p>
395 <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
396 </div>
397 ) : (
398 <div className="grid gap-2 sm:grid-cols-2">
399 {results.map((extension) => {
400 const isInstalling = installingId === extension.id;
401 const isInstalled = getCustomThemes().some(
402 (theme) => theme.collection?.id === extension.collectionId,
403 );
404 const action = isInstalled ? "Update" : "Install";
405 const progressAction = isInstalled ? "Updating" : "Installing";
406 return (
407 <article
408 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"
409 key={extension.id}
410 >
411 <div className="flex min-w-0 gap-3">
412 <ThemeExtensionIcon key={extension.iconUrl} extension={extension} />
413 <div className="min-w-0 flex-1">
414 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
415 <p className="truncate text-muted-foreground text-xs">
416 {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
417 downloads
418 </p>
419 </div>
420 </div>
421 <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
422 {extension.description || "A community color theme for your editor."}
423 </p>
424 <div className="mt-auto flex items-center justify-between gap-2">
425 <div className="flex min-w-0 items-center gap-2">
426 {extension.sourceUrl ? (
427 <Button
428 aria-label={`View source for ${extension.name}`}
429 render={<a href={extension.sourceUrl} rel="noreferrer" target="_blank" />}
430 size="icon-micro"
431 variant="ghost-muted"
432 >
433 {sourceLinkIcon(extension.sourceUrl)}
434 </Button>
435 ) : null}
436 </div>
437 <Button
438 aria-label={`${isInstalling ? progressAction : action} ${extension.name}`}
439 disabled={installingId !== null}
440 size="xs"
441 variant="outline"
442 onClick={() => void handleInstall(extension, false)}
443 >
444 {isInstalling ? (
445 <Spinner />
446 ) : isInstalled ? (
447 <RefreshIcon />
448 ) : (
449 <PackagePlusIcon />
450 )}
451 {isInstalling ? `${progressAction}...` : action}
452 </Button>
453 </div>
454 </article>
455 );
456 })}
457 </div>
458 )
459 ) : null}
460​
461 <AlertDialog
462 open={pendingUpdate !== null}
463 onOpenChange={(nextOpen) => {
464 if (!nextOpen) setPendingUpdate(null);
465 }}
466 >
467 <AlertDialogPopup>
468 <AlertDialogHeader>
469 <AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle>
470 <AlertDialogDescription>
471 This replaces its installed variants, including any local edits. Variants no longer in
472 the extension will be removed.
473 </AlertDialogDescription>
474 </AlertDialogHeader>
475 <AlertDialogFooter>
476 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
477 <Button
478 onClick={() => {
479 const extension = pendingUpdate;
480 setPendingUpdate(null);
481 if (extension) void handleInstall(extension, true);
482 }}
483 >
484 Update theme
485 </Button>
486 </AlertDialogFooter>
487 </AlertDialogPopup>
488 </AlertDialog>
489 </section>
490 );