import { useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useSettingsStore } from '../../stores/settingsStore' import { useKeybindingStore } from '../../stores/keybindingStore' import type { AppLanguage } from '../../i18n/detectLocale' import { Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X, RotateCcw, AlertTriangle, Copy, Check, Sparkles, } from 'lucide-react' import AiProviderSettings from './AiProviderSettings' import { themes, type TerminalThemeId } from '../../themes/terminal' import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings' // ─── Tab 类型 ─────────────────────────────────────────────── type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about' interface TabMeta { id: SettingsTab labelKey: string descriptionKey: string icon: React.ComponentType<{ className?: string }> } interface RuntimeInfo { appVersion: string os: string arch: string nativeBackend: string sshBackend: string dataPath: string credentialPath: string } const TAB_LIST: TabMeta[] = [ { id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor }, { id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette }, { id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal }, { id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard }, { id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield }, { id: 'security', labelKey: 'nav.security', descriptionKey: 'nav.securityDesc', icon: Database }, { id: 'ai', labelKey: 'nav.ai', descriptionKey: 'nav.aiDesc', icon: Sparkles }, { id: 'about', labelKey: 'nav.about', descriptionKey: 'nav.aboutDesc', icon: Info }, ] // ─── 快捷键录入 Hook ───────────────────────────────────────── // 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P") function useKeyRecording() { const [recordingId, setRecordingId] = useState(null) const [recordedKeys, setRecordedKeys] = useState('') const startRecording = useCallback((actionId: KeybindingActionId) => { setRecordingId(actionId) setRecordedKeys('') }, []) const stopRecording = useCallback(() => { setRecordingId(null) setRecordedKeys('') }, []) // 全局键盘监听 useEffect(() => { if (!recordingId) return const handleKeyDown = (e: KeyboardEvent) => { e.preventDefault() e.stopPropagation() // Escape 取消录入 if (e.key === 'Escape') { stopRecording() return } // 忽略单独的修饰键 if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return const parts: string[] = [] if (e.ctrlKey || e.metaKey) parts.push('Ctrl') if (e.shiftKey) parts.push('Shift') if (e.altKey) parts.push('Alt') // 主键 let mainKey = e.key if (mainKey === ' ') mainKey = 'Space' else if (mainKey === 'Tab') mainKey = 'Tab' else if (mainKey.length === 1) mainKey = mainKey.toUpperCase() parts.push(mainKey) const keySequence = parts.join('+') setRecordedKeys(keySequence) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) }, [recordingId, stopRecording]) return { recordingId, recordedKeys, startRecording, stopRecording } } // ─── 主组件 ───────────────────────────────────────────────── export default function SettingsView() { const { t } = useTranslation(['settings', 'common']) const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore() const keybindingState = useKeybindingStore() const [activeTab, setActiveTab] = useState('general') const [searchQuery, setSearchQuery] = useState('') const [runtimeInfo, setRuntimeInfo] = useState(null) const [copiedField, setCopiedField] = useState(null) const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording() useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps // Runtime facts come from Rust so diagnostics match the installed build. useEffect(() => { if (window.opsxApi) { window.opsxApi .appRuntimeInfo() .then(setRuntimeInfo) .catch(() => undefined) } }, []) const copyText = useCallback(async (label: string, text: string) => { try { await navigator.clipboard.writeText(text) setCopiedField(label) setTimeout(() => setCopiedField(null), 1500) } catch { // ignore } }, []) // 当前 tab 元信息 const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab]) // ── 搜索过滤 ────────────────────────────────────────────── const filtered = useMemo((): SettingsTab[] | null => { if (!searchQuery.trim()) return null const q = searchQuery.toLowerCase() const keywords: Record = { general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'], appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'], terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'], ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'], shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'], ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'], security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'], about: [t('nav.about'), 'version', 'about', 'license'], } const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][]) .filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k))) .map(([id]) => id) return matchIds.length > 0 ? matchIds : [] }, [searchQuery, t]) // 如果搜索过滤后有结果,自动跳转到第一个匹配 tab useEffect(() => { if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) { setActiveTab(filtered[0]) } }, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST const appVersion = runtimeInfo?.appVersion ?? '—' const diagnosticText = runtimeInfo ? [ `OpsXShell v${runtimeInfo.appVersion}`, `${runtimeInfo.os} ${runtimeInfo.arch}`, `Data: ${runtimeInfo.dataPath}`, ].join('\n') : `OpsXShell v${appVersion}` // ── 加载态 ──────────────────────────────────────────────── if (!isLoaded) { return (
{t('common:status.loading')}
) } // ── 渲染内容区 ──────────────────────────────────────────── const renderContent = () => { switch (activeTab) { // ──────── 常规 ───────── case 'general': return (
setSetting('sidebarWidth', Number(e.target.value))} className="w-28 accent-blue-500" aria-label={t('general.sidebarWidth')} /> {settings.sidebarWidth}px
) // ──────── 外观 ───────── case 'appearance': return (
) // ──────── 终端 ───────── case 'terminal': return (
{settings.fontSize || 14}px
setSetting('scrollback', Math.max(1000, Number(e.target.value) || 10000))} className="ui-input-sm w-24 text-center" /> setSetting('keywordHighlightEnabled', value)} />
{Object.entries(themes).map(([key, theme]) => ( ))}
) case 'ai': return // ──────── 快捷键 ─────── case 'shortcuts': { const allKeys = keybindingState.getAllEffective() const categories = getKeybindingCategories() const hasCustom = Object.keys(keybindingState.customKeybindings).length > 0 // 检查录入的键是否与当前录制匹配 const recordedConflict = recordedKeys && recordingId ? keybindingState.getConflicts(recordingId, recordedKeys) : [] return (
{/* 重置全部按钮 */} {hasCustom && (
)} {categories.map((category) => { const defs = getKeybindingsByCategory(category) return (

{t(`shortcuts.category.${category}`)}

{defs.map((def) => { const currentKeys = allKeys[def.id] const isCustom = keybindingState.customKeybindings[def.id] !== undefined const isRecordingThis = recordingId === def.id return (
{t(def.labelKey)} {isCustom && ( {t('shortcuts.custom')} )}
{isRecordingThis ? (
{recordedKeys || t('shortcuts.pressKeys')} {recordedKeys && recordedConflict.length > 0 && (
{t('shortcuts.conflict', { label: t(recordedConflict[0].labelKey) })}
)}
) : ( <> {isCustom && ( )} )}
) })}
) })} {/* 录入确认弹层 */} {recordingId && recordedKeys && (
{t('shortcuts.setTo', { keys: recordedKeys })}
)}

