import React, { memo, useCallback, useMemo, useState } from "react"; import { applyCustomCssToDocument } from "../../../lib/customCss"; import { DebouncedTextarea } from "../DebouncedTextarea"; import { Check, HelpCircle, Monitor, Moon, Palette, Sun } from "lucide-react"; import { useI18n } from "../../../application/i18n/I18nProvider"; import { useStoredBoolean } from "../../../application/state/useStoredBoolean"; import { useStoredString } from "../../../application/state/useStoredString"; import { useStoredNumber } from "../../../application/state/useStoredNumber"; import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes"; import { useAvailableUIFonts } from "../../../application/state/uiFontStore"; import { useAvailableFonts } from "../../../application/state/fontStore"; import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n"; import { APP_ICON_ELF_SVG, APP_ICON_VARIANT_ASSET_PATH, APP_ICON_VARIANT_GROUPS, APP_ICON_VARIANT_I18N_KEY, APP_ICON_VARIANT_TILE } from "../../../infrastructure/config/appIconVariants"; import { STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, STORAGE_KEY_VAULT_NOTES_FONT_SIZE, STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, } from "../../../infrastructure/config/storageKeys"; import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant"; import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts"; import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport"; import { cn } from "../../../lib/utils"; import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui"; import { FontSelect } from "../FontSelect"; import { TerminalFontSelect } from "../TerminalFontSelect"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "../../ui/dialog"; import { LazyMessageResponse } from "../../ai-elements/LazyMessageResponse"; const CUSTOM_CSS_HELP_PROSE_CLASS = "text-xs text-foreground/90 leading-relaxed [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"; function SettingsAppearanceTab(props: { theme: "dark" | "light" | "system"; resolvedTheme: "dark" | "light"; setTheme: (theme: "dark" | "light" | "system") => void; lightUiThemeId: string; setLightUiThemeId: (themeId: string) => void; darkUiThemeId: string; setDarkUiThemeId: (themeId: string) => void; accentMode: "theme" | "custom"; setAccentMode: (mode: "theme" | "custom") => void; customAccent: string; setCustomAccent: (color: string) => void; uiFontFamilyId: string; setUiFontFamilyId: (fontId: string) => void; uiLanguage: string; setUiLanguage: (language: string) => void; customCSS: string; setCustomCSS: (css: string) => void; showRecentHosts: boolean; setShowRecentHosts: (enabled: boolean) => void; hostClickBehavior: "connect" | "select"; setHostClickBehavior: (behavior: "connect" | "select") => void; showOnlyUngroupedHostsInRoot: boolean; setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void; showSftpTab: boolean; setShowSftpTab: (enabled: boolean) => void; showHostTreeSidebar: boolean; setShowHostTreeSidebar: (enabled: boolean) => void; windowOpacity: number; setWindowOpacity: (opacity: number) => void; appIconVariant: AppIconVariant; setAppIconVariant: (variant: AppIconVariant) => void; }) { const { t } = useI18n(); const availableUIFonts = useAvailableUIFonts(); // Note code fonts come from the monospace-only store (fontStore); the note // body font follows the UI font setting instead. const availableMonoFonts = useAvailableFonts(); const noteFontOptions = useMemo(() => [ { id: "", name: t("notes.toolbar.defaultFont"), family: "", description: "", category: "monospace" as const }, ...availableMonoFonts, ], [availableMonoFonts, t]); const [customCssHelpOpen, setCustomCssHelpOpen] = useState(false); const [autoImportSystemKnownHosts, setAutoImportSystemKnownHosts] = useStoredBoolean( STORAGE_KEY_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS, ); const [noteFontFamily, setNoteFontFamily] = useStoredString( STORAGE_KEY_VAULT_NOTES_FONT_FAMILY, "", ); const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber( STORAGE_KEY_VAULT_NOTES_FONT_SIZE, 14, { min: 10, max: 32 }, ); const handleSetNoteFontSize = useCallback((size: number) => { setNoteFontSize(size); persistNoteFontSize(size); }, [persistNoteFontSize, setNoteFontSize]); const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber( STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE, 13, { min: 10, max: 32 }, ); const handleSetNoteCodeFontSize = useCallback((size: number) => { setNoteCodeFontSize(size); persistNoteCodeFontSize(size); }, [persistNoteCodeFontSize, setNoteCodeFontSize]); const { theme, resolvedTheme, setTheme, lightUiThemeId, setLightUiThemeId, darkUiThemeId, setDarkUiThemeId, accentMode, setAccentMode, customAccent, setCustomAccent, uiFontFamilyId, setUiFontFamilyId, uiLanguage, setUiLanguage, customCSS, setCustomCSS, showRecentHosts, setShowRecentHosts, hostClickBehavior, setHostClickBehavior, showOnlyUngroupedHostsInRoot, setShowOnlyUngroupedHostsInRoot, showSftpTab, setShowSftpTab, showHostTreeSidebar, setShowHostTreeSidebar, windowOpacity, setWindowOpacity, appIconVariant, setAppIconVariant, } = props; const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant); const WINDOW_OPACITY_PRESETS = [ { label: '100%', value: 1 }, { label: '85%', value: 0.85 }, { label: '70%', value: 0.7 }, ] as const; const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []); const hexToHsl = useCallback((hex: string) => { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0; let s = 0; const l = (max + min) / 2; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; case g: h = ((b - r) / d + 2) / 6; break; case b: h = ((r - g) / d + 4) / 6; break; } } return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`; }, []); const ACCENT_COLORS = [ { name: "Sky", value: "199 89% 48%" }, { name: "Blue", value: "221.2 83.2% 53.3%" }, { name: "Indigo", value: "234 89% 62%" }, { name: "Violet", value: "262.1 83.3% 57.8%" }, { name: "Purple", value: "271 81% 56%" }, { name: "Fuchsia", value: "292 84% 61%" }, { name: "Pink", value: "330 81% 60%" }, { name: "Rose", value: "346.8 77.2% 49.8%" }, { name: "Red", value: "0 84.2% 60.2%" }, { name: "Orange", value: "24.6 95% 53.1%" }, { name: "Amber", value: "38 92% 50%" }, { name: "Yellow", value: "48 96% 53%" }, { name: "Lime", value: "84 81% 44%" }, { name: "Green", value: "142.1 76.2% 36.3%" }, { name: "Emerald", value: "160 84% 39%" }, { name: "Teal", value: "173 80% 40%" }, { name: "Cyan", value: "189 94% 43%" }, { name: "Slate", value: "215 16% 47%" }, ]; const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [ { value: "light", icon: , label: t("settings.appearance.theme.light") }, { value: "system", icon: , label: t("settings.appearance.theme.system") }, { value: "dark", icon: , label: t("settings.appearance.theme.dark") }, ]; const renderThemeSwatches = ( options: { id: string; name: string; tokens: { background: string } }[], value: string, onChange: (next: string) => void, ) => (
{options.map((preset) => ( {preset.name} ))}
); const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES; const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId; const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId; return (
setWindowOpacity(Number(e.target.value) / 100)} className="w-28 accent-primary" /> {Math.round(windowOpacity * 100)}%
{WINDOW_OPACITY_PRESETS.map((preset) => ( ))}
{THEME_OPTIONS.map((opt) => ( ))}
{resolvedTheme === "dark" ? t("settings.appearance.themeColor.dark") : t("settings.appearance.themeColor.light")}
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
setAccentMode(checked ? "custom" : "theme")} />
{accentMode === "custom" && (
{t("settings.appearance.accentColor.custom")}
{ACCENT_COLORS.map((c) => ( {c.name} ))} {t("settings.appearance.customColor")}
)}

{t("settings.appearance.appIcon.desc")}

{APP_ICON_VARIANT_GROUPS.map((group) => (
{t(group.labelKey)}
{group.variants.map((variant) => ( {t(APP_ICON_VARIANT_I18N_KEY[variant])} ))}
))}
setHostClickBehavior(enabled ? 'select' : 'connect')} /> setNoteFontFamily(resolveNoteFontSelectionFamily(noteFontOptions, v))} className="w-48" ariaLabel={t('settings.vault.notesFont')} />
handleSetNoteFontSize(Number(e.target.value))} className="w-28 accent-primary" /> {noteFontSize}px
handleSetNoteCodeFontSize(Number(e.target.value))} className="w-28 accent-primary" /> {noteCodeFontSize}px

{t("settings.appearance.customCss")}

{t("settings.appearance.customCss.desc")}

{t("settings.appearance.customCss.help.title")}
{customCssHelpOpen ? ( {t("settings.appearance.customCss.help.body")} ) : null}
); } export default memo(SettingsAppearanceTab);