Files
Netcatty/components/HostDetailsConnectionSections.tsx
T
陈大猫 6b9b71ac8a feat(scripts): terminal script automation, recording, and side panel (#1763)
* feat(scripts): add terminal script automation, recording, and side panel

Introduce nct script runtime in the main process with terminal-side recording,
execution overlay, Monaco editor, connect-script queues, and scripts side panel
integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt
for root/user shells, and save recorded scripts through the global editor dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Bugbot review findings for recording and side panel

Refresh script run state in side panel memo, guard toolbar recording against
multi-session conflicts, reset connect scripts on reconnect, resolve focused
session via getActiveTerminalSessionId, and avoid wiping host edit form when
snippets list updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): prevent pending script re-run and improve workspace run feedback

Track one-shot pendingScript execution separately from onConnect queue so
reconnects do not replay Run-now scripts. Fall back to focused session when
running on all tabs outside a workspace, and toast when every tab is still
connecting.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): consume connect-script window once and filter serial hosts

Mark the onConnect script evaluation as consumed even when the queue is
empty so later snippet/host edits do not rerun automation mid-session.
Restore serial-host filtering in SelectHostPanel confirm handler.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): grace period before giving up on connect-script queue

Only mark onConnect automation as consumed after scripts run or a short
post-connect grace window expires, so late-loading snippets still trigger
while mid-session Vault edits do not.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): gate connect-script consumption on vault hydration

Replace the grace timer with vault-init and unresolved host script binding
checks so late-loading snippets still run onConnect automation while
mid-session edits stay suppressed. Clear pending script run state on
disconnect so reconnect retries workspace pending scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): honor run targets and retry failed connect automation

Route QuickScriptEditorDialog Run through onRunSnippet for multi-host
workspaces. Defer connect/pending script completion markers until each
script succeeds and retry the queue after failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): stabilize pending runs and global connect queue sync

Keep pending script run state across reconnects, run onConnect scripts
before one-shot workspace pending scripts, and strip per-host queue entries
when an onConnect script targets all hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): defer host queue snippet sync and guard connect runs

Persist snippet target links only when the host panel is saved, block
duplicate connect-script scheduling while a batch is running, and require
runnable hosts before showing Run in the snippets editor footer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): retry connect failures and surface output-trigger errors

Use a retry tick in connect-script effect deps, toast automation failures
from output triggers, and require runnable hosts before QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): track id-less pending runs and re-arm output triggers

Remember anonymous pendingScript objects after first run and allow
onOutput triggers to fire again when new terminal output arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): unblock paused connect runs and pass session meta

Resolve waitForScriptRun on pause, run workspace scripts per session
with hostname context, defer output-trigger arming until runs start, and
add noRunnableHosts i18n for QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): serialize output triggers and defer empty connect finalize

Fire at most one onOutput trigger per scan while a run is active, and
only finalize an empty connect queue after vault snippets are loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): guard output launch race and script run waits

Block overlapping onOutput launches, timeout stuck waitForScriptRun
promises, and disable vault Run when no connectable targets exist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): match output triggers on new chunks only

Scan onOutput patterns against recent terminal output instead of buffer
length dedupe, and restore overflow-visible on SelectHostPanel new-host flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): await sequential workspace runs and output launch

Wait for each tab script to finish in sequential workspace mode, and hold
output-trigger launch guard until the session run becomes active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): wait through pause and hold output trigger until done

Keep connect and sequential workspace queues blocked until scripts
complete or fail, and retain output-trigger guard until the launched run
finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden output trigger wait helpers

Add explicit timeouts to output-trigger wait helpers and stop rescanning
the buffer after script completion to avoid sticky-match rerun loops.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): allow empty connect queue and match output in new chunks

Treat an empty connectScriptIds array as an explicit cleared queue instead
of re-migrating from snippet targets, and only fire onOutput triggers when
the latest terminal output contains the pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): require fresh output overlap for onOutput triggers

Only fire output-matched scripts when the regex match extends into the
latest terminal chunk, preventing reruns from stale buffer text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): promote manual scripts added to host connect queue

Diff connect queue changes from raw connectScriptIds so saving a host
updates manual scripts to onConnect when they are newly queued.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): dedupe output triggers by match position

Track the buffer end offset of each output trigger match and ignore
stale re-scans, with a longer wait for run registration before retry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): record output trigger matches only after run starts

Propagate automation launch failures to output triggers and commit match
dedupe only once the session run is registered as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Codex review on wait cursor and sensitive codegen

Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden onOutput triggers against PTY echo and local input

