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 += `${escapeJsonHtml(token)}`; 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(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) => { const highlightElement = highlightRef.current; if (!highlightElement) return; highlightElement.scrollTop = event.currentTarget.scrollTop; highlightElement.scrollLeft = event.currentTarget.scrollLeft; }, []); return (
{isPlainText ? null : (
          
        
)}