/** * Terminal Theme Customize Modal * Left-right split design: list on left, large preview on right * Uses React Portal to render at document root for proper z-index * * Features: * - Real-time preview: changes are applied immediately to the terminal * - Save: persists the current settings * - Cancel: reverts to the original settings when modal was opened * - Custom themes: create, edit, delete, import .itermcolors */ import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from 'react'; import { createPortal } from 'react-dom'; import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { useAvailableFonts } from '../../application/state/fontStore'; import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes'; import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts'; import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore'; import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser'; import { CustomThemeModal } from './CustomThemeModal'; import { Button } from '../ui/button'; import { cn } from '../../lib/utils'; import { TerminalTheme } from '../../domain/models'; type TabType = 'theme' | 'font' | 'custom'; // Memoized theme item component to prevent unnecessary re-renders const ThemeItem = memo(({ theme, isSelected, onSelect, onEdit, }: { theme: TerminalThemeConfig; isSelected: boolean; onSelect: (id: string) => void; onEdit?: (id: string) => void; }) => (
onSelect(theme.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }} className={cn( 'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all group cursor-pointer', isSelected ? 'bg-primary/15 ring-1 ring-primary' : 'hover:bg-muted' )} > {/* Color swatch */}
{theme.name}
{theme.type} {theme.isCustom && ' • custom'}
{onEdit && (
{ e.stopPropagation(); onEdit(theme.id); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }} className="w-6 h-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all" >
)} {isSelected && !onEdit && ( )}
)); ThemeItem.displayName = 'ThemeItem'; // Memoized font item component const FontItem = memo(({ font, isSelected, onSelect }: { font: TerminalFont; isSelected: boolean; onSelect: (id: string) => void; }) => ( )); FontItem.displayName = 'FontItem'; interface ThemeCustomizeModalProps { open: boolean; onClose: () => void; currentThemeId?: string; displayThemeId?: string; currentFontFamilyId?: string; currentFontSize?: number; /** Called immediately when user selects a theme (for real-time preview) */ onThemeChange?: (themeId: string) => void; /** Called when the theme should return to inherited/default state */ onThemeReset?: () => void; /** Called immediately when user selects a font (for real-time preview) */ onFontFamilyChange?: (fontFamilyId: string) => void; /** Called immediately when user changes font size (for real-time preview) */ onFontSizeChange?: (fontSize: number) => void; /** Called when user clicks Save to persist settings */ onSave?: () => void; /** Optional live preview callback for consumers that render outside this modal */ onPreviewThemeChange?: (theme: TerminalTheme | null) => void; } // Memoized preview component to avoid re-rendering on every state change const TerminalPreview = memo(({ theme, font, fontSize }: { theme: TerminalThemeConfig; font: TerminalFont; fontSize: number; }) => (
{/* Fake title bar */}
user@server — bash
{/* Terminal content */}
user@server : ~ $ neofetch
{' _,met$$$$$gg. '}
{' ,g$$$$$$$$$$$$$$$P. '} user @ server
{' ,g$$P" """Y$$."". '} -----------
{` ,$$P' $$$. `} OS : Ubuntu 22.04 LTS
{`'', $$P, ggs. $$b: `} Kernel : 5.15.0-generic
{`d$$' ,$P"' . $$$ `} Uptime : 42 days, 3 hours
{` $$P d$' , $$P `} Shell : bash 5.1.16
{` $$: $$. - ,d$$' `} Memory : 4.2G / 16G (26%)
 
{/* ANSI color palette preview row */}
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow, theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
))}
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow, theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
))}
 
