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 @@...@@ -23,6 +23,7 @@
23 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
24 "@legendapp/list": "catalog:",24 "@legendapp/list": "catalog:",
25 "@lexical/react": "^0.41.0",25 "@lexical/react": "^0.41.0",
26 "@noble/hashes": "catalog:",
26 "@pierre/diffs": "catalog:",27 "@pierre/diffs": "catalog:",
27 "@pierre/trees": "1.0.0-beta.4",28 "@pierre/trees": "1.0.0-beta.4",
28 "@t3tools/client-runtime": "workspace:*",29 "@t3tools/client-runtime": "workspace:*",
...@@ -34,6 +35,8 @@...@@ -34,6 +35,8 @@
34 "culori": "^4.0.2",35 "culori": "^4.0.2",
35 "effect": "catalog:",36 "effect": "catalog:",
36 "jose": "catalog:",37 "jose": "catalog:",
38 "jsonc-parser": "3.3.1",
39 "jszip": "3.10.1",
37 "lexical": "^0.41.0",40 "lexical": "^0.41.0",
38 "lucide-react": "^0.564.0",41 "lucide-react": "^0.564.0",
39 "react": "19.2.6",42 "react": "19.2.6",
apps/web/src/components/settings/ThemeEditorPanel.tsx+14-23
...@@ -610,7 +610,7 @@ export function ThemeEditorPanel({...@@ -610,7 +610,7 @@ export function ThemeEditorPanel({
610 [activeAppearance, editingTheme, selectedRole],610 [activeAppearance, editingTheme, selectedRole],
611 );611 );
612612
613 const handleSubmit = useCallback(() => {613 const handleSubmit = () => {
614 if (!name.trim()) {614 if (!name.trim()) {
615 setError("Name your theme first.");615 setError("Name your theme first.");
616 return;616 return;
...@@ -645,8 +645,8 @@ export function ThemeEditorPanel({...@@ -645,8 +645,8 @@ export function ThemeEditorPanel({
645 return;645 return;
646 }646 }
647 mergedAppearance = editedModes[0] ?? null;647 mergedAppearance = editedModes[0] ?? null;
648 savedTheme = updateCustomTheme(648 savedTheme = updateCustomTheme({
649 parseThemeFile({649 ...parseThemeFile({
650 version: THEME_FILE_VERSION,650 version: THEME_FILE_VERSION,
651 id: mergeTarget.id,651 id: mergeTarget.id,
652 name: mergeTarget.label,652 name: mergeTarget.label,
...@@ -658,7 +658,8 @@ export function ThemeEditorPanel({...@@ -658,7 +658,8 @@ export function ThemeEditorPanel({
658 },658 },
659 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),659 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
660 }),660 }),
661 );661 ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}),
662 });
662 retiredTheme = editingTheme;663 retiredTheme = editingTheme;
663 try {664 try {
664 removeCustomTheme(editingTheme.id);665 removeCustomTheme(editingTheme.id);
...@@ -676,8 +677,8 @@ export function ThemeEditorPanel({...@@ -676,8 +677,8 @@ export function ThemeEditorPanel({
676 } else if (editingTheme) {677 } else if (editingTheme) {
677 const baseAppearance = editingTheme.appearance;678 const baseAppearance = editingTheme.appearance;
678 const variantAppearance = baseAppearance === "light" ? "dark" : "light";679 const variantAppearance = baseAppearance === "light" ? "dark" : "light";
679 savedTheme = updateCustomTheme(680 savedTheme = updateCustomTheme({
680 parseThemeFile({681 ...parseThemeFile({
681 version: THEME_FILE_VERSION,682 version: THEME_FILE_VERSION,
682 id: editingTheme.id,683 id: editingTheme.id,
683 name,684 name,
...@@ -688,7 +689,8 @@ export function ThemeEditorPanel({...@@ -688,7 +689,8 @@ export function ThemeEditorPanel({
688 : {}),689 : {}),
689 ...(isAdvanced ? {} : { managed: true }),690 ...(isAdvanced ? {} : { managed: true }),
690 }),691 }),
691 );692 ...(editingTheme.collection ? { collection: editingTheme.collection } : {}),
693 });
692 } else if (mergeTarget) {694 } else if (mergeTarget) {
693 if (takenAppearances.includes(activeAppearance)) {695 if (takenAppearances.includes(activeAppearance)) {
694 setError(696 setError(
...@@ -701,8 +703,8 @@ export function ThemeEditorPanel({...@@ -701,8 +703,8 @@ export function ThemeEditorPanel({
701 // survives when every palette in the theme came from the guided703 // survives when every palette in the theme came from the guided
702 // editor.704 // editor.
703 mergedAppearance = activeAppearance;705 mergedAppearance = activeAppearance;
704 savedTheme = updateCustomTheme(706 savedTheme = updateCustomTheme({
705 parseThemeFile({707 ...parseThemeFile({
706 version: THEME_FILE_VERSION,708 version: THEME_FILE_VERSION,
707 id: mergeTarget.id,709 id: mergeTarget.id,
708 name: mergeTarget.label,710 name: mergeTarget.label,
...@@ -714,7 +716,8 @@ export function ThemeEditorPanel({...@@ -714,7 +716,8 @@ export function ThemeEditorPanel({
714 },716 },
715 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),717 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
716 }),718 }),
717 );719 ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}),
720 });
718 } else {721 } else {
719 savedTheme = installCustomTheme(722 savedTheme = installCustomTheme(
720 parseThemeFile({723 parseThemeFile({
...@@ -762,19 +765,7 @@ export function ThemeEditorPanel({...@@ -762,19 +765,7 @@ export function ThemeEditorPanel({
762 : "Could not create the theme.",765 : "Could not create the theme.",
763 );766 );
764 }767 }
765 }, [768 };
766 activeAppearance,
767 colorsByAppearance,
768 editingTheme,
769 isAdvanced,
770 isEditing,
771 mergeTarget,
772 name,
773 onOpenChange,
774 onSaved,
775 simpleColorsDirtyByAppearance,
776 takenAppearances,
777 ]);
778769
779 const renderNameField = () => (770 const renderNameField = () => (
780 <label className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">771 <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 {...@@ -28,6 +28,7 @@ import {
28 DialogPopup,28 DialogPopup,
29 DialogTitle,29 DialogTitle,
30} from "../ui/dialog";30} from "../ui/dialog";
31import { ThemeSearchSection } from "./ThemeSearchSection";
3132
32/**33/**
33 * A full theme export is a few KB, so anything past this is not a theme file.34 * A full theme export is a few KB, so anything past this is not a theme file.
...@@ -360,9 +361,16 @@ export function ThemeImportDialog({...@@ -360,9 +361,16 @@ export function ThemeImportDialog({
360 : null;361 : null;
361 for (const theme of conflicts) {362 for (const theme of conflicts) {
362 try {363 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;
363 resolved.push(371 resolved.push(
364 mode === "update"372 mode === "update"
365 ? updateCustomTheme(theme)373 ? updateCustomTheme(themeToUpdate)
366 : installCustomTheme(versionedCopy(theme, preferredName)),374 : installCustomTheme(versionedCopy(theme, preferredName)),
367 );375 );
368 } catch (cause) {376 } catch (cause) {
...@@ -426,7 +434,23 @@ export function ThemeImportDialog({...@@ -426,7 +434,23 @@ export function ThemeImportDialog({
426 <DialogHeader>434 <DialogHeader>
427 <DialogTitle>Add a theme</DialogTitle>435 <DialogTitle>Add a theme</DialogTitle>
428 </DialogHeader>436 </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
430 {(() => {454 {(() => {
431 const dropHandlers = {455 const dropHandlers = {
432 onDragEnter: (event: DragEvent<HTMLDivElement>) => {456 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 @@...@@ -1,17 +1,20 @@
1import {1import {
2 CheckIcon,
2 CopyIcon,3 CopyIcon,
3 DownloadIcon,4 DownloadIcon,
5 MoonIcon,
4 PenLineIcon,6 PenLineIcon,
5 PlusIcon,7 PlusIcon,
8 SunIcon,
6 Trash2Icon,9 Trash2Icon,
7 UploadIcon,10 UploadIcon,
8} from "lucide-react";11} from "lucide-react";
9import { useCallback, useEffect, useRef, useState } from "react";12import { useCallback, useEffect, useState, type ReactElement } from "react";
10import { cn } from "../../lib/utils";13import { cn } from "../../lib/utils";
11import {14import {
12 getThemeDefinition,15 getThemeDefinition,
13 getThemeModes,16 getThemeModes,
14 removeCustomTheme,17 removeCustomThemes,
15 serializeThemeFile,18 serializeThemeFile,
16 type ThemeAppearance,19 type ThemeAppearance,
17 type ThemeDefinition,20 type ThemeDefinition,
...@@ -41,6 +44,7 @@ import {...@@ -41,6 +44,7 @@ import {
41 getThemeCardDefinition,44 getThemeCardDefinition,
42 previewColorsOf,45 previewColorsOf,
43 ThemePreviewCircles,46 ThemePreviewCircles,
47 ThemePreviewCircle,
44 type ThemeCardDefinition,48 type ThemeCardDefinition,
45 type ThemeMode,49 type ThemeMode,
46} from "./ThemePreviewCircles";50} from "./ThemePreviewCircles";
...@@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [...@@ -54,6 +58,21 @@ const MAINTAINER_THEMES: ReadonlyArray<ThemeDefinition> = [
54 IRIS_THEME,58 IRIS_THEME,
55];59];
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
57function downloadThemeFile(filename: string, contents: string): void {76function downloadThemeFile(filename: string, contents: string): void {
58 const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));77 const url = URL.createObjectURL(new Blob([contents], { type: "application/json" }));
59 const anchor = document.createElement("a");78 const anchor = document.createElement("a");
...@@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void {...@@ -65,6 +84,15 @@ function downloadThemeFile(filename: string, contents: string): void {
65 setTimeout(() => URL.revokeObjectURL(url), 30_000);84 setTimeout(() => URL.revokeObjectURL(url), 30_000);
66}85}
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
68function ThemeLibraryCard({96function ThemeLibraryCard({
69 theme,97 theme,
70 isActive,98 isActive,
...@@ -75,6 +103,7 @@ function ThemeLibraryCard({...@@ -75,6 +103,7 @@ function ThemeLibraryCard({
75 onDuplicate,103 onDuplicate,
76 onDownload,104 onDownload,
77 onRemove,105 onRemove,
106 variantNavigation,
78}: {107}: {
79 theme: ThemeCardDefinition;108 theme: ThemeCardDefinition;
80 isActive: boolean;109 isActive: boolean;
...@@ -85,10 +114,33 @@ function ThemeLibraryCard({...@@ -85,10 +114,33 @@ function ThemeLibraryCard({
85 onDuplicate?: () => void;114 onDuplicate?: () => void;
86 onDownload?: () => void;115 onDownload?: () => void;
87 onRemove?: () => void;116 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 };
88}) {127}) {
89 // A one-appearance theme can only take its own side of the mix, so the card128 // A one-appearance theme can only take its own side of the mix, so the card
90 // tooltip promises exactly what clicking it does.129 // tooltip promises exactly what clicking it does.
91 const cardModes = theme.previews.map((preview) => preview.mode);130 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 });
92 return (144 return (
93 // The card surface stays a plain div (buttons cannot nest inside a button145 // The card surface stays a plain div (buttons cannot nest inside a button
94 // role); the title button and mode circles carry the accessible actions,146 // role); the title button and mode circles carry the accessible actions,
...@@ -105,17 +157,147 @@ function ThemeLibraryCard({...@@ -105,17 +157,147 @@ function ThemeLibraryCard({
105 onClick={onUse}157 onClick={onUse}
106 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}158 style={isActive ? { boxShadow: "inset 0 0 0 1px var(--ring)" } : undefined}
107 >159 >
108 <ThemePreviewCircles160 <div className="relative">
109 label={theme.label}161 {variantNavigation ? (
110 activeModes={activeModes}162 <div
111 onSelectMode={onUseMode}163 aria-label="Light and dark theme variants"
112 previews={theme.previews}164 className="relative h-20"
113 />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>
114 <div className="flex items-center gap-2 px-3 pb-3 pt-2">296 <div className="flex items-center gap-2 px-3 pb-3 pt-2">
115 <div className="min-w-0 flex-1">297 <div className="min-w-0 flex-1">
116 <div className="flex items-center gap-1.5">298 <div className="flex items-center gap-1.5">
117 <button299 <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" : ""}`}
119 aria-pressed={isActive}301 aria-pressed={isActive}
120 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"302 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"
121 type="button"303 type="button"
...@@ -124,7 +306,7 @@ function ThemeLibraryCard({...@@ -124,7 +306,7 @@ function ThemeLibraryCard({
124 onUse();306 onUse();
125 }}307 }}
126 >308 >
127 {theme.label}309 {variantNavigation?.collectionLabel ?? theme.label}
128 </button>310 </button>
129 </div>311 </div>
130 </div>312 </div>
...@@ -195,7 +377,11 @@ function ThemeLibraryCard({...@@ -195,7 +377,11 @@ function ThemeLibraryCard({
195 <TooltipTrigger377 <TooltipTrigger
196 render={378 render={
197 <Button379 <Button
198 aria-label={`Remove ${theme.label}`}380 aria-label={
381 variantNavigation
382 ? `Remove themes from ${variantNavigation.collectionLabel}`
383 : `Remove ${theme.label}`
384 }
199 size="icon-xs"385 size="icon-xs"
200 variant="ghost"386 variant="ghost"
201 className="text-muted-foreground hover:text-destructive"387 className="text-muted-foreground hover:text-destructive"
...@@ -208,7 +394,9 @@ function ThemeLibraryCard({...@@ -208,7 +394,9 @@ function ThemeLibraryCard({
208 </Button>394 </Button>
209 }395 }
210 />396 />
211 <TooltipPopup>Remove theme</TooltipPopup>397 <TooltipPopup>
398 {variantNavigation ? "Remove themes" : "Remove theme"}
399 </TooltipPopup>
212 </Tooltip>400 </Tooltip>
213 ) : null}401 ) : null}
214 </div>402 </div>
...@@ -218,12 +406,97 @@ function ThemeLibraryCard({...@@ -218,12 +406,97 @@ function ThemeLibraryCard({
218 }406 }
219 />407 />
220 <TooltipPopup>408 <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`}
222 </TooltipPopup>414 </TooltipPopup>
223 </Tooltip>415 </Tooltip>
224 );416 );
225}417}
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
227export function ThemeLibrary({500export function ThemeLibrary({
228 theme,501 theme,
229 setTheme,502 setTheme,
...@@ -250,14 +523,20 @@ export function ThemeLibrary({...@@ -250,14 +523,20 @@ export function ThemeLibrary({
250 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;523 setThemeHalf: (appearance: ThemeAppearance, themeId: string | null) => boolean;
251}) {524}) {
252 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);525 const openThemeEditor = useThemeEditorStore((store) => store.openThemeEditor);
253 const [themeToRemove, setThemeToRemove] = useState<ThemeDefinition | null>(null);526 const [themeRemovalTarget, setThemeRemovalTarget] = useState<{
254 // Keep the last removal target so the dialog title stays populated while the527 theme: ThemeDefinition;
255 // close animation plays after confirming.528 collectionThemes: ReadonlyArray<ThemeDefinition>;
256 const lastThemeToRemoveRef = useRef<ThemeDefinition | null>(null);529 } | null>(null);
257 useEffect(() => {530 // Keep the target after closing so the dialog text remains populated during
258 if (themeToRemove) lastThemeToRemoveRef.current = themeToRemove;531 // its exit animation. The next trash action replaces it before reopening.
259 }, [themeToRemove]);532 const [isThemeRemovalOpen, setIsThemeRemovalOpen] = useState(false);
260 const removeDialogTheme = themeToRemove ?? lastThemeToRemoveRef.current;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
262 const notifyThemeSaveFailure = useCallback(() => {541 const notifyThemeSaveFailure = useCallback(() => {
263 toastManager.add(542 toastManager.add(
...@@ -288,37 +567,43 @@ export function ThemeLibrary({...@@ -288,37 +567,43 @@ export function ThemeLibrary({
288 [notifyThemeSaveFailure, setTheme],567 [notifyThemeSaveFailure, setTheme],
289 );568 );
290569
291 const handleRemoveTheme = useCallback((customTheme: ThemeDefinition) => {570 const handleRemoveTheme = useCallback(
292 setThemeToRemove(customTheme);571 (customTheme: ThemeDefinition, collectionThemes: ReadonlyArray<ThemeDefinition>) => {
293 }, []);572 setThemeRemovalTarget({ theme: customTheme, collectionThemes });
573 setThemeIdsToRemove(collectionThemes.length > 1 ? [] : [customTheme.id]);
574 setIsThemeRemovalOpen(true);
575 },
576 [],
577 );
294578
295 const handleConfirmRemoveTheme = useCallback(() => {579 const handleConfirmRemoveTheme = useCallback(() => {
296 if (!themeToRemove) return;580 if (!themeRemovalTarget) return;
297 const removesBase = getThemeDefinition(theme)?.id === themeToRemove.id;581 const removedIds = new Set(themeIdsToRemove);
298 // Keep the theme installed if we cannot move the selection off it; the582 if (removedIds.size === 0) return;
299 // dialog stays open so the user can retry or cancel.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.
300 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {586 if (removesBase && !persistTheme(appearanceMode === "system" ? "system" : appearanceMode)) {
301 return;587 return;
302 }588 }
303 for (const appearance of ["light", "dark"] as const) {589 for (const appearance of ["light", "dark"] as const) {
304 const half = themeHalves?.[appearance];590 const half = themeHalves?.[appearance];
305 if (half === undefined) continue;591 if (half === undefined) continue;
306 // Writing a base preference clears the whole mix, so halves that name a592 // Writing a base preference clears the whole mix, so halves that name
307 // surviving theme are written back; halves on the removed theme fall593 // a surviving theme are written back; removed halves fall back to base.
308 // back to the base.594 const next = half && removedIds.has(half) ? null : removesBase ? half : undefined;
309 const next = half === themeToRemove.id ? null : removesBase ? half : undefined;
310 if (next !== undefined && !setThemeHalf(appearance, next)) {595 if (next !== undefined && !setThemeHalf(appearance, next)) {
311 notifyThemeRemovalFailure();596 notifyThemeRemovalFailure();
312 return;597 return;
313 }598 }
314 }599 }
315 try {600 try {
316 removeCustomTheme(themeToRemove.id);601 removeCustomThemes([...removedIds]);
317 } catch {602 } catch {
318 notifyThemeRemovalFailure();603 notifyThemeRemovalFailure();
319 return;604 return;
320 }605 }
321 setThemeToRemove(null);606 setIsThemeRemovalOpen(false);
322 }, [607 }, [
323 appearanceMode,608 appearanceMode,
324 notifyThemeRemovalFailure,609 notifyThemeRemovalFailure,
...@@ -326,7 +611,8 @@ export function ThemeLibrary({...@@ -326,7 +611,8 @@ export function ThemeLibrary({
326 setThemeHalf,611 setThemeHalf,
327 theme,612 theme,
328 themeHalves,613 themeHalves,
329 themeToRemove,614 themeIdsToRemove,
615 themeRemovalTarget,
330 ]);616 ]);
331617
332 // ----- Automatic-mode mixing -------------------------------------------618 // ----- Automatic-mode mixing -------------------------------------------
...@@ -460,6 +746,20 @@ export function ThemeLibrary({...@@ -460,6 +746,20 @@ export function ThemeLibrary({
460 </div>746 </div>
461 );747 );
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
463 const renderPairGrid = () => (763 const renderPairGrid = () => (
464 // One shared provider so every tooltip in the grid hands off instantly to764 // One shared provider so every tooltip in the grid hands off instantly to
465 // the next hovered trigger instead of stacking on top of it. The card765 // the next hovered trigger instead of stacking on top of it. The card
...@@ -509,43 +809,39 @@ export function ThemeLibrary({...@@ -509,43 +809,39 @@ export function ThemeLibrary({
509 />809 />
510 );810 );
511 })}811 })}
512 {customThemes.map((customTheme) => {812 {customThemeCollections.map(([collectionId, themes]) => (
513 const card = getThemeCardDefinition(customTheme);813 <CustomThemeCollectionCard
514 return (814 activeModesFor={pickedModesFor}
515 <ThemeLibraryCard815 key={collectionId}
516 activeModes={pickedModesFor(customTheme.id)}816 onDownload={(customTheme) =>
517 isActive={false}817 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))
518 key={customTheme.id}818 }
519 onDuplicate={() =>819 onDuplicate={(customTheme) =>
520 openThemeEditor({820 openThemeEditor({
521 editingThemeId: null,821 editingThemeId: null,
522 seedThemeId: customTheme.id,822 seedThemeId: customTheme.id,
523 seedName: `${customTheme.label} copy`,823 seedName: `${customTheme.label} copy`,
524 initialAppearance,824 initialAppearance,
525 })825 })
526 }826 }
527 onEdit={() =>827 onEdit={(customTheme) =>
528 openThemeEditor({828 openThemeEditor({
529 editingThemeId: customTheme.id,829 editingThemeId: customTheme.id,
530 seedThemeId: null,830 seedThemeId: null,
531 seedName: null,831 seedName: null,
532 initialAppearance,832 initialAppearance,
533 })833 })
534 }834 }
535 onDownload={() =>835 onRemove={(customTheme) => handleRemoveTheme(customTheme, themes)}
536 downloadThemeFile(`${customTheme.id}.json`, serializeThemeFile(customTheme))836 onUse={(customTheme) => {
537 }837 const modes = getThemeModes(customTheme);
538 onRemove={() => handleRemoveTheme(customTheme)}838 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);
539 onUse={() => {839 else persistTheme(customTheme.id);
540 const modes = getThemeModes(customTheme);840 }}
541 if (modes.length === 1) assignHalf(modes[0]!, customTheme.id);841 onUseMode={(customTheme, mode) => handlePairPick(customTheme.id)(mode)}
542 else persistTheme(customTheme.id);842 themes={themes}
543 }}843 />
544 onUseMode={handlePairPick(customTheme.id)}844 ))}
545 theme={card}
546 />
547 );
548 })}
549 </div>845 </div>
550 </TooltipProvider>846 </TooltipProvider>
551 );847 );
...@@ -561,7 +857,7 @@ export function ThemeLibrary({...@@ -561,7 +857,7 @@ export function ThemeLibrary({
561 {renderModeTiles()}857 {renderModeTiles()}
562 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">858 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
563 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>859 <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">
565 <Button861 <Button
566 size="xs"862 size="xs"
567 variant="outline"863 variant="outline"
...@@ -586,19 +882,9 @@ export function ThemeLibrary({...@@ -586,19 +882,9 @@ export function ThemeLibrary({
586 {renderPairGrid()}882 {renderPairGrid()}
587 <ThemeImportDialog883 <ThemeImportDialog
588 onImportedMany={(importedThemes, { updated }) => {884 onImportedMany={(importedThemes, { updated }) => {
589 // An updated theme that is showing (as the base or either half)885 // Re-apply after collection updates. The update may remove the
590 // needs its palette re-applied.886 // selected variant, in which case the theme hook falls back safely.
591 if (887 if (updated) refreshTheme();
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 }
602 const verb = updated ? "updated" : "added";888 const verb = updated ? "updated" : "added";
603 toastManager.add(889 toastManager.add(
604 stackedThreadToast({890 stackedThreadToast({
...@@ -639,23 +925,80 @@ export function ThemeLibrary({...@@ -639,23 +925,80 @@ export function ThemeLibrary({
639 onOpenChange={onImportOpenChange}925 onOpenChange={onImportOpenChange}
640 open={isImportOpen}926 open={isImportOpen}
641 />927 />
642 <AlertDialog928 <AlertDialog open={isThemeRemovalOpen} onOpenChange={setIsThemeRemovalOpen}>
643 open={themeToRemove !== null}
644 onOpenChange={(open) => {
645 if (!open) setThemeToRemove(null);
646 }}
647 >
648 <AlertDialogPopup>929 <AlertDialogPopup>
649 <AlertDialogHeader>930 <AlertDialogHeader>
650 <AlertDialogTitle>Remove “{removeDialogTheme?.label}”?</AlertDialogTitle>931 <AlertDialogTitle>
932 {canRemoveCollection
933 ? `Remove themes from “${removeDialogCollectionLabel}”?`
934 : `Remove “${removeDialogTheme?.label}”?`}
935 </AlertDialogTitle>
651 <AlertDialogDescription>936 <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."}
653 </AlertDialogDescription>940 </AlertDialogDescription>
654 </AlertDialogHeader>941 </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}
655 <AlertDialogFooter>992 <AlertDialogFooter>
656 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>993 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
657 <Button variant="destructive" onClick={handleConfirmRemoveTheme}>994 <Button
658 Remove theme995 disabled={themeIdsToRemove.length === 0}
996 variant="destructive"
997 onClick={handleConfirmRemoveTheme}
998 >
999 {canRemoveCollection
1000 ? `Remove selected${themeIdsToRemove.length > 0 ? ` (${themeIdsToRemove.length})` : ""}`
1001 : "Remove theme"}
659 </Button>1002 </Button>
660 </AlertDialogFooter>1003 </AlertDialogFooter>
661 </AlertDialogPopup>1004 </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 {...@@ -10,12 +10,15 @@ import {
10 getThemePreferenceMode,10 getThemePreferenceMode,
11 isKnownThemePreference,11 isKnownThemePreference,
12 getCustomThemes,12 getCustomThemes,
13 getStoredCustomThemeCollection,
13 invalidateCustomThemes,14 invalidateCustomThemes,
14 installCustomTheme,15 installCustomTheme,
15 canonicalThemePreference,16 canonicalThemePreference,
16 parseThemeFile,17 parseThemeFile,
17 parseThemeHalves,18 parseThemeHalves,
18 removeCustomTheme,19 removeCustomTheme,
20 removeCustomThemes,
21 replaceCustomThemeCollection,
19 resolveDesktopTheme,22 resolveDesktopTheme,
20 resolveThemeAppearance,23 resolveThemeAppearance,
21 serializeThemeFile,24 serializeThemeFile,
...@@ -277,6 +280,28 @@ describe("theme files", () => {...@@ -277,6 +280,28 @@ describe("theme files", () => {
277 });280 });
278 });281 });
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
280 it("keeps sidebar artwork disabled for custom theme files", () => {305 it("keeps sidebar artwork disabled for custom theme files", () => {
281 const theme = parseThemeFile({306 const theme = parseThemeFile({
282 version: THEME_FILE_VERSION,307 version: THEME_FILE_VERSION,
...@@ -491,6 +516,178 @@ describe("theme files", () => {...@@ -491,6 +516,178 @@ describe("theme files", () => {
491 vi.unstubAllGlobals();516 vi.unstubAllGlobals();
492 });517 });
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
494 it("canonicalizes explicit writes without migrating untouched themes", () => {691 it("canonicalizes explicit writes without migrating untouched themes", () => {
495 const stored = new Map<string, string>();692 const stored = new Map<string, string>();
496 const untouchedTheme = {693 const untouchedTheme = {
...@@ -551,6 +748,56 @@ describe("theme files", () => {...@@ -551,6 +748,56 @@ describe("theme files", () => {
551 invalidateCustomThemes();748 invalidateCustomThemes();
552 });749 });
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
554 it("writes from the cached raw snapshot without risking a destructive reread", () => {801 it("writes from the cached raw snapshot without risking a destructive reread", () => {
555 const legacyTheme = {802 const legacyTheme = {
556 id: "legacy",803 id: "legacy",
apps/web/src/themePalette.ts+133-13
...@@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string...@@ -92,12 +92,15 @@ export type ThemeColorOverrides = Readonly<Partial<Record<ThemeColorRole, string
92export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;92export type ThemeVariants = Readonly<Partial<Record<ThemeAppearance, ThemeColors>>>;
93export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;93export type ThemeVariantOverrides = Readonly<Partial<Record<ThemeAppearance, ThemeColorOverrides>>>;
94export type ThemePreferenceMode = ThemeAppearance | "system";94export type ThemePreferenceMode = ThemeAppearance | "system";
95export type ThemeCollection = Readonly<{ id: string; label: string }>;
95export type ThemeDefinition = Readonly<{96export type ThemeDefinition = Readonly<{
96 id: string;97 id: string;
97 label: string;98 label: string;
98 appearance: ThemeAppearance;99 appearance: ThemeAppearance;
99 colors: ThemeColors;100 colors: ThemeColors;
100 variants?: ThemeVariants;101 variants?: ThemeVariants;
102 /** Groups related imported variants into one library card. */
103 collection?: ThemeCollection;
101 /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */104 /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
102 sidebarArtwork?: boolean;105 sidebarArtwork?: boolean;
103 /** True when the palette was generated by the guided editor from its106 /** True when the palette was generated by the guided editor from its
...@@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{...@@ -111,6 +114,7 @@ export type ThemeFile = Readonly<{
111 appearance: ThemeAppearance;114 appearance: ThemeAppearance;
112 colors: ThemeColorOverrides;115 colors: ThemeColorOverrides;
113 variants?: ThemeVariantOverrides;116 variants?: ThemeVariantOverrides;
117 collection?: ThemeCollection;
114 managed?: boolean;118 managed?: boolean;
115}>;119}>;
116120
...@@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string {...@@ -179,6 +183,15 @@ function isThemeLabel(value: unknown): value is string {
179 return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;183 return typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
180}184}
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
182function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {195function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): ThemeColors | null {
183 if (!isRecord(value)) return null;196 if (!isRecord(value)) return null;
184197
...@@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {...@@ -224,6 +237,7 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
224 if (!colors) return null;237 if (!colors) return null;
225 const variants = parseStoredThemeVariants(value.variants, value.appearance);238 const variants = parseStoredThemeVariants(value.variants, value.appearance);
226 if (value.variants !== undefined && variants === null) return null;239 if (value.variants !== undefined && variants === null) return null;
240 const collection = parseThemeCollection(value.collection);
227241
228 return {242 return {
229 id: value.id,243 id: value.id,
...@@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {...@@ -231,10 +245,22 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
231 appearance: value.appearance,245 appearance: value.appearance,
232 colors,246 colors,
233 ...(variants ? { variants } : {}),247 ...(variants ? { variants } : {}),
248 ...(collection ? { collection } : {}),
234 ...(value.managed === true ? { managed: true } : {}),249 ...(value.managed === true ? { managed: true } : {}),
235 };250 };
236}251}
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
238function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {264function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
239 if (typeof window === "undefined") {265 if (typeof window === "undefined") {
240 return { status: "ready", storedThemes: [], themes: [] };266 return { status: "ready", storedThemes: [], themes: [] };
...@@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {...@@ -256,15 +282,7 @@ function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
256 }282 }
257 if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" };283 if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" };
258284
259 const themes: ThemeDefinition[] = [];285 return { status: "ready", storedThemes: parsed, themes: parseStoredThemes(parsed) };
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 };
268}286}
269287
270function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {288function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot {
...@@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {...@@ -288,6 +306,14 @@ export function getCustomThemes(): ReadonlyArray<ThemeDefinition> {
288 return snapshot.status === "ready" ? snapshot.themes : [];306 return snapshot.status === "ready" ? snapshot.themes : [];
289}307}
290308
309export function getStoredCustomThemeCollection(
310 collectionId: string,
311): ReadonlyArray<ThemeDefinition> {
312 return readWritableCustomThemeLibrary().themes.filter(
313 (theme) => theme.collection?.id === collectionId,
314 );
315}
316
291export function subscribeToCustomThemes(listener: () => void): () => void {317export function subscribeToCustomThemes(listener: () => void): () => void {
292 customThemeListeners.add(listener);318 customThemeListeners.add(listener);
293 if (typeof window === "undefined") {319 if (typeof window === "undefined") {
...@@ -1598,8 +1624,9 @@ function saveCustomThemes(...@@ -1598,8 +1624,9 @@ function saveCustomThemes(
1598 notifyCustomThemeListeners();1624 notifyCustomThemeListeners();
1599}1625}
16001626
1601function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {1627function requireWritableCustomThemeLibrary(
1602 const snapshot = getCustomThemeLibrarySnapshot();1628 snapshot: CustomThemeLibrarySnapshot,
1629): Extract<CustomThemeLibrarySnapshot, { status: "ready" }> {
1603 if (snapshot.status === "unavailable") {1630 if (snapshot.status === "unavailable") {
1604 throw new ThemeLibraryStorageError({1631 throw new ThemeLibraryStorageError({
1605 storageKey: CUSTOM_THEMES_STORAGE_KEY,1632 storageKey: CUSTOM_THEMES_STORAGE_KEY,
...@@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, {...@@ -1611,10 +1638,29 @@ function getWritableCustomThemeLibrary(): Extract<CustomThemeLibrarySnapshot, {
1611 return snapshot;1638 return snapshot;
1612}1639}
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
1614function storedThemeHasId(storedTheme: unknown, themeId: string): boolean {1652function storedThemeHasId(storedTheme: unknown, themeId: string): boolean {
1615 return isRecord(storedTheme) && storedTheme.id === themeId;1653 return isRecord(storedTheme) && storedTheme.id === themeId;
1616}1654}
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
1618export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {1664export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1619 if (RESERVED_THEME_IDS.has(theme.id)) {1665 if (RESERVED_THEME_IDS.has(theme.id)) {
1620 throw new Error(`The theme id "${theme.id}" is reserved.`);1666 throw new Error(`The theme id "${theme.id}" is reserved.`);
...@@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {...@@ -1662,12 +1708,80 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition {
1662 return canonicalTheme;1708 return canonicalTheme;
1663}1709}
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
1665export function removeCustomTheme(themeId: string): void {1768export 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;
1666 const library = getWritableCustomThemeLibrary();1775 const library = getWritableCustomThemeLibrary();
1667 const nextThemes = library.themes.filter((theme) => theme.id !== themeId);1776 const nextThemes = library.themes.filter((theme) => !removedIds.has(theme.id));
1668 if (nextThemes.length === library.themes.length) return;1777 if (nextThemes.length === library.themes.length) return;
1669 saveCustomThemes(1778 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 ),
1671 nextThemes,1785 nextThemes,
1672 );1786 );
1673}1787}
...@@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition {...@@ -1720,6 +1834,10 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
1720 }1834 }
17211835
1722 const overrides = parseThemeColorOverrides(rawColors);1836 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
1724 const fallback = getDefaultThemeColors(appearance);1842 const fallback = getDefaultThemeColors(appearance);
1725 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};1843 const variants: Partial<Record<ThemeAppearance, ThemeColors>> = {};
...@@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {...@@ -1746,6 +1864,7 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
1746 appearance,1864 appearance,
1747 colors: { ...fallback, ...overrides },1865 colors: { ...fallback, ...overrides },
1748 ...(Object.keys(variants).length > 0 ? { variants } : {}),1866 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1867 ...(collection ? { collection } : {}),
1749 ...(value.managed === true ? { managed: true } : {}),1868 ...(value.managed === true ? { managed: true } : {}),
1750 };1869 };
1751}1870}
...@@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {...@@ -1759,6 +1878,7 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
1759 appearance: canonicalTheme.appearance,1878 appearance: canonicalTheme.appearance,
1760 colors: canonicalTheme.colors,1879 colors: canonicalTheme.colors,
1761 ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}),1880 ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}),
1881 ...(canonicalTheme.collection ? { collection: canonicalTheme.collection } : {}),
1762 ...(canonicalTheme.managed ? { managed: true } : {}),1882 ...(canonicalTheme.managed ? { managed: true } : {}),
1763 };1883 };
1764 return `${JSON.stringify(file, null, 2)}\n`;1884 return `${JSON.stringify(file, null, 2)}\n`;
apps/web/src/vscodeThemeImport.ts+4
...@@ -337,6 +337,9 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -337,6 +337,9 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
337 */337 */
338export function pairVsCodeThemes(338export function pairVsCodeThemes(
339 themes: ReadonlyArray<ThemeDefinition>,339 themes: ReadonlyArray<ThemeDefinition>,
340 options?: {
341 pairedId?: (light: ThemeDefinition, dark: ThemeDefinition) => string;
342 },
340): ReadonlyArray<ThemeDefinition> {343): ReadonlyArray<ThemeDefinition> {
341 const stripAppearance = (label: string) =>344 const stripAppearance = (label: string) =>
342 label345 label
...@@ -372,6 +375,7 @@ export function pairVsCodeThemes(...@@ -372,6 +375,7 @@ export function pairVsCodeThemes(
372 order: group.order,375 order: group.order,
373 theme: parseThemeFile({376 theme: parseThemeFile({
374 version: THEME_FILE_VERSION,377 version: THEME_FILE_VERSION,
378 ...(options?.pairedId ? { id: options.pairedId(group.light[0]!, group.dark[0]!) } : {}),
375 name: key,379 name: key,
376 appearance: "light",380 appearance: "light",
377 colors: group.light[0]!.colors,381 colors: group.light[0]!.colors,
pnpm-lock.yaml+9
...@@ -546,6 +546,9 @@ importers:...@@ -546,6 +546,9 @@ importers:
546 '@lexical/react':546 '@lexical/react':
547 specifier: ^0.41.0547 specifier: ^0.41.0
548 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)548 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
549 '@pierre/diffs':552 '@pierre/diffs':
550 specifier: 'catalog:'553 specifier: 'catalog:'
551 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)554 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:...@@ -579,6 +582,12 @@ importers:
579 jose:582 jose:
580 specifier: 'catalog:'583 specifier: 'catalog:'
581 version: 6.2.2584 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
582 lexical:591 lexical:
583 specifier: ^0.41.0592 specifier: ^0.41.0
584 version: 0.41.0593 version: 0.41.0