authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-11 20:06:05+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 20:06:05+00:00
logf0b57ca2313bd7971cefb366d3b3808152b014fc
treed67e164093ddf27a8bfe79f0b46385326142d393
parent2db08457f2f4eaaa713a067b2ea480ca2b583025
signature Signed by PGP key B5690EEEBB952194

feat(web): add Open VSX theme search (#5654)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

11 files changed, 2566 insertions(+), 134 deletions(-)

apps/web/package.json+3
......@@ -23,6 +23,7 @@
2323 "@formkit/auto-animate": "^0.9.0",
2424 "@legendapp/list": "catalog:",
2525 "@lexical/react": "^0.41.0",
26 "@noble/hashes": "catalog:",
2627 "@pierre/diffs": "catalog:",
2728 "@pierre/trees": "1.0.0-beta.4",
2829 "@t3tools/client-runtime": "workspace:*",
......@@ -34,6 +35,8 @@
3435 "culori": "^4.0.2",
3536 "effect": "catalog:",
3637 "jose": "catalog:",
38 "jsonc-parser": "3.3.1",
39 "jszip": "3.10.1",
3740 "lexical": "^0.41.0",
3841 "lucide-react": "^0.564.0",
3942 "react": "19.2.6",
apps/web/src/components/settings/ThemeEditorPanel.tsx+14-23
......@@ -610,7 +610,7 @@ export function ThemeEditorPanel({
610610 [activeAppearance, editingTheme, selectedRole],
611611 );
612612
613 const handleSubmit = useCallback(() => {
613 const handleSubmit = () => {
614614 if (!name.trim()) {
615615 setError("Name your theme first.");
616616 return;
......@@ -645,8 +645,8 @@ export function ThemeEditorPanel({
645645 return;
646646 }
647647 mergedAppearance = editedModes[0] ?? null;
648 savedTheme = updateCustomTheme(
649 parseThemeFile({
648 savedTheme = updateCustomTheme({
649 ...parseThemeFile({
650650 version: THEME_FILE_VERSION,
651651 id: mergeTarget.id,
652652 name: mergeTarget.label,
......@@ -658,7 +658,8 @@ export function ThemeEditorPanel({
658658 },
659659 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
660660 }),
661 );
661 ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}),
662 });
662663 retiredTheme = editingTheme;
663664 try {
664665 removeCustomTheme(editingTheme.id);
......@@ -676,8 +677,8 @@ export function ThemeEditorPanel({
676677 } else if (editingTheme) {
677678 const baseAppearance = editingTheme.appearance;
678679 const variantAppearance = baseAppearance === "light" ? "dark" : "light";
679 savedTheme = updateCustomTheme(
680 parseThemeFile({
680 savedTheme = updateCustomTheme({
681 ...parseThemeFile({
681682 version: THEME_FILE_VERSION,
682683 id: editingTheme.id,
683684 name,
......@@ -688,7 +689,8 @@ export function ThemeEditorPanel({
688689 : {}),
689690 ...(isAdvanced ? {} : { managed: true }),
690691 }),
691 );
692 ...(editingTheme.collection ? { collection: editingTheme.collection } : {}),
693 });
692694 } else if (mergeTarget) {
693695 if (takenAppearances.includes(activeAppearance)) {
694696 setError(
......@@ -701,8 +703,8 @@ export function ThemeEditorPanel({
701703 // survives when every palette in the theme came from the guided
702704 // editor.
703705 mergedAppearance = activeAppearance;
704 savedTheme = updateCustomTheme(
705 parseThemeFile({
706 savedTheme = updateCustomTheme({
707 ...parseThemeFile({
706708 version: THEME_FILE_VERSION,
707709 id: mergeTarget.id,
708710 name: mergeTarget.label,
......@@ -714,7 +716,8 @@ export function ThemeEditorPanel({
714716 },
715717 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
716718 }),
717 );
719 ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}),
720 });
718721 } else {
719722 savedTheme = installCustomTheme(
720723 parseThemeFile({
......@@ -762,19 +765,7 @@ export function ThemeEditorPanel({
762765 : "Could not create the theme.",
763766 );
764767 }
765 }, [
766 activeAppearance,
767 colorsByAppearance,
768 editingTheme,
769 isAdvanced,
770 isEditing,
771 mergeTarget,
772 name,
773 onOpenChange,
774 onSaved,
775 simpleColorsDirtyByAppearance,
776 takenAppearances,
777 ]);
768 };
778769
779770 const renderNameField = () => (
780771 <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
apps/web/src/components/settings/ThemeImportDialog.tsx+26-2
......@@ -28,6 +28,7 @@ import {
2828 DialogPopup,
2929 DialogTitle,
3030} from "../ui/dialog";
31import { ThemeSearchSection } from "./ThemeSearchSection";
3132
3233/**
3334 * A full theme export is a few KB, so anything past this is not a theme file.
......@@ -360,9 +361,16 @@ export function ThemeImportDialog({
360361 : null;
361362 for (const theme of conflicts) {
362363 try {
364 const existingTheme =
365 mode === "update"
366 ? getCustomThemes().find((candidate) => candidate.id === theme.id)
367 : undefined;
368 const themeToUpdate = existingTheme?.collection
369 ? { ...theme, collection: existingTheme.collection }
370 : theme;
363371 resolved.push(
364372 mode === "update"
365 ? updateCustomTheme(theme)
373 ? updateCustomTheme(themeToUpdate)
366374 : installCustomTheme(versionedCopy(theme, preferredName)),
367375 );
368376 } catch (cause) {
......@@ -426,7 +434,23 @@ export function ThemeImportDialog({
426434 <DialogHeader>
427435 <DialogTitle>Add a theme</DialogTitle>
428436 </DialogHeader>
429 <DialogPanel className="space-y-4">
437 <DialogPanel className="space-y-5">
438 <ThemeSearchSection
439 onInstalled={(themes, context) => {
440 onImportedMany(themes, context);
441 onOpenChange(false);
442 }}
443 open={open}
444 />
445
446 <div className="flex items-center gap-3" aria-hidden>
447 <div className="h-px flex-1 bg-border" />
448 <span className="text-muted-foreground text-[11px] uppercase tracking-wider">
449 or import a file
450 </span>
451 <div className="h-px flex-1 bg-border" />
452 </div>
453
430454 {(() => {
431455 const dropHandlers = {
432456 onDragEnter: (event: DragEvent<HTMLDivElement>) => {
apps/web/src/components/settings/ThemeSearchSection.tsx created+389
......@@ -0,0 +1,389 @@
1import {
2 ExternalLinkIcon,
3 PaletteIcon,
4 PlusIcon,
5 RefreshCwIcon,
6 SearchIcon,
7 ShieldCheckIcon,
8} from "lucide-react";
9import type { FormEvent } from "react";
10import { useCallback, useEffect, useRef, useState } from "react";
11import {
12 importOpenVsxThemeExtension,
13 searchOpenVsxThemes,
14 type OpenVsxThemeExtension,
15 type OpenVsxThemeSort,
16} from "../../openVsxThemes";
17import {
18 getCustomThemes,
19 getStoredCustomThemeCollection,
20 replaceCustomThemeCollection,
21 type ThemeDefinition,
22} from "../../themePalette";
23import {
24 AlertDialog,
25 AlertDialogClose,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogPopup,
30 AlertDialogTitle,
31} from "../ui/alert-dialog";
32import { Button } from "../ui/button";
33import { Input } from "../ui/input";
34import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
35import { Spinner } from "../ui/spinner";
36
37const DOWNLOAD_FORMAT = new Intl.NumberFormat(undefined, {
38 notation: "compact",
39 maximumFractionDigits: 1,
40});
41const SUGGESTED_SEARCHES = ["Dracula", "Catppuccin", "Nord", "Tokyo Night"];
42const SORT_OPTIONS: ReadonlyArray<{ value: OpenVsxThemeSort; label: string }> = [
43 { value: "downloadCount", label: "Most downloaded" },
44 { value: "rating", label: "Best rated" },
45 { value: "timestamp", label: "Newest" },
46 { value: "relevance", label: "Most relevant" },
47];
48
49function ThemeExtensionIcon({ extension }: { extension: OpenVsxThemeExtension }) {
50 const [failed, setFailed] = useState(false);
51
52 return (
53 <div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
54 {extension.iconUrl && !failed ? (
55 <img
56 alt=""
57 className="size-full object-cover"
58 loading="lazy"
59 referrerPolicy="no-referrer"
60 src={extension.iconUrl}
61 onError={() => setFailed(true)}
62 />
63 ) : (
64 <PaletteIcon className="size-4" />
65 )}
66 </div>
67 );
68}
69
70export function ThemeSearchSection({
71 open,
72 onInstalled,
73}: {
74 open: boolean;
75 onInstalled: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
76}) {
77 const [query, setQuery] = useState("");
78 const [sortBy, setSortBy] = useState<OpenVsxThemeSort>("downloadCount");
79 const [results, setResults] = useState<ReadonlyArray<OpenVsxThemeExtension> | null>(null);
80 const [error, setError] = useState<string | null>(null);
81 const [isSearching, setIsSearching] = useState(false);
82 const [installingId, setInstallingId] = useState<string | null>(null);
83 const [pendingUpdate, setPendingUpdate] = useState<OpenVsxThemeExtension | null>(null);
84 const requestRef = useRef<AbortController | null>(null);
85
86 useEffect(() => {
87 requestRef.current?.abort();
88 requestRef.current = null;
89 if (open) {
90 setQuery("");
91 setSortBy("downloadCount");
92 setResults(null);
93 setError(null);
94 setIsSearching(false);
95 setInstallingId(null);
96 setPendingUpdate(null);
97 }
98 return () => {
99 requestRef.current?.abort();
100 requestRef.current = null;
101 };
102 }, [open]);
103
104 const runSearch = useCallback(
105 async (searchText: string, nextSort = sortBy) => {
106 const trimmed = searchText.trim();
107 if (!trimmed) return;
108 requestRef.current?.abort();
109 const controller = new AbortController();
110 requestRef.current = controller;
111 setQuery(trimmed);
112 setIsSearching(true);
113 setError(null);
114 setResults(null);
115 try {
116 const nextResults = await searchOpenVsxThemes(trimmed, {
117 signal: controller.signal,
118 sortBy: nextSort,
119 });
120 if (!controller.signal.aborted) setResults(nextResults);
121 } catch (cause) {
122 if (!controller.signal.aborted) {
123 setError(cause instanceof Error ? cause.message : "Open VSX search failed.");
124 }
125 }
126 if (requestRef.current === controller) {
127 requestRef.current = null;
128 setIsSearching(false);
129 }
130 },
131 [sortBy],
132 );
133
134 const handleSearch = useCallback(
135 (event: FormEvent<HTMLFormElement>) => {
136 event.preventDefault();
137 void runSearch(query);
138 },
139 [query, runSearch],
140 );
141
142 const handleSortChange = useCallback(
143 (value: OpenVsxThemeSort | null) => {
144 const nextSort = SORT_OPTIONS.find((option) => option.value === value)?.value;
145 if (!nextSort) return;
146 setSortBy(nextSort);
147 if ((results !== null || isSearching) && query.trim()) {
148 void runSearch(query, nextSort);
149 }
150 },
151 [isSearching, query, results, runSearch],
152 );
153
154 const handleInstall = useCallback(
155 async (extension: OpenVsxThemeExtension, allowUpdate: boolean) => {
156 setError(null);
157 let installedCollection: ReadonlyArray<ThemeDefinition>;
158 try {
159 installedCollection = getStoredCustomThemeCollection(extension.collectionId);
160 } catch (cause) {
161 setError(cause instanceof Error ? cause.message : "Installed themes could not be read.");
162 return;
163 }
164 const updated = installedCollection.length > 0;
165 if (updated && !allowUpdate) {
166 setPendingUpdate(extension);
167 return;
168 }
169
170 requestRef.current?.abort();
171 const controller = new AbortController();
172 requestRef.current = controller;
173 setInstallingId(extension.id);
174 try {
175 const themes = await importOpenVsxThemeExtension(extension, controller.signal);
176 if (!controller.signal.aborted) {
177 const imported = replaceCustomThemeCollection(extension.collectionId, themes, {
178 expectedCollection: installedCollection,
179 });
180 onInstalled(imported, { updated });
181 }
182 } catch (cause) {
183 if (!controller.signal.aborted) {
184 setError(cause instanceof Error ? cause.message : "That theme could not be added.");
185 }
186 }
187 if (requestRef.current === controller) {
188 requestRef.current = null;
189 setInstallingId(null);
190 }
191 },
192 [onInstalled],
193 );
194
195 return (
196 <section className="space-y-3" aria-labelledby="theme-search-heading">
197 <div>
198 <h3 className="text-sm font-medium" id="theme-search-heading">
199 Search community themes
200 </h3>
201 <p className="mt-0.5 text-muted-foreground text-xs">
202 Find open-source themes from Open VSX.
203 </p>
204 </div>
205 <form className="relative flex gap-2" onSubmit={handleSearch}>
206 <SearchIcon className="pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground" />
207 <Input
208 aria-label="Search Open VSX themes"
209 className="rounded-xl [&_input]:pl-9"
210 autoFocus
211 onChange={(event) => setQuery(event.currentTarget.value)}
212 placeholder="try dracula, nord, catppuccin..."
213 size="lg"
214 type="search"
215 value={query}
216 />
217 <Button
218 disabled={!query.trim() || isSearching || installingId !== null}
219 type="submit"
220 variant="outline"
221 >
222 {isSearching ? <Spinner /> : <SearchIcon />}
223 Search
224 </Button>
225 </form>
226
227 {!isSearching ? (
228 <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
229 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
230 <p className="text-muted-foreground text-xs">Popular</p>
231 {SUGGESTED_SEARCHES.map((suggestion) => (
232 <Button
233 key={suggestion}
234 size="xs"
235 variant="ghost"
236 onClick={() => void runSearch(suggestion)}
237 >
238 {suggestion}
239 </Button>
240 ))}
241 </div>
242 {results && results.length > 0 ? (
243 <div className="flex shrink-0 items-center justify-end gap-2">
244 <p className="text-muted-foreground text-xs">Sort</p>
245 <Select
246 disabled={installingId !== null}
247 value={sortBy}
248 onValueChange={handleSortChange}
249 >
250 <SelectTrigger size="sm" className="w-40" aria-label="Sort themes">
251 <SelectValue>
252 {SORT_OPTIONS.find((option) => option.value === sortBy)?.label}
253 </SelectValue>
254 </SelectTrigger>
255 <SelectPopup align="end" alignItemWithTrigger={false}>
256 {SORT_OPTIONS.map((option) => (
257 <SelectItem key={option.value} hideIndicator value={option.value}>
258 {option.label}
259 </SelectItem>
260 ))}
261 </SelectPopup>
262 </Select>
263 </div>
264 ) : null}
265 </div>
266 ) : null}
267
268 <div className="sr-only" role="status">
269 {isSearching
270 ? "Finding themes..."
271 : results
272 ? `${results.length} supported ${results.length === 1 ? "theme" : "themes"} found.`
273 : ""}
274 </div>
275
276 {error ? (
277 <div
278 aria-live="polite"
279 className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-sm"
280 >
281 {error}
282 </div>
283 ) : null}
284
285 {isSearching ? (
286 <div className="flex min-h-20 items-center justify-center gap-2 text-muted-foreground text-sm">
287 <Spinner /> Finding themes...
288 </div>
289 ) : null}
290
291 {results ? (
292 results.length === 0 ? (
293 <div className="flex min-h-40 flex-col items-center justify-center rounded-2xl border border-dashed text-center">
294 <p className="text-sm font-medium">No supported open-source themes found</p>
295 <p className="mt-1 text-muted-foreground text-xs">Try a broader search.</p>
296 </div>
297 ) : (
298 <div className="grid gap-2 sm:grid-cols-2">
299 {results.map((extension) => {
300 const isInstalling = installingId === extension.id;
301 const isInstalled = getCustomThemes().some(
302 (theme) => theme.collection?.id === extension.collectionId,
303 );
304 const action = isInstalled ? "Update" : "Add";
305 const progressAction = isInstalled ? "Updating" : "Adding";
306 return (
307 <article
308 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"
309 key={extension.id}
310 >
311 <div className="flex min-w-0 gap-3">
312 <ThemeExtensionIcon key={extension.iconUrl} extension={extension} />
313 <div className="min-w-0 flex-1">
314 <h4 className="truncate text-sm font-medium">{extension.name}</h4>
315 <p className="truncate text-muted-foreground text-xs">
316 {extension.publisher} · {DOWNLOAD_FORMAT.format(extension.downloadCount)}{" "}
317 downloads
318 </p>
319 </div>
320 </div>
321 <p className="line-clamp-2 min-h-8 text-muted-foreground text-xs leading-4">
322 {extension.description || "A community color theme for your editor."}
323 </p>
324 <div className="mt-auto flex items-center justify-between gap-2">
325 <div className="flex min-w-0 items-center gap-2">
326 <span className="inline-flex items-center gap-1 text-muted-foreground text-[11px]">
327 <ShieldCheckIcon className="size-3" /> {extension.license}
328 </span>
329 {extension.sourceUrl ? (
330 <a
331 aria-label={`View source for ${extension.name}`}
332 className="inline-flex items-center gap-1 text-muted-foreground text-[11px] hover:text-foreground"
333 href={extension.sourceUrl}
334 rel="noreferrer"
335 target="_blank"
336 >
337 Source <ExternalLinkIcon className="size-3" />
338 </a>
339 ) : null}
340 </div>
341 <Button
342 aria-label={`${isInstalling ? progressAction : action} ${extension.name}`}
343 disabled={installingId !== null}
344 size="xs"
345 variant="outline"
346 onClick={() => void handleInstall(extension, false)}
347 >
348 {isInstalling ? <Spinner /> : isInstalled ? <RefreshCwIcon /> : <PlusIcon />}
349 {isInstalling ? `${progressAction}...` : action}
350 </Button>
351 </div>
352 </article>
353 );
354 })}
355 </div>
356 )
357 ) : null}
358
359 <AlertDialog
360 open={pendingUpdate !== null}
361 onOpenChange={(nextOpen) => {
362 if (!nextOpen) setPendingUpdate(null);
363 }}
364 >
365 <AlertDialogPopup>
366 <AlertDialogHeader>
367 <AlertDialogTitle>Update “{pendingUpdate?.name}”?</AlertDialogTitle>
368 <AlertDialogDescription>
369 This replaces its installed variants, including any local edits. Variants no longer in
370 the extension will be removed.
371 </AlertDialogDescription>
372 </AlertDialogHeader>
373 <AlertDialogFooter>
374 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
375 <Button
376 onClick={() => {
377 const extension = pendingUpdate;
378 setPendingUpdate(null);
379 if (extension) void handleInstall(extension, true);
380 }}
381 >
382 Update theme
383 </Button>
384 </AlertDialogFooter>
385 </AlertDialogPopup>
386 </AlertDialog>
387 </section>
388 );
389}
apps/web/src/components/settings/ThemeSettings.tsx+439-96
......@@ -1,17 +1,20 @@
11import {
2 CheckIcon,
23 CopyIcon,
34 DownloadIcon,
5 MoonIcon,
46 PenLineIcon,
57 PlusIcon,
8 SunIcon,
69 Trash2Icon,
710 UploadIcon,
811} from "lucide-react";
9import { useCallback, useEffect, useRef, useState } from "react";
12import { useCallback, useEffect, useState, type ReactElement } from "react";
1013import { cn } from "../../lib/utils";
1114import {
1215 getThemeDefinition,
1316 getThemeModes,
14 removeCustomTheme,
17 removeCustomThemes,
1518 serializeThemeFile,
1619 type ThemeAppearance,
1720 type ThemeDefinition,
......@@ -41,6 +44,7 @@ import {
4144 getThemeCardDefinition,
4245 previewColorsOf,
4346 ThemePreviewCircles,
47 ThemePreviewCircle,
4448 type ThemeCardDefinition,
4549 type ThemeMode,
4650} from "./ThemePreviewCircles";
......@@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [
5458 IRIS_THEME,
5559];
5660
61function collectionVariantLabels(themes: ReadonlyArray<ThemeDefinition>): ReadonlyArray<string> {
62 if (themes.length === 0) return [];
63 const words = themes.map((theme) => theme.label.trim().split(/\s+/));
64 const firstWords = words[0]!;
65 const sharedWordCount = firstWords.findIndex((word, index) =>
66 words.some((labelWords) => labelWords[index]?.toLocaleLowerCase() !== word.toLocaleLowerCase()),
67 );
68 const prefixLength = sharedWordCount === -1 ? firstWords.length - 1 : sharedWordCount;
69
70 return themes.map((theme, index) => {
71 const shortLabel = words[index]?.slice(Math.max(0, prefixLength)).join(" ").trim();
72 return shortLabel || theme.label;
73 });
74}
75
5776function downloadThemeFile(filename: string, contents: string): void {
5877 const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
5978 const anchor = document.createElement("a");
......@@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void {
6584 setTimeout(() => URL.revokeObjectURL(url), 30_000);
6685}
6786
87function ThemeVariantTooltip({ label, children }: { label: string; children: ReactElement }) {
88 return (
89 <Tooltip>
90 <TooltipTrigger render={children} />
91 <TooltipPopup>{label}</TooltipPopup>
92 </Tooltip>
93 );
94}
95
6896function ThemeLibraryCard({
6997 theme,
7098 isActive,
......@@ -75,6 +103,7 @@ function ThemeLibraryCard({
75103 onDuplicate,
76104 onDownload,
77105 onRemove,
106 variantNavigation,
78107}: {
79108 theme: ThemeCardDefinition;
80109 isActive: boolean;
......@@ -85,10 +114,33 @@ function ThemeLibraryCard({
85114 onDuplicate?: () => void;
86115 onDownload?: () => void;
87116 onRemove?: () => void;
117 variantNavigation?: {
118 collectionLabel: string;
119 options: ReadonlyArray<{
120 themeIndex: number;
121 label: string;
122 activeModes: ReadonlyArray<ThemeMode>;
123 preview: ThemeCardDefinition["previews"][number];
124 }>;
125 onSelectAndUse: (themeIndex: number, mode: ThemeAppearance) => void;
126 };
88127}) {
89128 // A one-appearance theme can only take its own side of the mix, so the card
90129 // tooltip promises exactly what clicking it does.
91130 const cardModes = theme.previews.map((preview) => preview.mode);
131 const [radialModeOpen, setRadialModeOpen] = useState<ThemeAppearance | null>(null);
132 const radialModeGroups = (["light", "dark"] as const).map((mode) => {
133 const options =
134 variantNavigation?.options.flatMap((option) => {
135 const preview = option.preview;
136 return preview.mode === mode ? [{ option, preview }] : [];
137 }) ?? [];
138 return {
139 mode,
140 options,
141 selected: options.find(({ option }) => option.activeModes.includes(mode)) ?? options[0],
142 };
143 });
92144 return (
93145 // The card surface stays a plain div (buttons cannot nest inside a button
94146 // role); the title button and mode circles carry the accessible actions,
......@@ -105,17 +157,147 @@ function ThemeLibraryCard({
105157 onClick={onUse}
106158 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
107159 >
108 <ThemePreviewCircles
109 label={theme.label}
110 activeModes={activeModes}
111 onSelectMode={onUseMode}
112 previews={theme.previews}
113 />
160 <div className="relative">
161 {variantNavigation ? (
162 <div
163 aria-label="Light and dark theme variants"
164 className="relative h-20"
165 role="group"
166 onBlurCapture={(event) => {
167 const nextTarget = event.relatedTarget;
168 if (
169 !(nextTarget instanceof Node) ||
170 !event.currentTarget.contains(nextTarget)
171 ) {
172 setRadialModeOpen(null);
173 }
174 }}
175 onKeyDown={(event) => {
176 if (event.key === "Escape") setRadialModeOpen(null);
177 }}
178 onMouseLeave={() => setRadialModeOpen(null)}
179 >
180 {radialModeGroups.map(({ mode, options, selected }) => {
181 if (!selected) return null;
182 const rootOffsetX = mode === "light" ? -52 : 52;
183 const isOpen = radialModeOpen === mode;
184 const isActive = selected.option.activeModes.includes(mode);
185 const modeLabel = mode === "light" ? "Light" : "Dark";
186 return (
187 <div className="contents" key={mode}>
188 <ThemeVariantTooltip label={`${modeLabel}: ${selected.option.label}`}>
189 <button
190 aria-label={
191 options.length > 1
192 ? `Choose ${mode} variant, ${options.length} options, currently ${selected.option.label}`
193 : `Use ${mode} variant, currently ${selected.option.label}`
194 }
195 aria-pressed={isActive}
196 className="absolute left-1/2 top-2 z-20 flex size-14 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
197 style={{
198 transform: `translateX(calc(-50% + ${rootOffsetX}px))`,
199 }}
200 type="button"
201 onClick={(event) => {
202 event.stopPropagation();
203 variantNavigation.onSelectAndUse(selected.option.themeIndex, mode);
204 }}
205 onFocus={() => setRadialModeOpen(mode)}
206 onMouseEnter={() => setRadialModeOpen(mode)}
207 >
208 <ThemePreviewCircle
209 colors={selected.preview.colors}
210 mode={selected.preview.mode}
211 />
212 {isActive ? (
213 <span
214 aria-hidden
215 className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-ring"
216 />
217 ) : null}
218 {isActive ? (
219 <span className="pointer-events-none absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full border border-border/70 bg-background text-foreground shadow-sm">
220 {mode === "light" ? (
221 <SunIcon className="size-2.5" />
222 ) : (
223 <MoonIcon className="size-2.5" />
224 )}
225 </span>
226 ) : null}
227 </button>
228 </ThemeVariantTooltip>
229 <span
230 className="pointer-events-none absolute bottom-0 left-1/2 inline-flex max-w-24 -translate-x-1/2 items-center gap-1 text-[11px] font-medium text-foreground"
231 style={{ marginLeft: rootOffsetX }}
232 >
233 <span className="truncate">{selected.option.label}</span>
234 {options.length > 1 ? (
235 <span className="shrink-0 rounded-full bg-muted px-1 text-[9px] text-muted-foreground">
236 +{options.length - 1}
237 </span>
238 ) : null}
239 </span>
240 {options.length > 1
241 ? options.map(({ option, preview }, optionIndex) => {
242 const progress = optionIndex / (options.length - 1) - 0.5;
243 const childOffsetX = rootOffsetX + progress * 68;
244 const childOffsetY = Math.abs(progress) * 10;
245 const optionIsActive = option.activeModes.includes(mode);
246 return (
247 <ThemeVariantTooltip
248 key={option.label}
249 label={`Use ${option.label} for ${mode} mode`}
250 >
251 <button
252 aria-label={`Use ${option.label} for ${mode} mode${optionIsActive ? ", currently active" : ""}`}
253 aria-pressed={optionIsActive}
254 className={cn(
255 "absolute left-1/2 top-1 z-30 flex size-7 items-center justify-center rounded-full bg-background shadow-sm outline-none transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-ring",
256 optionIsActive ? "ring-2 ring-ring" : "ring-1 ring-border/70",
257 )}
258 style={{
259 opacity: isOpen ? 1 : 0,
260 pointerEvents: isOpen ? "auto" : "none",
261 transform: `translate(calc(-50% + ${isOpen ? childOffsetX : rootOffsetX}px), ${isOpen ? childOffsetY : 28}px) scale(${isOpen ? 1 : 0.55})`,
262 transitionDelay: isOpen ? `${optionIndex * 35}ms` : "0ms",
263 }}
264 type="button"
265 onClick={(event) => {
266 event.stopPropagation();
267 variantNavigation.onSelectAndUse(option.themeIndex, mode);
268 }}
269 onFocus={() => setRadialModeOpen(mode)}
270 onMouseEnter={() => setRadialModeOpen(mode)}
271 >
272 <span className="pointer-events-none scale-[0.43]">
273 <ThemePreviewCircle
274 colors={preview.colors}
275 mode={preview.mode}
276 />
277 </span>
278 </button>
279 </ThemeVariantTooltip>
280 );
281 })
282 : null}
283 </div>
284 );
285 })}
286 </div>
287 ) : (
288 <ThemePreviewCircles
289 label={theme.label}
290 activeModes={activeModes}
291 onSelectMode={onUseMode}
292 previews={theme.previews}
293 />
294 )}
295 </div>
114296 <div className="flex items-center gap-2 px-3 pb-3 pt-2">
115297 <div className="min-w-0 flex-1">
116298 <div className="flex items-center gap-1.5">
117299 <button
118 aria-label={`Use ${theme.label} theme${isActive ? ", currently active" : ""}`}
300 aria-label={`Use ${variantNavigation ? `${variantNavigation.collectionLabel}, ${theme.label} variant` : `${theme.label} theme`}${isActive ? ", currently active" : ""}`}
119301 aria-pressed={isActive}
120302 className="min-w-0 cursor-pointer truncate rounded-sm text-left text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
121303 type="button"
......@@ -124,7 +306,7 @@ function ThemeLibraryCard({
124306 onUse();
125307 }}
126308 >
127 {theme.label}
309 {variantNavigation?.collectionLabel ?? theme.label}
128310 </button>
129311 </div>
130312 </div>
......@@ -195,7 +377,11 @@ function ThemeLibraryCard({
195377 <TooltipTrigger
196378 render={
197379 <Button
198 aria-label={`Remove ${theme.label}`}
380 aria-label={
381 variantNavigation
382 ? `Remove themes from ${variantNavigation.collectionLabel}`
383 : `Remove ${theme.label}`
384 }
199385 size="icon-xs"
200386 variant="ghost"
201387 className="text-muted-foreground hover:text-destructive"
......@@ -208,7 +394,9 @@ function ThemeLibraryCard({
208394 </Button>
209395 }
210396 />
211 <TooltipPopup>Remove theme</TooltipPopup>
397 <TooltipPopup>
398 {variantNavigation ? "Remove themes" : "Remove theme"}
399 </TooltipPopup>
212400 </Tooltip>
213401 ) : null}
214402 </div>
......@@ -218,12 +406,97 @@ function ThemeLibraryCard({
218406 }
219407 />
220408 <TooltipPopup>
221 {cardModes.length > 1 ? "Use for both light and dark" : `Use for ${cardModes[0]} mode only`}
409 {variantNavigation
410 ? "Use the first variants for light and dark"
411 : cardModes.length > 1
412 ? "Use for both light and dark"
413 : `Use for ${cardModes[0]} mode only`}
222414 </TooltipPopup>
223415 </Tooltip>
224416 );
225417}
226418
419function CustomThemeCollectionCard({
420 themes,
421 activeModesFor,
422 onUse,
423 onUseMode,
424 onDuplicate,
425 onEdit,
426 onDownload,
427 onRemove,
428}: {
429 themes: ReadonlyArray<ThemeDefinition>;
430 activeModesFor: (themeId: string) => ReadonlyArray<ThemeMode>;
431 onUse: (theme: ThemeDefinition) => void;
432 onUseMode: (theme: ThemeDefinition, mode: ThemeMode) => void;
433 onDuplicate: (theme: ThemeDefinition) => void;
434 onEdit: (theme: ThemeDefinition) => void;
435 onDownload: (theme: ThemeDefinition) => void;
436 onRemove: (theme: ThemeDefinition) => void;
437}) {
438 const [variantIndex, setVariantIndex] = useState(() => {
439 const activeIndex = themes.findIndex((theme) => activeModesFor(theme.id).length > 0);
440 return activeIndex < 0 ? 0 : activeIndex;
441 });
442 const safeIndex = Math.min(variantIndex, themes.length - 1);
443 const theme = themes[safeIndex];
444
445 useEffect(() => {
446 if (variantIndex !== safeIndex) setVariantIndex(safeIndex);
447 }, [safeIndex, variantIndex]);
448
449 if (!theme) return null;
450 const collectionLabel = theme.collection?.label ?? theme.label;
451 const variantLabels = collectionVariantLabels(themes);
452 const defaultLightTheme = themes.find((candidate) => getThemeModes(candidate).includes("light"));
453 const defaultDarkTheme = themes.find((candidate) => getThemeModes(candidate).includes("dark"));
454 const selectCollectionDefaults = () => {
455 if (themes.length === 1) {
456 onUse(theme);
457 return;
458 }
459 if (defaultLightTheme) onUseMode(defaultLightTheme, "light");
460 if (defaultDarkTheme) onUseMode(defaultDarkTheme, "dark");
461 setVariantIndex(0);
462 };
463
464 return (
465 <ThemeLibraryCard
466 activeModes={activeModesFor(theme.id)}
467 isActive={false}
468 onDownload={() => onDownload(theme)}
469 onDuplicate={() => onDuplicate(theme)}
470 onEdit={() => onEdit(theme)}
471 onRemove={() => onRemove(theme)}
472 onUse={selectCollectionDefaults}
473 onUseMode={(mode) => onUseMode(theme, mode)}
474 theme={getThemeCardDefinition(theme)}
475 {...(themes.length > 1
476 ? {
477 variantNavigation: {
478 collectionLabel,
479 options: themes.flatMap((variant, themeIndex) =>
480 getThemeCardDefinition(variant).previews.map((preview) => ({
481 themeIndex,
482 label: variantLabels[themeIndex] ?? variant.label,
483 activeModes: activeModesFor(variant.id),
484 preview,
485 })),
486 ),
487 onSelectAndUse: (themeIndex, mode) => {
488 const selectedTheme = themes[themeIndex];
489 if (!selectedTheme) return;
490 setVariantIndex(themeIndex);
491 onUseMode(selectedTheme, mode);
492 },
493 },
494 }
495 : {})}
496 />
497 );
498}
499
227500export function ThemeLibrary({
228501 theme,
229502 setTheme,
......@@ -250,14 +523,20 @@ export function ThemeLibrary({
250523 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
251524}) {
252525 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
253 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);
254 // Keep the last removal target so the dialog title stays populated while the
255 // close animation plays after confirming.
256 const lastThemeToRemoveRef = useRef<ThemeDefinition | null>(null);
257 useEffect(() => {
258 if (themeToRemove) lastThemeToRemoveRef.current = themeToRemove;
259 }, [themeToRemove]);
260 const removeDialogTheme = themeToRemove ?? lastThemeToRemoveRef.current;
526 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{
527 theme: ThemeDefinition;
528 collectionThemes: ReadonlyArray<ThemeDefinition>;
529 } | null>(null);
530 // Keep the target after closing so the dialog text remains populated during
531 // its exit animation. The next trash action replaces it before reopening.
532 const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false);
533 const [themeIdsToRemove, setThemeIdsToRemove] = useState<ReadonlyArray<string>>([]);
534 const themeIdsToRemoveSet = new Set(themeIdsToRemove);
535 const removeDialogTheme = themeRemovalTarget?.theme;
536 const removeDialogCollectionThemes = themeRemovalTarget?.collectionThemes ?? [];
537 const canRemoveCollection = removeDialogCollectionThemes.length > 1;
538 const removeDialogCollectionLabel =
539 removeDialogTheme?.collection?.label ?? removeDialogTheme?.label;
261540
262541 const notifyThemeSaveFailure = useCallback(() => {
263542 toastManager.add(
......@@ -288,37 +567,43 @@ export function ThemeLibrary({
288567 [notifyThemeSaveFailure, setTheme],
289568 );
290569
291 const handleRemoveTheme = useCallback((customTheme: ThemeDefinition) => {
292 setThemeToRemove(customTheme);
293 }, []);
570 const handleRemoveTheme = useCallback(
571 (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => {
572 setThemeRemovalTarget({ theme: customTheme, collectionThemes });
573 setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]);
574 setIsThemeRemovalOpen(true);
575 },
576 [],
577 );
294578
295579 const handleConfirmRemoveTheme = useCallback(() => {
296 if (!themeToRemove) return;
297 const removesBase = getThemeDefinition(theme)?.id === themeToRemove.id;
298 // Keep the theme installed if we cannot move the selection off it; the
299 // dialog stays open so the user can retry or cancel.
580 if (!themeRemovalTarget) return;
581 const removedIds = new Set(themeIdsToRemove);
582 if (removedIds.size === 0) return;
583 const removesBase = removedIds.has(getThemeDefinition(theme)?.id ?? "");
584 // Keep the themes installed if we cannot move the selection off one of
585 // them; the dialog stays open so the user can retry or cancel.
300586 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
301587 return;
302588 }
303589 for (const appearance of ["light", "dark"] as const) {
304590 const half = themeHalves?.[appearance];
305591 if (half === undefined) continue;
306 // Writing a base preference clears the whole mix, so halves that name a
307 // surviving theme are written back; halves on the removed theme fall
308 // back to the base.
309 const next = half === themeToRemove.id ? null : removesBase ? half : undefined;
592 // Writing a base preference clears the whole mix, so halves that name
593 // a surviving theme are written back; removed halves fall back to base.
594 const next = half && removedIds.has(half) ? null : removesBase ? half : undefined;
310595 if (next !== undefined && !setThemeHalf(appearance, next)) {
311596 notifyThemeRemovalFailure();
312597 return;
313598 }
314599 }
315600 try {
316 removeCustomTheme(themeToRemove.id);
601 removeCustomThemes([...removedIds]);
317602 } catch {
318603 notifyThemeRemovalFailure();
319604 return;
320605 }
321 setThemeToRemove(null);
606 setIsThemeRemovalOpen(false);
322607 }, [
323608 appearanceMode,
324609 notifyThemeRemovalFailure,
......@@ -326,7 +611,8 @@ export function ThemeLibrary({
326611 setThemeHalf,
327612 theme,
328613 themeHalves,
329 themeToRemove,
614 themeIdsToRemove,
615 themeRemovalTarget,
330616 ]);
331617
332618 // ----- Automatic-mode mixing -------------------------------------------
......@@ -460,6 +746,20 @@ export function ThemeLibrary({
460746 </div>
461747 );
462748
749 const customThemeCollections = [
750 ...customThemes
751 .reduce((groups, customTheme) => {
752 const groupId = customTheme.collection
753 ? `collection:${customTheme.collection.id}`
754 : `theme:${customTheme.id}`;
755 const group = groups.get(groupId);
756 if (group) group.push(customTheme);
757 else groups.set(groupId, [customTheme]);
758 return groups;
759 }, new Map<string, ThemeDefinition[]>())
760 .entries(),
761 ];
762
463763 const renderPairGrid = () => (
464764 // One shared provider so every tooltip in the grid hands off instantly to
465765 // the next hovered trigger instead of stacking on top of it. The card
......@@ -509,43 +809,39 @@ export function ThemeLibrary({
509809 />
510810 );
511811 })}
512 {customThemes.map((customTheme) => {
513 const card = getThemeCardDefinition(customTheme);
514 return (
515 <ThemeLibraryCard
516 activeModes={pickedModesFor(customTheme.id)}
517 isActive={false}
518 key={customTheme.id}
519 onDuplicate={() =>
520 openThemeEditor({
521 editingThemeId: null,
522 seedThemeId: customTheme.id,
523 seedName: `${customTheme.label} copy`,
524 initialAppearance,
525 })
526 }
527 onEdit={() =>
528 openThemeEditor({
529 editingThemeId: customTheme.id,
530 seedThemeId: null,
531 seedName: null,
532 initialAppearance,
533 })
534 }
535 onDownload={() =>
536 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
537 }
538 onRemove={() => handleRemoveTheme(customTheme)}
539 onUse={() => {
540 const modes = getThemeModes(customTheme);
541 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
542 else persistTheme(customTheme.id);
543 }}
544 onUseMode={handlePairPick(customTheme.id)}
545 theme={card}
546 />
547 );
548 })}
812 {customThemeCollections.map(([collectionId, themes]) => (
813 <CustomThemeCollectionCard
814 activeModesFor={pickedModesFor}
815 key={collectionId}
816 onDownload={(customTheme) =>
817 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
818 }
819 onDuplicate={(customTheme) =>
820 openThemeEditor({
821 editingThemeId: null,
822 seedThemeId: customTheme.id,
823 seedName: `${customTheme.label} copy`,
824 initialAppearance,
825 })
826 }
827 onEdit={(customTheme) =>
828 openThemeEditor({
829 editingThemeId: customTheme.id,
830 seedThemeId: null,
831 seedName: null,
832 initialAppearance,
833 })
834 }
835 onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)}
836 onUse={(customTheme) => {
837 const modes = getThemeModes(customTheme);
838 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
839 else persistTheme(customTheme.id);
840 }}
841 onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)}
842 themes={themes}
843 />
844 ))}
549845 </div>
550846 </TooltipProvider>
551847 );
......@@ -561,7 +857,7 @@ export function ThemeLibrary({
561857 {renderModeTiles()}
562858 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
563859 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>
564 <div className="flex flex-wrap items-center justify-end gap-3">
860 <div className="flex flex-wrap items-center justify-end gap-2">
565861 <Button
566862 size="xs"
567863 variant="outline"
......@@ -586,19 +882,9 @@ export function ThemeLibrary({
586882 {renderPairGrid()}
587883 <ThemeImportDialog
588884 onImportedMany={(importedThemes, { updated }) => {
589 // An updated theme that is showing (as the base or either half)
590 // needs its palette re-applied.
591 if (
592 updated &&
593 importedThemes.some(
594 (imported) =>
595 getThemeDefinition(theme)?.id === imported.id ||
596 themeHalves?.light === imported.id ||
597 themeHalves?.dark === imported.id,
598 )
599 ) {
600 refreshTheme();
601 }
885 // Re-apply after collection updates. The update may remove the
886 // selected variant, in which case the theme hook falls back safely.
887 if (updated) refreshTheme();
602888 const verb = updated ? "updated" : "added";
603889 toastManager.add(
604890 stackedThreadToast({
......@@ -639,23 +925,80 @@ export function ThemeLibrary({
639925 onOpenChange={onImportOpenChange}
640926 open={isImportOpen}
641927 />
642 <AlertDialog
643 open={themeToRemove !== null}
644 onOpenChange={(open) => {
645 if (!open) setThemeToRemove(null);
646 }}
647 >
928 <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}>
648929 <AlertDialogPopup>
649930 <AlertDialogHeader>
650 <AlertDialogTitle>Remove “{removeDialogTheme?.label}”?</AlertDialogTitle>
931 <AlertDialogTitle>
932 {canRemoveCollection
933 ? `Remove themes from “${removeDialogCollectionLabel}”?`
934 : `Remove “${removeDialogTheme?.label}”?`}
935 </AlertDialogTitle>
651936 <AlertDialogDescription>
652 You can bring it back anytime by importing its JSON file.
937 {canRemoveCollection
938 ? "Select the variants you want to remove. You can restore them by importing the extension again."
939 : "You can bring it back anytime by importing its JSON file."}
653940 </AlertDialogDescription>
654941 </AlertDialogHeader>
942 {canRemoveCollection ? (
943 <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto px-6 pb-6 sm:grid-cols-2">
944 {removeDialogCollectionThemes.map((customTheme) => {
945 const checked = themeIdsToRemoveSet.has(customTheme.id);
946 const card = getThemeCardDefinition(customTheme);
947 const checkboxId = `remove-theme-${customTheme.id}`;
948 return (
949 <label
950 className="group relative flex min-h-28 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-border/70 bg-muted/25 p-3 has-checked:border-ring has-checked:bg-accent/20 hover:bg-muted/40"
951 htmlFor={checkboxId}
952 key={customTheme.id}
953 >
954 <span className="absolute right-2 top-2 inline-grid size-5 grid-cols-1 sm:size-4">
955 <input
956 checked={checked}
957 className="col-start-1 row-start-1 size-full appearance-none rounded-sm border border-input bg-background outline-none checked:border-primary checked:bg-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring dark:not-checked:bg-input/32 forced-colors:appearance-auto"
958 id={checkboxId}
959 name="themes-to-remove"
960 type="checkbox"
961 onChange={(event) => {
962 const shouldRemove = event.currentTarget.checked;
963 setThemeIdsToRemove((current) =>
964 shouldRemove
965 ? [...current, customTheme.id]
966 : current.filter((themeId) => themeId !== customTheme.id),
967 );
968 }}
969 />
970 <CheckIcon className="pointer-events-none col-start-1 row-start-1 size-3.5 shrink-0 self-center justify-self-center stroke-primary-foreground opacity-0 group-has-checked:opacity-100 sm:size-3" />
971 </span>
972 <span className="flex min-h-12 items-center justify-center gap-1">
973 {card.previews.map((preview) => (
974 <span
975 className="flex size-11 shrink-0 items-center justify-center"
976 key={preview.mode}
977 >
978 <span className="flex scale-75">
979 <ThemePreviewCircle colors={preview.colors} mode={preview.mode} />
980 </span>
981 </span>
982 ))}
983 </span>
984 <p className="max-w-full truncate text-center text-base font-medium text-foreground sm:text-sm">
985 {customTheme.label}
986 </p>
987 </label>
988 );
989 })}
990 </div>
991 ) : null}
655992 <AlertDialogFooter>
656993 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
657 <Button variant="destructive" onClick={handleConfirmRemoveTheme}>
658 Remove theme
994 <Button
995 disabled={themeIdsToRemove.length === 0}
996 variant="destructive"
997 onClick={handleConfirmRemoveTheme}
998 >
999 {canRemoveCollection
1000 ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}`
1001 : "Remove theme"}
6591002 </Button>
6601003 </AlertDialogFooter>
6611004 </AlertDialogPopup>
apps/web/src/openVsxThemes.test.ts created+526
......@@ -0,0 +1,526 @@
1import { sha256 } from "@noble/hashes/sha2";
2import JSZip from "jszip";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 importOpenVsxThemeExtension,
7 searchOpenVsxThemes,
8 type OpenVsxThemeExtension,
9} from "./openVsxThemes";
10import { getThemeColorsForMode, themeColorToHex } from "./themePalette";
11
12const ASSET_ROOT = "https://open-vsx.org/api/demo/theme/1.0.0/file";
13
14function extensionDetail(overrides: Record<string, unknown> = {}) {
15 return {
16 namespace: "demo",
17 name: "theme",
18 displayName: "Demo Theme",
19 description: "A nice theme",
20 version: "1.0.0",
21 downloadCount: 123456,
22 license: "MIT",
23 repository: "https://github.com/demo/theme",
24 files: {
25 icon: `${ASSET_ROOT}/icon.png`,
26 manifest: `${ASSET_ROOT}/package.json`,
27 sha256: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
28 download: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
29 },
30 ...overrides,
31 };
32}
33
34afterEach(() => {
35 vi.useRealTimers();
36 vi.unstubAllGlobals();
37});
38
39describe("Open VSX themes", () => {
40 it("searches theme extensions and keeps only supported open-source licenses", async () => {
41 const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
42 const url = String(input);
43 if (url.includes("/-/search?")) {
44 return new Response(
45 JSON.stringify({
46 extensions: [
47 { namespace: "demo", name: "theme" },
48 { namespace: "icons", name: "theme" },
49 { namespace: "oversized", name: "theme" },
50 { namespace: "unlicensed", name: "theme" },
51 { namespace: "unavailable", name: "theme" },
52 { namespace: "closed", name: "theme" },
53 { namespace: "huge", name: "theme" },
54 ],
55 }),
56 { status: 200 },
57 );
58 }
59 if (url.endsWith("/icons/theme")) {
60 return new Response(
61 JSON.stringify(
62 extensionDetail({
63 namespace: "icons",
64 files: {
65 ...extensionDetail().files,
66 manifest: `${ASSET_ROOT.replace("demo/theme", "icons/theme")}/package.json`,
67 },
68 }),
69 ),
70 { status: 200 },
71 );
72 }
73 if (url.endsWith("/oversized/theme")) {
74 return new Response(
75 JSON.stringify(
76 extensionDetail({
77 namespace: "oversized",
78 files: {
79 ...extensionDetail().files,
80 download: `${ASSET_ROOT.replace("demo/theme", "oversized/theme")}/oversized.vsix`,
81 },
82 }),
83 ),
84 { status: 200 },
85 );
86 }
87 if (url.endsWith("/unlicensed/theme")) {
88 return new Response(
89 JSON.stringify(
90 extensionDetail({
91 namespace: "unlicensed",
92 files: {
93 ...extensionDetail().files,
94 manifest: `${ASSET_ROOT.replace("demo/theme", "unlicensed/theme")}/package.json`,
95 },
96 }),
97 ),
98 { status: 200 },
99 );
100 }
101 if (url.endsWith("/unavailable/theme")) {
102 return new Response(
103 JSON.stringify(
104 extensionDetail({
105 namespace: "unavailable",
106 files: {
107 ...extensionDetail().files,
108 download: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/unavailable.vsix`,
109 manifest: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/package.json`,
110 },
111 }),
112 ),
113 { status: 200 },
114 );
115 }
116 if (url.endsWith("/closed/theme")) {
117 return new Response(
118 JSON.stringify(extensionDetail({ namespace: "closed", license: "All Rights Reserved" })),
119 { status: 200 },
120 );
121 }
122 if (url.endsWith("/huge/theme")) {
123 return new Response(JSON.stringify({ padding: "x".repeat(256 * 1024) }), { status: 200 });
124 }
125 if (url.endsWith("/oversized/theme/1.0.0/file/oversized.vsix")) {
126 return new Response(null, {
127 headers: { "content-length": String(20 * 1024 * 1024 + 1) },
128 status: 200,
129 });
130 }
131 if (url.endsWith("/unavailable/theme/1.0.0/file/unavailable.vsix")) {
132 return new Response(null, { status: 404 });
133 }
134 if (url.endsWith(".vsix")) {
135 return new Response(null, { headers: { "content-length": "1024" }, status: 200 });
136 }
137 if (url.endsWith("/icons/theme/1.0.0/file/package.json")) {
138 return new Response(JSON.stringify({ contributes: { iconThemes: [{}] } }), { status: 200 });
139 }
140 if (url.endsWith("/unlicensed/theme/1.0.0/file/package.json")) {
141 return new Response(
142 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
143 { status: 200 },
144 );
145 }
146 if (url.endsWith("/unavailable/theme/1.0.0/file/package.json")) {
147 return new Response(
148 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
149 { status: 200 },
150 );
151 }
152 if (url.endsWith("/demo/theme/1.0.0/file/package.json")) {
153 return new Response(
154 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
155 { status: 200 },
156 );
157 }
158 return new Response(JSON.stringify(extensionDetail()), { status: 200 });
159 });
160 vi.stubGlobal("fetch", fetchMock);
161
162 const results = await searchOpenVsxThemes(" dracula ");
163
164 expect(results).toEqual([
165 expect.objectContaining({
166 id: "demo.theme",
167 name: "Demo Theme",
168 publisher: "demo",
169 downloadCount: 123456,
170 iconUrl: `${ASSET_ROOT}/icon.png`,
171 sourceUrl: "https://github.com/demo/theme",
172 license: "MIT",
173 }),
174 ]);
175 const searchUrl = new URL(String(fetchMock.mock.calls[0]![0]));
176 expect(searchUrl.searchParams.get("query")).toBe("dracula");
177 expect(searchUrl.searchParams.get("category")).toBe("Themes");
178 expect(searchUrl.searchParams.get("sortBy")).toBe("downloadCount");
179 expect(searchUrl.searchParams.get("sortOrder")).toBe("desc");
180 expect(searchUrl.searchParams.get("size")).toBe("16");
181 });
182
183 it("supports sorting theme searches", async () => {
184 const fetchMock = vi.fn(
185 async (..._args: unknown[]) =>
186 new Response(JSON.stringify({ extensions: [] }), { status: 200 }),
187 );
188 vi.stubGlobal("fetch", fetchMock);
189
190 await searchOpenVsxThemes("nord", { sortBy: "rating" });
191
192 const searchUrl = new URL(String(fetchMock.mock.calls[0]![0]));
193 expect(searchUrl.searchParams.get("sortBy")).toBe("rating");
194 expect(searchUrl.searchParams.get("sortOrder")).toBe("desc");
195 });
196
197 it("rejects malformed search responses", async () => {
198 vi.stubGlobal(
199 "fetch",
200 vi.fn(async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 200 })),
201 );
202
203 await expect(searchOpenVsxThemes("nord")).rejects.toThrow(
204 "Open VSX returned an unreadable search response.",
205 );
206 });
207
208 it("reports an unavailable search when every detail request fails", async () => {
209 vi.stubGlobal(
210 "fetch",
211 vi.fn(async (input: RequestInfo | URL) => {
212 if (String(input).includes("/-/search?")) {
213 return new Response(
214 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
215 { status: 200 },
216 );
217 }
218 return new Response(null, { status: 429 });
219 }),
220 );
221
222 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
223 "Open VSX theme details are unavailable",
224 );
225 });
226
227 it("reports an unavailable search when every detail response is malformed", async () => {
228 vi.stubGlobal(
229 "fetch",
230 vi.fn(async (input: RequestInfo | URL) => {
231 if (String(input).includes("/-/search?")) {
232 return new Response(
233 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
234 { status: 200 },
235 );
236 }
237 return new Response("{}", { status: 200 });
238 }),
239 );
240
241 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
242 "Open VSX theme details are unavailable",
243 );
244 });
245
246 it("returns no results when every theme package is unavailable", async () => {
247 vi.stubGlobal(
248 "fetch",
249 vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
250 const url = String(input);
251 if (url.includes("/-/search?")) {
252 return new Response(
253 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
254 { status: 200 },
255 );
256 }
257 if (url.endsWith("/demo/theme")) {
258 return new Response(JSON.stringify(extensionDetail()), { status: 200 });
259 }
260 if (init?.method === "HEAD") return new Response(null, { status: 404 });
261 return new Response(
262 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
263 { status: 200 },
264 );
265 }),
266 );
267
268 await expect(searchOpenVsxThemes("dracula")).resolves.toEqual([]);
269 });
270
271 it("times out a stalled search request", async () => {
272 vi.useFakeTimers();
273 vi.stubGlobal(
274 "fetch",
275 vi.fn(
276 (_input: RequestInfo | URL, init?: RequestInit) =>
277 new Promise<Response>((_resolve, reject) => {
278 init?.signal?.addEventListener("abort", () => {
279 reject(new DOMException("The operation was aborted.", "AbortError"));
280 });
281 }),
282 ),
283 );
284
285 const search = expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
286 "Open VSX took too long to respond",
287 );
288 await vi.advanceTimersByTimeAsync(10_000);
289
290 await search;
291 });
292
293 it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => {
294 const zip = new JSZip();
295 zip.file("extension/.gitkeep", "");
296 zip.file(
297 "extension/themes/base.jsonc",
298 `{
299 // inherited workbench colors
300 "colors": {
301 "editor.foreground": "#eeeeee",
302 "focusBorder": "#8b5cf6",
303 },
304 }`,
305 );
306 zip.file(
307 "extension/themes/demo-dark.json",
308 `{
309 "include": "./base.jsonc",
310 "colors": { "editor.background": "#111111" }
311 }`,
312 );
313 zip.file(
314 "extension/themes/demo-light.json",
315 `{
316 "colors": {
317 "editor.background": "#fafafa",
318 "editor.foreground": "#222222",
319 "focusBorder": "#8b5cf6"
320 }
321 }`,
322 );
323 zip.file(
324 "extension/themes/demo.json",
325 `{
326 "colors": {
327 "editor.background": "#181818",
328 "editor.foreground": "#eeeeee"
329 }
330 }`,
331 );
332 const packagedManifest = {
333 publisher: "demo",
334 name: "theme",
335 version: "1.0.0",
336 license: "MIT",
337 contributes: {
338 themes: [
339 { label: "Demo Dark", uiTheme: "vs-dark", path: "./themes/demo-dark.json" },
340 { label: "Demo Light", uiTheme: "vs", path: "./themes/demo-light.json" },
341 { label: "Demo", uiTheme: "vs-dark", path: "./themes/demo.json" },
342 ],
343 },
344 };
345 let packageBytes = new ArrayBuffer(0);
346 let checksum = "";
347 const rebuildPackage = async () => {
348 zip.file("extension/package.json", JSON.stringify(packagedManifest));
349 packageBytes = await zip.generateAsync({
350 type: "arraybuffer",
351 comment: `PK\u0005\u0006${"x".repeat(26)}`,
352 });
353 checksum = [...sha256(new Uint8Array(packageBytes))]
354 .map((byte) => byte.toString(16).padStart(2, "0"))
355 .join("");
356 };
357 await rebuildPackage();
358 // The public manifest is only a preflight hint. Imports must use the manifest
359 // inside the checksummed VSIX rather than this inconsistent contribution.
360 const manifest = {
361 contributes: { themes: [{ label: "Wrong", path: "./themes/missing.json" }] },
362 };
363 const extension: OpenVsxThemeExtension = {
364 collectionId: "open-vsx:demo.theme",
365 id: "demo.theme",
366 name: "Demo Theme",
367 publisher: "demo",
368 description: "",
369 downloadCount: 1,
370 iconUrl: null,
371 sourceUrl: null,
372 license: "MIT",
373 manifestUrl: `${ASSET_ROOT}/package.json`,
374 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
375 version: "1.0.0",
376 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
377 };
378 vi.stubGlobal(
379 "fetch",
380 vi.fn(async (input: string | URL | Request) => {
381 const url = String(input);
382 if (url === extension.manifestUrl) {
383 return new Response(JSON.stringify(manifest), { status: 200 });
384 }
385 if (url === extension.sha256Url) return new Response(`${checksum} demo.vsix`);
386 return new Response(packageBytes, {
387 status: 200,
388 headers: { "Content-Length": String(packageBytes.byteLength) },
389 });
390 }),
391 );
392
393 const themes = await importOpenVsxThemeExtension(extension);
394
395 expect(themes).toHaveLength(2);
396 expect(new Set(themes.map((theme) => theme.id)).size).toBe(2);
397 expect(themes.every((theme) => /^ovx-[a-z0-9-]+-[0-9a-f]{12}$/.test(theme.id))).toBe(true);
398 expect(themes.every((theme) => theme.collection?.id === "open-vsx:demo.theme")).toBe(true);
399 const paired = themes.find(
400 (theme) =>
401 getThemeColorsForMode(theme, "light") !== null &&
402 getThemeColorsForMode(theme, "dark") !== null,
403 )!;
404 expect(paired.label).toBe("Demo");
405 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");
406 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");
407 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee");
408
409 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";
410 packagedManifest.contributes.themes[1]!.label = "Renamed Light";
411 packagedManifest.contributes.themes[2]!.label = "Renamed Solo";
412 await rebuildPackage();
413 const renamedThemes = await importOpenVsxThemeExtension(extension);
414 expect(renamedThemes.map((theme) => theme.id)).toEqual(themes.map((theme) => theme.id));
415
416 packagedManifest.contributes.themes.push({
417 label: "Duplicate Demo",
418 uiTheme: "vs-dark",
419 path: "./themes/demo.json",
420 });
421 await rebuildPackage();
422 const duplicatePathThemes = await importOpenVsxThemeExtension(extension);
423 expect(new Set(duplicatePathThemes.map((theme) => theme.id)).size).toBe(
424 duplicatePathThemes.length,
425 );
426 expect(themes.every(({ id }) => duplicatePathThemes.some((theme) => theme.id === id))).toBe(
427 true,
428 );
429 packagedManifest.contributes.themes.pop();
430
431 packagedManifest.contributes.themes[2]!.path = "./themes/missing.json";
432 await rebuildPackage();
433 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
434 "could not be imported safely",
435 );
436 packagedManifest.contributes.themes[2]!.path = "./themes/demo.json";
437
438 packagedManifest.license = "Proprietary";
439 await rebuildPackage();
440 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
441 "does not match its advertised license",
442 );
443
444 Reflect.deleteProperty(packagedManifest, "license");
445 await rebuildPackage();
446 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
447 "does not match its advertised license",
448 );
449 });
450
451 it("stops import work when the request is cancelled", async () => {
452 const packageBytes = new Uint8Array([1, 2, 3]);
453 const checksum = [...sha256(packageBytes)]
454 .map((byte) => byte.toString(16).padStart(2, "0"))
455 .join("");
456 const controller = new AbortController();
457 const extension: OpenVsxThemeExtension = {
458 collectionId: "open-vsx:demo.theme",
459 id: "demo.theme",
460 name: "Demo Theme",
461 publisher: "demo",
462 description: "",
463 downloadCount: 1,
464 iconUrl: null,
465 sourceUrl: null,
466 license: "MIT",
467 manifestUrl: `${ASSET_ROOT}/package.json`,
468 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
469 version: "1.0.0",
470 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
471 };
472 vi.stubGlobal(
473 "fetch",
474 vi.fn(async (input: string | URL | Request) => {
475 const url = String(input);
476 if (url === extension.manifestUrl) {
477 return new Response(
478 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
479 );
480 }
481 if (url === extension.sha256Url) {
482 controller.abort();
483 return new Response(checksum);
484 }
485 return new Response(packageBytes);
486 }),
487 );
488
489 await expect(importOpenVsxThemeExtension(extension, controller.signal)).rejects.toMatchObject({
490 name: "AbortError",
491 });
492 });
493
494 it("rejects a package whose Open VSX checksum does not match", async () => {
495 const extension: OpenVsxThemeExtension = {
496 collectionId: "open-vsx:demo.theme",
497 id: "demo.theme",
498 name: "Demo Theme",
499 publisher: "demo",
500 description: "",
501 downloadCount: 1,
502 iconUrl: null,
503 sourceUrl: null,
504 license: "MIT",
505 manifestUrl: `${ASSET_ROOT}/package.json`,
506 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
507 version: "1.0.0",
508 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
509 };
510 vi.stubGlobal(
511 "fetch",
512 vi.fn(async (input: string | URL | Request) => {
513 const url = String(input);
514 if (url === extension.manifestUrl) {
515 return new Response(
516 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
517 );
518 }
519 if (url === extension.sha256Url) return new Response("0".repeat(64));
520 return new Response(new Uint8Array([1, 2, 3]));
521 }),
522 );
523
524 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow("integrity check");
525 });
526});
apps/web/src/openVsxThemes.ts created+776
......@@ -0,0 +1,776 @@
1import { sha256 } from "@noble/hashes/sha2";
2import JSZip from "jszip";
3import { parse, type ParseError } from "jsonc-parser";
4
5import type { ThemeDefinition } from "./themePalette";
6import {
7 isVsCodeThemeFile,
8 pairVsCodeThemes,
9 parseVsCodeThemeFile,
10 resolveThemeLabelCollisions,
11} from "./vscodeThemeImport";
12
13const OPEN_VSX_SEARCH_URL = "https://open-vsx.org/api/-/search";
14const MAX_VSIX_BYTES = 20 * 1024 * 1024;
15const MAX_SEARCH_BYTES = 512 * 1024;
16const MAX_DETAIL_BYTES = 256 * 1024;
17const MAX_MANIFEST_BYTES = 256 * 1024;
18const SEARCH_REQUEST_TIMEOUT_MS = 10_000;
19const MAX_THEME_BYTES = 256 * 1024;
20const MAX_ZIP_ENTRIES = 2_000;
21const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024;
22const MAX_COMPRESSION_RATIO = 200;
23const MAX_THEMES_PER_EXTENSION = 40;
24const MAX_INCLUDE_DEPTH = 8;
25const MAX_PACKAGE_PATH_LENGTH = 1_024;
26const MAX_COLOR_VALUE_LENGTH = 128;
27const MAX_RESOLVED_THEME_FILES = MAX_THEMES_PER_EXTENSION * MAX_INCLUDE_DEPTH;
28const SUPPORTED_LICENSES = new Set([
29 "0BSD",
30 "Apache-2.0",
31 "BSD-2-Clause",
32 "BSD-3-Clause",
33 "CC0-1.0",
34 "ISC",
35 "MIT",
36 "MPL-2.0",
37 "Unlicense",
38]);
39const USED_WORKBENCH_COLORS = new Set([
40 "activityBar.background",
41 "activityBarBadge.background",
42 "badge.background",
43 "button.background",
44 "button.foreground",
45 "contrastBorder",
46 "descriptionForeground",
47 "disabledForeground",
48 "dropdown.background",
49 "dropdown.border",
50 "editor.background",
51 "editor.foreground",
52 "editor.selectionBackground",
53 "editorCursor.foreground",
54 "editorError.foreground",
55 "editorGroup.border",
56 "editorPane.background",
57 "editorWarning.foreground",
58 "editorWidget.background",
59 "errorForeground",
60 "focusBorder",
61 "foreground",
62 "input.border",
63 "input.placeholderForeground",
64 "list.activeSelectionBackground",
65 "list.hoverBackground",
66 "list.inactiveSelectionBackground",
67 "menu.background",
68 "panel.background",
69 "panel.border",
70 "progressBar.background",
71 "quickInput.background",
72 "scrollbarSlider.background",
73 "sideBar.background",
74 "sideBar.border",
75 "sideBar.foreground",
76 "terminal.background",
77 "terminal.foreground",
78 "terminal.selectionBackground",
79 "terminalCursor.foreground",
80 "textCodeBlock.background",
81 "textLink.foreground",
82]);
83
84export type OpenVsxThemeSort = "downloadCount" | "rating" | "timestamp" | "relevance";
85
86export type OpenVsxThemeExtension = {
87 id: string;
88 collectionId: string;
89 name: string;
90 publisher: string;
91 description: string;
92 downloadCount: number;
93 iconUrl: string | null;
94 sourceUrl: string | null;
95 manifestUrl: string;
96 sha256Url: string;
97 vsixUrl: string;
98 version: string;
99 license: string;
100};
101
102export type OpenVsxThemeSearchOptions = {
103 signal?: AbortSignal;
104 sortBy?: OpenVsxThemeSort;
105};
106
107type ThemeContribution = { label?: unknown; uiTheme?: unknown; path?: unknown };
108
109function isRecord(value: unknown): value is Record<string, unknown> {
110 return typeof value === "object" && value !== null && !Array.isArray(value);
111}
112
113function shortHash(value: string): string {
114 return [...sha256(new TextEncoder().encode(value))]
115 .slice(0, 6)
116 .map((byte) => byte.toString(16).padStart(2, "0"))
117 .join("");
118}
119
120function openVsxThemeId(extensionId: string, source: string): string {
121 return `ovx-theme-${shortHash(`${extensionId}:${source}`)}`;
122}
123
124function openVsxCollectionId(extensionId: string): string {
125 const normalized = `open-vsx:${extensionId.toLowerCase()}`;
126 return /^[a-z0-9][a-z0-9.:-]{0,127}$/.test(normalized)
127 ? normalized
128 : `open-vsx:${shortHash(extensionId)}`;
129}
130
131function trustedOpenVsxUrl(value: unknown): string | null {
132 if (typeof value !== "string") return null;
133 try {
134 const url = new URL(value);
135 return url.protocol === "https:" && url.hostname.toLowerCase() === "open-vsx.org"
136 ? url.toString()
137 : null;
138 } catch {
139 return null;
140 }
141}
142
143function publicSourceUrl(value: unknown): string | null {
144 const rawValue =
145 typeof value === "string"
146 ? value
147 : isRecord(value) && typeof value.url === "string"
148 ? value.url
149 : null;
150 if (!rawValue) return null;
151 try {
152 const url = new URL(rawValue);
153 return url.protocol === "https:" && !url.username && !url.password ? url.toString() : null;
154 } catch {
155 return null;
156 }
157}
158
159function themeContributions(manifest: Record<string, unknown>): ThemeContribution[] {
160 const contributes = isRecord(manifest.contributes) ? manifest.contributes : null;
161 return Array.isArray(contributes?.themes)
162 ? (contributes.themes.filter(isRecord) as ThemeContribution[])
163 : [];
164}
165
166function manifestLicenseMatches(manifest: Record<string, unknown>, license: string): boolean {
167 return (
168 typeof manifest.license === "string" &&
169 manifest.license.trim().toLowerCase() === license.toLowerCase()
170 );
171}
172
173function extensionFromDetail(value: unknown): OpenVsxThemeExtension | null {
174 if (!isRecord(value) || !isRecord(value.files)) {
175 throw new Error("Open VSX returned malformed theme details.");
176 }
177 const namespace = typeof value.namespace === "string" ? value.namespace.trim() : "";
178 const extensionName = typeof value.name === "string" ? value.name.trim() : "";
179 const displayName =
180 (typeof value.displayName === "string" ? value.displayName.trim() : "") || extensionName;
181 const version = typeof value.version === "string" ? value.version.trim() : "";
182 const license = typeof value.license === "string" ? value.license.trim() : "";
183 const manifestUrl = trustedOpenVsxUrl(value.files.manifest);
184 const sha256Url = trustedOpenVsxUrl(value.files.sha256);
185 const vsixUrl = trustedOpenVsxUrl(value.files.download);
186 if (!namespace || !extensionName || !version || !manifestUrl || !sha256Url || !vsixUrl) {
187 throw new Error("Open VSX returned malformed theme details.");
188 }
189 if (!SUPPORTED_LICENSES.has(license)) return null;
190 const id = `${namespace}.${extensionName}`;
191 return {
192 id,
193 collectionId: openVsxCollectionId(id),
194 name: displayName,
195 publisher: namespace,
196 description: typeof value.description === "string" ? value.description : "",
197 downloadCount:
198 typeof value.downloadCount === "number" && Number.isFinite(value.downloadCount)
199 ? value.downloadCount
200 : 0,
201 iconUrl: trustedOpenVsxUrl(value.files.icon),
202 sourceUrl:
203 publicSourceUrl(value.repository) ??
204 publicSourceUrl(value.homepage) ??
205 publicSourceUrl(value.url),
206 manifestUrl,
207 sha256Url,
208 vsixUrl,
209 version,
210 license,
211 };
212}
213
214async function withSearchTimeout<T>(
215 operation: (signal: AbortSignal) => Promise<T>,
216 parentSignal?: AbortSignal,
217): Promise<T> {
218 const controller = new AbortController();
219 const abort = () => controller.abort();
220 if (parentSignal?.aborted) abort();
221 else parentSignal?.addEventListener("abort", abort, { once: true });
222 const timeout = setTimeout(abort, SEARCH_REQUEST_TIMEOUT_MS);
223 try {
224 return await operation(controller.signal);
225 } catch (cause) {
226 if (controller.signal.aborted && !parentSignal?.aborted) {
227 throw new Error("Open VSX took too long to respond.", { cause });
228 }
229 throw cause;
230 } finally {
231 clearTimeout(timeout);
232 parentSignal?.removeEventListener("abort", abort);
233 }
234}
235
236export async function searchOpenVsxThemes(
237 query: string,
238 { signal, sortBy = "downloadCount" }: OpenVsxThemeSearchOptions = {},
239): Promise<OpenVsxThemeExtension[]> {
240 const searchText = query.trim();
241 if (!searchText) return [];
242 const url = new URL(OPEN_VSX_SEARCH_URL);
243 url.searchParams.set("query", searchText);
244 url.searchParams.set("category", "Themes");
245 url.searchParams.set("sortBy", sortBy);
246 url.searchParams.set("sortOrder", "desc");
247 // Ask for a few extras because results without a supported SPDX license
248 // are intentionally omitted.
249 url.searchParams.set("size", "16");
250 const value = await withSearchTimeout(async (requestSignal) => {
251 const response = await fetch(url, { signal: requestSignal });
252 if (!response.ok) throw new Error("Open VSX search is unavailable right now.");
253 const searchBytes = await readCappedResponse(
254 response,
255 MAX_SEARCH_BYTES,
256 "Open VSX returned an unexpectedly large response.",
257 );
258 try {
259 return JSON.parse(new TextDecoder().decode(searchBytes)) as unknown;
260 } catch {
261 throw new Error("Open VSX returned an unreadable response.");
262 }
263 }, signal);
264 if (!isRecord(value) || !Array.isArray(value.extensions)) {
265 throw new Error("Open VSX returned an unreadable search response.");
266 }
267 const identities = value.extensions.flatMap((candidate): Array<[string, string]> => {
268 if (!isRecord(candidate)) return [];
269 const namespace = typeof candidate.namespace === "string" ? candidate.namespace : "";
270 const name = typeof candidate.name === "string" ? candidate.name : "";
271 return namespace && name ? [[namespace, name]] : [];
272 });
273 const details = await Promise.allSettled(
274 identities.slice(0, 16).map(([namespace, name]) =>
275 withSearchTimeout(async (requestSignal) => {
276 const detailUrl = `https://open-vsx.org/api/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`;
277 const detailResponse = await fetch(detailUrl, { signal: requestSignal });
278 if (!detailResponse.ok) throw new Error("Open VSX theme details are unavailable.");
279 const detailBytes = await readCappedResponse(
280 detailResponse,
281 MAX_DETAIL_BYTES,
282 "Open VSX returned an unexpectedly large detail response.",
283 );
284 try {
285 const extension = extensionFromDetail(JSON.parse(new TextDecoder().decode(detailBytes)));
286 if (!extension) return null;
287 const [manifestResponse, packageResponse] = await Promise.all([
288 fetch(extension.manifestUrl, { signal: requestSignal }),
289 fetch(extension.vsixUrl, { method: "HEAD", signal: requestSignal }),
290 ]);
291 if (!manifestResponse.ok) throw new Error("manifest unavailable");
292 if (!packageResponse.ok) return null;
293 const packageLength = Number(packageResponse.headers.get("content-length"));
294 if (Number.isFinite(packageLength) && packageLength > MAX_VSIX_BYTES) {
295 return null;
296 }
297 const manifestBytes = await readCappedResponse(
298 manifestResponse,
299 MAX_MANIFEST_BYTES,
300 "Open VSX returned an unexpectedly large manifest.",
301 );
302 const manifest = parseJsoncObject(
303 new TextDecoder().decode(manifestBytes),
304 "Extension manifest",
305 );
306 return themeContributions(manifest).length > 0 &&
307 manifestLicenseMatches(manifest, extension.license)
308 ? extension
309 : null;
310 } catch {
311 throw new Error("Open VSX returned unreadable theme details.");
312 }
313 }, signal),
314 ),
315 );
316 if (signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError");
317 const completedDetails = details.filter((result) => result.status === "fulfilled");
318 if (identities.length > 0 && completedDetails.length === 0) {
319 throw new Error("Open VSX theme details are unavailable right now.");
320 }
321 return completedDetails.flatMap((result) => (result.value ? [result.value] : [])).slice(0, 8);
322}
323
324function parseJsoncObject(source: string, description: string): Record<string, unknown> {
325 const errors: ParseError[] = [];
326 const value: unknown = parse(source, errors, { allowTrailingComma: true });
327 if (errors.length > 0 || !isRecord(value)) throw new Error(`${description} is not valid JSON.`);
328 return value;
329}
330
331function sanitizeThemeObject(value: Record<string, unknown>): Record<string, unknown> {
332 const colors: Record<string, string> = {};
333 if (isRecord(value.colors)) {
334 for (const [key, color] of Object.entries(value.colors)) {
335 if (
336 USED_WORKBENCH_COLORS.has(key) &&
337 typeof color === "string" &&
338 color.length <= MAX_COLOR_VALUE_LENGTH
339 ) {
340 colors[key] = color;
341 }
342 }
343 }
344 return {
345 ...(typeof value.include === "string" ? { include: value.include } : {}),
346 colors,
347 };
348}
349
350function normalizePackagePath(path: string, relativeTo = "extension/"): string {
351 if (
352 path.length > MAX_PACKAGE_PATH_LENGTH ||
353 path.includes("\0") ||
354 path.startsWith("/") ||
355 /^[a-zA-Z]:/.test(path)
356 ) {
357 throw new Error("Theme path is not a safe relative package path.");
358 }
359 const normalizedInput = path.replaceAll("\\", "/");
360 const baseSegments = relativeTo.split("/").slice(0, -1);
361 const segments = baseSegments;
362 for (const segment of normalizedInput.split("/")) {
363 if (!segment || segment === ".") continue;
364 if (segment === "..") {
365 if (segments.length <= 1) throw new Error("Theme path escapes the extension package.");
366 segments.pop();
367 continue;
368 }
369 segments.push(segment);
370 }
371 if (segments[0] !== "extension") segments.unshift("extension");
372 return segments.join("/");
373}
374
375function contributionType(uiTheme: unknown): string | null {
376 if (uiTheme === "vs") return "light";
377 if (uiTheme === "vs-dark") return "dark";
378 if (uiTheme === "hc-black" || uiTheme === "hc-light") return uiTheme;
379 return null;
380}
381
382type ZipEntrySizes = {
383 uncompressedSize?: unknown;
384};
385
386type InspectableZipObject = JSZip.JSZipObject & {
387 _data?: ZipEntrySizes;
388 unsafeOriginalName?: string;
389 internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper<Uint8Array>;
390};
391
392function inspectZipDirectory(bytes: Uint8Array): Uint8Array {
393 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
394 const minimumOffset = Math.max(0, bytes.byteLength - 65_557);
395 let endOffset = bytes.byteLength - 22;
396 while (
397 endOffset >= minimumOffset &&
398 (view.getUint32(endOffset, true) !== 0x06054b50 ||
399 endOffset + 22 + view.getUint16(endOffset + 20, true) !== bytes.byteLength)
400 ) {
401 endOffset -= 1;
402 }
403 if (endOffset < minimumOffset) throw new Error("That extension package has no ZIP directory.");
404
405 const directorySize = view.getUint32(endOffset + 12, true);
406 const directoryOffset = view.getUint32(endOffset + 16, true);
407 const directoryEnd = directoryOffset + directorySize;
408 if (directoryEnd !== endOffset || directoryEnd > bytes.byteLength) {
409 throw new Error("That extension package has an invalid ZIP directory.");
410 }
411
412 let entryCount = 0;
413 let totalUncompressed = 0;
414 let offset = directoryOffset;
415 while (offset < directoryEnd) {
416 if (offset + 46 > directoryEnd || view.getUint32(offset, true) !== 0x02014b50) {
417 throw new Error("That extension package has an invalid ZIP directory.");
418 }
419 entryCount += 1;
420 if (entryCount > MAX_ZIP_ENTRIES) {
421 throw new Error("That extension package has too many files.");
422 }
423 const compressed = view.getUint32(offset + 20, true);
424 const uncompressed = view.getUint32(offset + 24, true);
425 if (compressed === 0xffffffff || uncompressed === 0xffffffff) {
426 throw new Error("That extension package has unsupported ZIP64 metadata.");
427 }
428 totalUncompressed += uncompressed;
429 if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
430 throw new Error("That extension package expands beyond the safe import limit.");
431 }
432 if (
433 uncompressed > 0 &&
434 (compressed === 0 || uncompressed / compressed > MAX_COMPRESSION_RATIO)
435 ) {
436 throw new Error("That extension package has an unsafe compression ratio.");
437 }
438 const nameLength = view.getUint16(offset + 28, true);
439 const extraLength = view.getUint16(offset + 30, true);
440 const commentLength = view.getUint16(offset + 32, true);
441 offset += 46 + nameLength + extraLength + commentLength;
442 }
443 if (offset !== directoryEnd)
444 throw new Error("That extension package has an invalid ZIP directory.");
445
446 const commentLength = view.getUint16(endOffset + 20, true);
447 if (commentLength === 0) return bytes;
448
449 // JSZip mistakes EOCD-like bytes inside an archive comment for the real EOCD.
450 // The comment is not needed for theme import, so remove it before parsing.
451 const withoutComment = bytes.slice(0, endOffset + 22);
452 withoutComment[endOffset + 20] = 0;
453 withoutComment[endOffset + 21] = 0;
454 return withoutComment;
455}
456
457function inspectZip(zip: JSZip): void {
458 const entries = Object.values(zip.files) as InspectableZipObject[];
459 if (entries.length > MAX_ZIP_ENTRIES)
460 throw new Error("That extension package has too many files.");
461
462 for (const entry of entries) {
463 if (entry.unsafeOriginalName) normalizePackagePath(entry.unsafeOriginalName);
464 }
465}
466
467async function readZipText(
468 zip: JSZip,
469 path: string,
470 description: string,
471 signal?: AbortSignal,
472): Promise<string> {
473 signal?.throwIfAborted();
474 const file = zip.file(path) as InspectableZipObject | null;
475 if (!file) throw new Error(`${description} is missing from the extension package.`);
476 if (typeof file._data?.uncompressedSize !== "number" || !file.internalStream) {
477 throw new Error(`${description} has unreadable size metadata.`);
478 }
479 if (file._data.uncompressedSize > MAX_THEME_BYTES) {
480 throw new Error(`${description} is too large.`);
481 }
482
483 return new Promise((resolve, reject) => {
484 const chunks: Uint8Array[] = [];
485 let byteLength = 0;
486 let settled = false;
487 const stream = file.internalStream!("uint8array");
488 const cleanup = () => signal?.removeEventListener("abort", handleAbort);
489 const handleAbort = () => {
490 if (settled) return;
491 settled = true;
492 stream.pause();
493 cleanup();
494 reject(signal?.reason);
495 };
496 signal?.addEventListener("abort", handleAbort, { once: true });
497 stream
498 .on("data", (chunk) => {
499 if (settled) return;
500 byteLength += chunk.byteLength;
501 if (byteLength > MAX_THEME_BYTES) {
502 settled = true;
503 stream.pause();
504 cleanup();
505 reject(new Error(`${description} is too large.`));
506 return;
507 }
508 chunks.push(chunk);
509 })
510 .on("error", (cause) => {
511 if (settled) return;
512 settled = true;
513 cleanup();
514 reject(cause);
515 })
516 .on("end", () => {
517 if (settled) return;
518 settled = true;
519 cleanup();
520 const bytes = new Uint8Array(byteLength);
521 let offset = 0;
522 for (const chunk of chunks) {
523 bytes.set(chunk, offset);
524 offset += chunk.byteLength;
525 }
526 resolve(new TextDecoder().decode(bytes));
527 })
528 .resume();
529 });
530}
531
532async function loadThemeObject(
533 zip: JSZip,
534 path: string,
535 cache: Map<string, Record<string, unknown>>,
536 budget: { files: number },
537 ancestors: ReadonlySet<string> = new Set(),
538 signal?: AbortSignal,
539): Promise<Record<string, unknown>> {
540 signal?.throwIfAborted();
541 if (ancestors.size >= MAX_INCLUDE_DEPTH) throw new Error("Theme includes are nested too deeply.");
542 if (ancestors.has(path)) throw new Error("Theme includes contain a cycle.");
543 const cached = cache.get(path);
544 if (cached) return cached;
545 budget.files += 1;
546 if (budget.files > MAX_RESOLVED_THEME_FILES) {
547 throw new Error("That extension references too many theme files.");
548 }
549
550 const value = sanitizeThemeObject(
551 parseJsoncObject(await readZipText(zip, path, path, signal), path),
552 );
553 if (typeof value.include !== "string") {
554 cache.set(path, value);
555 return value;
556 }
557
558 const includePath = normalizePackagePath(value.include, path);
559 const nextAncestors = new Set(ancestors);
560 nextAncestors.add(path);
561 const base = await loadThemeObject(zip, includePath, cache, budget, nextAncestors, signal);
562 const resolved = {
563 ...base,
564 ...value,
565 colors: {
566 ...(isRecord(base.colors) ? base.colors : {}),
567 ...(isRecord(value.colors) ? value.colors : {}),
568 },
569 };
570 cache.set(path, resolved);
571 return resolved;
572}
573
574async function readCappedResponse(
575 response: Response,
576 limit: number,
577 tooLargeMessage: string,
578): Promise<Uint8Array> {
579 const contentLength = response.headers.get("content-length");
580 if (contentLength && Number(contentLength) > limit) throw new Error(tooLargeMessage);
581 if (!response.body) {
582 const bytes = new Uint8Array(await response.arrayBuffer());
583 if (bytes.byteLength > limit) throw new Error(tooLargeMessage);
584 return bytes;
585 }
586
587 const reader = response.body.getReader();
588 const chunks: Uint8Array[] = [];
589 let byteLength = 0;
590 try {
591 while (true) {
592 const { done, value } = await reader.read();
593 if (done) break;
594 byteLength += value.byteLength;
595 if (byteLength > limit) {
596 await reader.cancel();
597 throw new Error(tooLargeMessage);
598 }
599 chunks.push(value);
600 }
601 } finally {
602 reader.releaseLock();
603 }
604
605 const result = new Uint8Array(byteLength);
606 let offset = 0;
607 for (const chunk of chunks) {
608 result.set(chunk, offset);
609 offset += chunk.byteLength;
610 }
611 return result;
612}
613
614async function fetchPackage(url: string, signal?: AbortSignal): Promise<Uint8Array> {
615 const response = await fetch(url, signal ? { signal } : {});
616 if (!response.ok) throw new Error("That Open VSX theme could not be downloaded.");
617 return readCappedResponse(
618 response,
619 MAX_VSIX_BYTES,
620 "That theme extension is too large to import safely.",
621 );
622}
623
624export async function importOpenVsxThemeExtension(
625 extension: OpenVsxThemeExtension,
626 signal?: AbortSignal,
627): Promise<ReadonlyArray<ThemeDefinition>> {
628 const manifestResponse = await fetch(extension.manifestUrl, signal ? { signal } : {});
629 if (!manifestResponse.ok) throw new Error("That Open VSX extension has no readable manifest.");
630 const manifestBytes = await readCappedResponse(
631 manifestResponse,
632 MAX_MANIFEST_BYTES,
633 "That Open VSX extension manifest is too large.",
634 );
635 const manifest = parseJsoncObject(new TextDecoder().decode(manifestBytes), "Extension manifest");
636 const advertisedContributions = themeContributions(manifest);
637 if (advertisedContributions.length === 0) {
638 throw new Error("That extension does not contain color themes.");
639 }
640 if (advertisedContributions.length > MAX_THEMES_PER_EXTENSION) {
641 throw new Error("That extension contains too many color themes to import safely.");
642 }
643
644 const packageBytes = await fetchPackage(extension.vsixUrl, signal);
645 signal?.throwIfAborted();
646 const checksumResponse = await fetch(extension.sha256Url, signal ? { signal } : {});
647 if (!checksumResponse.ok) throw new Error("That Open VSX theme has no readable checksum.");
648 const expectedChecksum = new TextDecoder()
649 .decode(
650 await readCappedResponse(
651 checksumResponse,
652 256,
653 "That Open VSX checksum response is invalid.",
654 ),
655 )
656 .trim()
657 .split(/\s+/)[0];
658 if (!expectedChecksum || !/^[a-f\d]{64}$/i.test(expectedChecksum)) {
659 throw new Error("That Open VSX theme has an invalid checksum.");
660 }
661 signal?.throwIfAborted();
662 const actualChecksum = [...sha256(packageBytes)]
663 .map((byte) => byte.toString(16).padStart(2, "0"))
664 .join("");
665 if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) {
666 throw new Error("That Open VSX theme failed its integrity check.");
667 }
668 signal?.throwIfAborted();
669 let zip: JSZip;
670 try {
671 const inspectedPackageBytes = inspectZipDirectory(packageBytes);
672 zip = await JSZip.loadAsync(inspectedPackageBytes);
673 signal?.throwIfAborted();
674 inspectZip(zip);
675 } catch (cause) {
676 if (signal?.aborted) signal.throwIfAborted();
677 if (cause instanceof Error && cause.message.startsWith("That extension package")) throw cause;
678 throw new Error("That Open VSX extension package could not be opened.", { cause });
679 }
680
681 const packagedManifest = parseJsoncObject(
682 await readZipText(zip, "extension/package.json", "Extension manifest", signal),
683 "Extension manifest",
684 );
685 if (
686 typeof packagedManifest.publisher !== "string" ||
687 packagedManifest.publisher.toLowerCase() !== extension.publisher.toLowerCase() ||
688 typeof packagedManifest.name !== "string" ||
689 `${packagedManifest.publisher}.${packagedManifest.name}`.toLowerCase() !==
690 extension.id.toLowerCase() ||
691 packagedManifest.version !== extension.version
692 ) {
693 throw new Error("That extension package does not match the selected Open VSX theme.");
694 }
695 if (!manifestLicenseMatches(packagedManifest, extension.license)) {
696 throw new Error("That extension package does not match its advertised license.");
697 }
698 const contributions = themeContributions(packagedManifest);
699 if (contributions.length === 0) throw new Error("That extension does not contain color themes.");
700 if (contributions.length > MAX_THEMES_PER_EXTENSION) {
701 throw new Error("That extension contains too many color themes to import safely.");
702 }
703
704 const parsed: Array<{ theme: ThemeDefinition; sourceName: string; sourcePath: string }> = [];
705 const failures: string[] = [];
706 const themeCache = new Map<string, Record<string, unknown>>();
707 const themeBudget = { files: 0 };
708 for (const contribution of contributions) {
709 signal?.throwIfAborted();
710 if (typeof contribution.path !== "string") {
711 failures.push("theme path is missing");
712 continue;
713 }
714 try {
715 const path = normalizePackagePath(contribution.path);
716 const themeValue = await loadThemeObject(
717 zip,
718 path,
719 themeCache,
720 themeBudget,
721 new Set(),
722 signal,
723 );
724 const type = contributionType(contribution.uiTheme);
725 const label =
726 typeof contribution.label === "string" && contribution.label.trim()
727 ? contribution.label.trim()
728 : extension.name;
729 const decorated = {
730 ...themeValue,
731 displayName: label,
732 ...(type ? { type } : {}),
733 };
734 if (!isVsCodeThemeFile(decorated)) throw new Error("not a VS Code color theme");
735 parsed.push({
736 theme: parseVsCodeThemeFile(decorated),
737 sourceName: path.split("/").at(-1)!,
738 sourcePath: path,
739 });
740 } catch (cause) {
741 signal?.throwIfAborted();
742 failures.push(cause instanceof Error ? cause.message : "theme could not be read");
743 }
744 }
745 if (failures.length > 0) {
746 throw new Error("One or more color themes in that extension could not be imported safely.");
747 }
748 if (parsed.length === 0) {
749 throw new Error("That extension has no compatible color themes.");
750 }
751 const extensionId = extension.id.toLowerCase();
752 const sourcePathCounts = new Map<string, number>();
753 for (const { sourcePath } of parsed) {
754 sourcePathCounts.set(sourcePath, (sourcePathCounts.get(sourcePath) ?? 0) + 1);
755 }
756 const sourcePathOccurrences = new Map<string, number>();
757 const sourceIdentities = parsed.map(({ sourcePath }) => {
758 if (sourcePathCounts.get(sourcePath) === 1) return sourcePath;
759 const occurrence = sourcePathOccurrences.get(sourcePath) ?? 0;
760 sourcePathOccurrences.set(sourcePath, occurrence + 1);
761 return occurrence === 0 ? sourcePath : `${sourcePath}\0${occurrence}`;
762 });
763 const resolved = resolveThemeLabelCollisions(parsed).map((theme, index) => ({
764 ...theme,
765 id: openVsxThemeId(extensionId, sourceIdentities[index]!),
766 }));
767 const paired = pairVsCodeThemes(resolved, {
768 pairedId: (light, dark) => openVsxThemeId(extensionId, [light.id, dark.id].sort().join(":")),
769 });
770 const themes = resolveThemeLabelCollisions(paired.map((theme) => ({ theme })));
771 const collection = {
772 id: extension.collectionId,
773 label: extension.name.slice(0, 48),
774 };
775 return themes.map((theme) => ({ ...theme, collection }));
776}
apps/web/src/themePalette.test.ts+247
......@@ -10,12 +10,15 @@ import {
1010 getThemePreferenceMode,
1111 isKnownThemePreference,
1212 getCustomThemes,
13 getStoredCustomThemeCollection,
1314 invalidateCustomThemes,
1415 installCustomTheme,
1516 canonicalThemePreference,
1617 parseThemeFile,
1718 parseThemeHalves,
1819 removeCustomTheme,
20 removeCustomThemes,
21 replaceCustomThemeCollection,
1922 resolveDesktopTheme,
2023 resolveThemeAppearance,
2124 serializeThemeFile,
......@@ -277,6 +280,28 @@ describe("theme files", () => {
277280 });
278281 });
279282
283 it("serializes a theme back into the importable file shape", () => {
284 const theme = {
285 ...parseThemeFile({
286 version: THEME_FILE_VERSION,
287 id: "community-demo",
288 name: "Community Demo",
289 appearance: "dark",
290 colors: { canvas: "#111111" },
291 }),
292 collection: { id: "open-vsx:demo.theme", label: "Demo Theme" },
293 };
294 const serialized = serializeThemeFile(theme);
295 expect(JSON.parse(serialized)).toMatchObject({
296 version: THEME_FILE_VERSION,
297 id: theme.id,
298 name: theme.label,
299 appearance: "dark",
300 collection: theme.collection,
301 });
302 expect(parseThemeFile(JSON.parse(serialized)).collection).toEqual(theme.collection);
303 });
304
280305 it("keeps sidebar artwork disabled for custom theme files", () => {
281306 const theme = parseThemeFile({
282307 version: THEME_FILE_VERSION,
......@@ -491,6 +516,178 @@ describe("theme files", () => {
491516 vi.unstubAllGlobals();
492517 });
493518
519 it("preserves valid imported-theme collections and drops malformed metadata", () => {
520 vi.stubGlobal("window", {
521 localStorage: {
522 getItem: (key: string) =>
523 key === CUSTOM_THEMES_STORAGE_KEY
524 ? JSON.stringify([
525 {
526 id: "github-dark",
527 label: "GitHub Dark",
528 appearance: "dark",
529 colors: { canvas: "#0d1117" },
530 collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" },
531 },
532 {
533 id: "github-light",
534 label: "GitHub Light",
535 appearance: "light",
536 colors: { canvas: "#ffffff" },
537 collection: { id: "bad collection id", label: "GitHub Theme" },
538 },
539 ])
540 : null,
541 },
542 });
543
544 invalidateCustomThemes();
545 expect(getCustomThemes()).toMatchObject([
546 { collection: { id: "open-vsx:github.github-vscode-theme", label: "GitHub Theme" } },
547 { id: "github-light" },
548 ]);
549 expect(getCustomThemes()[1]).not.toHaveProperty("collection");
550
551 vi.unstubAllGlobals();
552 invalidateCustomThemes();
553 });
554
555 it("atomically replaces an imported collection and removes stale variants", () => {
556 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
557 const personalTheme = {
558 id: "personal",
559 label: "Personal",
560 appearance: "dark",
561 colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" },
562 futureMetadata: { version: 2 },
563 };
564 const stored = new Map<string, string>([
565 [
566 CUSTOM_THEMES_STORAGE_KEY,
567 JSON.stringify([
568 personalTheme,
569 {
570 id: "old-light",
571 label: "Old Light",
572 appearance: "light",
573 colors: { canvas: "#ffffff" },
574 collection,
575 },
576 {
577 id: "removed-dark",
578 label: "Removed Dark",
579 appearance: "dark",
580 colors: { canvas: "#000000" },
581 collection,
582 },
583 ]),
584 ],
585 ]);
586 const setItem = vi.fn((key: string, value: string) => stored.set(key, value));
587 vi.stubGlobal("window", {
588 localStorage: {
589 getItem: (key: string) => stored.get(key) ?? null,
590 setItem,
591 },
592 });
593
594 invalidateCustomThemes();
595 const expectedCollection = getStoredCustomThemeCollection(collection.id);
596 getCustomThemes();
597 const concurrentlyAddedTheme = {
598 id: "other-tab",
599 label: "Other Tab",
600 appearance: "dark",
601 colors: { canvas: "#222222" },
602 };
603 stored.set(
604 CUSTOM_THEMES_STORAGE_KEY,
605 JSON.stringify([
606 ...JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"),
607 concurrentlyAddedTheme,
608 ]),
609 );
610 const replacement = [
611 {
612 ...parseThemeFile({
613 version: THEME_FILE_VERSION,
614 id: "old-light",
615 name: "New Light",
616 appearance: "light",
617 colors: { canvas: "#fafafa" },
618 }),
619 collection,
620 },
621 {
622 ...parseThemeFile({
623 version: THEME_FILE_VERSION,
624 id: "new-dark",
625 name: "New Dark",
626 appearance: "dark",
627 colors: { canvas: "#101010" },
628 }),
629 collection,
630 },
631 ];
632
633 expect(
634 replaceCustomThemeCollection(collection.id, replacement, { expectedCollection }),
635 ).toEqual(replacement);
636 expect(getCustomThemes().map((theme) => theme.id)).toEqual([
637 "personal",
638 "old-light",
639 "new-dark",
640 "other-tab",
641 ]);
642 expect(setItem).toHaveBeenCalledTimes(1);
643 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toEqual(personalTheme);
644
645 vi.unstubAllGlobals();
646 invalidateCustomThemes();
647 });
648
649 it("replaces collection entries even when their stored collection label is malformed", () => {
650 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
651 const stored = new Map<string, string>([
652 [
653 CUSTOM_THEMES_STORAGE_KEY,
654 JSON.stringify([
655 {
656 id: "old-light",
657 label: "Old Light",
658 appearance: "light",
659 colors: { canvas: "#ffffff" },
660 collection: { id: collection.id, label: 42 },
661 },
662 ]),
663 ],
664 ]);
665 vi.stubGlobal("window", {
666 localStorage: {
667 getItem: (key: string) => stored.get(key) ?? null,
668 setItem: (key: string, value: string) => stored.set(key, value),
669 },
670 });
671
672 invalidateCustomThemes();
673 const replacement = {
674 ...parseThemeFile({
675 version: THEME_FILE_VERSION,
676 id: "old-light",
677 name: "New Light",
678 appearance: "light",
679 colors: { canvas: "#fafafa" },
680 }),
681 collection,
682 };
683
684 expect(replaceCustomThemeCollection(collection.id, [replacement])).toEqual([replacement]);
685 expect(getCustomThemes()).toEqual([replacement]);
686
687 vi.unstubAllGlobals();
688 invalidateCustomThemes();
689 });
690
494691 it("canonicalizes explicit writes without migrating untouched themes", () => {
495692 const stored = new Map<string, string>();
496693 const untouchedTheme = {
......@@ -551,6 +748,56 @@ describe("theme files", () => {
551748 invalidateCustomThemes();
552749 });
553750
751 it("removes multiple custom themes atomically while preserving unrelated entries", () => {
752 const collection = { id: "open-vsx:demo.theme", label: "Demo Theme" };
753 const personalTheme = {
754 id: "personal",
755 label: "Personal",
756 appearance: "dark",
757 colors: { canvas: "#111111", futureRole: "hsl(10 20% 30%)" },
758 futureMetadata: { version: 2 },
759 };
760 const stored = new Map<string, string>([
761 [
762 CUSTOM_THEMES_STORAGE_KEY,
763 JSON.stringify([
764 personalTheme,
765 {
766 id: "demo-light",
767 label: "Demo Light",
768 appearance: "light",
769 colors: { canvas: "#ffffff" },
770 collection,
771 },
772 {
773 id: "demo-dark",
774 label: "Demo Dark",
775 appearance: "dark",
776 colors: { canvas: "#000000" },
777 collection,
778 },
779 ]),
780 ],
781 ]);
782 const setItem = vi.fn((key: string, value: string) => stored.set(key, value));
783 vi.stubGlobal("window", {
784 localStorage: {
785 getItem: (key: string) => stored.get(key) ?? null,
786 setItem,
787 },
788 });
789
790 invalidateCustomThemes();
791 removeCustomThemes(["demo-light", "demo-dark"]);
792
793 expect(setItem).toHaveBeenCalledOnce();
794 expect(getCustomThemes()).toEqual([expect.objectContaining({ id: "personal" })]);
795 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([personalTheme]);
796
797 vi.unstubAllGlobals();
798 invalidateCustomThemes();
799 });
800
554801 it("writes from the cached raw snapshot without risking a destructive reread", () => {
555802 const legacyTheme = {
556803 id: "legacy",
apps/web/src/themePalette.ts+133-13
......@@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string
9292export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
9393export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
9494export type ThemePreferenceMode = ThemeAppearance | "system";
95export type ThemeCollection = Readonly<{ id: string; label: string }>;
9596export type ThemeDefinition = Readonly<{
9697 id: string;
9798 label: string;
9899 appearance: ThemeAppearance;
99100 colors: ThemeColors;
100101 variants?: ThemeVariants;
102 /** Groups related imported variants into one library card. */
103 collection?: ThemeCollection;
101104 /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
102105 sidebarArtwork?: boolean;
103106 /** True when the palette was generated by the guided editor from its
......@@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{
111114 appearance: ThemeAppearance;
112115 colors: ThemeColorOverrides;
113116 variants?: ThemeVariantOverrides;
117 collection?: ThemeCollection;
114118 managed?: boolean;
115119}>;
116120
......@@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string {
179183 return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
180184}
181185
186function parseThemeCollection(value: unknown): ThemeCollection | undefined {
187 return isRecord(value) &&
188 typeof value.id === "string" &&
189 /^[a-z0-9][a-z0-9.:-]{0,127}$/i.test(value.id) &&
190 isThemeLabel(value.label)
191 ? { id: value.id, label: value.label.trim() }
192 : undefined;
193}
194
182195function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {
183196 if (!isRecord(value)) return null;
184197
......@@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
224237 if (!colors) return null;
225238 const variants = parseStoredThemeVariants(value.variants, value.appearance);
226239 if (value.variants !== undefined && variants === null) return null;
240 const collection = parseThemeCollection(value.collection);
227241
228242 return {
229243 id: value.id,
......@@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
231245 appearance: value.appearance,
232246 colors,
233247 ...(variants ? { variants } : {}),
248 ...(collection ? { collection } : {}),
234249 ...(value.managed === true ? { managed: true } : {}),
235250 };
236251}
237252
253function parseStoredThemes(storedThemes: ReadonlyArray<unknown>): ReadonlyArray<ThemeDefinition> {
254 const themes: ThemeDefinition[] = [];
255 for (const value of storedThemes) {
256 const theme = parseStoredTheme(value);
257 if (theme && !themes.some((existing) => existing.id === theme.id)) {
258 themes.push(theme);
259 }
260 }
261 return themes;
262}
263
238264function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
239265 if (typeof window === "undefined") {
240266 return { status: "ready", storedThemes: [], themes: [] };
......@@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
256282 }
257283 if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" };
258284
259 const themes: ThemeDefinition[] = [];
260 for (const value of parsed) {
261 const theme = parseStoredTheme(value);
262 if (theme && !themes.some((existing) => existing.id === theme.id)) {
263 themes.push(theme);
264 }
265 }
266
267 return { status: "ready", storedThemes: parsed, themes };
285 return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) };
268286}
269287
270288function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
......@@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
288306 return snapshot.status === "ready" ? snapshot.themes : [];
289307}
290308
309export function getStoredCustomThemeCollection(
310 collectionId: string,
311): ReadonlyArray<ThemeDefinition> {
312 return readWritableCustomThemeLibrary().themes.filter(
313 (theme) => theme.collection?.id === collectionId,
314 );
315}
316
291317export function subscribeToCustomThemes(listener: () => void): () => void {
292318 customThemeListeners.add(listener);
293319 if (typeof window === "undefined") {
......@@ -1598,8 +1624,9 @@ function saveCustomThemes(
15981624 notifyCustomThemeListeners();
15991625}
16001626
1601function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
1602 const snapshot = getCustomThemeLibrarySnapshot();
1627function requireWritableCustomThemeLibrary(
1628 snapshot: CustomThemeLibrarySnapshot,
1629): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
16031630 if (snapshot.status === "unavailable") {
16041631 throw new ThemeLibraryStorageError({
16051632 storageKey: CUSTOM_THEMES_STORAGE_KEY,
......@@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, {
16111638 return snapshot;
16121639}
16131640
1641function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
1642 return requireWritableCustomThemeLibrary(getCustomThemeLibrarySnapshot());
1643}
1644
1645function readWritableCustomThemeLibrary(): Extract<
1646 CustomThemeLibrarySnapshot,
1647 { status: "ready" }
1648> {
1649 return requireWritableCustomThemeLibrary(readCustomThemeLibrarySnapshot());
1650}
1651
16141652function storedThemeHasId(storedTheme: unknown, themeId: string): boolean {
16151653 return isRecord(storedTheme) && storedTheme.id === themeId;
16161654}
16171655
1656function storedThemeHasCollectionId(storedTheme: unknown, collectionId: string): boolean {
1657 return (
1658 isRecord(storedTheme) &&
1659 isRecord(storedTheme.collection) &&
1660 storedTheme.collection.id === collectionId
1661 );
1662}
1663
16181664export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {
16191665 if (RESERVED_THEME_IDS.has(theme.id)) {
16201666 throw new Error(`The theme id "${theme.id}" is reserved.`);
......@@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
16621708 return canonicalTheme;
16631709}
16641710
1711export function replaceCustomThemeCollection(
1712 collectionId: string,
1713 themes: ReadonlyArray<ThemeDefinition>,
1714 options?: { expectedCollection?: ReadonlyArray<ThemeDefinition> },
1715): ReadonlyArray<ThemeDefinition> {
1716 if (themes.length === 0) throw new Error("A theme collection cannot be empty.");
1717
1718 const validated = themes.map((theme) => parseStoredTheme(theme));
1719 if (
1720 validated.some((theme) => theme === null || theme.collection?.id !== collectionId) ||
1721 new Set(validated.map((theme) => theme?.id)).size !== validated.length
1722 ) {
1723 throw new Error("That theme collection is invalid.");
1724 }
1725 const replacement = validated as ThemeDefinition[];
1726 const library = readWritableCustomThemeLibrary();
1727 const current = library.themes;
1728 const currentCollection = current.filter((theme) => theme.collection?.id === collectionId);
1729 if (
1730 options?.expectedCollection &&
1731 JSON.stringify(currentCollection) !== JSON.stringify(options.expectedCollection)
1732 ) {
1733 throw new Error("Your installed themes changed while this package was downloading. Try again.");
1734 }
1735 const occupiedIds = new Set(BUILT_IN_THEME_DEFINITIONS.map((theme) => theme.id));
1736 for (const storedTheme of library.storedThemes) {
1737 if (
1738 !storedThemeHasCollectionId(storedTheme, collectionId) &&
1739 isRecord(storedTheme) &&
1740 typeof storedTheme.id === "string"
1741 ) {
1742 occupiedIds.add(storedTheme.id);
1743 }
1744 }
1745 const conflictingTheme = replacement.find(
1746 (theme) => RESERVED_THEME_IDS.has(theme.id) || occupiedIds.has(theme.id),
1747 );
1748 if (conflictingTheme) {
1749 throw new Error(`A theme named "${conflictingTheme.label}" is already installed.`);
1750 }
1751
1752 const nextStoredThemes: unknown[] = [];
1753 let insertedReplacement = false;
1754 for (const storedTheme of library.storedThemes) {
1755 if (!storedThemeHasCollectionId(storedTheme, collectionId)) {
1756 nextStoredThemes.push(storedTheme);
1757 } else if (!insertedReplacement) {
1758 nextStoredThemes.push(...replacement);
1759 insertedReplacement = true;
1760 }
1761 }
1762 if (!insertedReplacement) nextStoredThemes.push(...replacement);
1763
1764 saveCustomThemes(nextStoredThemes, parseStoredThemes(nextStoredThemes));
1765 return replacement;
1766}
1767
16651768export function removeCustomTheme(themeId: string): void {
1769 removeCustomThemes([themeId]);
1770}
1771
1772export function removeCustomThemes(themeIds: ReadonlyArray<string>): void {
1773 const removedIds = new Set(themeIds);
1774 if (removedIds.size === 0) return;
16661775 const library = getWritableCustomThemeLibrary();
1667 const nextThemes = library.themes.filter((theme) => theme.id !== themeId);
1776 const nextThemes = library.themes.filter((theme) => !removedIds.has(theme.id));
16681777 if (nextThemes.length === library.themes.length) return;
16691778 saveCustomThemes(
1670 library.storedThemes.filter((storedTheme) => !storedThemeHasId(storedTheme, themeId)),
1779 library.storedThemes.filter(
1780 (storedTheme) =>
1781 !isRecord(storedTheme) ||
1782 typeof storedTheme.id !== "string" ||
1783 !removedIds.has(storedTheme.id),
1784 ),
16711785 nextThemes,
16721786 );
16731787}
......@@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
17201834 }
17211835
17221836 const overrides = parseThemeColorOverrides(rawColors);
1837 const collection = parseThemeCollection(value.collection);
1838 if (value.collection !== undefined && !collection) {
1839 throw new Error("Theme collections need a valid id and label.");
1840 }
17231841
17241842 const fallback = getDefaultThemeColors(appearance);
17251843 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};
......@@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
17461864 appearance,
17471865 colors: { ...fallback, ...overrides },
17481866 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1867 ...(collection ? { collection } : {}),
17491868 ...(value.managed === true ? { managed: true } : {}),
17501869 };
17511870}
......@@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
17591878 appearance: canonicalTheme.appearance,
17601879 colors: canonicalTheme.colors,
17611880 ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}),
1881 ...(canonicalTheme.collection ? { collection: canonicalTheme.collection } : {}),
17621882 ...(canonicalTheme.managed ? { managed: true } : {}),
17631883 };
17641884 return `${JSON.stringify(file, null, 2)}\n`;
apps/web/src/vscodeThemeImport.ts+4
......@@ -337,6 +337,9 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
337337 */
338338export function pairVsCodeThemes(
339339 themes: ReadonlyArray<ThemeDefinition>,
340 options?: {
341 pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string;
342 },
340343): ReadonlyArray<ThemeDefinition> {
341344 const stripAppearance = (label: string) =>
342345 label
......@@ -372,6 +375,7 @@ export function pairVsCodeThemes(
372375 order: group.order,
373376 theme: parseThemeFile({
374377 version: THEME_FILE_VERSION,
378 ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}),
375379 name: key,
376380 appearance: "light",
377381 colors: group.light[0]!.colors,
pnpm-lock.yaml+9
......@@ -546,6 +546,9 @@ importers:
546546 '@lexical/react':
547547 specifier: ^0.41.0
548548 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
549 '@noble/hashes':
550 specifier: 'catalog:'
551 version: 1.8.0
549552 '@pierre/diffs':
550553 specifier: 'catalog:'
551554 version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -579,6 +582,12 @@ importers:
579582 jose:
580583 specifier: 'catalog:'
581584 version: 6.2.2
585 jsonc-parser:
586 specifier: 3.3.1
587 version: 3.3.1
588 jszip:
589 specifier: 3.10.1
590 version: 3.10.1
582591 lexical:
583592 specifier: ^0.41.0
584593 version: 0.41.0