mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: improve quick connect system using plugin api
This commit is contained in:
1 parent
b4457b6121
commit
44062e30d1
5 files changed
+316
-146
No files matched your search
@@ -1831,8 +1831,9 @@
|
|||||||
"domainLabel": "Domain",
|
"domainLabel": "Domain",
|
||||||
"domainPlaceholder": "optional",
|
"domainPlaceholder": "optional",
|
||||||
"connectToTerminal": "Connect to Terminal",
|
"connectToTerminal": "Connect to Terminal",
|
||||||
"connectWith": "Connect via {{protocol}}",
|
"connectToAction": "Connect to {{name}}",
|
||||||
"connectToAction": "Connect to {{name}}"
|
"sshProtocol": "SSH",
|
||||||
|
"noTarget": "Nothing installed can connect to this host type"
|
||||||
},
|
},
|
||||||
"sshTools": {
|
"sshTools": {
|
||||||
"keyRecordingTitle": "Key Recording",
|
"keyRecordingTitle": "Key Recording",
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Eye, EyeOff, Terminal } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import { getCredentials } from "@/api/credentials-api";
|
import { getCredentials } from "@/api/credentials-api";
|
||||||
import { mapCredentials } from "./HostManagerData";
|
import { mapCredentials } from "./HostManagerData";
|
||||||
import { createQuickConnectHost } from "./quick-connect-host";
|
import {
|
||||||
|
createQuickConnectHost,
|
||||||
|
quickConnectTargets,
|
||||||
|
} from "./quick-connect-host";
|
||||||
import { useHostProtocols } from "./host-protocols";
|
import { useHostProtocols } from "./host-protocols";
|
||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
|
||||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||||
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
||||||
import { useHostActions } from "./host-contributions";
|
import { useHostActions, type HostActionDef } from "./host-contributions";
|
||||||
|
|
||||||
// Core types Quick Connect draws its own fields for.
|
// Core types Quick Connect draws its own fields for.
|
||||||
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
||||||
|
|
||||||
|
const LABEL_CLASS =
|
||||||
|
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||||
|
const SELECT_CLASS =
|
||||||
|
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
|
||||||
|
|
||||||
interface QuickConnectPanelProps {
|
interface QuickConnectPanelProps {
|
||||||
onConnect: (host: Host, type: string) => void;
|
onConnect: (host: Host, type: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className={LABEL_CLASS}>{label}</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [host, setHost] = useState("");
|
const [host, setHost] = useState("");
|
||||||
@@ -55,9 +72,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
|
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
|
||||||
? undefined
|
? undefined
|
||||||
: authEditor?.component;
|
: authEditor?.component;
|
||||||
const quickActions = useHostActions().filter(
|
const allActions = useHostActions();
|
||||||
(action) => action.quickConnect && action.tabType,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pluginProtocols = useHostProtocols().filter(
|
const pluginProtocols = useHostProtocols().filter(
|
||||||
(entry) => entry.quickConnect,
|
(entry) => entry.quickConnect,
|
||||||
@@ -73,12 +88,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
setProtocol(next);
|
setProtocol(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The tab comes from whichever plugin connects hosts of this protocol.
|
const buildHost = () =>
|
||||||
const connect = (type?: string) => {
|
createQuickConnectHost({
|
||||||
if (!host) return;
|
ip: host.trim(),
|
||||||
if (!isDesktop && !username) return;
|
|
||||||
const hostConfig = createQuickConnectHost({
|
|
||||||
ip: host,
|
|
||||||
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
||||||
username,
|
username,
|
||||||
authType: isDesktop ? "password" : authType,
|
authType: isDesktop ? "password" : authType,
|
||||||
@@ -89,69 +101,65 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
domain: domain || undefined,
|
domain: domain || undefined,
|
||||||
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
||||||
});
|
});
|
||||||
const target = type ?? resolveHostTabType(hostConfig);
|
|
||||||
if (target) onConnect(hostConfig, target);
|
const targets = quickConnectTargets(allActions, buildHost());
|
||||||
|
|
||||||
|
const connect = (action?: HostActionDef) => {
|
||||||
|
if (!host.trim()) return;
|
||||||
|
if (!isDesktop && !username) return;
|
||||||
|
const hostConfig = buildHost();
|
||||||
|
const target = action ?? quickConnectTargets(allActions, hostConfig)[0];
|
||||||
|
if (!target?.tabType) {
|
||||||
|
toast.error(t("newUi.sidebar.quickConnect.noTarget"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConnect(hostConfig, target.tabType);
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectDefault = () => connect();
|
const onEnter = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") connect();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-[1fr_5rem] gap-2">
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
|
||||||
{t("newUi.sidebar.quickConnect.protocolLabel")}
|
<Select2
|
||||||
</label>
|
value={protocol}
|
||||||
<div className="flex gap-1">
|
onChange={(e) => switchProtocol(e.target.value)}
|
||||||
{["ssh", ...pluginProtocols.map((entry) => entry.id)].map(
|
className={SELECT_CLASS}
|
||||||
(type) => (
|
>
|
||||||
<button
|
<option value="ssh">
|
||||||
key={type}
|
{t("newUi.sidebar.quickConnect.sshProtocol")}
|
||||||
onClick={() => switchProtocol(type)}
|
</option>
|
||||||
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
|
{pluginProtocols.map((entry) => (
|
||||||
protocol === type
|
<option key={entry.id} value={entry.id}>
|
||||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
{t(entry.titleKey)}
|
||||||
: "border-border text-muted-foreground hover:text-foreground"
|
</option>
|
||||||
}`}
|
))}
|
||||||
>
|
</Select2>
|
||||||
{type}
|
</Field>
|
||||||
</button>
|
<Field label={t("newUi.sidebar.quickConnect.portLabel")}>
|
||||||
),
|
<Input
|
||||||
)}
|
placeholder={defaultPort(protocol)}
|
||||||
</div>
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
onKeyDown={onEnter}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.hostLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.hostLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
|
||||||
value={host}
|
value={host}
|
||||||
onChange={(e) => setHost(e.target.value)}
|
onChange={(e) => setHost(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={onEnter}
|
||||||
if (e.key === "Enter") connectDefault();
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.portLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
|
|
||||||
value={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") connectDefault();
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.usernameLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||||
value={username}
|
value={username}
|
||||||
@@ -162,42 +170,30 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
if (username === "") setUsername("root");
|
if (username === "") setUsername("root");
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={onEnter}
|
||||||
if (e.key === "Enter") connectDefault();
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
{!isDesktop && (
|
{!isDesktop && authOptions.length > 0 && (
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.authLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
<Select2
|
||||||
{t("newUi.sidebar.quickConnect.authLabel")}
|
value={authType}
|
||||||
</label>
|
onChange={(e) => {
|
||||||
<div className="flex flex-wrap gap-1">
|
setAuthType(e.target.value);
|
||||||
|
setAuthFields({});
|
||||||
|
}}
|
||||||
|
className={SELECT_CLASS}
|
||||||
|
>
|
||||||
{authOptions.map((option) => (
|
{authOptions.map((option) => (
|
||||||
<button
|
<option key={option.type} value={option.type}>
|
||||||
key={option.type}
|
|
||||||
onClick={() => {
|
|
||||||
setAuthType(option.type);
|
|
||||||
setAuthFields({});
|
|
||||||
}}
|
|
||||||
className={`flex-1 py-1 px-1.5 text-[10px] font-semibold border transition-colors ${
|
|
||||||
authType === option.type
|
|
||||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
|
||||||
: "border-border text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(option.editorTitleKey ?? option.labelKey)}
|
{t(option.editorTitleKey ?? option.labelKey)}
|
||||||
</button>
|
</option>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Select2>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{(isDesktop || authType === "password") && (
|
{(isDesktop || authType === "password") && (
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.passwordLabel")}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
@@ -206,12 +202,11 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
)}
|
)}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={onEnter}
|
||||||
if (e.key === "Enter") connectDefault();
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs pr-8"
|
className="h-7 text-xs pr-8"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setShowPassword((o) => !o)}
|
onClick={() => setShowPassword((o) => !o)}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
@@ -222,29 +217,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{selected?.quickConnect?.showDomain && (
|
{selected?.quickConnect?.showDomain && (
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.domainLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.domainLabel")}
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
|
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
|
||||||
value={domain}
|
value={domain}
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={onEnter}
|
||||||
if (e.key === "Enter") connectDefault();
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{!isDesktop && authType === "key" && (
|
{!isDesktop && authType === "key" && (
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||||
@@ -253,13 +240,10 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
onChange={(e) => setPrivateKey(e.target.value)}
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{!isDesktop && authType === "credential" && (
|
{!isDesktop && authType === "credential" && (
|
||||||
<div className="flex flex-col gap-1">
|
<Field label={t("newUi.sidebar.quickConnect.credentialLabel")}>
|
||||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{t("newUi.sidebar.quickConnect.credentialLabel")}
|
|
||||||
</label>
|
|
||||||
<Select2
|
<Select2
|
||||||
value={credentialId}
|
value={credentialId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -268,7 +252,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
const cred = credentials.find((c) => c.id === newId);
|
const cred = credentials.find((c) => c.id === newId);
|
||||||
if (cred?.username) setUsername(cred.username);
|
if (cred?.username) setUsername(cred.username);
|
||||||
}}
|
}}
|
||||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className={SELECT_CLASS}
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
|
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
|
||||||
@@ -279,7 +263,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select2>
|
</Select2>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{!isDesktop && AuthEditor && (
|
{!isDesktop && AuthEditor && (
|
||||||
<AuthEditor
|
<AuthEditor
|
||||||
@@ -290,38 +274,29 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5 pt-1">
|
<div className="flex flex-col gap-1.5 pt-1">
|
||||||
{selected ? (
|
{targets.length === 0 ? (
|
||||||
<button
|
<p className="text-xs text-muted-foreground">
|
||||||
onClick={() => connect()}
|
{t("newUi.sidebar.quickConnect.noTarget")}
|
||||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
</p>
|
||||||
>
|
|
||||||
<selected.icon className="size-3.5" />
|
|
||||||
{t("newUi.sidebar.quickConnect.connectWith", {
|
|
||||||
protocol: t(selected.titleKey),
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
targets.map((action, index) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => connect()}
|
key={action.id}
|
||||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
type="button"
|
||||||
|
disabled={!host.trim()}
|
||||||
|
onClick={() => connect(action)}
|
||||||
|
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
||||||
|
index === 0
|
||||||
|
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
|
||||||
|
: "border-border text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Terminal className="size-3.5" />
|
<action.icon className="size-3.5" />
|
||||||
{t("newUi.sidebar.quickConnect.connectToTerminal")}
|
{t("newUi.sidebar.quickConnect.connectToAction", {
|
||||||
|
name: t(action.titleKey),
|
||||||
|
})}
|
||||||
</button>
|
</button>
|
||||||
{quickActions.map((action) => (
|
))
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
onClick={() => connect(action.tabType)}
|
|
||||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
|
||||||
>
|
|
||||||
<action.icon className="size-3.5" />
|
|
||||||
{t("newUi.sidebar.quickConnect.connectToAction", {
|
|
||||||
name: t(action.titleKey),
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SSHHostData } from "@/types";
|
import type { SSHHostData } from "@/types";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import type { HostProtocolDef } from "./host-protocols";
|
import type { HostProtocolDef } from "./host-protocols";
|
||||||
|
import { hostActionsFor, type HostActionDef } from "./host-contributions";
|
||||||
|
|
||||||
type QuickConnectInput = Pick<
|
type QuickConnectInput = Pick<
|
||||||
Host,
|
Host,
|
||||||
@@ -98,3 +99,22 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
|
|||||||
sshPort: host.sshPort,
|
sshPort: host.sshPort,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every way to reach the address: connect actions (terminal, RDP, VNC...)
|
||||||
|
* by priority, then tools that opted into Quick Connect. Only actions that
|
||||||
|
* open a tab work here, since the host is never saved.
|
||||||
|
*/
|
||||||
|
export function quickConnectTargets(
|
||||||
|
all: HostActionDef[],
|
||||||
|
host: Host,
|
||||||
|
): HostActionDef[] {
|
||||||
|
const usable = hostActionsFor(all, host).filter((action) => action.tabType);
|
||||||
|
const connects = usable
|
||||||
|
.filter((action) => action.kind === "connect")
|
||||||
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||||
|
const tools = usable.filter(
|
||||||
|
(action) => action.kind !== "connect" && action.quickConnect,
|
||||||
|
);
|
||||||
|
return [...connects, ...tools];
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { FolderSearch, Monitor, Server, Terminal } from "lucide-react";
|
||||||
|
|
||||||
|
vi.mock("@/api/credentials-api", () => ({
|
||||||
|
getCredentials: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
vi.mock("@/api/auth-methods-api", () => ({
|
||||||
|
getSshAuthProviders: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { QuickConnectPanel } from "../../sidebar/QuickConnectPanel";
|
||||||
|
import {
|
||||||
|
registerHostAction,
|
||||||
|
resetHostContributions,
|
||||||
|
} from "../../sidebar/host-contributions";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
resetHostContributions();
|
||||||
|
});
|
||||||
|
|
||||||
|
function registerDefaults() {
|
||||||
|
registerHostAction({
|
||||||
|
id: "terminal",
|
||||||
|
titleKey: "nav.terminal",
|
||||||
|
icon: Terminal,
|
||||||
|
kind: "connect",
|
||||||
|
priority: 100,
|
||||||
|
tabType: "terminal",
|
||||||
|
when: (host) => !!host.enableSsh,
|
||||||
|
});
|
||||||
|
registerHostAction({
|
||||||
|
id: "files",
|
||||||
|
titleKey: "nav.files",
|
||||||
|
icon: FolderSearch,
|
||||||
|
kind: "open",
|
||||||
|
tabType: "files",
|
||||||
|
quickConnect: true,
|
||||||
|
when: (host) => !!host.enableSsh,
|
||||||
|
});
|
||||||
|
registerHostAction({
|
||||||
|
id: "docker",
|
||||||
|
titleKey: "nav.docker",
|
||||||
|
icon: Server,
|
||||||
|
kind: "open",
|
||||||
|
tabType: "docker",
|
||||||
|
when: (host) => !!host.enableSsh,
|
||||||
|
});
|
||||||
|
registerHostAction({
|
||||||
|
id: "rdp",
|
||||||
|
titleKey: "hosts.tabRdp",
|
||||||
|
icon: Monitor,
|
||||||
|
kind: "connect",
|
||||||
|
priority: 50,
|
||||||
|
tabType: "rdp",
|
||||||
|
when: () => false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonName = (key: string) =>
|
||||||
|
`newUi.sidebar.quickConnect.connectToAction:${JSON.stringify({ name: key })}`;
|
||||||
|
|
||||||
|
describe("QuickConnectPanel", () => {
|
||||||
|
it("offers every usable connect target and opt-in tool", () => {
|
||||||
|
registerDefaults();
|
||||||
|
render(<QuickConnectPanel onConnect={vi.fn()} />);
|
||||||
|
expect(screen.getByText(buttonName("nav.terminal"))).toBeTruthy();
|
||||||
|
expect(screen.getByText(buttonName("nav.files"))).toBeTruthy();
|
||||||
|
expect(screen.queryByText(buttonName("nav.docker"))).toBeNull();
|
||||||
|
expect(screen.queryByText(buttonName("hosts.tabRdp"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the chosen tab for the typed address", async () => {
|
||||||
|
registerDefaults();
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||||
|
fireEvent.change(
|
||||||
|
screen.getByPlaceholderText("newUi.sidebar.quickConnect.hostPlaceholder"),
|
||||||
|
{ target: { value: "10.0.0.1" } },
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText(buttonName("nav.files")));
|
||||||
|
await waitFor(() => expect(onConnect).toHaveBeenCalledTimes(1));
|
||||||
|
const [host, type] = onConnect.mock.calls[0];
|
||||||
|
expect(type).toBe("files");
|
||||||
|
expect(host).toMatchObject({ ip: "10.0.0.1", username: "root", port: 22 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects with the top target on Enter", () => {
|
||||||
|
registerDefaults();
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||||
|
const input = screen.getByPlaceholderText(
|
||||||
|
"newUi.sidebar.quickConnect.hostPlaceholder",
|
||||||
|
);
|
||||||
|
fireEvent.change(input, { target: { value: "example.com" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(onConnect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ip: "example.com" }),
|
||||||
|
"terminal",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing without an address", () => {
|
||||||
|
registerDefaults();
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
render(<QuickConnectPanel onConnect={onConnect} />);
|
||||||
|
fireEvent.click(screen.getByText(buttonName("nav.terminal")));
|
||||||
|
expect(onConnect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when nothing can connect", () => {
|
||||||
|
render(<QuickConnectPanel onConnect={vi.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText("newUi.sidebar.quickConnect.noTarget"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
createQuickConnectHost,
|
createQuickConnectHost,
|
||||||
isQuickConnectHost,
|
isQuickConnectHost,
|
||||||
quickConnectHostToPayload,
|
quickConnectHostToPayload,
|
||||||
|
quickConnectTargets,
|
||||||
} from "../../sidebar/quick-connect-host";
|
} from "../../sidebar/quick-connect-host";
|
||||||
|
import type { HostActionDef } from "../../sidebar/host-contributions";
|
||||||
|
|
||||||
describe("quick connect host", () => {
|
describe("quick connect host", () => {
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
@@ -132,3 +134,44 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("quickConnectTargets", () => {
|
||||||
|
const action = (over: Partial<HostActionDef>): HostActionDef => ({
|
||||||
|
id: "x",
|
||||||
|
titleKey: "x",
|
||||||
|
icon: () => null,
|
||||||
|
kind: "open",
|
||||||
|
when: () => true,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const host = createQuickConnectHost({
|
||||||
|
ip: "10.0.0.3",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts connect actions first by priority, then opted-in tools", () => {
|
||||||
|
const targets = quickConnectTargets(
|
||||||
|
[
|
||||||
|
action({ id: "files", tabType: "files", quickConnect: true }),
|
||||||
|
action({ id: "docker", tabType: "docker" }),
|
||||||
|
action({ id: "vnc", kind: "connect", priority: 40, tabType: "vnc" }),
|
||||||
|
action({ id: "term", kind: "connect", priority: 100, tabType: "t" }),
|
||||||
|
action({ id: "run-only", kind: "connect", priority: 200 }),
|
||||||
|
action({
|
||||||
|
id: "off",
|
||||||
|
kind: "connect",
|
||||||
|
tabType: "off",
|
||||||
|
when: () => false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
expect(targets.map((target) => target.id)).toEqual([
|
||||||
|
"term",
|
||||||
|
"vnc",
|
||||||
|
"files",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user