diff --git a/src/types/index.ts b/src/types/index.ts index 2c49b276..22dacc8e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -242,7 +242,7 @@ export interface HostData { | "agent"; useWarpgate?: boolean; password?: string; - key?: File | null; + key?: File | string | null; keyPassword?: string; keyType?: string; sudoPassword?: string; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 392c0295..acde8077 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -122,6 +122,7 @@ import { addOpenTab, deleteOpenTab, patchOpenTab, + createSSHHost, getActiveSessions, getUserPreferences, type UserPreferences, @@ -134,6 +135,7 @@ import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { changeAppLanguage } from "@/i18n/i18n"; +import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; function buildHostTree( hosts: SSHHostWithStatus[], @@ -1092,6 +1094,21 @@ export function AppShell({ openTab(host, type); } + const saveQuickConnectHost = useCallback( + async (tab: Tab, host: Host) => { + try { + const savedHost = await createSSHHost(quickConnectHostToPayload(host)); + await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); + await loadHosts(); + toast.success(t("hosts.hostCreated")); + } catch (error) { + toast.error(t("hosts.failedToSave")); + throw error; + } + }, + [loadHosts, t], + ); + function openSerialTab(config: SerialConfig) { const pseudoHost: Host = { id: `serial-${Date.now()}`, @@ -1877,6 +1894,7 @@ export function AppShell({ initialFilePath: path, }), renameTab, + saveQuickConnectHost, ), tabNode, tab.id, diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index e844916a..7eda9981 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -55,6 +55,7 @@ import { import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx"; import { toast } from "sonner"; import { Button } from "@/components/button"; +import { Save } from "lucide-react"; import { resolveTermixThemeColors } from "./terminal-theme.ts"; import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; import { @@ -85,6 +86,8 @@ interface SSHTerminalProps { previewTheme?: string | null; /** When true, suppress automatic focus on connect/visibility change. */ disableAutoFocus?: boolean; + isQuickConnect?: boolean; + onSaveQuickConnect?: () => Promise; } const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g; @@ -118,6 +121,8 @@ const TerminalInner = forwardRef( onOpenFileInEditor, previewTheme, disableAutoFocus = false, + isQuickConnect = false, + onSaveQuickConnect, }, ref, ) { @@ -159,6 +164,8 @@ const TerminalInner = forwardRef( const pongReceivedRef = useRef(true); const pongTimeoutRef = useRef(null); const [isConnected, setIsConnected] = useState(false); + const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false); + const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isFitted, setIsFitted] = useState(false); const [connectionError, setConnectionError] = useState(null); @@ -2810,6 +2817,32 @@ const TerminalInner = forwardRef( )} + {isQuickConnect && + isConnected && + !isQuickConnectSaved && + onSaveQuickConnect && ( + + )} + void; onOpenFileInEditor?: (filePath: string) => void; onOpenFileManager?: (path?: string) => void; + onSaveQuickConnect?: () => Promise; }) { const { previewTerminalTheme } = useTabsSafe(); const isMobile = useIsMobile(); @@ -248,6 +250,8 @@ function TerminalTabContent({ previewTheme={previewTerminalTheme} onOpenFileInEditor={onOpenFileInEditor} onOpenFileManager={onOpenFileManager} + isQuickConnect={host.id.startsWith("quick-connect-")} + onSaveQuickConnect={onSaveQuickConnect} /> {isMobile && ( @@ -272,6 +276,7 @@ export function renderTabContent( onOpenFileManager?: (host: Host, path?: string) => void, onOpenTerminalTab?: (host: Host, path?: string) => void, onRenameTab?: (tabId: string, newLabel: string) => void, + onSaveQuickConnect?: (tab: Tab, host: Host) => Promise, ) { const { host, label } = tab; @@ -309,6 +314,9 @@ export function renderTabContent( onOpenFileManager={ onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined } + onSaveQuickConnect={ + onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined + } /> ); diff --git a/src/ui/sidebar/QuickConnectPanel.tsx b/src/ui/sidebar/QuickConnectPanel.tsx index 460e87d8..f6a33e52 100644 --- a/src/ui/sidebar/QuickConnectPanel.tsx +++ b/src/ui/sidebar/QuickConnectPanel.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/input"; import type { Host } from "@/types/ui-types"; import { getCredentials } from "@/api/credentials-api"; import { mapCredentials } from "./HostManagerData"; +import { createQuickConnectHost } from "./quick-connect-host"; interface QuickConnectPanelProps { onConnect: (host: Host, type: "terminal" | "files") => void; @@ -34,38 +35,15 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) { const connect = (type: "terminal" | "files") => { if (!host || !username) return; - const hostConfig: Host = { - id: `quick-connect-${Date.now()}`, - name: `${username}@${host}`, + const hostConfig = createQuickConnectHost({ ip: host, port: parseInt(port) || 22, username, authType, - password: authType === "password" ? password : undefined, - key: authType === "key" ? privateKey : undefined, - credentialId: authType === "credential" ? credentialId : undefined, - folder: "", - online: false, - cpu: null, - ram: null, - lastAccess: new Date().toISOString(), - pin: false, - defaultPath: "", - serverTunnels: [], - quickActions: [], - enableTerminal: true, - enableFileManager: true, - enableTunnel: true, - enableDocker: true, - enableSsh: true, - enableRdp: false, - enableVnc: false, - enableTelnet: false, - sshPort: parseInt(port) || 22, - rdpPort: 3389, - vncPort: 5900, - telnetPort: 23, - }; + password, + key: privateKey, + credentialId, + }); onConnect(hostConfig, type); }; diff --git a/src/ui/sidebar/quick-connect-host.test.ts b/src/ui/sidebar/quick-connect-host.test.ts new file mode 100644 index 00000000..6d5d06b5 --- /dev/null +++ b/src/ui/sidebar/quick-connect-host.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createQuickConnectHost, + quickConnectHostToPayload, +} from "./quick-connect-host"; + +describe("quick connect host", () => { + afterEach(() => vi.restoreAllMocks()); + + it("preserves password authentication when saving the connection", () => { + vi.spyOn(Date, "now").mockReturnValue(1234); + + const host = createQuickConnectHost({ + ip: "server.example.com", + port: 2222, + username: "root", + authType: "password", + password: "secret", + }); + + expect(host.id).toBe("quick-connect-1234"); + expect(quickConnectHostToPayload(host)).toMatchObject({ + name: "root@server.example.com", + ip: "server.example.com", + port: 2222, + username: "root", + authType: "password", + password: "secret", + connectionType: "ssh", + }); + }); + + it("keeps only the selected credential authentication data", () => { + const host = createQuickConnectHost({ + ip: "10.0.0.2", + port: 22, + username: "deploy", + authType: "credential", + credentialId: "42", + password: "ignored", + key: "ignored", + }); + const payload = quickConnectHostToPayload(host); + + expect(payload.credentialId).toBe(42); + expect(payload.password).toBeUndefined(); + expect(payload.key).toBeUndefined(); + }); +}); diff --git a/src/ui/sidebar/quick-connect-host.ts b/src/ui/sidebar/quick-connect-host.ts new file mode 100644 index 00000000..59e76fc5 --- /dev/null +++ b/src/ui/sidebar/quick-connect-host.ts @@ -0,0 +1,86 @@ +import type { SSHHostData } from "@/types"; +import type { Host } from "@/types/ui-types"; + +type QuickConnectInput = Pick< + Host, + "ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId" +>; + +export function createQuickConnectHost(input: QuickConnectInput): Host { + return { + id: `quick-connect-${Date.now()}`, + name: `${input.username}@${input.ip}`, + ip: input.ip, + port: input.port, + username: input.username, + authType: input.authType, + password: input.authType === "password" ? input.password : undefined, + key: input.authType === "key" ? input.key : undefined, + credentialId: + input.authType === "credential" ? input.credentialId : undefined, + folder: "", + online: false, + cpu: null, + ram: null, + lastAccess: new Date().toISOString(), + pin: false, + defaultPath: "", + serverTunnels: [], + quickActions: [], + enableTerminal: true, + enableCommandHistory: true, + enableFileManager: true, + enableTunnel: true, + enableDocker: true, + enableProxmox: false, + enableTmuxMonitor: false, + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + sshPort: input.port, + rdpPort: 3389, + vncPort: 5900, + telnetPort: 23, + }; +} + +export function quickConnectHostToPayload(host: Host): SSHHostData { + return { + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + authType: host.authType, + password: host.password, + key: host.key, + credentialId: host.credentialId + ? Number.parseInt(host.credentialId, 10) + : null, + folder: host.folder, + pin: host.pin, + defaultPath: host.defaultPath, + enableTerminal: true, + enableSessionLogging: true, + enableCommandHistory: host.enableCommandHistory, + enableFileManager: host.enableFileManager, + enableTunnel: host.enableTunnel, + enableDocker: host.enableDocker, + enableProxmox: host.enableProxmox, + enableTmuxMonitor: host.enableTmuxMonitor, + showTerminalInSidebar: true, + showFileManagerInSidebar: true, + showTunnelInSidebar: true, + showDockerInSidebar: true, + showServerStatsInSidebar: true, + connectionType: "ssh", + enableSsh: true, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + sshPort: host.sshPort, + rdpPort: host.rdpPort, + vncPort: host.vncPort, + telnetPort: host.telnetPort, + }; +}