Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
627 lines
25 KiB
TypeScript
627 lines
25 KiB
TypeScript
/**
|
|
* 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;
|
|
}) => (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => 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 */}
|
|
<div
|
|
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
|
|
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
|
|
>
|
|
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
|
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
|
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium truncate">
|
|
{theme.name}
|
|
</div>
|
|
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{theme.type}
|
|
{theme.isCustom && ' • custom'}
|
|
</div>
|
|
</div>
|
|
{onEdit && (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) => { 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)' }}
|
|
>
|
|
<Pencil size={10} />
|
|
</div>
|
|
)}
|
|
{isSelected && !onEdit && (
|
|
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
|
)}
|
|
</div>
|
|
));
|
|
ThemeItem.displayName = 'ThemeItem';
|
|
|
|
// Memoized font item component
|
|
const FontItem = memo(({
|
|
font,
|
|
isSelected,
|
|
onSelect
|
|
}: {
|
|
font: TerminalFont;
|
|
isSelected: boolean;
|
|
onSelect: (id: string) => void;
|
|
}) => (
|
|
<button
|
|
onClick={() => onSelect(font.id)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
|
|
)}
|
|
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';
|
|
}}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className="text-xs font-medium truncate"
|
|
style={{ fontFamily: font.family }}
|
|
>
|
|
{font.name}
|
|
</div>
|
|
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
|
|
</div>
|
|
{isSelected && (
|
|
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
|
)}
|
|
</button>
|
|
));
|
|
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<ThemeSidePanelProps> = ({
|
|
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<TabType>('theme');
|
|
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
|
const [isNewTheme, setIsNewTheme] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<>
|
|
<div
|
|
className="h-full flex flex-col overflow-hidden"
|
|
style={{
|
|
...panelVars,
|
|
backgroundColor: 'var(--terminal-panel-bg)',
|
|
color: 'var(--terminal-panel-fg)',
|
|
borderColor: 'var(--terminal-panel-border)',
|
|
}}
|
|
>
|
|
{/* Tab Bar */}
|
|
<div
|
|
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
|
|
style={{ borderColor: 'var(--terminal-panel-border)' }}
|
|
>
|
|
<button
|
|
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
|
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
|
style={{
|
|
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
|
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
|
}}
|
|
>
|
|
<Palette size={12} />
|
|
{t('terminal.themeModal.tab.theme')}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('font')}
|
|
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
|
style={{
|
|
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
|
|
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
|
}}
|
|
>
|
|
<Type size={12} />
|
|
{t('terminal.themeModal.tab.font')}
|
|
</button>
|
|
{!followAppTerminalTheme && (
|
|
<button
|
|
onClick={() => setActiveTab('custom')}
|
|
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
|
style={{
|
|
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
|
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
|
}}
|
|
>
|
|
<Sparkles size={12} />
|
|
{t('terminal.themeModal.tab.custom')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* List Content */}
|
|
<ScrollArea className="flex-1 min-h-0">
|
|
<div className="py-1">
|
|
{activeTab === 'theme' && (
|
|
<div>
|
|
{!followAppTerminalTheme && hiddenSelectedTheme && (
|
|
<ThemeItem
|
|
theme={hiddenSelectedTheme}
|
|
isSelected={currentThemeId === hiddenSelectedTheme.id && !editingTheme}
|
|
onSelect={handleThemeSelect}
|
|
/>
|
|
)}
|
|
{builtinThemes.map(theme => (
|
|
<ThemeItem
|
|
key={theme.id}
|
|
theme={theme}
|
|
isSelected={currentThemeId === theme.id && !editingTheme}
|
|
onSelect={handleThemeSelect}
|
|
/>
|
|
))}
|
|
{!followAppTerminalTheme && customThemes.length > 0 && (
|
|
<>
|
|
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.customTheme.section')}
|
|
</div>
|
|
{customThemes.map(theme => (
|
|
<ThemeItem
|
|
key={theme.id}
|
|
theme={theme}
|
|
isSelected={currentThemeId === theme.id && !editingTheme}
|
|
onSelect={handleThemeSelect}
|
|
onEdit={handleEditTheme}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
{canResetTheme && (
|
|
<>
|
|
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.themeModal.globalTheme')}
|
|
</div>
|
|
<ThemeItem
|
|
theme={globalTheme}
|
|
isSelected={!canResetTheme}
|
|
onSelect={() => onThemeReset?.()}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeTab === 'font' && (
|
|
<div>
|
|
{availableFonts.map(font => (
|
|
<FontItem
|
|
key={font.id}
|
|
font={font}
|
|
isSelected={currentFontFamilyId === font.id}
|
|
onSelect={handleFontSelect}
|
|
/>
|
|
))}
|
|
{canResetFontFamily && (
|
|
<>
|
|
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.themeModal.globalFont')}
|
|
</div>
|
|
<FontItem
|
|
font={globalFont}
|
|
isSelected={!canResetFontFamily}
|
|
onSelect={() => onFontFamilyReset?.()}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeTab === 'custom' && !editingTheme && (
|
|
<div>
|
|
<button
|
|
onClick={handleNewTheme}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<div
|
|
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
|
|
color: 'var(--terminal-panel-fg)',
|
|
}}
|
|
>
|
|
<Plus size={12} />
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
|
|
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.newDesc')}</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={handleImportFile}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
>
|
|
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
|
|
<Download size={12} />
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
|
|
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
|
|
</div>
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".itermcolors"
|
|
onChange={handleFileSelected}
|
|
className="hidden"
|
|
/>
|
|
{customThemes.length > 0 && (
|
|
<>
|
|
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.customTheme.yourThemes')}
|
|
</div>
|
|
{customThemes.map(theme => (
|
|
<ThemeItem
|
|
key={theme.id}
|
|
theme={theme}
|
|
isSelected={currentThemeId === theme.id}
|
|
onSelect={handleThemeSelect}
|
|
onEdit={handleEditTheme}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Font Size Control (only in font tab) */}
|
|
{activeTab === 'font' && (
|
|
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
|
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.themeModal.fontSize')}
|
|
</div>
|
|
{canResetFontSize && (
|
|
<button
|
|
onClick={onFontSizeReset}
|
|
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
|
style={{ color: 'var(--terminal-panel-fg)' }}
|
|
>
|
|
{t('common.useGlobal')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
|
<button
|
|
onClick={() => handleFontSizeChange(-1)}
|
|
disabled={currentFontSize <= MIN_FONT_SIZE}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
|
style={{
|
|
backgroundColor: 'var(--terminal-panel-bg)',
|
|
color: 'var(--terminal-panel-fg)',
|
|
borderColor: 'var(--terminal-panel-border)',
|
|
}}
|
|
>
|
|
<Minus size={12} />
|
|
</button>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
|
|
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>px</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleFontSizeChange(1)}
|
|
disabled={currentFontSize >= MAX_FONT_SIZE}
|
|
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
|
style={{
|
|
backgroundColor: 'var(--terminal-panel-bg)',
|
|
color: 'var(--terminal-panel-fg)',
|
|
borderColor: 'var(--terminal-panel-border)',
|
|
}}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Font Weight Control (only in font tab) */}
|
|
{activeTab === 'font' && (
|
|
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
|
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{t('terminal.themeModal.fontWeight')}
|
|
</div>
|
|
{canResetFontWeight && (
|
|
<button
|
|
onClick={onFontWeightReset}
|
|
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
|
style={{ color: 'var(--terminal-panel-fg)' }}
|
|
>
|
|
{t('common.useGlobal')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
|
<Select
|
|
value={String(currentFontWeight)}
|
|
onValueChange={(v) => onFontWeightChange(Number(v))}
|
|
>
|
|
<SelectTrigger
|
|
className="flex-1 h-7 text-xs"
|
|
style={{
|
|
backgroundColor: 'var(--terminal-panel-bg)',
|
|
color: 'var(--terminal-panel-fg)',
|
|
borderColor: 'var(--terminal-panel-border)',
|
|
}}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="100">100 Thin</SelectItem>
|
|
<SelectItem value="200">200 ExtraLight</SelectItem>
|
|
<SelectItem value="300">300 Light</SelectItem>
|
|
<SelectItem value="400">400 Normal</SelectItem>
|
|
<SelectItem value="500">500 Medium</SelectItem>
|
|
<SelectItem value="600">600 SemiBold</SelectItem>
|
|
<SelectItem value="700">700 Bold</SelectItem>
|
|
<SelectItem value="800">800 ExtraBold</SelectItem>
|
|
<SelectItem value="900">900 Black</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Current selection info */}
|
|
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
|
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
|
|
{footerLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Theme Editor Modal */}
|
|
{editingTheme && (
|
|
<CustomThemeModal
|
|
open={!!editingTheme}
|
|
theme={editingTheme}
|
|
isNew={isNewTheme}
|
|
onSave={(theme) => {
|
|
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';
|