Filter server output before pattern matching so onOutput fires only after
Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or
alternate-screen apps. Add dev diagnostics and scope triggers to the active
session when no explicit host targets are configured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(scripts): remove onOutput trigger debug logging

Drop temporary diagnostics module and console traces now that PTY echo
filtering is verified; keep the production trigger pipeline unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:48:12 +08:00

651 lines
29 KiB
TypeScript

import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
import type { Host } from "../types";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Switch } from "./ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HostDetailsConnectionSectionsProps = Record<string, any>;
export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectionsProps> = ({
t,
form,
update,
groupDefaults,
selectedIdentity,
clearIdentity,
identities,
identitySuggestionsOpen,
filteredIdentitySuggestions,
setIdentitySuggestionsOpen,
availableKeys,
applyIdentity,
showPassword,
setShowPassword,
pendingReferenceKeyPath,
setPendingReferenceKeyPath,
selectedCredentialType,
setSelectedCredentialType,
credentialPopoverOpen,
setCredentialPopoverOpen,
keysByCategory,
newKeyFilePath,
setNewKeyFilePath,
addLocalKeyFilePath,
distroOptions,
effectiveFormDistro,
getDistroOptionLabel,
}) => {
return (
<>
<HostDetailsSection
icon={<MapPin size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.address")}
>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 w-full"
/>
</HostDetailsSection>
<HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.portCredentials")}
className="overflow-hidden"
>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
<span className="text-xs text-muted-foreground">SSH on</span>
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
<Input
type="number"
value={form.port ?? ""}
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
className="h-8 flex-1 min-w-0 text-center"
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.port")}
</span>
</div>
</div>
</div>
<div className="grid gap-2">
{selectedIdentity ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
<User size={16} className="text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{selectedIdentity.label}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : form.identityId ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
<User size={16} className="text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{t("hostDetails.identity.missing")}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : (
(() => {
const hasIdentities = identities.length > 0;
if (!hasIdentities) {
return (
<Input
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => update("username", e.target.value)}
className="h-10"
/>
);
}
return (
<Popover
open={
identitySuggestionsOpen &&
filteredIdentitySuggestions.length > 0
}
onOpenChange={setIdentitySuggestionsOpen}
>
<PopoverTrigger asChild>
<div className="relative">
<Input
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => {
const next = e.target.value;
update("username", next);
const q = next.toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
onFocus={() => {
const q = (form.username || "").toLowerCase().trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
setIdentitySuggestionsOpen(matches.length > 0);
}}
className="h-10 pr-9"
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
>
<ChevronDown size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 border-border/60"
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{filteredIdentitySuggestions.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("common.noResultsFound")}
</div>
) : (
<div className="space-y-1">
{filteredIdentitySuggestions.map((identity) => {
const keyLabel = identity.keyId
? availableKeys.find(
(k) => k.id === identity.keyId,
)?.label
: undefined;
const methodLabel =
identity.authMethod === "certificate"
? t("hostDetails.credential.certificate")
: identity.authMethod === "key"
? t("hostDetails.credential.key")
: t("keychain.identity.method.passwordOnly");
const summaryParts = [
identity.username,
identity.password ? "******" : undefined,
keyLabel,
].filter(Boolean);
return (
<button
key={identity.id}
type="button"
className="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-secondary/80 transition-colors text-left"
onMouseDown={(e) => {
e.preventDefault();
applyIdentity(identity);
}}
>
<div className="h-8 w-8 rounded-md bg-green-500/15 text-green-500 flex items-center justify-center shrink-0">
<User size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{identity.label}
</div>
<div className="text-xs text-muted-foreground truncate">
{methodLabel}
{summaryParts.length
? ` - ${summaryParts.join(", ")}`
: ""}
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
);
})()
)}
{!selectedIdentity && !form.identityId && (
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</TooltipTrigger>
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
</Tooltip>
</div>
)}
{/* Save Password toggle - shown when password is entered */}
{!selectedIdentity && !form.identityId && form.password && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
{t("hostDetails.password.save")}
</span>
<Switch
checked={form.savePassword ?? true}
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
/>
</div>
)}
{/* Local key file paths display */}
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
{keyPath}
</span>
</TooltipTrigger>
<TooltipContent>{keyPath}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
if (keyPath === pendingReferenceKeyPath) {
setPendingReferenceKeyPath(null);
}
}}
>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 min-w-0 overflow-hidden p-2 rounded-md bg-secondary/50 border border-border/60">
{form.authMethod === "certificate" ? (
<Shield size={14} className="text-primary shrink-0" />
) : (
<Key size={14} className="text-primary shrink-0" />
)}
<span className="text-sm min-w-0 flex-1 truncate">
{availableKeys.find((k) => k.id === form.identityFileId)
?.label || "Key"}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
update("identityFileId", undefined);
update("authMethod", "password");
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
>
<X size={12} />
</Button>
</div>
)}
{/* Credential type selection with inline popover - hidden when credential is selected */}
{!selectedIdentity &&
!form.identityFileId &&
!selectedCredentialType && (
<Popover
open={credentialPopoverOpen}
onOpenChange={setCredentialPopoverOpen}
>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors py-1"
>
<Plus size={12} />
<span>{t("hostDetails.credential.keyCertificate")}</span>
</button>
</PopoverTrigger>
<PopoverContent
className="w-[200px] p-1"
align="start"
sideOffset={4}
>
<div className="space-y-0.5">
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("key");
setCredentialPopoverOpen(false);
}}
>
<Key size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.key")}
</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("certificate");
setCredentialPopoverOpen(false);
}}
>
<Shield size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.certificate")}
</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("localKeyFile");
setCredentialPopoverOpen(false);
}}
>
<FileKey size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.localKeyFile")}
</span>
</button>
</div>
</PopoverContent>
</Popover>
)}
{/* Key selection combobox - appears after selecting "Key" type */}
{!selectedIdentity &&
selectedCredentialType === "key" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.key.map((k) => ({
value: k.id,
label: k.label,
sublabel: `${k.type}${k.keySize ? ` ${k.keySize}` : ""}`,
icon: <Key size={14} className="text-muted-foreground" />,
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
emptyText={t("hostDetails.keys.empty")}
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
{/* Certificate selection combobox - appears after selecting "Certificate" type */}
{!selectedIdentity &&
selectedCredentialType === "certificate" &&
!form.identityFileId && (
<div className="flex items-center gap-1">
<Combobox
options={keysByCategory.certificate.map((k) => ({
value: k.id,
label: k.label,
icon: (
<Shield size={14} className="text-muted-foreground" />
),
}))}
value={form.identityFileId}
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
emptyText={t("hostDetails.certs.empty")}
icon={
<Shield size={14} className="text-muted-foreground" />
}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</div>
)}
{/* Local key file path input - appears after selecting "Local Key File" type */}
{!selectedIdentity &&
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
addLocalKeyFilePath(newKeyFilePath);
}
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setSelectedCredentialType(null);
setNewKeyFilePath("");
}}
>
<X size={14} />
</Button>
</div>
</div>
)}
</div>
</HostDetailsSection>
<HostDetailsSection
icon={<FolderLock size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.sftp")}
>
<HostDetailsSettingRow
label={t("hostDetails.sftp.sudo")}
hint={t("hostDetails.sftp.sudo.desc")}
>
<Switch
checked={form.sftpSudo || false}
onCheckedChange={(val) => update("sftpSudo", val)}
/>
</HostDetailsSettingRow>
{form.sftpSudo && !form.password && !selectedIdentity?.password && (
<p className="text-xs text-amber-500">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<HostDetailsSettingRow
label={t("hostDetails.sftp.encoding")}
hint={t("hostDetails.sftp.encoding.desc")}
>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-10 w-32">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
<HostDetailsSection
icon={<Shapes size={14} className="text-muted-foreground" />}
title={t("hostDetails.icon.sectionTitle")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
previewHost={form as Host}
distroMode={form.distroMode}
manualDistro={form.manualDistro}
effectiveDistro={effectiveFormDistro}
distroOptions={distroOptions}
getDistroOptionLabel={getDistroOptionLabel}
iconMode={form.iconMode}
iconId={form.iconId}
iconColorMode={form.iconColorMode}
iconColor={form.iconColor}
iconColorCustom={form.iconColorCustom}
onChange={(next) => {
if ("distroMode" in next) update("distroMode", next.distroMode);
if ("manualDistro" in next) update("manualDistro", next.manualDistro);
if ("iconMode" in next) update("iconMode", next.iconMode);
if ("iconId" in next) update("iconId", next.iconId);
if ("iconColorMode" in next) update("iconColorMode", next.iconColorMode);
if ("iconColor" in next) update("iconColor", next.iconColor);
if ("iconColorCustom" in next) update("iconColorCustom", next.iconColorCustom);
}}
/>
</HostDetailsSection>
</>
);
};