/** * ThemeSidePanel - Theme/Font customization panel for the terminal side panel * * Adapted from ThemeCustomizeModal's left panel content. * No preview - the actual terminal behind serves as a live preview. * Changes apply in real-time. */ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react'; import { useI18n } from '../../application/i18n/I18nProvider'; import { useAvailableFonts } from '../../application/state/fontStore'; import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, getBuiltinTerminalThemeById, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes'; import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts'; import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore'; import { terminalAppearanceThemePanelVars } from '../../infrastructure/theme/terminalAppearanceTokens'; import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser'; import { CustomThemeModal } from './CustomThemeModal'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { cn } from '../../lib/utils'; import { TerminalTheme } from '../../domain/models'; import { ScrollArea } from '../ui/scroll-area'; import { isFollowAppTerminalThemeId } from '../../domain/terminalAppearance'; import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome'; type TabType = 'theme' | 'font' | 'custom'; // Memoized theme item component 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-2.5 px-3 py-2 text-left group cursor-pointer' )} style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }} onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }} onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent'; }} > {/* 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-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all" style={{ color: 'var(--terminal-panel-muted)' }} >
)} {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 ThemeSidePanelProps { followAppTerminalTheme?: boolean; currentThemeId: string; globalThemeId: string; currentFontFamilyId: string; globalFontFamilyId: string; currentFontSize: number; currentFontWeight: number; canResetTheme?: boolean; canResetFontFamily?: boolean; canResetFontSize?: boolean; canResetFontWeight?: boolean; onThemeChange: (themeId: string) => void; onThemeReset?: () => void; onFontFamilyChange: (fontFamilyId: string) => void; onFontFamilyReset?: () => void; onFontSizeChange: (fontSize: number) => void; onFontSizeReset?: () => void; onFontWeightChange: (fontWeight: number) => void; onFontWeightReset?: () => void; isVisible?: boolean; } const ThemeSidePanelInner: React.FC = ({ followAppTerminalTheme = false, currentThemeId, globalThemeId, currentFontFamilyId, globalFontFamilyId, currentFontSize, currentFontWeight, canResetTheme = false, canResetFontFamily = false, canResetFontSize = false, canResetFontWeight = false, onThemeChange, onThemeReset, onFontFamilyChange, onFontFamilyReset, onFontSizeChange, onFontSizeReset, onFontWeightChange, onFontWeightReset, isVisible = true, }) => { const { t } = useI18n(); const availableFonts = useAvailableFonts(); const customThemes = useCustomThemes(); const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions(); const [activeTab, setActiveTab] = useState('theme'); const [editingTheme, setEditingTheme] = useState(null); const [isNewTheme, setIsNewTheme] = useState(false); const fileInputRef = useRef(null); useEffect(() => { if (followAppTerminalTheme && activeTab === 'custom') { setActiveTab('theme'); setEditingTheme(null); } }, [activeTab, followAppTerminalTheme]); const customThemeById = useMemo( () => new Map(customThemes.map((theme) => [theme.id, theme])), [customThemes], ); const fontById = useMemo( () => new Map(availableFonts.map((font) => [font.id, font])), [availableFonts], ); const getThemeById = useCallback((themeId: string): TerminalTheme | undefined => getBuiltinTerminalThemeById(themeId) ?? customThemeById.get(themeId), [customThemeById]); const globalTheme = useMemo( () => getThemeById(globalThemeId) || TERMINAL_THEMES[0], [getThemeById, globalThemeId], ); const hiddenSelectedTheme = useMemo( () => (isUiMatchTerminalThemeId(currentThemeId) ? getBuiltinTerminalThemeById(currentThemeId) || null : null), [currentThemeId], ); const globalFont = useMemo( () => fontById.get(globalFontFamilyId) || availableFonts[0], [availableFonts, fontById, globalFontFamilyId], ); const builtinThemes = useMemo( () => (followAppTerminalTheme ? TERMINAL_THEMES.filter((theme) => isFollowAppTerminalThemeId(theme.id)) : USER_VISIBLE_TERMINAL_THEMES), [followAppTerminalTheme], ); const handleThemeSelect = useCallback((themeId: string) => { setEditingTheme(null); onThemeChange(themeId); }, [onThemeChange]); const handleFontSelect = useCallback((fontId: string) => { onFontFamilyChange(fontId); }, [onFontFamilyChange]); const handleFontSizeChange = useCallback((delta: number) => { const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta)); onFontSizeChange(newSize); }, [currentFontSize, onFontSizeChange]); const handleNewTheme = useCallback(() => { const base = getThemeById(currentThemeId) || TERMINAL_THEMES[0]; const newTheme: TerminalTheme = { ...base, id: `custom-${Date.now()}`, name: `${base.name} (Custom)`, isCustom: true, colors: { ...base.colors }, }; setEditingTheme(newTheme); setIsNewTheme(true); }, [currentThemeId, getThemeById]); 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) { addTheme(parsed); onThemeChange(parsed.id); setActiveTab('theme'); } else { window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.'); } }; reader.readAsText(file); e.target.value = ''; }, [addTheme, onThemeChange, t]); const handleEditTheme = useCallback((themeId: string) => { const theme = customThemeById.get(themeId); if (theme) { setEditingTheme({ ...theme, colors: { ...theme.colors } }); setIsNewTheme(false); } }, [customThemeById]); const handleEditorDelete = useCallback((themeId: string) => { deleteTheme(themeId); if (currentThemeId === themeId) { onThemeChange(TERMINAL_THEMES[0].id); } setEditingTheme(null); setIsNewTheme(false); }, [deleteTheme, currentThemeId, onThemeChange]); if (!isVisible) return null; const footerThemeName = getThemeById(currentThemeId)?.name ?? currentThemeId; const footerFontName = fontById.get(currentFontFamilyId)?.name ?? currentFontFamilyId; const footerLabel = `${footerThemeName} • ${footerFontName} • ${currentFontSize}px • ${currentFontWeight}`; const panelVars = terminalAppearanceThemePanelVars; return ( <>
{/* Tab Bar */}
{!followAppTerminalTheme && ( )}
{/* List Content */}
{activeTab === 'theme' && (
{!followAppTerminalTheme && hiddenSelectedTheme && ( )} {builtinThemes.map(theme => ( ))} {!followAppTerminalTheme && customThemes.length > 0 && ( <>
{t('terminal.customTheme.section')}
{customThemes.map(theme => ( ))} )} {canResetTheme && ( <>
{t('terminal.themeModal.globalTheme')}
onThemeReset?.()} /> )}
)} {activeTab === 'font' && (
{availableFonts.map(font => ( ))} {canResetFontFamily && ( <>
{t('terminal.themeModal.globalFont')}
onFontFamilyReset?.()} /> )}
)} {activeTab === 'custom' && !editingTheme && (
{customThemes.length > 0 && ( <>
{t('terminal.customTheme.yourThemes')}
{customThemes.map(theme => ( ))} )}
)}
{/* Font Size Control (only in font tab) */} {activeTab === 'font' && (
{t('terminal.themeModal.fontSize')}
{canResetFontSize && ( )}
{currentFontSize} px
)} {/* Font Weight Control (only in font tab) */} {activeTab === 'font' && (
{t('terminal.themeModal.fontWeight')}
{canResetFontWeight && ( )}
)} {/* Current selection info */}
{footerLabel}
{/* Custom Theme Editor Modal */} {editingTheme && ( { if (isNewTheme) { addTheme(theme); onThemeChange(theme.id); } else { updateTheme(theme.id, theme); if (currentThemeId === theme.id) { onThemeChange(theme.id); } } setEditingTheme(null); setIsNewTheme(false); }} onDelete={isNewTheme ? undefined : handleEditorDelete} onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }} /> )} ); }; export const ThemeSidePanel = memo(ThemeSidePanelInner); ThemeSidePanel.displayName = 'ThemeSidePanel';