/**
* 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 */}
{/* 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';