user@server : ~ $
)); TerminalPreview.displayName = 'TerminalPreview'; const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({ ...theme, colors: { ...theme.colors }, isCustom: true, }); const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme); export const ThemeCustomizeModal: React.FC = ({ open, onClose, currentThemeId, displayThemeId, currentFontFamilyId = 'menlo', currentFontSize = DEFAULT_FONT_SIZE, onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onSave, onPreviewThemeChange, }) => { const { t } = useI18n(); const availableFonts = useAvailableFonts(); const customThemes = useCustomThemes(); const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions(); const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id; const [activeTab, setActiveTab] = useState('theme'); const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId); const [selectedFont, setSelectedFont] = useState(currentFontFamilyId); const [fontSize, setFontSize] = useState(currentFontSize); const [draftCustomThemes, setDraftCustomThemes] = useState(() => customThemes.map(cloneTheme)); // Custom theme editor state const [editingTheme, setEditingTheme] = useState(null); const [isNewTheme, setIsNewTheme] = useState(false); const fileInputRef = useRef(null); // Store original values when modal opens (for cancel/revert) const originalValuesRef = useRef({ theme: currentThemeId, font: currentFontFamilyId, fontSize: currentFontSize, }); const originalCustomThemesRef = useRef([]); const wasOpenRef = useRef(false); // Combine built-in + custom themes const allThemes = useMemo( () => [...TERMINAL_THEMES, ...draftCustomThemes], [draftCustomThemes] ); // Sync state when modal opens useEffect(() => { if (open && !wasOpenRef.current) { // Store original values for potential cancel originalValuesRef.current = { theme: currentThemeId, font: currentFontFamilyId, fontSize: currentFontSize, }; originalCustomThemesRef.current = customThemes.map((theme) => ({ ...cloneTheme(theme), })); // Initialize selected values setSelectedTheme(resolvedThemeId); setSelectedFont(currentFontFamilyId); setFontSize(currentFontSize); setDraftCustomThemes(customThemes.map(cloneTheme)); setEditingTheme(null); setIsNewTheme(false); } wasOpenRef.current = open; }, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]); const currentFont = useMemo( (): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0], [selectedFont, availableFonts] ); const currentTheme = useMemo( () => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0], [selectedTheme, allThemes, editingTheme] ); const hiddenSelectedTheme = useMemo( () => (isUiMatchTerminalThemeId(selectedTheme) ? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null : null), [selectedTheme] ); useEffect(() => { onPreviewThemeChange?.(open ? currentTheme : null); }, [currentTheme, onPreviewThemeChange, open]); // Handle theme selection - apply immediately for real-time preview const handleThemeSelect = useCallback((themeId: string) => { setSelectedTheme(themeId); setEditingTheme(null); onThemeChange?.(themeId); // Apply immediately }, [onThemeChange]); // Handle font selection - apply immediately for real-time preview const handleFontSelect = useCallback((fontId: string) => { setSelectedFont(fontId); onFontFamilyChange?.(fontId); // Apply immediately }, [onFontFamilyChange]); // Handle font size change - apply immediately for real-time preview const handleFontSizeChange = useCallback((delta: number) => { setFontSize(prev => { const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, prev + delta)); onFontSizeChange?.(newSize); // Apply immediately return newSize; }); }, [onFontSizeChange]); // ---- Custom Theme Actions ---- const handleNewTheme = useCallback(() => { // Clone current theme as starting point const base = allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0]; const newTheme: TerminalTheme = { ...base, id: `custom-${Date.now()}`, name: `${base.name} (Custom)`, isCustom: true, colors: { ...base.colors }, }; setEditingTheme(newTheme); setIsNewTheme(true); }, [selectedTheme, allThemes]); const handleImportFile = useCallback(() => { fileInputRef.current?.click(); }, []); const handleFileSelected = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const name = file.name.replace(/\.(itermcolors|xml)$/i, ''); const reader = new FileReader(); reader.onload = () => { const xml = reader.result as string; const parsed = parseItermcolors(xml, name); if (parsed) { setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]); setSelectedTheme(parsed.id); onThemeChange?.(parsed.id); setActiveTab('theme'); } else { console.error('[ThemeCustomize] Failed to parse .itermcolors file:', file.name); window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.'); } }; reader.onerror = () => { console.error('[ThemeCustomize] Failed to read file:', file.name, reader.error); }; reader.readAsText(file); // Reset file input so the same file can be re-imported e.target.value = ''; }, [onThemeChange, t]); const handleEditTheme = useCallback((themeId: string) => { const theme = draftCustomThemes.find(t => t.id === themeId); if (theme) { setEditingTheme({ ...theme, colors: { ...theme.colors } }); setIsNewTheme(false); setActiveTab('custom'); } }, [draftCustomThemes]); const handleEditorBack = useCallback(() => { setEditingTheme(null); setIsNewTheme(false); }, []); const handleEditorDelete = useCallback((themeId: string) => { setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId)); if (selectedTheme === themeId) { const originalThemeId = originalValuesRef.current.theme; const fallbackThemeId = originalThemeId && originalThemeId !== themeId ? originalThemeId : (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id); setSelectedTheme(fallbackThemeId); if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) { onThemeReset?.(); } else { onThemeChange?.(fallbackThemeId); } } setEditingTheme(null); setIsNewTheme(false); }, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]); // Save: just close (changes are already applied) const handleSave = useCallback(() => { const originalThemes = originalCustomThemesRef.current; const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme])); const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme])); for (const [id, originalTheme] of originalMap) { if (!draftMap.has(id)) { deleteTheme(id); continue; } const nextTheme = draftMap.get(id)!; if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) { updateTheme(id, nextTheme); } } for (const [id, draftTheme] of draftMap) { if (!originalMap.has(id)) { addTheme(draftTheme); } } onSave?.(); onClose(); }, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]); // Cancel: revert to original values const handleCancel = useCallback(() => { const original = originalValuesRef.current; // Revert all changes if (original.theme) { onThemeChange?.(original.theme); } else { onThemeReset?.(); } onFontFamilyChange?.(original.font); onFontSizeChange?.(original.fontSize); onClose(); }, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]); // Handle ESC key - same as cancel, but skip when child editor is open useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && !editingTheme) handleCancel(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, handleCancel, editingTheme]); // Handle backdrop click - same as cancel const handleBackdropClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) handleCancel(); }, [handleCancel]); if (!open) return null; // Separate built-in and custom themes for display in the theme list const builtinThemes = USER_VISIBLE_TERMINAL_THEMES; const modalContent = (
e.stopPropagation()} > {/* Header */}

