Files
Termix/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx
Luke Gustafson 9936ef469d fix: file manager incorrectly decoding/encoding when editing files (#476)
* fix: electron build errors and skip macos job

* fix: testflight submit failure

* fix: made submit job match build type

* fix: resolve Vite build warnings for mixed static/dynamic imports (#473)

* Update Crowdin configuration file

* Update Crowdin configuration file

* fix: resolve Vite build warnings for mixed static/dynamic imports

- Convert all dynamic imports of main-axios.ts to static imports (10 files)
- Convert all dynamic imports of sonner to static imports (4 files)
- Add manual chunking configuration to vite.config.ts for better bundle splitting
  - react-vendor: React and React DOM
  - ui-vendor: Radix UI, lucide-react, clsx, tailwind-merge
  - monaco: Monaco Editor
  - codemirror: CodeMirror and related packages
- Increase chunkSizeWarningLimit to 1000kB

This resolves Vite warnings about mixed import strategies preventing
proper code-splitting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>

* fix: file manager incorrectly decoding/encoding when editing files (made base64/utf8 dependent)

---------

Co-authored-by: Jefferson Nunn <89030989+jeffersonwarrior@users.noreply.github.com>
Co-authored-by: Termix CI <ci@termix.dev>
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-02 00:33:10 -06:00

1225 lines
39 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@/lib/utils.ts";
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 { Badge } from "@/components/ui/badge.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Textarea } from "@/components/ui/textarea.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, AlertTitle } from "@/components/ui/alert.tsx";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
createSSHHost,
getCredentials,
getSSHHosts,
updateSSHHost,
enableAutoStart,
disableAutoStart,
getSnippets,
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
notifyHostCreatedOrUpdated,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github";
import { EditorView } from "@codemirror/view";
import { useTheme } from "@/components/theme-provider.tsx";
import type { StatsConfig } from "@/types/stats-widgets.ts";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
CURSOR_STYLES,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes.ts";
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
import type { TerminalConfig, SSHHost, Credential } from "@/types";
import {
Plus,
X,
Check,
ChevronsUpDown,
Save,
AlertCircle,
Trash2,
Users,
Shield,
Clock,
UserCircle,
} from "lucide-react";
import { HostGeneralTab } from "./tabs/HostGeneralTab";
import { HostTerminalTab } from "./tabs/HostTerminalTab";
import { HostDockerTab } from "./tabs/HostDockerTab";
import { HostTunnelTab } from "./tabs/HostTunnelTab";
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
import { HostSharingTab } from "./tabs/HostSharingTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface User {
id: string;
username: string;
is_admin: boolean;
}
interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: (updatedHost?: SSHHost) => void;
}
export function HostManagerEditor({
editingHost,
onFormSubmit,
}: SSHManagerHostEditorProps) {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const isDarkMode =
appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const editorTheme = isDarkMode ? oneDark : githubLight;
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [snippets, setSnippets] = useState<
Array<{ id: number; name: string; content: string }>
>([]);
const [proxyMode, setProxyMode] = useState<"single" | "chain">("single");
const [authTab, setAuthTab] = useState<
"password" | "key" | "credential" | "none"
>("password");
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload",
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
useEffect(() => {
setFormError(null);
}, [activeTab]);
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
"seconds" | "minutes"
>("seconds");
const [metricsIntervalUnit, setMetricsIntervalUnit] = useState<
"seconds" | "minutes"
>("seconds");
const ipInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const fetchData = async () => {
try {
const [hostsData, credentialsData, snippetsData] = await Promise.all([
getSSHHosts(),
getCredentials(),
getSnippets(),
]);
setHosts(hostsData);
setCredentials(credentialsData as Credential[]);
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 (error) {
console.error("Host manager operation failed:", error);
}
};
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 (error) {
console.error("Host manager operation failed:", error);
}
};
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",
"login_stats",
]),
)
.default([
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
]),
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",
"login_stats",
],
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(),
sudoPasswordAutoFill: z.boolean(),
sudoPassword: z.string().optional(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
jumpHosts: z
.array(
z.object({
hostId: z.number().min(1),
}),
)
.default([]),
quickActions: z
.array(
z.object({
name: z.string().min(1),
snippetId: z.number().min(1),
}),
)
.default([]),
notes: z.string().optional(),
useSocks5: z.boolean().optional(),
socks5Host: z.string().optional(),
socks5Port: z.coerce.number().min(1).max(65535).optional(),
socks5Username: z.string().optional(),
socks5Password: z.string().optional(),
socks5ProxyChain: z
.array(
z.object({
host: z.string().min(1),
port: z.number().min(1).max(65535),
type: z.union([z.literal(4), z.literal(5)]),
username: z.string().optional(),
password: z.string().optional(),
}),
)
.optional(),
enableDocker: z.boolean().default(false),
})
.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) {
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<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
mode: "all",
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: [],
jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
notes: "",
useSocks5: false,
socks5Host: "",
socks5Port: 1080,
socks5Username: "",
socks5Password: "",
socks5ProxyChain: [],
enableDocker: false,
},
});
const watchedFields = form.watch();
const formState = form.formState;
const isFormValid = React.useMemo(() => {
const values = form.getValues();
if (!values.ip || !values.username) return false;
if (authTab === "password") {
return !!(values.password && values.password.trim() !== "");
} else if (authTab === "key") {
return !!(values.key && values.keyType);
} else if (authTab === "credential") {
return !!values.credentialId;
} else if (authTab === "none") {
return true;
}
return false;
}, [watchedFields, authTab]);
useEffect(() => {
const updateAuthFields = async () => {
form.setValue("authType", authTab, { shouldValidate: true });
if (authTab === "password") {
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "key") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
} else if (authTab === "credential") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
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, {
shouldValidate: true,
});
}
}
} else if (authTab === "none") {
form.setValue("password", "", { shouldValidate: true });
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
form.setValue("credentialId", null, { shouldValidate: true });
}
await form.trigger();
};
updateAuthFields();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authTab, credentials]);
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: StatsConfig = DEFAULT_STATS_CONFIG;
try {
if (cleanedHost.statsConfig) {
parsedStatsConfig =
typeof cleanedHost.statsConfig === "string"
? JSON.parse(cleanedHost.statsConfig)
: (cleanedHost.statsConfig as StatsConfig);
}
} catch (error) {
console.error("Failed to parse statsConfig:", error);
}
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
const formData: Partial<FormData> = {
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
port: cleanedHost.port || 22,
username: cleanedHost.username || "",
folder: cleanedHost.folder || "",
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as "password" | "key" | "credential" | "none",
credentialId: cleanedHost.credentialId,
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: Array.isArray(cleanedHost.tunnelConnections)
? cleanedHost.tunnelConnections
: [],
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
? cleanedHost.jumpHosts
: [],
quickActions: Array.isArray(cleanedHost.quickActions)
? cleanedHost.quickActions
: [],
statsConfig: parsedStatsConfig,
terminalConfig: {
...DEFAULT_TERMINAL_CONFIG,
...(cleanedHost.terminalConfig || {}),
environmentVariables: Array.isArray(
cleanedHost.terminalConfig?.environmentVariables,
)
? cleanedHost.terminalConfig.environmentVariables
: [],
},
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
notes: cleanedHost.notes || "",
useSocks5: Boolean(cleanedHost.useSocks5),
socks5Host: cleanedHost.socks5Host || "",
socks5Port: cleanedHost.socks5Port || 1080,
socks5Username: cleanedHost.socks5Username || "",
socks5Password: cleanedHost.socks5Password || "",
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
? cleanedHost.socks5ProxyChain
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
};
if (
Array.isArray(cleanedHost.socks5ProxyChain) &&
cleanedHost.socks5ProxyChain.length > 0
) {
setProxyMode("chain");
} else {
setProxyMode("single");
}
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;
}
form.reset(formData as FormData);
} else {
setAuthTab("password");
const defaultFormData: Partial<FormData> = {
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: [],
jumpHosts: [],
quickActions: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
};
form.reset(defaultFormData as FormData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingHost]);
useEffect(() => {
const focusTimer = setTimeout(() => {
if (ipInputRef.current) {
ipInputRef.current.focus();
}
}, 300);
return () => clearTimeout(focusTimer);
}, [editingHost]);
const onSubmit = async (data: FormData) => {
try {
setIsSubmitting(true);
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = `${data.username}@${data.ip}`;
}
const submitData: Partial<SSHHost> = {
...data,
};
if (data.authType !== "credential") {
submitData.credentialId = undefined;
}
if (data.authType !== "password") {
submitData.password = undefined;
}
if (data.authType !== "key") {
submitData.key = undefined;
submitData.keyPassword = undefined;
submitData.keyType = undefined;
}
if (data.authType === "key") {
if (data.key instanceof File) {
submitData.key = await data.key.text();
} else if (data.key === "existing_key") {
delete submitData.key;
}
}
let savedHost;
if (editingHost && editingHost.id) {
savedHost = await updateSSHHost(editingHost.id, submitData as any);
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
} else {
savedHost = await createSSHHost(submitData as any);
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"));
if (savedHost?.id) {
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage);
console.error("Failed to save host:", error);
} finally {
setIsSubmitting(false);
}
};
const TAB_PRIORITY = [
"general",
"terminal",
"tunnel",
"file_manager",
"docker",
"statistics",
] as const;
const FIELD_TO_TAB_MAP: Record<string, string> = {
ip: "general",
port: "general",
username: "general",
name: "general",
folder: "general",
tags: "general",
pin: "general",
password: "general",
key: "general",
keyPassword: "general",
keyType: "general",
credentialId: "general",
overrideCredentialUsername: "general",
forceKeyboardInteractive: "general",
jumpHosts: "general",
authType: "general",
notes: "general",
useSocks5: "general",
socks5Host: "general",
socks5Port: "general",
socks5Username: "general",
socks5Password: "general",
socks5ProxyChain: "general",
quickActions: "general",
enableTerminal: "terminal",
terminalConfig: "terminal",
enableDocker: "docker",
enableTunnel: "tunnel",
tunnelConnections: "tunnel",
enableFileManager: "file_manager",
defaultPath: "file_manager",
statsConfig: "statistics",
};
const handleFormError = async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
const errors = form.formState.errors;
const errorFields = Object.keys(errors);
if (errorFields.length === 0) return;
for (const tab of TAB_PRIORITY) {
const hasErrorInTab = errorFields.some((field) => {
const baseField = field.split(".")[0].split("[")[0];
return FIELD_TO_TAB_MAP[baseField] === tab;
});
if (hasErrorInTab) {
setActiveTab(tab);
return;
}
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(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 currentHostId = editingHost?.id;
let filtered = sshConfigurations;
if (currentHostId) {
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
if (currentHostName) {
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
} else {
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
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 (
<div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
<SimpleLoader
visible={isSubmitting}
message={
editingHost?.id
? t("hosts.updatingHost")
: editingHost
? t("hosts.cloningHost")
: t("hosts.savingHost")
}
backgroundColor="var(--bg-base)"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<div className="pr-4">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="bg-button border border-edge-medium">
<TabsTrigger
value="general"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.general")}
</TabsTrigger>
<TabsTrigger
value="terminal"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.terminal")}
</TabsTrigger>
<TabsTrigger
value="docker"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
Docker
</TabsTrigger>
<TabsTrigger
value="tunnel"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.tunnel")}
</TabsTrigger>
<TabsTrigger
value="file_manager"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.fileManager")}
</TabsTrigger>
<TabsTrigger
value="statistics"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("hosts.statistics")}
</TabsTrigger>
{!editingHost?.isShared && (
<TabsTrigger value="sharing">
{t("rbac.sharing")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="general" className="pt-2">
<HostGeneralTab
form={form}
authTab={authTab}
setAuthTab={setAuthTab}
keyInputMethod={keyInputMethod}
setKeyInputMethod={setKeyInputMethod}
proxyMode={proxyMode}
setProxyMode={setProxyMode}
tagInput={tagInput}
setTagInput={setTagInput}
folderDropdownOpen={folderDropdownOpen}
setFolderDropdownOpen={setFolderDropdownOpen}
folderInputRef={folderInputRef}
folderDropdownRef={folderDropdownRef}
filteredFolders={filteredFolders}
handleFolderClick={handleFolderClick}
keyTypeDropdownOpen={keyTypeDropdownOpen}
setKeyTypeDropdownOpen={setKeyTypeDropdownOpen}
keyTypeButtonRef={keyTypeButtonRef}
keyTypeDropdownRef={keyTypeDropdownRef}
keyTypeOptions={keyTypeOptions}
ipInputRef={ipInputRef}
editorTheme={editorTheme}
hosts={hosts}
editingHost={editingHost}
folders={folders}
credentials={credentials}
t={t}
/>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<HostTerminalTab form={form} snippets={snippets} t={t} />
</TabsContent>
<TabsContent value="docker" className="space-y-4">
<HostDockerTab form={form} t={t} />
</TabsContent>
<TabsContent value="tunnel">
<HostTunnelTab
form={form}
sshConfigDropdownOpen={sshConfigDropdownOpen}
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
sshConfigInputRefs={sshConfigInputRefs}
sshConfigDropdownRefs={sshConfigDropdownRefs}
getFilteredSshConfigs={getFilteredSshConfigs}
handleSshConfigClick={handleSshConfigClick}
t={t}
/>
</TabsContent>
<TabsContent value="file_manager">
<HostFileManagerTab form={form} t={t} />
</TabsContent>
<TabsContent value="statistics" className="space-y-6">
<HostStatisticsTab
form={form}
statusIntervalUnit={statusIntervalUnit}
setStatusIntervalUnit={setStatusIntervalUnit}
metricsIntervalUnit={metricsIntervalUnit}
setMetricsIntervalUnit={setMetricsIntervalUnit}
snippets={snippets}
t={t}
/>
</TabsContent>
<TabsContent value="sharing" className="space-y-6">
<HostSharingTab
hostId={editingHost?.id}
isNewHost={!editingHost}
/>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
{!editingHost?.isShared && (
<Button
className="translate-y-2"
type="submit"
variant="outline"
disabled={!isFormValid || isSubmitting}
>
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
)}
</footer>
</form>
</Form>
</div>
);
}