459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
/**
|
|
* 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 { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
|
|
|
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,
|
|
} from './ui/aside-panel';
|
|
import { HostNotesEditor } from './host/HostNotesEditor';
|
|
|
|
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[];
|
|
onSave: (host: Host) => void;
|
|
onCancel: () => void;
|
|
layout?: AsidePanelLayout;
|
|
}
|
|
|
|
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<SerialHostDetailsPanelProps> = ({
|
|
initialData,
|
|
allTags = [],
|
|
groups = [],
|
|
onSave,
|
|
onCancel,
|
|
layout = 'overlay',
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const terminalBackend = useTerminalBackend();
|
|
const [ports, setPorts] = useState<SerialPort[]>([]);
|
|
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<SerialParity>(initialData.serialConfig?.parity || 'none');
|
|
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
|
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
|
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
|
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
|
const [tags, setTags] = useState<string[]>(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,
|
|
};
|
|
|
|
const portName = selectedPort.split('/').pop() || selectedPort;
|
|
const updatedHost: Host = {
|
|
...initialData,
|
|
label: label.trim() || `Serial: ${portName}`,
|
|
hostname: selectedPort,
|
|
port: baudRate,
|
|
tags,
|
|
group,
|
|
charset,
|
|
serialConfig: config,
|
|
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 (
|
|
<AsidePanel
|
|
open={true}
|
|
onClose={onCancel}
|
|
title={t('serial.edit.title')}
|
|
subtitle={initialData.label}
|
|
className="z-40"
|
|
layout={layout}
|
|
dataSection="serial-host-details-panel"
|
|
>
|
|
<AsidePanelContent>
|
|
{/* Label */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
|
<Input
|
|
id="serial-label"
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder={t('serial.field.configLabelPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Serial Port */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={loadPorts}
|
|
disabled={isLoadingPorts}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
{t('common.refresh')}
|
|
</Button>
|
|
</div>
|
|
<Combobox
|
|
options={portOptions}
|
|
value={selectedPort}
|
|
onValueChange={setSelectedPort}
|
|
placeholder={t('serial.field.selectPort')}
|
|
emptyText={t('serial.noPorts')}
|
|
allowCreate
|
|
createText={t('common.use')}
|
|
icon={<Usb size={14} className="text-muted-foreground" />}
|
|
/>
|
|
{!isPortValid && selectedPort && (
|
|
<p className="text-xs text-destructive">
|
|
{t('serial.field.customPortPlaceholder')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Baud Rate */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
|
<Combobox
|
|
options={BAUD_RATES.map((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) && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('serial.field.customBaudRate')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="space-y-2">
|
|
<Label className="flex items-center gap-2">
|
|
<Tag size={14} />
|
|
{t('hostDetails.tags')}
|
|
</Label>
|
|
<MultiCombobox
|
|
options={tagOptions}
|
|
values={tags}
|
|
onValuesChange={setTags}
|
|
placeholder={t('hostDetails.addTag')}
|
|
allowCreate
|
|
createText={t('hostDetails.createTag')}
|
|
/>
|
|
</div>
|
|
|
|
<HostNotesEditor
|
|
panelKey={initialData.id}
|
|
value={notes}
|
|
onChange={setNotes}
|
|
/>
|
|
|
|
{/* Group */}
|
|
<div className="space-y-2">
|
|
<Label>{t('hostDetails.group')}</Label>
|
|
<Combobox
|
|
options={groupOptions}
|
|
value={group}
|
|
onValueChange={setGroup}
|
|
placeholder={t('hostDetails.selectGroup')}
|
|
allowCreate
|
|
createText={t('hostDetails.createGroup')}
|
|
/>
|
|
</div>
|
|
|
|
{/* Advanced Options */}
|
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
|
>
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
{t('common.advanced')}
|
|
</span>
|
|
{showAdvanced ? (
|
|
<ChevronUp size={14} className="text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown size={14} className="text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-4 pt-2">
|
|
{/* Data Bits */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
|
<Select
|
|
value={String(dataBits)}
|
|
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
|
>
|
|
<SelectTrigger id="data-bits">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DATA_BITS.map((bits) => (
|
|
<SelectItem key={bits} value={String(bits)}>
|
|
{bits}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Stop Bits */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
|
<Select
|
|
value={String(stopBits)}
|
|
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
|
>
|
|
<SelectTrigger id="stop-bits">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{STOP_BITS.map((bits) => (
|
|
<SelectItem key={bits} value={String(bits)}>
|
|
{bits}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{isStopBits15 && (
|
|
<p className="text-xs text-yellow-500">
|
|
{t('serial.field.stopBits15Warning')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Parity */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
|
<Select
|
|
value={parity}
|
|
onValueChange={(v) => setParity(v as SerialParity)}
|
|
>
|
|
<SelectTrigger id="parity">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PARITY_OPTIONS.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{t(`serial.parity.${option}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Flow Control */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
|
<Select
|
|
value={flowControl}
|
|
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
|
>
|
|
<SelectTrigger id="flow-control">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FLOW_CONTROL_OPTIONS.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{t(`serial.flowControl.${option}`)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Terminal Options */}
|
|
<div className="space-y-3 pt-2 border-t border-border/60">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
|
{t('serial.field.localEcho')}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('serial.field.localEchoDesc')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
id="local-echo"
|
|
checked={localEcho}
|
|
onChange={(e) => setLocalEcho(e.target.checked)}
|
|
className="h-4 w-4 rounded border-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
|
{t('serial.field.lineMode')}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('serial.field.lineModeDesc')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
id="line-mode"
|
|
checked={lineMode}
|
|
onChange={(e) => setLineMode(e.target.checked)}
|
|
className="h-4 w-4 rounded border-input"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charset */}
|
|
<div className="space-y-1">
|
|
<Label htmlFor="serial-charset" className="text-sm font-medium">
|
|
{t('serial.field.charset')}
|
|
</Label>
|
|
<Input
|
|
id="serial-charset"
|
|
placeholder={t("hostDetails.charset.placeholder")}
|
|
value={charset}
|
|
onChange={(e) => setCharset(e.target.value)}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</AsidePanelContent>
|
|
|
|
<AsidePanelFooter>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
|
<Save size={14} className="mr-2" />
|
|
{t('common.save')}
|
|
</Button>
|
|
</div>
|
|
</AsidePanelFooter>
|
|
</AsidePanel>
|
|
);
|
|
};
|
|
|
|
export default SerialHostDetailsPanel;
|