import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Terminal as XTermTerminal } from '@xterm/xterm' import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit' import { useTranslation, type TranslationKey } from '../i18n' import { terminalApi } from '../api/terminal' import { useSettingsStore } from '../stores/settingsStore' import { Dropdown } from '../components/shared/Dropdown' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { DesktopTerminalStartupShell } from '../types/settings' type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' const STATUS_LABEL_KEYS: Record = { idle: 'settings.terminal.status.idle', starting: 'settings.terminal.status.starting', running: 'settings.terminal.status.running', exited: 'settings.terminal.status.exited', error: 'settings.terminal.status.error', unavailable: 'settings.terminal.status.unavailable', } type TerminalSettingsProps = { active?: boolean cwd?: string onNewTerminal?: () => void onOpenInTab?: () => void onClose?: () => void testId?: string workspace?: boolean docked?: boolean showPreferences?: boolean } export function TerminalSettings({ active = true, cwd, onNewTerminal, onOpenInTab, onClose, testId = 'settings-terminal-host', workspace = false, docked = false, showPreferences = false, }: TerminalSettingsProps = {}) { const t = useTranslation() const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const hostRef = useRef(null) const terminalRef = useRef(null) const fitRef = useRef(null) const sessionIdRef = useRef(null) const unlistenRef = useRef void>>([]) const [status, setStatus] = useState(() => terminalApi.isAvailable() ? 'idle' : 'unavailable') const [error, setError] = useState(null) const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null) const [startupShell, setStartupShell] = useState(desktopTerminal?.startupShell ?? 'system') const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '') const [preferencesError, setPreferencesError] = useState(null) const [preferencesSaved, setPreferencesSaved] = useState(false) const [preferencesSaving, setPreferencesSaving] = useState(false) const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent) useEffect(() => { setStartupShell(desktopTerminal?.startupShell ?? 'system') setCustomShellPath(desktopTerminal?.customShellPath ?? '') }, [desktopTerminal]) useEffect(() => { if (!preferencesSaved) return const timer = window.setTimeout(() => setPreferencesSaved(false), 2500) return () => window.clearTimeout(timer) }, [preferencesSaved]) const shellItems = useMemo(() => [ { value: 'system' as const, label: t('settings.terminal.shell.system'), description: t('settings.terminal.shell.systemDesc'), }, { value: 'pwsh' as const, label: t('settings.terminal.shell.pwsh'), description: t('settings.terminal.shell.pwshDesc'), }, { value: 'powershell' as const, label: t('settings.terminal.shell.powershell'), description: t('settings.terminal.shell.powershellDesc'), }, { value: 'cmd' as const, label: t('settings.terminal.shell.cmd'), description: t('settings.terminal.shell.cmdDesc'), }, { value: 'custom' as const, label: t('settings.terminal.shell.custom'), description: t('settings.terminal.shell.customDesc'), }, ], [t]) const resizeSession = useCallback(() => { const terminal = terminalRef.current const fit = fitRef.current const sessionId = sessionIdRef.current if (!terminal || !fit) return fit.fit() if (sessionId) { void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {}) } }, []) const startTerminal = useCallback(async () => { if (!terminalApi.isAvailable()) { setStatus('unavailable') return } const host = hostRef.current if (!host) return setError(null) setStatus('starting') setShellInfo(null) const existing = sessionIdRef.current if (existing) { await terminalApi.kill(existing).catch(() => {}) sessionIdRef.current = null } unlistenRef.current.forEach((unlisten) => unlisten()) unlistenRef.current = [] terminalRef.current?.dispose() fitRef.current = null host.innerHTML = '' const [{ Terminal }, { FitAddon }] = await Promise.all([ import('@xterm/xterm'), import('@xterm/addon-fit'), ]) const terminal = new Terminal({ cursorBlink: true, convertEol: false, fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace", fontSize: 12, lineHeight: 1.25, scrollback: 4000, theme: { background: '#121212', foreground: '#d7d2d0', cursor: '#ffb59f', selectionBackground: '#5f4a40', black: '#1f1f1f', red: '#ff6d67', green: '#7ef18a', yellow: '#f8c55f', blue: '#77a8ff', magenta: '#d699ff', cyan: '#61d6d6', white: '#d7d2d0', brightBlack: '#8f8683', brightRed: '#ff8a85', brightGreen: '#9ff7a7', brightYellow: '#ffdd7a', brightBlue: '#a6c5ff', brightMagenta: '#e3b8ff', brightCyan: '#8ceeee', brightWhite: '#ffffff', }, }) const fit = new FitAddon() terminal.loadAddon(fit) terminal.open(host) terminalRef.current = terminal fitRef.current = fit fit.fit() const outputUnlisten = await terminalApi.onOutput((payload) => { if (payload.session_id === sessionIdRef.current) { terminal.write(payload.data) } }) const exitUnlisten = await terminalApi.onExit((payload) => { if (payload.session_id !== sessionIdRef.current) return setStatus('exited') const signal = payload.signal ? `, ${payload.signal}` : '' terminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`) sessionIdRef.current = null }) unlistenRef.current = [outputUnlisten, exitUnlisten] terminal.onData((data) => { const sessionId = sessionIdRef.current if (sessionId) { void terminalApi.write(sessionId, data).catch((err) => { setError(err instanceof Error ? err.message : String(err)) setStatus('error') }) } }) try { const result = await terminalApi.spawn({ cols: terminal.cols, rows: terminal.rows, ...(cwd ? { cwd } : {}), }) sessionIdRef.current = result.session_id setShellInfo({ shell: result.shell, cwd: result.cwd }) setStatus('running') resizeSession() } catch (err) { outputUnlisten() exitUnlisten() terminal.dispose() terminalRef.current = null fitRef.current = null setError(err instanceof Error ? err.message : String(err)) setStatus('error') } }, [cwd, resizeSession]) useEffect(() => { if (!terminalApi.isAvailable()) return void startTerminal() const observer = new ResizeObserver(() => resizeSession()) if (hostRef.current) observer.observe(hostRef.current) return () => { observer.disconnect() const sessionId = sessionIdRef.current if (sessionId) { void terminalApi.kill(sessionId).catch(() => {}) } terminalRef.current?.dispose() terminalRef.current = null fitRef.current = null unlistenRef.current.forEach((unlisten) => unlisten()) unlistenRef.current = [] sessionIdRef.current = null } }, [resizeSession, startTerminal]) useEffect(() => { if (active) { requestAnimationFrame(() => resizeSession()) } }, [active, resizeSession]) const clearTerminal = () => { terminalRef.current?.clear() } const savePreferences = async () => { setPreferencesError(null) setPreferencesSaved(false) const trimmedPath = customShellPath.trim() if (startupShell === 'custom') { if (!trimmedPath) { setPreferencesError(t('settings.terminal.customPathRequired')) return } if (!/^[A-Za-z]:[\\/]/.test(trimmedPath)) { setPreferencesError(t('settings.terminal.customPathAbsolute')) return } } setPreferencesSaving(true) try { await setDesktopTerminal({ startupShell, customShellPath: trimmedPath, }) setPreferencesSaved(true) } catch (err) { setPreferencesError(err instanceof Error ? err.message : String(err)) } finally { setPreferencesSaving(false) } } return (

{t('settings.terminal.title')}

{!docked && (

{t('settings.terminal.description')}

)}
{onOpenInTab && ( )} {onNewTerminal && ( )} {onClose && ( )}
{shellInfo && ( <> {shellInfo.shell} / {shellInfo.cwd} )}
{error && (
{error}
)} {showPreferences && isWindows && ( <>

{t('settings.terminal.preferencesTitle')}

{t('settings.terminal.preferencesBody')}

{t('settings.terminal.startupShell')} items={shellItems} value={startupShell} onChange={(value) => { setStartupShell(value) setPreferencesError(null) setPreferencesSaved(false) }} width="100%" trigger={ } />
{startupShell === 'custom' && ( { setCustomShellPath(event.target.value) setPreferencesError(null) setPreferencesSaved(false) }} error={preferencesError ?? undefined} /> )} {preferencesError && startupShell !== 'custom' && (

{preferencesError}

)}
{preferencesSaved && ( {t('settings.terminal.saveShellSuccess')} )}
)} {status === 'unavailable' ? (
desktop_windows

{t('settings.terminal.unavailableTitle')}

{t('settings.terminal.unavailableBody')}

) : (
{t('settings.terminal.windowTitle')}
)}
) } function StatusPill({ status, label }: { status: TerminalStatus; label: string }) { const color = status === 'running' ? 'bg-[var(--color-success)]' : status === 'error' ? 'bg-[var(--color-error)]' : status === 'starting' ? 'bg-[var(--color-warning)]' : 'bg-[var(--color-text-tertiary)]' return ( {label} ) } function BashPathSettings({ isTauri }: { isTauri: boolean }) { const t = useTranslation() const [bashPath, setBashPath] = useState(null) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [invalid, setInvalid] = useState(false) useEffect(() => { if (!isTauri) return void terminalApi.getBashPath().then((path) => setBashPath(path)).catch(() => {}) }, [isTauri]) const handleSave = async () => { const trimmed = bashPath?.trim() || null setSaving(true) setInvalid(false) setSaved(false) try { await terminalApi.setBashPath(trimmed) setBashPath(trimmed) setSaved(true) setTimeout(() => setSaved(false), 2000) } catch { setInvalid(true) } finally { setSaving(false) } } const handleReset = async () => { setSaving(true) setSaved(false) setInvalid(false) try { await terminalApi.setBashPath(null) setBashPath(null) setSaved(true) setTimeout(() => setSaved(false), 2000) } catch { // ignore } finally { setSaving(false) } } const handleBrowse = async () => { if (!isTauri) return try { const { open } = await import('@tauri-apps/plugin-dialog') const selected = await open({ title: t('settings.terminal.bashPathLabel'), multiple: false, filters: [{ name: 'Bash Executable', extensions: ['exe', '', 'bat', 'cmd', 'ps1'], }], }) if (selected && typeof selected === 'string') { setBashPath(selected) setInvalid(false) } } catch { // user cancelled } } if (!isTauri) return null return (

{t('settings.terminal.bashPathDescription')}

{ setBashPath(e.target.value); setInvalid(false); setSaved(false) }} placeholder={t('settings.terminal.bashPathLabel')} className="flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-mono text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)]" />
{invalid && (

{t('settings.terminal.bashPathInvalid')}

)}
) }