import { zodResolver } from "@hookform/resolvers/zod"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button.tsx"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, } from "@/components/ui/form.tsx"; import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; import React, { useEffect, useRef, useState } from "react"; import { Switch } from "@/components/ui/switch.tsx"; import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; import { toast } from "sonner"; import { createSSHHost, getCredentials, getSSHHosts, updateSSHHost, enableAutoStart, disableAutoStart, getSnippets, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorView } from "@codemirror/view"; import type { StatsConfig } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { Checkbox } from "@/components/ui/checkbox.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx"; import { Slider } from "@/components/ui/slider.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion.tsx"; import { TERMINAL_THEMES, TERMINAL_FONTS, CURSOR_STYLES, BELL_STYLES, FAST_SCROLL_MODIFIERS, DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes"; import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"; import type { TerminalConfig } from "@/types"; import { Plus, X } from "lucide-react"; interface SSHHost { id: number; name: string; ip: string; port: number; username: string; folder: string; tags: string[]; pin: boolean; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; defaultPath: string; tunnelConnections: Array<{ sourcePort: number; endpointPort: number; endpointHost: string; maxRetries: number; retryInterval: number; autoStart: boolean; }>; statsConfig?: StatsConfig; terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; credentialId?: number; } interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: (updatedHost?: SSHHost) => void; } export function HostManagerEditor({ editingHost, onFormSubmit, }: SSHManagerHostEditorProps) { const { t } = useTranslation(); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); const [credentials, setCredentials] = useState< Array<{ id: number; username: string; authType: string }> >([]); const [snippets, setSnippets] = useState< Array<{ id: number; name: string; content: string }> >([]); const [snippetSearch, setSnippetSearch] = useState(""); const [authTab, setAuthTab] = useState< "password" | "key" | "credential" | "none" >("password"); const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( "upload", ); const isSubmittingRef = useRef(false); const [statusIntervalUnit, setStatusIntervalUnit] = useState< "seconds" | "minutes" >("seconds"); const [metricsIntervalUnit, setMetricsIntervalUnit] = useState< "seconds" | "minutes" >("seconds"); const ipInputRef = useRef(null); useEffect(() => { const fetchData = async () => { try { const [hostsData, credentialsData, snippetsData] = await Promise.all([ getSSHHosts(), getCredentials(), getSnippets(), ]); setCredentials(credentialsData); setSnippets(Array.isArray(snippetsData) ? snippetsData : []); const uniqueFolders = [ ...new Set( hostsData .filter((host) => host.folder && host.folder.trim() !== "") .map((host) => host.folder), ), ].sort(); const uniqueConfigurations = [ ...new Set( hostsData .filter((host) => host.name && host.name.trim() !== "") .map((host) => host.name), ), ].sort(); setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); } catch {} }; fetchData(); }, []); useEffect(() => { const handleCredentialChange = async () => { try { const hostsData = await getSSHHosts(); const uniqueFolders = [ ...new Set( hostsData .filter((host) => host.folder && host.folder.trim() !== "") .map((host) => host.folder), ), ].sort(); const uniqueConfigurations = [ ...new Set( hostsData .filter((host) => host.name && host.name.trim() !== "") .map((host) => host.name), ), ].sort(); setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); } catch {} }; window.addEventListener("credentials:changed", handleCredentialChange); return () => { window.removeEventListener("credentials:changed", handleCredentialChange); }; }, []); const formSchema = z .object({ name: z.string().optional(), ip: z.string().min(1), port: z.coerce.number().min(1).max(65535), username: z.string().min(1), folder: z.string().optional(), tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none"]), credentialId: z.number().optional().nullable(), overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), key: z.any().optional().nullable(), keyPassword: z.string().optional(), keyType: z .enum([ "auto", "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-dss", "ssh-rsa-sha2-256", "ssh-rsa-sha2-512", ]) .optional(), enableTerminal: z.boolean().default(true), enableTunnel: z.boolean().default(true), tunnelConnections: z .array( z.object({ sourcePort: z.coerce.number().min(1).max(65535), endpointPort: z.coerce.number().min(1).max(65535), endpointHost: z.string().min(1), maxRetries: z.coerce.number().min(0).max(100).default(3), retryInterval: z.coerce.number().min(1).max(3600).default(10), autoStart: z.boolean().default(false), }), ) .default([]), enableFileManager: z.boolean().default(true), defaultPath: z.string().optional(), statsConfig: z .object({ enabledWidgets: z .array( z.enum([ "cpu", "memory", "disk", "network", "uptime", "processes", "system", ]), ) .default(["cpu", "memory", "disk", "network", "uptime", "system"]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), metricsEnabled: z.boolean().default(true), metricsInterval: z.number().min(5).max(3600).default(30), }) .default({ enabledWidgets: [ "cpu", "memory", "disk", "network", "uptime", "system", ], statusCheckEnabled: true, statusCheckInterval: 30, metricsEnabled: true, metricsInterval: 30, }), terminalConfig: z .object({ cursorBlink: z.boolean(), cursorStyle: z.enum(["block", "underline", "bar"]), fontSize: z.number().min(8).max(24), fontFamily: z.string(), letterSpacing: z.number().min(-2).max(10), lineHeight: z.number().min(1.0).max(2.0), theme: z.string(), scrollback: z.number().min(1000).max(50000), bellStyle: z.enum(["none", "sound", "visual", "both"]), rightClickSelectsWord: z.boolean(), fastScrollModifier: z.enum(["alt", "ctrl", "shift"]), fastScrollSensitivity: z.number().min(1).max(10), minimumContrastRatio: z.number().min(1).max(21), backspaceMode: z.enum(["normal", "control-h"]), agentForwarding: z.boolean(), environmentVariables: z.array( z.object({ key: z.string(), value: z.string(), }), ), startupSnippetId: z.number().nullable(), autoMosh: z.boolean(), moshCommand: z.string(), }) .optional(), forceKeyboardInteractive: z.boolean().optional(), }) .superRefine((data, ctx) => { if (data.authType === "none") { return; } if (data.authType === "password") { if ( !data.password || (typeof data.password === "string" && data.password.trim() === "") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.passwordRequired"), path: ["password"], }); } } else if (data.authType === "key") { if ( !data.key || (typeof data.key === "string" && data.key.trim() === "") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.sshKeyRequired"), path: ["key"], }); } if (!data.keyType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.keyTypeRequired"), path: ["keyType"], }); } } else if (data.authType === "credential") { if ( !data.credentialId || (typeof data.credentialId === "string" && data.credentialId.trim() === "") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.credentialRequired"), path: ["credentialId"], }); } } data.tunnelConnections.forEach((connection, index) => { if ( connection.endpointHost && !sshConfigurations.includes(connection.endpointHost) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.mustSelectValidSshConfig"), path: ["tunnelConnections", index, "endpointHost"], }); } }); }); type FormData = z.infer; const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: "", ip: "", port: 22, username: "", folder: "", tags: [], pin: false, authType: "password" as const, credentialId: null, overrideCredentialUsername: false, password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: true, enableTunnel: true, enableFileManager: true, defaultPath: "/", tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, }, }); useEffect(() => { if (authTab === "credential") { const currentCredentialId = form.getValues("credentialId"); const overrideUsername = form.getValues("overrideCredentialUsername"); if (currentCredentialId && !overrideUsername) { const selectedCredential = credentials.find( (c) => c.id === currentCredentialId, ); if (selectedCredential) { form.setValue("username", selectedCredential.username); } } } }, [authTab, credentials, form]); useEffect(() => { if (editingHost) { const cleanedHost = { ...editingHost }; if (cleanedHost.credentialId && cleanedHost.key) { cleanedHost.key = undefined; cleanedHost.keyPassword = undefined; cleanedHost.keyType = undefined; } else if (cleanedHost.credentialId && cleanedHost.password) { cleanedHost.password = undefined; } else if (cleanedHost.key && cleanedHost.password) { cleanedHost.password = undefined; } const defaultAuthType = cleanedHost.credentialId ? "credential" : cleanedHost.key ? "key" : cleanedHost.password ? "password" : "none"; setAuthTab(defaultAuthType); let parsedStatsConfig = DEFAULT_STATS_CONFIG; try { if (cleanedHost.statsConfig) { parsedStatsConfig = typeof cleanedHost.statsConfig === "string" ? JSON.parse(cleanedHost.statsConfig) : cleanedHost.statsConfig; } } catch (error) { console.error("Failed to parse statsConfig:", error); } parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig }; const formData = { name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, username: cleanedHost.username || "", folder: cleanedHost.folder || "", tags: cleanedHost.tags || [], pin: Boolean(cleanedHost.pin), authType: defaultAuthType as "password" | "key" | "credential" | "none", credentialId: null, overrideCredentialUsername: Boolean( cleanedHost.overrideCredentialUsername, ), password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: Boolean(cleanedHost.enableTerminal), enableTunnel: Boolean(cleanedHost.enableTunnel), enableFileManager: Boolean(cleanedHost.enableFileManager), defaultPath: cleanedHost.defaultPath || "/", tunnelConnections: cleanedHost.tunnelConnections || [], statsConfig: parsedStatsConfig, terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), }; if (defaultAuthType === "password") { formData.password = cleanedHost.password || ""; } else if (defaultAuthType === "key") { formData.key = editingHost.id ? "existing_key" : editingHost.key; formData.keyPassword = cleanedHost.keyPassword || ""; formData.keyType = (cleanedHost.keyType as | "auto" | "ssh-rsa" | "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ecdsa-sha2-nistp384" | "ecdsa-sha2-nistp521" | "ssh-dss" | "ssh-rsa-sha2-256" | "ssh-rsa-sha2-512") || "auto"; } else if (defaultAuthType === "credential") { formData.credentialId = cleanedHost.credentialId || "existing_credential"; } form.reset(formData); } else { setAuthTab("password"); const defaultFormData = { name: "", ip: "", port: 22, username: "", folder: "", tags: [], pin: false, authType: "password" as const, credentialId: null, overrideCredentialUsername: false, password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: true, enableTunnel: true, enableFileManager: true, defaultPath: "/", tunnelConnections: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, }; form.reset(defaultFormData); } }, [editingHost?.id]); useEffect(() => { const focusTimer = setTimeout(() => { if (ipInputRef.current) { ipInputRef.current.focus(); } }, 300); return () => clearTimeout(focusTimer); }, [editingHost]); const onSubmit = async (data: FormData) => { try { isSubmittingRef.current = true; if (!data.name || data.name.trim() === "") { data.name = `${data.username}@${data.ip}`; } if (data.statsConfig) { const statusInterval = data.statsConfig.statusCheckInterval || 30; const metricsInterval = data.statsConfig.metricsInterval || 30; if (statusInterval < 5 || statusInterval > 3600) { toast.error(t("hosts.intervalValidation")); isSubmittingRef.current = false; return; } if (metricsInterval < 5 || metricsInterval > 3600) { toast.error(t("hosts.intervalValidation")); isSubmittingRef.current = false; return; } } const submitData: Record = { name: data.name, ip: data.ip, port: data.port, username: data.username, folder: data.folder || "", tags: data.tags || [], pin: Boolean(data.pin), authType: data.authType, overrideCredentialUsername: Boolean(data.overrideCredentialUsername), enableTerminal: Boolean(data.enableTerminal), enableTunnel: Boolean(data.enableTunnel), enableFileManager: Boolean(data.enableFileManager), defaultPath: data.defaultPath || "/", tunnelConnections: data.tunnelConnections || [], statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), }; submitData.credentialId = null; submitData.password = null; submitData.key = null; submitData.keyPassword = null; submitData.keyType = null; if (data.authType === "credential") { if ( data.credentialId === "existing_credential" && editingHost && editingHost.id ) { delete submitData.credentialId; } else { submitData.credentialId = data.credentialId; } } else if (data.authType === "password") { submitData.password = data.password; } else if (data.authType === "key") { if (data.key instanceof File) { const keyContent = await data.key.text(); submitData.key = keyContent; } else if (data.key === "existing_key") { delete submitData.key; } else { submitData.key = data.key; } submitData.keyPassword = data.keyPassword; submitData.keyType = data.keyType; } let savedHost; if (editingHost && editingHost.id) { savedHost = await updateSSHHost(editingHost.id, submitData); toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name })); } else { savedHost = await createSSHHost(submitData); toast.success(t("hosts.hostAddedSuccessfully", { name: data.name })); } if (savedHost && savedHost.id && data.tunnelConnections) { const hasAutoStartTunnels = data.tunnelConnections.some( (tunnel) => tunnel.autoStart, ); if (hasAutoStartTunnels) { try { await enableAutoStart(savedHost.id); } catch (error) { console.warn( `Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error, ); toast.warning( t("hosts.autoStartEnableFailed", { name: data.name }), ); } } else { try { await disableAutoStart(savedHost.id); } catch (error) { console.warn( `Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error, ); } } } if (onFormSubmit) { onFormSubmit(savedHost); } window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); const { refreshServerPolling } = await import("@/ui/main-axios.ts"); refreshServerPolling(); } catch { toast.error(t("hosts.failedToSaveHost")); } finally { isSubmittingRef.current = false; } }; const [tagInput, setTagInput] = useState(""); const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); const folderInputRef = useRef(null); const folderDropdownRef = useRef(null); const folderValue = form.watch("folder"); const filteredFolders = React.useMemo(() => { if (!folderValue) return folders; return folders.filter((f) => f.toLowerCase().includes(folderValue.toLowerCase()), ); }, [folderValue, folders]); const handleFolderClick = (folder: string) => { form.setValue("folder", folder); setFolderDropdownOpen(false); }; useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( folderDropdownRef.current && !folderDropdownRef.current.contains(event.target as Node) && folderInputRef.current && !folderInputRef.current.contains(event.target as Node) ) { setFolderDropdownOpen(false); } } if (folderDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [folderDropdownOpen]); const keyTypeOptions = [ { value: "auto", label: t("hosts.autoDetect") }, { value: "ssh-rsa", label: t("hosts.rsa") }, { value: "ssh-ed25519", label: t("hosts.ed25519") }, { value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") }, { value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") }, { value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") }, { value: "ssh-dss", label: t("hosts.dsa") }, { value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") }, { value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") }, ]; const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); const keyTypeButtonRef = useRef(null); const keyTypeDropdownRef = useRef(null); useEffect(() => { function onClickOutside(event: MouseEvent) { if ( keyTypeDropdownOpen && keyTypeDropdownRef.current && !keyTypeDropdownRef.current.contains(event.target as Node) && keyTypeButtonRef.current && !keyTypeButtonRef.current.contains(event.target as Node) ) { setKeyTypeDropdownOpen(false); } } document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, [keyTypeDropdownOpen]); const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean; }>({}); const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>( {}, ); const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null; }>({}); const getFilteredSshConfigs = (index: number) => { const value = form.watch(`tunnelConnections.${index}.endpointHost`); const currentHostName = form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`; let filtered = sshConfigurations.filter( (config) => config !== currentHostName, ); if (value) { filtered = filtered.filter((config) => config.toLowerCase().includes(value.toLowerCase()), ); } return filtered; }; const handleSshConfigClick = (config: string, index: number) => { form.setValue(`tunnelConnections.${index}.endpointHost`, config); setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); }; useEffect(() => { function handleSshConfigClickOutside(event: MouseEvent) { const openDropdowns = Object.keys(sshConfigDropdownOpen).filter( (key) => sshConfigDropdownOpen[parseInt(key)], ); openDropdowns.forEach((indexStr: string) => { const index = parseInt(indexStr); if ( sshConfigDropdownRefs.current[index] && !sshConfigDropdownRefs.current[index]?.contains( event.target as Node, ) && sshConfigInputRefs.current[index] && !sshConfigInputRefs.current[index]?.contains(event.target as Node) ) { setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); } }); } const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some( (open) => open, ); if (hasOpenDropdowns) { document.addEventListener("mousedown", handleSshConfigClickOutside); } else { document.removeEventListener("mousedown", handleSshConfigClickOutside); } return () => { document.removeEventListener("mousedown", handleSshConfigClickOutside); }; }, [sshConfigDropdownOpen]); return (
{t("hosts.general")} {t("hosts.terminal")} {t("hosts.tunnel")} {t("hosts.fileManager")} {t("hosts.statistics")} {t("hosts.connectionDetails")}
( {t("hosts.ipAddress")} { field.ref(e); ipInputRef.current = e; }} /> )} /> ( {t("hosts.port")} )} /> { const isCredentialAuth = authTab === "credential"; const hasCredential = !!form.watch("credentialId"); const overrideEnabled = !!form.watch( "overrideCredentialUsername", ); const shouldDisable = isCredentialAuth && hasCredential && !overrideEnabled; return ( {t("hosts.username")} ); }} />
{t("hosts.organization")}
( {t("hosts.name")} )} /> ( {t("hosts.folder")} setFolderDropdownOpen(true)} onChange={(e) => { field.onChange(e); setFolderDropdownOpen(true); }} /> {folderDropdownOpen && filteredFolders.length > 0 && (
{filteredFolders.map((folder) => ( ))}
)}
)} /> ( {t("hosts.tags")}
{field.value.map((tag: string, idx: number) => ( {tag} ))} setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === " " && tagInput.trim() !== "") { e.preventDefault(); if ( !field.value.includes(tagInput.trim()) ) { field.onChange([ ...field.value, tagInput.trim(), ]); } setTagInput(""); } else if ( e.key === "Backspace" && tagInput === "" && field.value.length > 0 ) { field.onChange(field.value.slice(0, -1)); } }} placeholder={t("hosts.addTagsSpaceToAdd")} />
)} /> ( {t("hosts.pin")} )} />
{t("hosts.authentication")} { const newAuthType = value as | "password" | "key" | "credential" | "none"; setAuthTab(newAuthType); form.setValue("authType", newAuthType); }} className="flex-1 flex flex-col h-full min-h-0" > {t("hosts.password")} {t("hosts.key")} {t("hosts.credential")} {t("hosts.none")} ( {t("hosts.password")} )} /> { setKeyInputMethod(value as "upload" | "paste"); if (value === "upload") { form.setValue("key", null); } else { form.setValue("key", ""); } }} className="w-full" > {t("hosts.uploadFile")} {t("hosts.pasteKey")} ( {t("hosts.sshPrivateKey")}
{ const file = e.target.files?.[0]; field.onChange(file || null); }} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
)} />
( {t("hosts.sshPrivateKey")} field.onChange(value)} placeholder={t( "placeholders.pastePrivateKey", )} theme={oneDark} className="border border-input rounded-md" minHeight="120px" basicSetup={{ lineNumbers: true, foldGutter: false, dropCursor: false, allowMultipleSelections: false, highlightSelectionMatches: false, searchKeymap: false, scrollPastEnd: false, }} extensions={[ EditorView.theme({ ".cm-scroller": { overflow: "auto", }, }), ]} /> )} />
( {t("hosts.keyPassword")} )} /> ( {t("hosts.keyType")}
{keyTypeDropdownOpen && (
{keyTypeOptions.map((opt) => ( ))}
)}
)} />
( { if ( credential && !form.getValues( "overrideCredentialUsername", ) ) { form.setValue( "username", credential.username, ); } }} /> {t("hosts.credentialDescription")} )} /> {form.watch("credentialId") && ( (
{t("hosts.overrideCredentialUsername")} {t("hosts.overrideCredentialUsernameDesc")}
)} /> )}
{t("hosts.noneAuthTitle")}
{t("hosts.noneAuthDescription")}
{t("hosts.noneAuthDetails")}
(
{t("hosts.forceKeyboardInteractive")} {t("hosts.forceKeyboardInteractiveDesc")}
)} />
( {t("hosts.enableTerminal")} {t("hosts.enableTerminalDesc")} )} /> {t("hosts.terminalCustomizationNotice")}

Terminal Customization

Appearance
( Theme Choose a color theme for the terminal )} /> ( Font Family Select the font to use in the terminal )} /> ( Font Size: {field.value}px field.onChange(value) } /> Adjust the terminal font size )} /> ( Letter Spacing: {field.value}px field.onChange(value) } /> Adjust spacing between characters )} /> ( Line Height: {field.value} field.onChange(value) } /> Adjust spacing between lines )} /> ( Cursor Style Choose the cursor appearance )} /> (
Cursor Blink Enable cursor blinking animation
)} />
Behavior ( Scrollback Buffer: {field.value} lines field.onChange(value) } /> Number of lines to keep in scrollback history )} /> ( Bell Style How to handle terminal bell (BEL character, \x07). Programs trigger this when completing tasks, encountering errors, or for notifications. "Sound" plays an audio beep, "Visual" flashes the screen briefly, "Both" does both, "None" disables bell alerts. )} /> (
Right Click Selects Word Right-clicking selects the word under cursor
)} /> ( Fast Scroll Modifier Modifier key for fast scrolling )} /> ( Fast Scroll Sensitivity: {field.value} field.onChange(value) } /> Scroll speed multiplier when modifier is held )} /> ( Minimum Contrast Ratio: {field.value} field.onChange(value) } /> Automatically adjust colors for better readability )} />
Advanced (
SSH Agent Forwarding Forward SSH authentication agent to remote host
)} /> ( Backspace Mode Backspace key behavior for compatibility )} /> ( Startup Snippet setSnippetSearch(e.target.value) } className="h-8" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} />
None {snippets .filter((snippet) => snippet.name .toLowerCase() .includes( snippetSearch.toLowerCase(), ), ) .map((snippet) => ( {snippet.name} ))} {snippets.filter((snippet) => snippet.name .toLowerCase() .includes(snippetSearch.toLowerCase()), ).length === 0 && snippetSearch && (
No snippets found
)}
Execute a snippet when the terminal connects )} /> (
Auto-MOSH Automatically run MOSH command on connect
)} /> {form.watch("terminalConfig.autoMosh") && ( ( MOSH Command The MOSH command to execute )} /> )}
Set custom environment variables for the terminal session {form .watch("terminalConfig.environmentVariables") ?.map((_, index) => (
( )} /> ( )} />
))}
( {t("hosts.enableTunnel")} {t("hosts.enableTunnelDesc")} )} /> {form.watch("enableTunnel") && ( <> {t("hosts.sshpassRequired")}
{t("hosts.sshpassRequiredDesc")}{" "} sudo apt install sshpass {" "} {t("hosts.debianUbuntuEquivalent")}
{t("hosts.otherInstallMethods")}
• {t("hosts.centosRhelFedora")}{" "} sudo yum install sshpass {" "} {t("hosts.or")}{" "} sudo dnf install sshpass
• {t("hosts.macos")}{" "} brew install hudochenkov/sshpass/sshpass
• {t("hosts.windows")}
{t("hosts.sshServerConfigRequired")}
{t("hosts.sshServerConfigDesc")}
•{" "} GatewayPorts yes {" "} {t("hosts.gatewayPortsYes")}
•{" "} AllowTcpForwarding yes {" "} {t("hosts.allowTcpForwardingYes")}
•{" "} PermitRootLogin yes {" "} {t("hosts.permitRootLoginYes")}
{t("hosts.editSshConfig")}
( {t("hosts.tunnelConnections")}
{field.value.map((connection, index) => (

{t("hosts.connection")} {index + 1}

( {t("hosts.sourcePort")} {t("hosts.sourcePortDesc")} )} /> ( {t("hosts.endpointPort")} )} /> ( {t("hosts.endpointSshConfig")} { sshConfigInputRefs.current[ index ] = el; }} placeholder={t( "placeholders.sshConfig", )} className="min-h-[40px]" autoComplete="off" value={endpointHostField.value} onFocus={() => setSshConfigDropdownOpen( (prev) => ({ ...prev, [index]: true, }), ) } onChange={(e) => { endpointHostField.onChange(e); setSshConfigDropdownOpen( (prev) => ({ ...prev, [index]: true, }), ); }} /> {sshConfigDropdownOpen[index] && getFilteredSshConfigs(index) .length > 0 && (
{ sshConfigDropdownRefs.current[ index ] = el; }} className="absolute top-full left-0 z-50 mt-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1" >
{getFilteredSshConfigs( index, ).map((config) => ( ))}
)}
)} />

{t("hosts.tunnelForwardDescription", { sourcePort: form.watch( `tunnelConnections.${index}.sourcePort`, ) || "22", endpointPort: form.watch( `tunnelConnections.${index}.endpointPort`, ) || "224", })}

( {t("hosts.maxRetries")} {t("hosts.maxRetriesDescription")} )} /> ( {t("hosts.retryInterval")} {t( "hosts.retryIntervalDescription", )} )} /> ( {t("hosts.autoStartContainer")} {t("hosts.autoStartDesc")} )} />
))}
)} /> )}
( {t("hosts.enableFileManager")} {t("hosts.enableFileManagerDesc")} )} /> {form.watch("enableFileManager") && (
( {t("hosts.defaultPath")} {t("hosts.defaultPathDesc")} )} />
)}
(
{t("hosts.statusCheckEnabled")} {t("hosts.statusCheckEnabledDesc")}
)} /> {form.watch("statsConfig.statusCheckEnabled") && ( { const displayValue = statusIntervalUnit === "minutes" ? Math.round((field.value || 30) / 60) : field.value || 30; const handleIntervalChange = (value: string) => { const numValue = parseInt(value) || 0; const seconds = statusIntervalUnit === "minutes" ? numValue * 60 : numValue; field.onChange(seconds); }; return ( {t("hosts.statusCheckInterval")}
handleIntervalChange(e.target.value) } className="flex-1" />
{t("hosts.statusCheckIntervalDesc")}
); }} /> )}
(
{t("hosts.metricsEnabled")} {t("hosts.metricsEnabledDesc")}
)} /> {form.watch("statsConfig.metricsEnabled") && ( { const displayValue = metricsIntervalUnit === "minutes" ? Math.round((field.value || 30) / 60) : field.value || 30; const handleIntervalChange = (value: string) => { const numValue = parseInt(value) || 0; const seconds = metricsIntervalUnit === "minutes" ? numValue * 60 : numValue; field.onChange(seconds); }; return ( {t("hosts.metricsInterval")}
handleIntervalChange(e.target.value) } className="flex-1" />
{t("hosts.metricsIntervalDesc")}
); }} /> )}
{form.watch("statsConfig.metricsEnabled") && ( <> ( {t("hosts.enabledWidgets")} {t("hosts.enabledWidgetsDesc")}
{( [ "cpu", "memory", "disk", "network", "uptime", "processes", "system", ] as const ).map((widget) => (
{ const currentWidgets = field.value || []; if (checked) { field.onChange([ ...currentWidgets, widget, ]); } else { field.onChange( currentWidgets.filter( (w) => w !== widget, ), ); } }} />
))}
)} /> )}
); }