{t('shortcuts.hint')}

) } // ──────── SSH ────────── case 'ssh': return (
setSetting('keepAlive', v)} /> setSetting('autoReconnect', v)} /> setSetting('timeout', parseInt(e.target.value) || 30)} className="ui-input-sm w-20 text-center" /> setSetting('maxReconnectAttempts', parseInt(e.target.value) || 5)} className="ui-input-sm w-20 text-center" /> setSetting('maxConnections', parseInt(e.target.value) || 10)} className="ui-input-sm w-20 text-center" /> setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} className="ui-input-sm w-20 text-center" /> setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} className="ui-input-sm w-20 text-center" />
) // ──────── 安全 ───────── case 'security': return (
{t('security.enabled')} {runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
) // ──────── 关于 ───────── case 'about': return (

OpsXShell

{t('about.tagline')}

{t('about.appVersion', { version: appVersion })}
{t('about.runtime')}
{t('about.system')} {runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
{t('about.localData')}
{t('about.sessionsSettings')}
{runtimeInfo?.dataPath ?? t('common:status.reading')}
{t('about.masterKeyPath')}
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}

{t('about.privacyNote')}

{t('about.license')}

) default: return null } } // ── 主布局 ──────────────────────────────────────────────── return (
{/* ── 左侧导航 ── */} {/* ── 右侧内容区 ── */}

{t(currentMeta.labelKey)}

{t(currentMeta.descriptionKey)}

{renderContent()}
) } // ─── 子组件 ───────────────────────────────────────────────── function KnownHostsSettingsCard() { const { t } = useTranslation(['settings', 'common']) const [entries, setEntries] = useState>( [] ) const [loading, setLoading] = useState(false) const load = useCallback(async () => { setLoading(true) try { const result = await window.opsxApi?.knownHostsList?.() if (result?.success && result.entries) { setEntries(result.entries) } } finally { setLoading(false) } }, []) useEffect(() => { void load() }, [load]) const handleRemove = async (host: string, port: number) => { await window.opsxApi?.knownHostsRemove?.(host, port) await load() } const handleClear = async () => { if (!confirm(t('security.clearConfirm'))) return await window.opsxApi?.knownHostsClear?.() await load() } return (

{t('security.knownHostsHint')}

{loading ? (
{t('common:status.loading')}
) : entries.length === 0 ? (
{t('security.empty')}
) : (
{entries.map((e) => (
{e.host}:{e.port} ({e.keyType})
SHA256:{e.fingerprint}
))}
)}
) } /** 分组卡片:多行设置 */ function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) { return (
{title &&

{title}

}
{children}
) } /** 卡片容器(快捷键等复用) */ function SettingCard({ children, highlight = false }: { children: React.ReactNode; highlight?: boolean }) { return (
{children}
) } /** 区域小标题 */ function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) { return
{children}
} /** 左文右控行 */ function SettingRow({ label, description, children, }: { label: string description?: string children: React.ReactNode }) { return (
{label}
{description &&
{description}
}
{children}
) } /** 开关设置项(行内) */ function SettingToggle({ label, description, checked, onChange, }: { label: string description: string checked: boolean onChange: (value: boolean) => void }) { return (
{label}
{description}
) }