1import { DownloadIcon, PlusIcon } from "lucide-react";
2import type { ChangeEvent, DragEvent, UIEvent } from "react";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { cn } from "../../lib/utils";
5import {
6 getCustomThemes,
7 installCustomTheme,
8 parseThemeFile,
9 removeCustomTheme,
10 THEME_FILE_VERSION,
11 updateCustomTheme,
12 type ThemeDefinition,
13} from "../../themePalette";
14import {
15 humanizeThemeName,
16 isVsCodeThemeFile,
17 pairVsCodeThemes,
18 parseVsCodeThemeFile,
19 resolveThemeLabelCollisions,
20} from "../../vscodeThemeImport";
21import { Alert } from "../ui/alert";
22import { Button } from "../ui/button";
23import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog";
24import { ThemeSearchSection } from "./ThemeSearchSection";
25
26/**
27 * A full theme export is a few KB, so anything past this is not a theme file.
28 * The guard runs on the size before the bytes are ever read: a large file
29 * would otherwise be pulled into memory, highlighted, and rendered, which
30 * locks the UI for as long as that takes.
31 */
32export const MAX_THEME_FILE_BYTES = 256 * 1024;
33
34/** Highlighting rebuilds the whole markup on every keystroke, so oversized
35 * pastes fall back to plain text instead of freezing the editor. */
36const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000;
37
38function formatByteSize(bytes: number): string {
39 if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
40 if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
41 return `${bytes} bytes`;
42}
43
44/** Returns the error to show for a file too large to be a theme, else null. */
45export function describeOversizedThemeFile(bytes: number): string | null {
46 if (bytes <= MAX_THEME_FILE_BYTES) return null;
47 return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`;
48}
49
50function escapeJsonHtml(value: string): string {
51 return value.replace(
52 /[&<>"']/g,
53 (character) =>
54 ({
55 "&": "&amp;",
56 "<": "&lt;",
57 ">": "&gt;",
58 '"': "&quot;",
59 "'": "&#39;",
60 })[character] ?? character,
61 );
62}
63
64function highlightJson(value: string): string {
65 const tokenPattern =
66 /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g;
67 let highlighted = "";
68 let cursor = 0;
69
70 for (const match of value.matchAll(tokenPattern)) {
71 const token = match[0];
72 const index = match.index ?? 0;
73 highlighted += escapeJsonHtml(value.slice(cursor, index));
74
75 let tokenClass = "text-[var(--app-theme-secondary-foreground,var(--color-amber-600))]";
76 if (token.startsWith('"')) {
77 tokenClass = /^\s*:/.test(value.slice(index + token.length))
78 ? "text-[var(--app-theme-accent,var(--color-blue-600))]"
79 : "text-[var(--app-theme-message-action,var(--color-emerald-600))]";
80 } else if (token === "true" || token === "false" || token === "null") {
81 tokenClass = "text-[var(--app-theme-accent-surface-foreground,var(--color-violet-600))]";
82 }
83 highlighted += `<span class="${tokenClass}">${escapeJsonHtml(token)}</span>`;
84 cursor = index + token.length;
85 }
86
87 return highlighted + escapeJsonHtml(value.slice(cursor));
88}
89
90function ThemeJsonEditor({
91 id,
92 value,
93 onChange,
94}: {
95 id: string;
96 value: string;
97 onChange: (value: string) => void;
98}) {
99 const highlightRef = useRef<HTMLPreElement>(null);
100 const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH;
101 const highlightedJson = useMemo(
102 () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)),
103 [value],
104 );
105
106 const syncScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
107 const highlightElement = highlightRef.current;
108 if (!highlightElement) return;
109 highlightElement.scrollTop = event.currentTarget.scrollTop;
110 highlightElement.scrollLeft = event.currentTarget.scrollLeft;
111 }, []);
112
113 return (
114 <div className="relative overflow-hidden rounded-xl border border-input bg-background shadow-xs/5 focus-within:border-ring focus-within:ring-3 focus-within:ring-inset focus-within:ring-ring/24">
115 {isPlainText ? null : (
116 <pre
117 ref={highlightRef}
118 aria-hidden
119 className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre-wrap break-words p-3 font-mono text-xs leading-5 text-foreground"
120 >
121 <code dangerouslySetInnerHTML={{ __html: highlightedJson }} />
122 </pre>
123 )}
124 <textarea
125 aria-label="Theme JSON"
126 className={cn(
127 "relative z-10 block min-h-44 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-xs leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground",
128 isPlainText ? "text-foreground" : "text-transparent",
129 )}
130 id={id}
131 onChange={(event) => onChange(event.currentTarget.value)}
132 onScroll={syncScroll}
133 placeholder={
134 '{\n "version": 1,\n "name": "Aurora",\n "appearance": "light",\n "colors": { ... }\n}'
135 }
136 spellCheck={false}
137 value={value}
138 />
139 </div>
140 );
141}
142
143/** What the import pipeline needs from a file; DOM File satisfies it. */
144type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> };
145
146export function ThemeImportDialog({
147 open,
148 onOpenChange,
149 onImported,
150 onImportedMany,
151}: {
152 open: boolean;
153 onOpenChange: (open: boolean) => void;
154 onImported: (theme: ThemeDefinition) => boolean;
155 /** Batch imports install without activating; the caller reports them. */
156 onImportedMany: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;
157}) {
158 const fileInputRef = useRef<HTMLInputElement>(null);
159 const [json, setJson] = useState("");
160 const [fileName, setFileName] = useState<string | null>(null);
161 const [error, setError] = useState<string | null>(null);
162 const [isReading, setIsReading] = useState(false);
163 const [isDropTarget, setIsDropTarget] = useState(false);
164 // Imports whose id is already installed wait here for an update-or-copy
165 // decision instead of failing.
166 const [conflicts, setConflicts] = useState<ReadonlyArray<ThemeDefinition> | null>(null);
167 const importRequestRef = useRef(0);
168
169 useEffect(() => {
170 importRequestRef.current += 1;
171 // Reset on close too: a dialog dismissed mid-drag would otherwise reopen
172 // still wearing the drop highlight.
173 setIsDropTarget(false);
174 if (!open) return;
175 setJson("");
176 setFileName(null);
177 setError(null);
178 setIsReading(false);
179 setConflicts(null);
180 }, [open]);
181
182 const readThemeFile = useCallback(async (file: ImportableThemeFile) => {
183 // Check the size first: reading a large file is what locks the UI, so it
184 // never gets read at all.
185 const oversized = describeOversizedThemeFile(file.size);
186 if (oversized) {
187 setError(oversized);
188 return;
189 }
190
191 const requestId = ++importRequestRef.current;
192 setIsReading(true);
193 try {
194 const fileText = await file.text();
195 if (requestId !== importRequestRef.current) return;
196 setJson(fileText);
197 setFileName(file.name);
198 setError(null);
199 } catch {
200 if (requestId !== importRequestRef.current) return;
201 setError("Could not read that file. Paste the JSON below instead.");
202 } finally {
203 if (requestId === importRequestRef.current) setIsReading(false);
204 }
205 }, []);
206
207 // Several files at once import as a batch: VS Code families pair their
208 // light and dark variants, everything installs without activating, and the
209 // single-file flow keeps filling the editor for review.
210 const readThemeBatch = useCallback(
211 async (files: ReadonlyArray<ImportableThemeFile>) => {
212 const requestId = ++importRequestRef.current;
213 setIsReading(true);
214 const failures: string[] = [];
215 const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = [];
216 try {
217 for (const file of files) {
218 const oversized = describeOversizedThemeFile(file.size);
219 if (oversized) {
220 failures.push(`${file.name}: too large`);
221 continue;
222 }
223 try {
224 const value: unknown = JSON.parse(await file.text());
225 parsed.push({
226 sourceName: file.name,
227 theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value),
228 });
229 } catch (cause) {
230 failures.push(
231 `${file.name}: ${cause instanceof Error ? cause.message : "not a theme file"}`,
232 );
233 }
234 }
235 if (requestId !== importRequestRef.current) return;
236 const installed: ThemeDefinition[] = [];
237 const conflicting: ThemeDefinition[] = [];
238 for (const theme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) {
239 if (getCustomThemes().some((existing) => existing.id === theme.id)) {
240 conflicting.push(theme);
241 continue;
242 }
243 try {
244 installed.push(installCustomTheme(theme));
245 } catch (cause) {
246 failures.push(
247 `${theme.label}: ${cause instanceof Error ? cause.message : "could not install"}`,
248 );
249 }
250 }
251 if (installed.length > 0) onImportedMany(installed, { updated: false });
252 if (failures.length > 0) {
253 setError(failures.join(" — "));
254 } else if (conflicting.length > 0) {
255 setConflicts(conflicting);
256 } else if (installed.length > 0) {
257 onOpenChange(false);
258 }
259 } finally {
260 if (requestId === importRequestRef.current) setIsReading(false);
261 }
262 },
263 [onImportedMany, onOpenChange],
264 );
265
266 const readThemeFiles = useCallback(
267 (files: ReadonlyArray<ImportableThemeFile>) => {
268 if (files.length === 0) return;
269 if (files.length === 1) void readThemeFile(files[0]!);
270 else void readThemeBatch(files);
271 },
272 [readThemeBatch, readThemeFile],
273 );
274
275 // On desktop the native picker opens in ~/.vscode/extensions (when it
276 // exists) and reads the files in the main process; the browser input is
277 // the fallback everywhere else.
278 const openFilePicker = useCallback(() => {
279 const bridge = window.desktopBridge;
280 if (bridge?.pickThemeFiles) {
281 void bridge.pickThemeFiles().then((picked) => {
282 if (!picked || picked.length === 0) return;
283 readThemeFiles(
284 picked.map((file) => ({
285 name: file.name,
286 size: file.size,
287 text: () => Promise.resolve(file.text),
288 })),
289 );
290 });
291 return;
292 }
293 fileInputRef.current?.click();
294 }, [readThemeFiles]);
295
296 const handleFileChange = useCallback(
297 (event: ChangeEvent<HTMLInputElement>) => {
298 const files = [...(event.currentTarget.files ?? [])];
299 event.currentTarget.value = "";
300 readThemeFiles(files);
301 },
302 [readThemeFiles],
303 );
304
305 const handleDrop = useCallback(
306 (event: DragEvent<HTMLDivElement>) => {
307 event.preventDefault();
308 setIsDropTarget(false);
309 readThemeFiles([...event.dataTransfer.files]);
310 },
311 [readThemeFiles],
312 );
313
314 /** Copy of an already-installed theme under the source file's name when
315 * that differs (Dracula Soft), else the next free "Name (1)". */
316 const versionedCopy = (
317 theme: ThemeDefinition,
318 preferredName?: string | null,
319 ): ThemeDefinition => {
320 if (preferredName && preferredName.toLowerCase() !== theme.label.toLowerCase()) {
321 const candidate = parseThemeFile({
322 version: THEME_FILE_VERSION,
323 name: preferredName.slice(0, 48),
324 appearance: theme.appearance,
325 colors: theme.colors,
326 ...(theme.variants ? { variants: theme.variants } : {}),
327 ...(theme.managed ? { managed: true } : {}),
328 });
329 if (!getCustomThemes().some((existing) => existing.id === candidate.id)) return candidate;
330 }
331 for (let copy = 1; copy < 100; copy += 1) {
332 const candidate = parseThemeFile({
333 version: THEME_FILE_VERSION,
334 name: `${theme.label.slice(0, 48 - ` (${copy})`.length)} (${copy})`,
335 appearance: theme.appearance,
336 colors: theme.colors,
337 ...(theme.variants ? { variants: theme.variants } : {}),
338 ...(theme.managed ? { managed: true } : {}),
339 });
340 if (getCustomThemes().some((existing) => existing.id === candidate.id)) continue;
341 return candidate;
342 }
343 throw new Error(`Too many copies of "${theme.label}".`);
344 };
345
346 const resolveConflicts = useCallback(
347 (mode: "update" | "copy") => {
348 if (!conflicts) return;
349 const resolved: ThemeDefinition[] = [];
350 const failures: string[] = [];
351 const preferredName =
352 conflicts.length === 1 && fileName
353 ? humanizeThemeName(fileName.replace(/\.[^.]+$/, ""))
354 : null;
355 for (const theme of conflicts) {
356 try {
357 const existingTheme =
358 mode === "update"
359 ? getCustomThemes().find((candidate) => candidate.id === theme.id)
360 : undefined;
361 const themeToUpdate = existingTheme?.collection
362 ? { ...theme, collection: existingTheme.collection }
363 : theme;
364 resolved.push(
365 mode === "update"
366 ? updateCustomTheme(themeToUpdate)
367 : installCustomTheme(versionedCopy(theme, preferredName)),
368 );
369 } catch (cause) {
370 failures.push(`${theme.label}: ${cause instanceof Error ? cause.message : "failed"}`);
371 }
372 }
373 if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" });
374 setConflicts(null);
375 if (failures.length > 0) setError(failures.join(" — "));
376 else onOpenChange(false);
377 },
378 [conflicts, fileName, onImportedMany, onOpenChange],
379 );
380
381 const handleSubmit = useCallback(() => {
382 // Pasted text bypasses the file guard, so the same limit applies here.
383 const oversized = describeOversizedThemeFile(json.length);
384 if (oversized) {
385 setError(oversized);
386 return;
387 }
388 try {
389 const parsed: unknown = JSON.parse(json);
390 // VS Code themes are converted on the way in; anything else has to be
391 // one of our own files.
392 const theme = isVsCodeThemeFile(parsed)
393 ? parseVsCodeThemeFile(parsed)
394 : parseThemeFile(parsed);
395 if (getCustomThemes().some((existing) => existing.id === theme.id)) {
396 setError(null);
397 setConflicts([theme]);
398 return;
399 }
400 const installedTheme = installCustomTheme(theme);
401 if (!onImported(installedTheme)) {
402 // Roll the install back so a retry can run it again instead of
403 // failing on the already-taken theme id.
404 try {
405 removeCustomTheme(installedTheme.id);
406 } catch {
407 // Storage is failing wholesale; the error below covers it.
408 }
409 setError("Theme added, but it could not be selected. Try again.");
410 return;
411 }
412 onOpenChange(false);
413 } catch (cause) {
414 setError(cause instanceof Error ? cause.message : "That theme file is invalid.");
415 }
416 }, [json, onImported, onOpenChange]);
417
418 return (
419 <Dialog
420 open={open}
421 onOpenChange={(nextOpen) => {
422 if (!nextOpen) setError(null);
423 onOpenChange(nextOpen);
424 }}
425 >
426 <DialogPopup className="max-w-3xl overflow-hidden">
427 <DialogHeader>
428 <DialogTitle>Add a theme</DialogTitle>
429 </DialogHeader>
430 <DialogPanel>
431 <ThemeSearchSection
432 onInstalled={(themes, context) => {
433 onImportedMany(themes, context);
434 onOpenChange(false);
435 }}
436 open={open}
437 />
438
439 <div className="flex items-center gap-3" aria-hidden>
440 <div className="h-px flex-1 bg-border" />
441 <span className="text-muted-foreground text-2xs uppercase tracking-wider">
442 or import a file
443 </span>
444 <div className="h-px flex-1 bg-border" />
445 </div>
446
447 {(() => {
448 const dropHandlers = {
449 onDragEnter: (event: DragEvent<HTMLDivElement>) => {
450 event.preventDefault();
451 setIsDropTarget(true);
452 },
453 onDragOver: (event: DragEvent<HTMLDivElement>) => {
454 event.preventDefault();
455 setIsDropTarget(true);
456 },
457 onDragLeave: (event: DragEvent<HTMLDivElement>) => {
458 // Ignore moves between children of the drop zone.
459 if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
460 setIsDropTarget(false);
461 },
462 onDrop: handleDrop,
463 };
464 const fileInput = (
465 <input
466 ref={fileInputRef}
467 accept=".json,application/json"
468 className="sr-only"
469 onChange={handleFileChange}
470 multiple
471 type="file"
472 />
473 );
474 const chooseButton = (label = "Choose files") => (
475 <Button disabled={isReading} size="sm" variant="outline" onClick={openFilePicker}>
476 <DownloadIcon />
477 {isReading ? "Reading…" : label}
478 </Button>
479 );
480 const editorSection = () => (
481 <div className="space-y-2">
482 <div className="flex items-baseline justify-between gap-3">
483 <label className="text-sm font-medium" htmlFor="theme-json-editor">
484 Theme JSON
485 </label>
486 </div>
487 <ThemeJsonEditor id="theme-json-editor" onChange={setJson} value={json} />
488 </div>
489 );
490 if (conflicts) {
491 return (
492 <div className="space-y-3">
493 <div className="rounded-xl border border-border/70 bg-muted/20 p-3">
494 <p className="text-sm font-medium">Already installed</p>
495 <p className="mt-1 text-xs text-muted-foreground">
496 {conflicts.map((theme) => theme.label).join(", ")}
497 </p>
498 </div>
499 <div className="flex flex-wrap items-center gap-2">
500 <Button size="sm" onClick={() => resolveConflicts("update")}>
501 Update existing
502 </Button>
503 <Button size="sm" variant="outline" onClick={() => resolveConflicts("copy")}>
504 Keep both
505 </Button>
506 <Button size="sm" variant="ghost" onClick={() => setConflicts(null)}>
507 Back
508 </Button>
509 <Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
510 Cancel
511 </Button>
512 </div>
513 </div>
514 );
515 }
516 return (
517 <div className="space-y-4">
518 <div
519 className={cn(
520 "flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed px-3 py-3 transition-colors",
521 isDropTarget ? "border-ring bg-accent/20" : "border-border/80 bg-muted/20",
522 )}
523 {...dropHandlers}
524 >
525 <div className="min-w-0">
526 <p className="text-sm font-medium">Theme file</p>
527 <p className="truncate text-xs text-muted-foreground">
528 {fileName ?? "Drop T3 Code or VS Code .json files"}
529 </p>
530 </div>
531 {chooseButton()}
532 {fileInput}
533 </div>
534 {editorSection()}
535 {/* The actions live with the import section, not in a DialogFooter,
536 because Add theme only applies to the file in this section. Pinning
537 them at the modal bottom would read as a modal-scoped action when
538 the dialog also has the search and conflict views. */}
539 <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
540 <Button variant="ghost" onClick={() => onOpenChange(false)}>
541 Cancel
542 </Button>
543 <Button disabled={!json.trim() || isReading} onClick={handleSubmit}>
544 <PlusIcon />
545 Add theme
546 </Button>
547 </div>
548 </div>
549 );
550 })()}
551
552 {error ? (
553 <Alert aria-live="polite" variant="error">
554 {error}
555 </Alert>
556 ) : null}
557 </DialogPanel>
558 </DialogPopup>
559 </Dialog>
560 );
561}