| 1 | import { DownloadIcon, PlusIcon } from "lucide-react"; |
| 2 | import type { ChangeEvent, DragEvent, UIEvent } from "react"; |
| 3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import { cn } from "../../lib/utils"; |
| 5 | import { |
| 6 | getCustomThemes, |
| 7 | installCustomTheme, |
| 8 | parseThemeFile, |
| 9 | removeCustomTheme, |
| 10 | THEME_FILE_VERSION, |
| 11 | updateCustomTheme, |
| 12 | type ThemeDefinition, |
| 13 | } from "../../themePalette"; |
| 14 | import { |
| 15 | humanizeThemeName, |
| 16 | isVsCodeThemeFile, |
| 17 | pairVsCodeThemes, |
| 18 | parseVsCodeThemeFile, |
| 19 | resolveThemeLabelCollisions, |
| 20 | } from "../../vscodeThemeImport"; |
| 21 | import { Alert } from "../ui/alert"; |
| 22 | import { Button } from "../ui/button"; |
| 23 | import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 24 | import { 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 | */ |
| 32 | export 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. */ |
| 36 | const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; |
| 37 | |
| 38 | function 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. */ |
| 45 | export 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 | |
| 50 | function 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 | |
| 64 | function 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 | |
| 90 | function 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. */ |
| 144 | type ImportableThemeFile = { name: string; size: number; text: () => Promise<string> }; |
| 145 | |
| 146 | export 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 | } |