{t('terminal.themeModal.title')}

{/* Main Content - Left/Right Split */}
{/* Left Panel - List */}
{/* Tab Bar */}
{/* List Content */} <>
{activeTab === 'theme' && (
{hiddenSelectedTheme && (
{t('terminal.hiddenTheme.title')}
{hiddenSelectedTheme.name}
{t('terminal.hiddenTheme.desc')}
)} {/* Built-in themes */} {builtinThemes.map(theme => ( ))} {/* Custom themes section */} {draftCustomThemes.length > 0 && ( <>
{t('terminal.customTheme.section')}
{draftCustomThemes.map(theme => ( ))} )}
)} {activeTab === 'font' && (
{availableFonts.map(font => ( ))}
)} {activeTab === 'custom' && !editingTheme && (
{/* Actions */} {/* Custom themes list */} {draftCustomThemes.length > 0 && ( <>
{t('terminal.customTheme.yourThemes')}
{draftCustomThemes.map(theme => ( ))} )}
)}
{/* Font Size Control (only in font tab) */} {activeTab === 'font' && (
{t('terminal.themeModal.fontSize')}
{fontSize} px
)}
{/* Right Panel - Large Preview */}
{t('terminal.themeModal.livePreview')}
{/* Info line */}
{currentTheme.name} • {currentFont.name} • {fontSize}px {t('terminal.themeModal.themeType', { type: currentTheme.type })}
{/* Footer */}
); // Use Portal to render at document root return ( <> {createPortal(modalContent, document.body)} {editingTheme && ( { setDraftCustomThemes((prev) => { if (isNewTheme) { return [...prev, cloneTheme(theme)]; } return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry); }); if (isNewTheme) { setSelectedTheme(theme.id); onThemeChange?.(theme.id); } else { if (selectedTheme === theme.id) { onThemeChange?.(theme.id); } } setEditingTheme(null); setIsNewTheme(false); }} onDelete={isNewTheme ? undefined : handleEditorDelete} onCancel={handleEditorBack} /> )} ); }; export default ThemeCustomizeModal;