Files
NetMesh/components/HostDetailsAdvancedSections.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

811 lines
34 KiB
TypeScript

import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore";
import { clearHostFontSizeOverride, clearHostThemeOverride } from "../domain/terminalAppearance";
import { resolveSshAgentToggleUpdate } from "../domain/sshAuth";
import { MAX_FONT_SIZE, MIN_FONT_SIZE } from "../infrastructure/config/fonts";
import { AlgorithmOverridesPanel } from "./host-details/AlgorithmOverridesPanel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { HostDetailsSection, HostDetailsSettingRow, HostDetailsOverrideReset } from "./host-details";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import {
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
MAX_SSH_CONNECTION_TIMEOUT_SECONDS,
} from "../domain/sshConnectionTimeouts";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HostDetailsAdvancedSectionsProps = Record<string, any>;
const ToggleRow: React.FC<{ label: string; hint?: React.ReactNode; enabled: boolean; disabled?: boolean; onToggle: () => void }> = ({ label, hint, enabled, disabled, onToggle }) => {
return (
<HostDetailsSettingRow label={label} hint={hint}>
<Switch checked={enabled} disabled={disabled} onCheckedChange={() => onToggle()} />
</HostDetailsSettingRow>
);
};
export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsProps> = ({
t,
form,
setForm,
update,
effectiveThemeId,
hasEffectiveThemeOverride,
effectiveFontSize,
hasEffectiveFontSizeOverride,
sshAgentStatus,
sshForwardingAgentStatus,
effectiveGroupDefaults,
effectiveAuthMethod,
showAlgorithmOverrides,
setShowAlgorithmOverrides,
chainedHosts,
setActiveSubPanel,
clearHostChain,
proxySummaryType,
proxySummaryLabel,
proxySummaryTooltip,
clearProxyConfig,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
const systemSshAgentSupported = effectiveAuthMethod === "auto" || effectiveAuthMethod === "key";
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
? form.useSshAgent !== false
: effectiveAuthMethod === "key" && form.useSshAgent === true;
return (
<>
<HostDetailsSection
icon={<Palette size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.appearance")}
>
{/* SSH Theme Selection */}
<div className="flex w-full items-center gap-1 rounded-lg bg-secondary/50 p-2 transition-colors hover:bg-secondary">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => setActiveSubPanel("theme-select")}
>
<div
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden shrink-0"
style={{
backgroundColor:
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
</div>
</div>
</div>
<span className="text-sm flex-1 truncate">
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasEffectiveThemeOverride && (
<HostDetailsOverrideReset
label={t("common.useGlobal")}
onClick={(event) => {
event.stopPropagation();
setForm((prev) => clearHostThemeOverride(prev));
}}
/>
)}
</div>
{/* Font Size */}
<HostDetailsSettingRow label="Font Size">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => {
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="h-8 w-8 px-0"
>
-
</Button>
<Input
type="number"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
value={effectiveFontSize}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: val,
fontSizeOverride: true,
}));
}
}}
className="h-8 w-16 text-center"
/>
<span className="text-sm text-muted-foreground">pt</span>
<Button
variant="outline"
size="sm"
onClick={() => {
if (effectiveFontSize < MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize + 1,
fontSizeOverride: true,
}));
}
}}
disabled={effectiveFontSize >= MAX_FONT_SIZE}
className="h-8 w-8 px-0"
>
+
</Button>
</div>
{hasEffectiveFontSizeOverride && (
<HostDetailsOverrideReset
size="sm"
label={t("common.useGlobal")}
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
/>
)}
</div>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
icon={<Wifi size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.mosh")}
>
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
onToggle={() => {
const enabling = !form.moshEnabled;
if (enabling) {
setForm(prev => ({
...prev,
moshEnabled: true,
etEnabled: false,
x11Forwarding: undefined,
}));
} else {
update("moshEnabled", false);
}
}}
/>
</HostDetailsSection>
<HostDetailsSection
icon={<Wifi size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.et")}
>
<ToggleRow
label="EternalTerminal"
enabled={!!form.etEnabled}
onToggle={() => {
const enabling = !form.etEnabled;
if (enabling) {
setForm(prev => ({
...prev,
etEnabled: true,
moshEnabled: false,
x11Forwarding: undefined,
}));
} else {
update("etEnabled", false);
}
}}
/>
{form.etEnabled && (
<>
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
<Input
type="number"
className="h-8 w-28"
placeholder="2022"
value={form.etPort ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
update("etPort", v === "" ? undefined : Number(v));
}}
/>
</HostDetailsSettingRow>
</>
)}
</HostDetailsSection>
{/* System SSH Agent login */}
<HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.systemSshAgent")}
>
<ToggleRow
label={t("hostDetails.systemSshAgent")}
hint={t("hostDetails.systemSshAgent.desc")}
enabled={systemSshAgentEnabled}
disabled={!systemSshAgentSupported}
onToggle={() => setForm((previous: typeof form) => {
const enabled = effectiveAuthMethod === "auto"
? previous.useSshAgent !== false
: effectiveAuthMethod === "key" && previous.useSshAgent === true;
const enabling = !enabled;
return {
...previous,
// Automatic mode treats the ambient agent as an optional first
// choice. Keep the unset state when re-enabling that default so
// an unavailable agent still falls back to local keys/password.
...resolveSshAgentToggleUpdate(previous, effectiveAuthMethod, enabling),
};
})}
/>
{systemSshAgentEnabled && (
<>
<HostDetailsSettingRow
label={t("hostDetails.systemSshAgent.socket")}
hint={t("hostDetails.systemSshAgent.socket.desc")}
>
<Input
className="h-8 w-44 font-mono text-xs"
placeholder="$SSH_AUTH_SOCK"
value={form.identityAgent ?? ""}
onChange={(event) => setForm((previous: typeof form) => ({
...previous,
useSshAgent: true,
identityAgent: event.target.value || undefined,
}))}
/>
</HostDetailsSettingRow>
<ToggleRow
label={t("hostDetails.systemSshAgent.identitiesOnly")}
hint={t("hostDetails.systemSshAgent.identitiesOnly.desc")}
enabled={!!form.identitiesOnly}
onToggle={() => setForm((previous: typeof form) => ({
...previous,
useSshAgent: true,
identitiesOnly: !previous.identitiesOnly,
}))}
/>
</>
)}
{systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{t("hostDetails.systemSshAgent.notRunning")}
</p>
<p className="text-xs text-muted-foreground">
{t("hostDetails.systemSshAgent.notRunningHint")}
</p>
</div>
</div>
)}
</HostDetailsSection>
{/* Agent Forwarding */}
<HostDetailsSection
icon={<Forward size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.agentForwarding")}
>
<ToggleRow
label={t("hostDetails.agentForwarding")}
hint={t("hostDetails.agentForwarding.desc")}
enabled={!!form.agentForwarding}
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
{form.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus.running && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs text-yellow-600 dark:text-yellow-400 font-medium">
{t("hostDetails.agentForwarding.agentNotRunning")}
</p>
<p className="text-xs text-muted-foreground">
{t("hostDetails.agentForwarding.agentNotRunningHint")}
</p>
</div>
</div>
)}
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")}
>
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!form.x11Forwarding}
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
/>
</HostDetailsSection>
)}
{(!form.protocol || ['ssh', 'telnet', 'mosh', 'et'].includes(form.protocol)) && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}
>
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (<>
<ToggleRow
label={t("hostDetails.deviceType")}
hint={t("hostDetails.deviceType.desc")}
enabled={effectiveDeviceType === 'network'}
onToggle={() => update("deviceType", effectiveDeviceType === 'network' ? 'general' : 'network')}
/>
{effectiveDeviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</>)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group w-full justify-between h-8 px-2 hover:bg-accent/50"
aria-label={t("hostDetails.os.title")}
>
<span className="text-xs font-medium text-muted-foreground">{t("hostDetails.os.title")}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{t(`hostDetails.os.${getHostOsSelection(form)}`)}
<ChevronDown size={14} className="group-data-[state=open]:rotate-180" />
</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<HostDetailsSettingRow label={t("hostDetails.os.selection")} hint={t("hostDetails.os.desc")}>
<Select value={getHostOsSelection(form)} onValueChange={(value) => update("osOverride", value)}>
<SelectTrigger className="h-8 w-36 text-xs" aria-label={t("hostDetails.os.selection")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOST_OS_SELECTIONS.map((value) => (
<SelectItem key={value} value={value}>{t(`hostDetails.os.${value}`)}</SelectItem>
))}
</SelectContent>
</Select>
</HostDetailsSettingRow>
<p className="text-xs text-muted-foreground">
{t("hostDetails.os.current")}: {t(`hostDetails.os.${resolveHostOs({ ...form, deviceType: effectiveDeviceType })}`)}
</p>
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
)}
{/* SSH Algorithms */}
<HostDetailsSection
icon={<ShieldAlert size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.sshAlgorithms")}
>
{/* Display the *effective* value of these toggles (host field
falling back to the resolved group default). Without the
fallback a host that inherits the flag from its group would
show "off" while the runtime applied it anyway, and the
toggle's onToggle handler would compute the wrong "next"
value from the raw host field. */}
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
hint={t("hostDetails.legacyAlgorithms.desc")}
enabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
onToggle={() => update(
"legacyAlgorithms",
!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms),
)}
/>
{(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms) && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.legacyAlgorithms.warning")}
</p>
</div>
)}
<ToggleRow
label={t("hostDetails.skipEcdsaHostKey")}
hint={t("hostDetails.skipEcdsaHostKey.desc")}
enabled={!!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey)}
onToggle={() => update(
"skipEcdsaHostKey",
!(form.skipEcdsaHostKey ?? effectiveGroupDefaults?.skipEcdsaHostKey),
)}
/>
<Collapsible open={showAlgorithmOverrides} onOpenChange={setShowAlgorithmOverrides}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between h-8 px-2 hover:bg-accent/50"
>
<span className="text-xs font-medium text-muted-foreground">
{t("hostDetails.algorithms.advanced")}
{form.algorithms && Object.keys(form.algorithms).length > 0 && (
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
({t("hostDetails.algorithms.customized")})
</span>
)}
</span>
{showAlgorithmOverrides
? <ChevronUp size={14} className="text-muted-foreground" />
: <ChevronDown size={14} className="text-muted-foreground" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<AlgorithmOverridesPanel
value={form.algorithms}
/* Use the effective legacy flag (host value falling back to
the currently selected group's default) so the seed
reflects what the host would actually advertise. We
read from `effectiveGroupDefaults` (re-resolved on
every form.group change), not the `groupDefaults` prop
— otherwise switching the host into a different group
without saving first would seed from the original
group's flag and silently mis-populate the override. */
legacyEnabled={!!(form.legacyAlgorithms ?? effectiveGroupDefaults?.legacyAlgorithms)}
inheritedFromGroup={effectiveGroupDefaults?.algorithms}
onChange={(next) => update("algorithms", next)}
/>
</CollapsibleContent>
</Collapsible>
</HostDetailsSection>
{/* Terminal Behavior — input/output key mappings (backspace, etc.) */}
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.terminalBehavior")}
>
<ToggleRow
label={t("hostDetails.lineTimestamps")}
hint={t("hostDetails.lineTimestamps.desc")}
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
{/* Per-host keepalive override */}
<HostDetailsSection
icon={<HeartPulse size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.keepalive")}
>
<ToggleRow
label={t("hostDetails.keepalive.override")}
hint={t("hostDetails.keepalive.desc")}
enabled={!!form.keepaliveOverride}
onToggle={() => {
const next = !form.keepaliveOverride;
update("keepaliveOverride", next);
// Seed sensible per-host defaults the first time the user
// turns the override on so the inputs aren't empty.
if (next) {
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
}
}}
/>
{form.keepaliveOverride && (
<div className="space-y-2 pt-1">
<HostDetailsSettingRow label={t("hostDetails.keepalive.interval")}>
<Input
type="number"
min={0}
max={3600}
className="h-8 w-24 text-xs"
value={form.keepaliveInterval ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 0 || v > 3600) return;
update("keepaliveInterval", v);
}}
/>
</HostDetailsSettingRow>
<HostDetailsSettingRow label={t("hostDetails.keepalive.countMax")}>
<Input
type="number"
min={1}
max={100}
className="h-8 w-24 text-xs"
value={form.keepaliveCountMax ?? 3}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 1 || v > 100) return;
update("keepaliveCountMax", v);
}}
/>
</HostDetailsSettingRow>
{(form.keepaliveInterval ?? 0) === 0 && (
<p className="text-xs text-muted-foreground break-words pl-1">
{t("hostDetails.keepalive.disabledHint")}
</p>
)}
</div>
)}
</HostDetailsSection>
<HostDetailsSection
icon={<Timer size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.sshTimeouts")}
>
<HostDetailsSettingRow
label={t("hostDetails.sshTimeouts.tcpConnect")}
hint={t("hostDetails.sshTimeouts.tcpConnect.desc")}
>
<Input
type="number"
min={1}
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
className="h-8 w-20 text-xs"
aria-label={t("hostDetails.sshTimeouts.tcpConnect")}
value={form.sshTcpConnectTimeoutSeconds ?? DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS}
onChange={(e) => {
const value = Number.parseInt(e.target.value, 10);
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
update("sshTcpConnectTimeoutSeconds", value);
}}
/>
</HostDetailsSettingRow>
<HostDetailsSettingRow
label={t("hostDetails.sshTimeouts.authReady")}
hint={t("hostDetails.sshTimeouts.authReady.desc")}
>
<Input
type="number"
min={1}
max={MAX_SSH_CONNECTION_TIMEOUT_SECONDS}
className="h-8 w-20 text-xs"
aria-label={t("hostDetails.sshTimeouts.authReady")}
value={form.sshAuthReadyTimeoutSeconds ?? DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS}
onChange={(e) => {
const value = Number.parseInt(e.target.value, 10);
if (!Number.isFinite(value) || value < 1 || value > MAX_SSH_CONNECTION_TIMEOUT_SECONDS) return;
update("sshAuthReadyTimeoutSeconds", value);
}}
/>
</HostDetailsSettingRow>
</HostDetailsSection>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<HostDetailsSection
icon={<Link2 size={14} className="text-muted-foreground" />}
title={t("hostDetails.jumpHosts")}
action={
chainedHosts.length > 0 ? (
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
</Badge>
)
}
>
{chainedHosts.length > 0 && (
<button
className="w-full flex flex-col items-start gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("chain")}
>
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0 flex-1">
<Link2
size={14}
className="text-muted-foreground flex-shrink-0"
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</span>
</div>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearHostChain();
}}
/>
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
<div key={h.id} className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">{idx + 1}.</span>
<span className="truncate">
{h.label !== h.hostname ? `${h.hostname} (${h.label})` : h.hostname}
</span>
</div>
))}
{chainedHosts.length > 5 && (
<div className="text-xs text-muted-foreground">
+{chainedHosts.length - 5} more...
</div>
)}
</div>
</button>
)}
{chainedHosts.length === 0 && (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("chain")}
>
<Plus size={14} />
{t("hostDetails.jumpHosts.configure")}
</Button>
)}
</HostDetailsSection>
{/* Proxy Configuration */}
<HostDetailsSection
icon={<Globe size={14} className="text-muted-foreground" />}
title={t("hostDetails.proxy")}
className="overflow-hidden"
>
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{proxySummaryType}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{proxySummaryTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
aria-label={t("hostDetails.proxyPanel.remove")}
onClick={clearProxyConfig}
>
<X size={14} />
</Button>
</div>
) : (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("proxy")}
>
<Plus size={14} />
{t("hostDetails.proxy.configure")}
</Button>
)}
</HostDetailsSection>
{/* Environment Variables */}
<HostDetailsSection
icon={<Variable size={14} className="text-muted-foreground" />}
title={t("hostDetails.envVars")}
>
{(form.environmentVariables?.length || 0) > 0 ? (
<button
className="w-full flex items-center gap-1 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer"
onClick={() => setActiveSubPanel("env-vars")}
>
<span className="text-sm truncate">
{form.environmentVariables
?.slice(0, 2)
.map((v) => `${v.name}=${v.value}`)
.join(", ")}
{(form.environmentVariables?.length || 0) > 2 && "..."}
</span>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
onClick={(e) => {
e.stopPropagation();
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
}}
/>
</button>
) : (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("env-vars")}
>
<Plus size={14} />
{t("hostDetails.envVars.add")}
</Button>
)}
</HostDetailsSection>
{/* Startup Command */}
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.startupCommand")}
hint={t("hostDetails.startupCommand.help")}
>
<Textarea
placeholder={effectiveGroupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<HostDetailsSettingRow
label={t("hostDetails.startupCommand.runMode")}
hint={t("hostDetails.startupCommand.runMode.help")}
>
<Select
value={effectiveStartupCommandRunMode}
onValueChange={(value) => update(
"startupCommandRunMode",
value === inheritedStartupCommandRunMode ? undefined : value,
)}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
</>
);
};