feat: save quick connect sessions as hosts (#1055)

This commit is contained in:
ZacharyZcR
2026-07-16 11:51:53 +08:00
committed by GitHub
parent 22124e1bc4
commit 287b0fa656
7 changed files with 201 additions and 29 deletions
+18
View File
@@ -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,
+33
View File
@@ -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<void>;
}
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
@@ -118,6 +121,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onOpenFileInEditor,
previewTheme,
disableAutoFocus = false,
isQuickConnect = false,
onSaveQuickConnect,
},
ref,
) {
@@ -159,6 +164,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const pongReceivedRef = useRef(true);
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(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<string | null>(null);
@@ -2810,6 +2817,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
</button>
)}
{isQuickConnect &&
isConnected &&
!isQuickConnectSaved &&
onSaveQuickConnect && (
<Button
size="sm"
variant="secondary"
disabled={isSavingQuickConnect}
onClick={async () => {
setIsSavingQuickConnect(true);
try {
await onSaveQuickConnect();
setIsQuickConnectSaved(true);
} catch {
// The shell reports the failure with a toast.
} finally {
setIsSavingQuickConnect(false);
}
}}
className="absolute top-2 left-2 z-[110] h-7 gap-1.5 bg-black/60 text-white/80 hover:bg-black/80 hover:text-white"
>
<Save className="size-3.5" />
{t("hosts.addHost")}
</Button>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
message={t("terminal.connecting")}
+8
View File
@@ -208,6 +208,7 @@ function TerminalTabContent({
onRenameTab,
onOpenFileInEditor,
onOpenFileManager,
onSaveQuickConnect,
}: {
tab: Tab;
host: Host;
@@ -217,6 +218,7 @@ function TerminalTabContent({
onRenameTab?: (tabId: string, newLabel: string) => void;
onOpenFileInEditor?: (filePath: string) => void;
onOpenFileManager?: (path?: string) => void;
onSaveQuickConnect?: () => Promise<void>;
}) {
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}
/>
</div>
{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<void>,
) {
const { host, label } = tab;
@@ -309,6 +314,9 @@ export function renderTabContent(
onOpenFileManager={
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
}
onSaveQuickConnect={
onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined
}
/>
);
+6 -28
View File
@@ -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);
};
+49
View File
@@ -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();
});
});
+86
View File
@@ -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,
};
}