import { DownloadIcon, PlusIcon } from "lucide-react";import type { ChangeEvent, DragEvent, UIEvent } from "react";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { cn } from "../../lib/utils";import { getCustomThemes, installCustomTheme, parseThemeFile, removeCustomTheme, THEME_FILE_VERSION, updateCustomTheme, type ThemeDefinition,} from "../../themePalette";import { humanizeThemeName, isVsCodeThemeFile, pairVsCodeThemes, parseVsCodeThemeFile, resolveThemeLabelCollisions,} from "../../vscodeThemeImport";import { Alert } from "../ui/alert";import { Button } from "../ui/button";import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog";import { ThemeSearchSection } from "./ThemeSearchSection"; /** * A full theme export is a few KB, so anything past this is not a theme file. * The guard runs on the size before the bytes are ever read: a large file * would otherwise be pulled into memory, highlighted, and rendered, which * locks the UI for as long as that takes. */export const MAX_THEME_FILE_BYTES = 256 * 1024; /** Highlighting rebuilds the whole markup on every keystroke, so oversized * pastes fall back to plain text instead of freezing the editor. */const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; function formatByteSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; return `${bytes} bytes`;} /** Returns the error to show for a file too large to be a theme, else null. */export function describeOversizedThemeFile(bytes: number): string | null { if (bytes <= MAX_THEME_FILE_BYTES) return null; 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)}).`;} function escapeJsonHtml(value: string): string { return value.replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character] ?? character, );} function highlightJson(value: string): string { const tokenPattern = /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; let highlighted = ""; let cursor = 0; for (const match of value.matchAll(tokenPattern)) { const token = match[0]; const index = match.index ?? 0; highlighted += escapeJsonHtml(value.slice(cursor, index)); let tokenClass = "text-[var(--app-theme-secondary-foreground,var(--color-amber-600))]"; if (token.startsWith('"')) { tokenClass = /^\s*:/.test(value.slice(index + token.length)) ? "text-[var(--app-theme-accent,var(--color-blue-600))]" : "text-[var(--app-theme-message-action,var(--color-emerald-600))]"; } else if (token === "true" || token === "false" || token === "null") { tokenClass = "text-[var(--app-theme-accent-surface-foreground,var(--color-violet-600))]"; } highlighted += `<span class="${tokenClass}">${escapeJsonHtml(token)}</span>`; cursor = index + token.length; } return highlighted + escapeJsonHtml(value.slice(cursor));} function ThemeJsonEditor({ id, value, onChange,}: { id: string; value: string; onChange: (value: string) => void;}) { const highlightRef = useRef<HTMLPreElement>(null); const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH; const highlightedJson = useMemo( () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)), [value], ); const syncScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => { const highlightElement = highlightRef.current; if (!highlightElement) return; highlightElement.scrollTop = event.currentTarget.scrollTop; highlightElement.scrollLeft = event.currentTarget.scrollLeft; }, []); return ( <div className="relative overflow-hidden rounded-xl border border-input bg-background shadow-xs/5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24"> {isPlainText ? null : ( <pre ref={highlightRef} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre-wrap break-words p-3 font-mono text-[12px] leading-5 text-foreground" > <code dangerouslySetInnerHTML={{ __html: highlightedJson }} /> </pre> )} <textarea aria-label="Theme JSON" className={cn( "relative z-10 block min-h-44 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-[12px] leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground", isPlainText ? "text-foreground" : "text-transparent", )} id={id} onChange={(event) => onChange(event.currentTarget.value)} onScroll={syncScroll} placeholder={ '{\n "version": 1,\n "name": "Aurora",\n "appearance": "light",\n "colors": { ... }\n}' } spellCheck={false} value={value} /> </div> );} /** What the import pipeline needs from a file; DOM File satisfies it. */type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> }; export function ThemeImportDialog({ open, onOpenChange, onImported, onImportedMany,}: { open: boolean; onOpenChange: (open: boolean) => void; onImported: (theme: ThemeDefinition) => boolean; /** Batch imports install without activating; the caller reports them. */ onImportedMany: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void;}) { const fileInputRef = useRef<HTMLInputElement>(null); const [json, setJson] = useState(""); const [fileName, setFileName] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const [isReading, setIsReading] = useState(false); const [isDropTarget, setIsDropTarget] = useState(false); // Imports whose id is already installed wait here for an update-or-copy // decision instead of failing. const [conflicts, setConflicts] = useState<ReadonlyArray<ThemeDefinition> | null>(null); const importRequestRef = useRef(0); useEffect(() => { importRequestRef.current += 1; // Reset on close too: a dialog dismissed mid-drag would otherwise reopen // still wearing the drop highlight. setIsDropTarget(false); if (!open) return; setJson(""); setFileName(null); setError(null); setIsReading(false); setConflicts(null); }, [open]); const readThemeFile = useCallback(async (file: ImportableThemeFile) => { // Check the size first: reading a large file is what locks the UI, so it // never gets read at all. const oversized = describeOversizedThemeFile(file.size); if (oversized) { setError(oversized); return; } const requestId = ++importRequestRef.current; setIsReading(true); try { const fileText = await file.text(); if (requestId !== importRequestRef.current) return; setJson(fileText); setFileName(file.name); setError(null); } catch { if (requestId !== importRequestRef.current) return; setError("Could not read that file. Paste the JSON below instead."); } finally { if (requestId === importRequestRef.current) setIsReading(false); } }, []); // Several files at once import as a batch: VS Code families pair their // light and dark variants, everything installs without activating, and the // single-file flow keeps filling the editor for review. const readThemeBatch = useCallback( async (files: ReadonlyArray<ImportableThemeFile>) => { const requestId = ++importRequestRef.current; setIsReading(true); const failures: string[] = []; const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = []; try { for (const file of files) { const oversized = describeOversizedThemeFile(file.size); if (oversized) { failures.push(`${file.name}: too large`); continue; } try { const value: unknown = JSON.parse(await file.text()); parsed.push({ sourceName: file.name, theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value), }); } catch (cause) { failures.push( `${file.name}: ${cause instanceof Error ? cause.message : "not a theme file"}`, ); } } if (requestId !== importRequestRef.current) return; const installed: ThemeDefinition[] = []; const conflicting: ThemeDefinition[] = []; for (const theme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) { if (getCustomThemes().some((existing) => existing.id === theme.id)) { conflicting.push(theme); continue; } try { installed.push(installCustomTheme(theme)); } catch (cause) { failures.push( `${theme.label}: ${cause instanceof Error ? cause.message : "could not install"}`, ); } } if (installed.length > 0) onImportedMany(installed, { updated: false }); if (failures.length > 0) { setError(failures.join(" — ")); } else if (conflicting.length > 0) { setConflicts(conflicting); } else if (installed.length > 0) { onOpenChange(false); } } finally { if (requestId === importRequestRef.current) setIsReading(false); } }, [onImportedMany, onOpenChange], ); const readThemeFiles = useCallback( (files: ReadonlyArray<ImportableThemeFile>) => { if (files.length === 0) return; if (files.length === 1) void readThemeFile(files[0]!); else void readThemeBatch(files); }, [readThemeBatch, readThemeFile], ); // On desktop the native picker opens in ~/.vscode/extensions (when it // exists) and reads the files in the main process; the browser input is // the fallback everywhere else. const openFilePicker = useCallback(() => { const bridge = window.desktopBridge; if (bridge?.pickThemeFiles) { void bridge.pickThemeFiles().then((picked) => { if (!picked || picked.length === 0) return; readThemeFiles( picked.map((file) => ({ name: file.name, size: file.size, text: () => Promise.resolve(file.text), })), ); }); return; } fileInputRef.current?.click(); }, [readThemeFiles]); const handleFileChange = useCallback( (event: ChangeEvent<HTMLInputElement>) => { const files = [...(event.currentTarget.files ?? [])]; event.currentTarget.value = ""; readThemeFiles(files); }, [readThemeFiles], ); const handleDrop = useCallback( (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(false); readThemeFiles([...event.dataTransfer.files]); }, [readThemeFiles], ); /** Copy of an already-installed theme under the source file's name when * that differs (Dracula Soft), else the next free "Name (1)". */ const versionedCopy = ( theme: ThemeDefinition, preferredName?: string | null, ): ThemeDefinition => { if (preferredName && preferredName.toLowerCase() !== theme.label.toLowerCase()) { const candidate = parseThemeFile({ version: THEME_FILE_VERSION, name: preferredName.slice(0, 48), appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); if (!getCustomThemes().some((existing) => existing.id === candidate.id)) return candidate; } for (let copy = 1; copy < 100; copy += 1) { const candidate = parseThemeFile({ version: THEME_FILE_VERSION, name: `${theme.label.slice(0, 48 - ` (${copy})`.length)} (${copy})`, appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); if (getCustomThemes().some((existing) => existing.id === candidate.id)) continue; return candidate; } throw new Error(`Too many copies of "${theme.label}".`); }; const resolveConflicts = useCallback( (mode: "update" | "copy") => { if (!conflicts) return; const resolved: ThemeDefinition[] = []; const failures: string[] = []; const preferredName = conflicts.length === 1 && fileName ? humanizeThemeName(fileName.replace(/\.[^.]+$/, "")) : null; for (const theme of conflicts) { try { const existingTheme = mode === "update" ? getCustomThemes().find((candidate) => candidate.id === theme.id) : undefined; const themeToUpdate = existingTheme?.collection ? { ...theme, collection: existingTheme.collection } : theme; resolved.push( mode === "update" ? updateCustomTheme(themeToUpdate) : installCustomTheme(versionedCopy(theme, preferredName)), ); } catch (cause) { failures.push(`${theme.label}: ${cause instanceof Error ? cause.message : "failed"}`); } } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); if (failures.length > 0) setError(failures.join(" — ")); else onOpenChange(false); }, [conflicts, fileName, onImportedMany, onOpenChange], ); const handleSubmit = useCallback(() => { // Pasted text bypasses the file guard, so the same limit applies here. const oversized = describeOversizedThemeFile(json.length); if (oversized) { setError(oversized); return; } try { const parsed: unknown = JSON.parse(json); // VS Code themes are converted on the way in; anything else has to be // one of our own files. const theme = isVsCodeThemeFile(parsed) ? parseVsCodeThemeFile(parsed) : parseThemeFile(parsed); if (getCustomThemes().some((existing) => existing.id === theme.id)) { setError(null); setConflicts([theme]); return; } const installedTheme = installCustomTheme(theme); if (!onImported(installedTheme)) { // Roll the install back so a retry can run it again instead of // failing on the already-taken theme id. try { removeCustomTheme(installedTheme.id); } catch { // Storage is failing wholesale; the error below covers it. } setError("Theme added, but it could not be selected. Try again."); return; } onOpenChange(false); } catch (cause) { setError(cause instanceof Error ? cause.message : "That theme file is invalid."); } }, [json, onImported, onOpenChange]); return ( <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) setError(null); onOpenChange(nextOpen); }} > <DialogPopup className="max-w-3xl overflow-hidden"> <DialogHeader> <DialogTitle>Add a theme</DialogTitle> </DialogHeader> <DialogPanel className="space-y-5"> <ThemeSearchSection onInstalled={(themes, context) => { onImportedMany(themes, context); onOpenChange(false); }} open={open} /> <div className="flex items-center gap-3" aria-hidden> <div className="h-px flex-1 bg-border" /> <span className="text-muted-foreground text-[11px] uppercase tracking-wider"> or import a file </span> <div className="h-px flex-1 bg-border" /> </div> {(() => { const dropHandlers = { onDragEnter: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(true); }, onDragOver: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(true); }, onDragLeave: (event: DragEvent<HTMLDivElement>) => { // Ignore moves between children of the drop zone. if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setIsDropTarget(false); }, onDrop: handleDrop, }; const fileInput = ( <input ref={fileInputRef} accept=".json,application/json" className="sr-only" onChange={handleFileChange} multiple type="file" /> ); const chooseButton = (label = "Choose files") => ( <Button disabled={isReading} size="sm" variant="outline" onClick={openFilePicker}> <DownloadIcon /> {isReading ? "Reading…" : label} </Button> ); const editorSection = () => ( <div className="space-y-2"> <div className="flex items-baseline justify-between gap-3"> <label className="text-sm font-medium" htmlFor="theme-json-editor"> Theme JSON </label> </div> <ThemeJsonEditor id="theme-json-editor" onChange={setJson} value={json} /> </div> ); if (conflicts) { return ( <div className="space-y-3"> <div className="rounded-xl border border-border/70 bg-muted/20 p-3"> <p className="text-sm font-medium">Already installed</p> <p className="mt-1 text-xs text-muted-foreground"> {conflicts.map((theme) => theme.label).join(", ")} </p> </div> <div className="flex flex-wrap items-center gap-2"> <Button size="sm" onClick={() => resolveConflicts("update")}>Lines 1–500 of 561. Load more
import { DownloadIcon, PlusIcon } from "lucide-react"; import type { ChangeEvent, DragEvent, UIEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../../lib/utils"; import { getCustomThemes, installCustomTheme, parseThemeFile, removeCustomTheme, THEME_FILE_VERSION, updateCustomTheme, type ThemeDefinition, } from "../../themePalette"; import { humanizeThemeName, isVsCodeThemeFile, pairVsCodeThemes, parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "../../vscodeThemeImport"; import { Alert } from "../ui/alert"; import { Button } from "../ui/button"; import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; import { ThemeSearchSection } from "./ThemeSearchSection"; /** * A full theme export is a few KB, so anything past this is not a theme file. * The guard runs on the size before the bytes are ever read: a large file * would otherwise be pulled into memory, highlighted, and rendered, which * locks the UI for as long as that takes. */ export const MAX_THEME_FILE_BYTES = 256 * 1024; /** Highlighting rebuilds the whole markup on every keystroke, so oversized * pastes fall back to plain text instead of freezing the editor. */ const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; function formatByteSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; return `${bytes} bytes`; } /** Returns the error to show for a file too large to be a theme, else null. */ export function describeOversizedThemeFile(bytes: number): string | null { if (bytes <= MAX_THEME_FILE_BYTES) return null; 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)}).`; } function escapeJsonHtml(value: string): string { return value.replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character] ?? character, ); } function highlightJson(value: string): string { const tokenPattern = /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; let highlighted = ""; let cursor = 0; for (const match of value.matchAll(tokenPattern)) { const token = match[0]; const index = match.index ?? 0; highlighted += escapeJsonHtml(value.slice(cursor, index)); let tokenClass = "text-[var(--app-theme-secondary-foreground,var(--color-amber-600))]"; if (token.startsWith('"')) { tokenClass = /^\s*:/.test(value.slice(index + token.length)) ? "text-[var(--app-theme-accent,var(--color-blue-600))]" : "text-[var(--app-theme-message-action,var(--color-emerald-600))]"; } else if (token === "true" || token === "false" || token === "null") { tokenClass = "text-[var(--app-theme-accent-surface-foreground,var(--color-violet-600))]"; } highlighted += `<span class="${tokenClass}">${escapeJsonHtml(token)}</span>`; cursor = index + token.length; } return highlighted + escapeJsonHtml(value.slice(cursor)); } function ThemeJsonEditor({ id, value, onChange, }: { id: string; value: string; onChange: (value: string) => void; }) { const highlightRef = useRef<HTMLPreElement>(null); const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH; const highlightedJson = useMemo( () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)), [value], ); const syncScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => { const highlightElement = highlightRef.current; if (!highlightElement) return; highlightElement.scrollTop = event.currentTarget.scrollTop; highlightElement.scrollLeft = event.currentTarget.scrollLeft; }, []); return ( <div className="relative overflow-hidden rounded-xl border border-input bg-background shadow-xs/5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24"> {isPlainText ? null : ( <pre ref={highlightRef} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre-wrap break-words p-3 font-mono text-[12px] leading-5 text-foreground" > <code dangerouslySetInnerHTML={{ __html: highlightedJson }} /> </pre> )} <textarea aria-label="Theme JSON" className={cn( "relative z-10 block min-h-44 w-full resize-y overflow-auto bg-transparent p-3 font-mono text-[12px] leading-5 caret-foreground outline-none placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground", isPlainText ? "text-foreground" : "text-transparent", )} id={id} onChange={(event) => onChange(event.currentTarget.value)} onScroll={syncScroll} placeholder={ '{\n "version": 1,\n "name": "Aurora",\n "appearance": "light",\n "colors": { ... }\n}' } spellCheck={false} value={value} /> </div> ); } /** What the import pipeline needs from a file; DOM File satisfies it. */ type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> }; export function ThemeImportDialog({ open, onOpenChange, onImported, onImportedMany, }: { open: boolean; onOpenChange: (open: boolean) => void; onImported: (theme: ThemeDefinition) => boolean; /** Batch imports install without activating; the caller reports them. */ onImportedMany: (themes: ReadonlyArray<ThemeDefinition>, context: { updated: boolean }) => void; }) { const fileInputRef = useRef<HTMLInputElement>(null); const [json, setJson] = useState(""); const [fileName, setFileName] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const [isReading, setIsReading] = useState(false); const [isDropTarget, setIsDropTarget] = useState(false); // Imports whose id is already installed wait here for an update-or-copy // decision instead of failing. const [conflicts, setConflicts] = useState<ReadonlyArray<ThemeDefinition> | null>(null); const importRequestRef = useRef(0); useEffect(() => { importRequestRef.current += 1; // Reset on close too: a dialog dismissed mid-drag would otherwise reopen // still wearing the drop highlight. setIsDropTarget(false); if (!open) return; setJson(""); setFileName(null); setError(null); setIsReading(false); setConflicts(null); }, [open]); const readThemeFile = useCallback(async (file: ImportableThemeFile) => { // Check the size first: reading a large file is what locks the UI, so it // never gets read at all. const oversized = describeOversizedThemeFile(file.size); if (oversized) { setError(oversized); return; } const requestId = ++importRequestRef.current; setIsReading(true); try { const fileText = await file.text(); if (requestId !== importRequestRef.current) return; setJson(fileText); setFileName(file.name); setError(null); } catch { if (requestId !== importRequestRef.current) return; setError("Could not read that file. Paste the JSON below instead."); } finally { if (requestId === importRequestRef.current) setIsReading(false); } }, []); // Several files at once import as a batch: VS Code families pair their // light and dark variants, everything installs without activating, and the // single-file flow keeps filling the editor for review. const readThemeBatch = useCallback( async (files: ReadonlyArray<ImportableThemeFile>) => { const requestId = ++importRequestRef.current; setIsReading(true); const failures: string[] = []; const parsed: Array<{ theme: ThemeDefinition; sourceName: string }> = []; try { for (const file of files) { const oversized = describeOversizedThemeFile(file.size); if (oversized) { failures.push(`${file.name}: too large`); continue; } try { const value: unknown = JSON.parse(await file.text()); parsed.push({ sourceName: file.name, theme: isVsCodeThemeFile(value) ? parseVsCodeThemeFile(value) : parseThemeFile(value), }); } catch (cause) { failures.push( `${file.name}: ${cause instanceof Error ? cause.message : "not a theme file"}`, ); } } if (requestId !== importRequestRef.current) return; const installed: ThemeDefinition[] = []; const conflicting: ThemeDefinition[] = []; for (const theme of pairVsCodeThemes(resolveThemeLabelCollisions(parsed))) { if (getCustomThemes().some((existing) => existing.id === theme.id)) { conflicting.push(theme); continue; } try { installed.push(installCustomTheme(theme)); } catch (cause) { failures.push( `${theme.label}: ${cause instanceof Error ? cause.message : "could not install"}`, ); } } if (installed.length > 0) onImportedMany(installed, { updated: false }); if (failures.length > 0) { setError(failures.join(" — ")); } else if (conflicting.length > 0) { setConflicts(conflicting); } else if (installed.length > 0) { onOpenChange(false); } } finally { if (requestId === importRequestRef.current) setIsReading(false); } }, [onImportedMany, onOpenChange], ); const readThemeFiles = useCallback( (files: ReadonlyArray<ImportableThemeFile>) => { if (files.length === 0) return; if (files.length === 1) void readThemeFile(files[0]!); else void readThemeBatch(files); }, [readThemeBatch, readThemeFile], ); // On desktop the native picker opens in ~/.vscode/extensions (when it // exists) and reads the files in the main process; the browser input is // the fallback everywhere else. const openFilePicker = useCallback(() => { const bridge = window.desktopBridge; if (bridge?.pickThemeFiles) { void bridge.pickThemeFiles().then((picked) => { if (!picked || picked.length === 0) return; readThemeFiles( picked.map((file) => ({ name: file.name, size: file.size, text: () => Promise.resolve(file.text), })), ); }); return; } fileInputRef.current?.click(); }, [readThemeFiles]); const handleFileChange = useCallback( (event: ChangeEvent<HTMLInputElement>) => { const files = [...(event.currentTarget.files ?? [])]; event.currentTarget.value = ""; readThemeFiles(files); }, [readThemeFiles], ); const handleDrop = useCallback( (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(false); readThemeFiles([...event.dataTransfer.files]); }, [readThemeFiles], ); /** Copy of an already-installed theme under the source file's name when * that differs (Dracula Soft), else the next free "Name (1)". */ const versionedCopy = ( theme: ThemeDefinition, preferredName?: string | null, ): ThemeDefinition => { if (preferredName && preferredName.toLowerCase() !== theme.label.toLowerCase()) { const candidate = parseThemeFile({ version: THEME_FILE_VERSION, name: preferredName.slice(0, 48), appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); if (!getCustomThemes().some((existing) => existing.id === candidate.id)) return candidate; } for (let copy = 1; copy < 100; copy += 1) { const candidate = parseThemeFile({ version: THEME_FILE_VERSION, name: `${theme.label.slice(0, 48 - ` (${copy})`.length)} (${copy})`, appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), ...(theme.managed ? { managed: true } : {}), }); if (getCustomThemes().some((existing) => existing.id === candidate.id)) continue; return candidate; } throw new Error(`Too many copies of "${theme.label}".`); }; const resolveConflicts = useCallback( (mode: "update" | "copy") => { if (!conflicts) return; const resolved: ThemeDefinition[] = []; const failures: string[] = []; const preferredName = conflicts.length === 1 && fileName ? humanizeThemeName(fileName.replace(/\.[^.]+$/, "")) : null; for (const theme of conflicts) { try { const existingTheme = mode === "update" ? getCustomThemes().find((candidate) => candidate.id === theme.id) : undefined; const themeToUpdate = existingTheme?.collection ? { ...theme, collection: existingTheme.collection } : theme; resolved.push( mode === "update" ? updateCustomTheme(themeToUpdate) : installCustomTheme(versionedCopy(theme, preferredName)), ); } catch (cause) { failures.push(`${theme.label}: ${cause instanceof Error ? cause.message : "failed"}`); } } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); if (failures.length > 0) setError(failures.join(" — ")); else onOpenChange(false); }, [conflicts, fileName, onImportedMany, onOpenChange], ); const handleSubmit = useCallback(() => { // Pasted text bypasses the file guard, so the same limit applies here. const oversized = describeOversizedThemeFile(json.length); if (oversized) { setError(oversized); return; } try { const parsed: unknown = JSON.parse(json); // VS Code themes are converted on the way in; anything else has to be // one of our own files. const theme = isVsCodeThemeFile(parsed) ? parseVsCodeThemeFile(parsed) : parseThemeFile(parsed); if (getCustomThemes().some((existing) => existing.id === theme.id)) { setError(null); setConflicts([theme]); return; } const installedTheme = installCustomTheme(theme); if (!onImported(installedTheme)) { // Roll the install back so a retry can run it again instead of // failing on the already-taken theme id. try { removeCustomTheme(installedTheme.id); } catch { // Storage is failing wholesale; the error below covers it. } setError("Theme added, but it could not be selected. Try again."); return; } onOpenChange(false); } catch (cause) { setError(cause instanceof Error ? cause.message : "That theme file is invalid."); } }, [json, onImported, onOpenChange]); return ( <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) setError(null); onOpenChange(nextOpen); }} > <DialogPopup className="max-w-3xl overflow-hidden"> <DialogHeader> <DialogTitle>Add a theme</DialogTitle> </DialogHeader> <DialogPanel className="space-y-5"> <ThemeSearchSection onInstalled={(themes, context) => { onImportedMany(themes, context); onOpenChange(false); }} open={open} /> <div className="flex items-center gap-3" aria-hidden> <div className="h-px flex-1 bg-border" /> <span className="text-muted-foreground text-[11px] uppercase tracking-wider"> or import a file </span> <div className="h-px flex-1 bg-border" /> </div> {(() => { const dropHandlers = { onDragEnter: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(true); }, onDragOver: (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); setIsDropTarget(true); }, onDragLeave: (event: DragEvent<HTMLDivElement>) => { // Ignore moves between children of the drop zone. if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setIsDropTarget(false); }, onDrop: handleDrop, }; const fileInput = ( <input ref={fileInputRef} accept=".json,application/json" className="sr-only" onChange={handleFileChange} multiple type="file" /> ); const chooseButton = (label = "Choose files") => ( <Button disabled={isReading} size="sm" variant="outline" onClick={openFilePicker}> <DownloadIcon /> {isReading ? "Reading…" : label} </Button> ); const editorSection = () => ( <div className="space-y-2"> <div className="flex items-baseline justify-between gap-3"> <label className="text-sm font-medium" htmlFor="theme-json-editor"> Theme JSON </label> </div> <ThemeJsonEditor id="theme-json-editor" onChange={setJson} value={json} /> </div> ); if (conflicts) { return ( <div className="space-y-3"> <div className="rounded-xl border border-border/70 bg-muted/20 p-3"> <p className="text-sm font-medium">Already installed</p> <p className="mt-1 text-xs text-muted-foreground"> {conflicts.map((theme) => theme.label).join(", ")} </p> </div> <div className="flex flex-wrap items-center gap-2"> <Button size="sm" onClick={() => resolveConflicts("update")}>