feat: improve quick connect system using plugin api

This commit is contained in:
LukeGus committed 2026-09-28 21:23:06 -05:00
1 parent b4457b6121
commit 44062e30d1
5 files changed
+316 -146

No files matched your search

+3 -2
View File
@@ -1831,8 +1831,9 @@
"domainLabel": "Domain",
"domainPlaceholder": "optional",
"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": {
"keyRecordingTitle": "Key Recording",
+119 -144
View File
@@ -1,25 +1,42 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
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 type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
import { createQuickConnectHost } from "./quick-connect-host";
import {
createQuickConnectHost,
quickConnectTargets,
} from "./quick-connect-host";
import { useHostProtocols } from "./host-protocols";
import { Select2 } from "@/components/select2";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
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.
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 {
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) {
const { t } = useTranslation();
const [host, setHost] = useState("");
@@ -55,9 +72,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
? undefined
: authEditor?.component;
const quickActions = useHostActions().filter(
(action) => action.quickConnect && action.tabType,
);
const allActions = useHostActions();
const pluginProtocols = useHostProtocols().filter(
(entry) => entry.quickConnect,
@@ -73,12 +88,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
setProtocol(next);
};
// The tab comes from whichever plugin connects hosts of this protocol.
const connect = (type?: string) => {
if (!host) return;
if (!isDesktop && !username) return;
const hostConfig = createQuickConnectHost({
ip: host,
const buildHost = () =>
createQuickConnectHost({
ip: host.trim(),
port: parseInt(port) || parseInt(defaultPort(protocol)),
username,
authType: isDesktop ? "password" : authType,
@@ -89,69 +101,65 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
domain: domain || undefined,
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 (
<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-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.protocolLabel")}
</label>
<div className="flex gap-1">
{["ssh", ...pluginProtocols.map((entry) => entry.id)].map(
(type) => (
<button
key={type}
onClick={() => switchProtocol(type)}
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors uppercase ${
protocol === type
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{type}
</button>
),
)}
</div>
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
value={protocol}
onChange={(e) => switchProtocol(e.target.value)}
className={SELECT_CLASS}
>
<option value="ssh">
{t("newUi.sidebar.quickConnect.sshProtocol")}
</option>
{pluginProtocols.map((entry) => (
<option key={entry.id} value={entry.id}>
{t(entry.titleKey)}
</option>
))}
</Select2>
</Field>
<Field label={t("newUi.sidebar.quickConnect.portLabel")}>
<Input
placeholder={defaultPort(protocol)}
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</Field>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.hostLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.hostLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
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.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>
</Field>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
value={username}
@@ -162,42 +170,30 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
if (username === "") setUsername("root");
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</div>
{!isDesktop && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.authLabel")}
</label>
<div className="flex flex-wrap gap-1">
</Field>
{!isDesktop && authOptions.length > 0 && (
<Field label={t("newUi.sidebar.quickConnect.authLabel")}>
<Select2
value={authType}
onChange={(e) => {
setAuthType(e.target.value);
setAuthFields({});
}}
className={SELECT_CLASS}
>
{authOptions.map((option) => (
<button
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"
}`}
>
<option key={option.type} value={option.type}>
{t(option.editorTitleKey ?? option.labelKey)}
</button>
</option>
))}
</div>
</div>
</Select2>
</Field>
)}
{(isDesktop || authType === "password") && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.passwordLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
@@ -206,12 +202,11 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs pr-8"
/>
<button
type="button"
onClick={() => setShowPassword((o) => !o)}
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>
</div>
</div>
</Field>
)}
{selected?.quickConnect?.showDomain && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.domainLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.domainLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.domainPlaceholder")}
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connectDefault();
}}
onKeyDown={onEnter}
className="h-7 text-xs"
/>
</div>
</Field>
)}
{!isDesktop && authType === "key" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.privateKeyLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
@@ -253,13 +240,10 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
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"
/>
</div>
</Field>
)}
{!isDesktop && authType === "credential" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.credentialLabel")}
</label>
<Field label={t("newUi.sidebar.quickConnect.credentialLabel")}>
<Select2
value={credentialId}
onChange={(e) => {
@@ -268,7 +252,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const cred = credentials.find((c) => c.id === newId);
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="">
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
@@ -279,7 +263,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
</option>
))}
</Select2>
</div>
</Field>
)}
{!isDesktop && AuthEditor && (
<AuthEditor
@@ -290,38 +274,29 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
{selected ? (
<button
onClick={() => connect()}
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"
>
<selected.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectWith", {
protocol: t(selected.titleKey),
})}
</button>
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
</p>
) : (
<>
targets.map((action, index) => (
<button
onClick={() => connect()}
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"
key={action.id}
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" />
{t("newUi.sidebar.quickConnect.connectToTerminal")}
<action.icon className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToAction", {
name: t(action.titleKey),
})}
</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>
+20
View File
@@ -1,6 +1,7 @@
import type { SSHHostData } from "@/types";
import type { Host } from "@/types/ui-types";
import type { HostProtocolDef } from "./host-protocols";
import { hostActionsFor, type HostActionDef } from "./host-contributions";
type QuickConnectInput = Pick<
Host,
@@ -98,3 +99,22 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
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,
isQuickConnectHost,
quickConnectHostToPayload,
quickConnectTargets,
} from "../../sidebar/quick-connect-host";
import type { HostActionDef } from "../../sidebar/host-contributions";
describe("quick connect host", () => {
afterEach(() => vi.restoreAllMocks());
@@ -132,3 +134,44 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
).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",
]);
});
});