/** * Serial Host Details Panel * A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel) */ import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useI18n } from '../application/i18n/I18nProvider'; import { useTerminalBackend } from '../application/state/useTerminalBackend'; import type { GroupConfig, Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models'; import { resolveSerialBackspaceFormValue, resolveSerialBackspaceOverrideOnSave, } from '../domain/serialBackspace'; import { Button } from './ui/button'; import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { AsidePanel, AsidePanelContent, AsidePanelFooter, type AsidePanelLayout, type AsidePanelResizeProps, } from './ui/aside-panel'; import { HostNotesEditor } from './host/HostNotesEditor'; import { cn } from '../lib/utils'; interface SerialPort { path: string; manufacturer: string; serialNumber: string; vendorId: string; productId: string; pnpId: string; type?: 'hardware' | 'pseudo' | 'custom'; } interface SerialHostDetailsPanelProps { initialData: Host; allTags?: string[]; groups?: string[]; groupDefaults?: Partial; onSave: (host: Host) => void; onCancel: () => void; layout?: AsidePanelLayout; className?: string; } type SerialHostDetailsPanelPropsWithResize = SerialHostDetailsPanelProps & AsidePanelResizeProps; const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]; const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8]; const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2]; const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space']; const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts']; export const SerialHostDetailsPanel: React.FC = ({ initialData, allTags = [], groups = [], groupDefaults, onSave, onCancel, layout = 'overlay', className, resizable, persistWidthStorageKey, resizeAriaLabel, }) => { const { t } = useI18n(); const terminalBackend = useTerminalBackend(); const [ports, setPorts] = useState([]); const [isLoadingPorts, setIsLoadingPorts] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); // Form state const [label, setLabel] = useState(initialData.label); const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || ''); const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200); const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8); const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1); const [parity, setParity] = useState(initialData.serialConfig?.parity || 'none'); const [flowControl, setFlowControl] = useState(initialData.serialConfig?.flowControl || 'none'); const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false); const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false); const [backspaceBehavior, setBackspaceBehavior] = useState( resolveSerialBackspaceFormValue(initialData, groupDefaults), ); const [backspaceBehaviorChanged, setBackspaceBehaviorChanged] = useState(false); const [charset, setCharset] = useState(initialData.charset || 'UTF-8'); const [tags, setTags] = useState(initialData.tags || []); const [group, setGroup] = useState(initialData.group || ''); const [notes, setNotes] = useState(initialData.notes ?? ''); const loadPorts = useCallback(async () => { setIsLoadingPorts(true); try { const result = await terminalBackend.listSerialPorts(); setPorts(result); } catch (err) { console.error('[Serial] Failed to list ports:', err); } finally { setIsLoadingPorts(false); } }, [terminalBackend]); useEffect(() => { loadPorts(); }, [loadPorts]); const handleSave = () => { if (!selectedPort) return; const config: SerialConfig = { path: selectedPort, baudRate, dataBits, stopBits, parity, flowControl, localEcho, lineMode, backspaceBehavior: resolveSerialBackspaceOverrideOnSave({ initialHost: initialData, selectedBehavior: backspaceBehavior, behaviorChanged: backspaceBehaviorChanged, }), }; const portName = selectedPort.split('/').pop() || selectedPort; const updatedHost: Host = { ...initialData, label: label.trim() || `Serial: ${portName}`, hostname: selectedPort, port: baudRate, tags, group, charset, serialConfig: config, backspaceBehavior: undefined, notes: notes.trim() || undefined, }; onSave(updatedHost); }; // Convert ports to Combobox options const portOptions: ComboboxOption[] = useMemo(() => { return ports.map((port) => ({ value: port.path, label: port.path, sublabel: port.manufacturer || undefined, })); }, [ports]); // Tag options for MultiCombobox const tagOptions: ComboboxOption[] = useMemo(() => { const allUniqueTags = new Set([...allTags, ...tags]); return Array.from(allUniqueTags).map((tag) => ({ value: tag, label: tag, })); }, [allTags, tags]); // Group options for Combobox const groupOptions: ComboboxOption[] = useMemo(() => { const allGroups = new Set(groups); if (group && !allGroups.has(group)) { allGroups.add(group); } return Array.from(allGroups).map((g) => ({ value: g, label: g, })); }, [groups, group]); // Validation const trimmedPort = selectedPort.trim(); const isPortValid = trimmedPort.startsWith('/dev/') || /^COM\d+$/i.test(trimmedPort) || /^\\\\\.\\COM\d+$/i.test(trimmedPort); const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0; const isValid = isPortValid && isBaudRateValid; // Check if using 1.5 stop bits (limited Windows support) const isStopBits15 = stopBits === 1.5; return ( {/* Label */}
setLabel(e.target.value)} placeholder={t('serial.field.configLabelPlaceholder')} />
{/* Serial Port */}
} /> {!isPortValid && selectedPort && (

{t('serial.field.customPortPlaceholder')}

)}
{/* Baud Rate */}
({ value: String(rate), label: String(rate), }))} value={String(baudRate)} onValueChange={(val) => { const parsed = parseInt(val, 10); if (!isNaN(parsed) && parsed > 0) { setBaudRate(parsed); } }} placeholder={t('serial.field.baudRatePlaceholder')} emptyText={t('serial.field.baudRateEmpty')} allowCreate createText={t('common.use')} /> {baudRate > 0 && !BAUD_RATES.includes(baudRate) && (

{t('serial.field.customBaudRate')}

)}
{/* Tags */}
{/* Group */}
{/* Advanced Options */} {/* Data Bits */}
{/* Stop Bits */}
{isStopBits15 && (

{t('serial.field.stopBits15Warning')}

)}
{/* Parity */}
{/* Flow Control */}
{/* Terminal Options */}

{t('serial.field.backspaceBehaviorDesc')}

{t('serial.field.localEchoDesc')}

setLocalEcho(e.target.checked)} className="h-4 w-4 rounded border-input" />

{t('serial.field.lineModeDesc')}

setLineMode(e.target.checked)} className="h-4 w-4 rounded border-input" />
{/* Charset */}
setCharset(e.target.value)} className="h-9" />
); }; export default SerialHostDetailsPanel;