refactor: convert to bun-workspaces monorepo (desktop/ + www/)

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DndContext } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import type { Connection, Tag } from "../../lib/types";
import { useUiStore } from "../../stores/uiStore";
const tags: Tag[] = [
{ id: "t1", name: "production", color: "#ef4444", created_at: "" },
{ id: "t2", name: "primary", color: "#3b82f6", created_at: "" },
];
const conn: Connection = {
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
port: 5432, username: null, folder_id: null, keychain_ref: null,
tag_ids: ["t1", "t2"], favorite: false, created_at: "", updated_at: "",
};
function Wrapper({ children }: { children: React.ReactNode }) {
return <DndContext>{children}</DndContext>;
}
describe("ConnectionCard", () => {
beforeEach(() => {
vi.clearAllMocks();
useUiStore.setState({ selectedItemIds: [] });
});
it("renders name and host", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
});
it("renders db type label", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText(/postgresql/i)).toBeInTheDocument();
});
it("renders tag badges", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("production")).toBeInTheDocument();
expect(screen.getByText("primary")).toBeInTheDocument();
});
it("renders drag handle", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
});
it("mounts the connection actions kebab menu with action callbacks", () => {
const onEdit = vi.fn();
const onDuplicate = vi.fn();
const onDelete = vi.fn();
render(
<ConnectionCard
connection={conn}
tags={tags}
onEdit={onEdit}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>,
{ wrapper: Wrapper },
);
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
});
it("omits port for sqlite", () => {
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
expect(screen.getByText("/data/x.db")).toBeInTheDocument();
expect(screen.queryByText(/:5432/)).not.toBeInTheDocument();
});
it("fires onTagToggle when a tag badge is clicked", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onTagToggle={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("production"));
expect(fn).toHaveBeenCalledWith("t1");
});
it("opens DbViewer on single click when nothing is selected", async () => {
const user = userEvent.setup();
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
expect(fn).toHaveBeenCalledWith(conn.id);
});
it("toggles selection on single click when something is already selected", async () => {
const user = userEvent.setup();
useUiStore.setState({ selectedItemIds: ["other-id"] });
const fn = vi.fn();
render(<ConnectionCard connection={conn} tags={tags} onOpenDbViewer={fn} />, { wrapper: Wrapper });
await user.click(screen.getByText("Prod DB"));
// Should NOT open — should toggle selection instead
expect(fn).not.toHaveBeenCalled();
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
});
it("no longer renders a favorite star (replaced by kebab menu)", () => {
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
expect(screen.queryByLabelText(/favorite|unfavorite/i)).not.toBeInTheDocument();
});
it("no longer renders a status indicator and still shows name/host/tags", () => {
render(<ConnectionCard connection={{ ...conn, favorite: true }} tags={tags} />, { wrapper: Wrapper });
expect(screen.queryByLabelText(/check connection/i)).not.toBeInTheDocument();
expect(screen.getByText("Prod DB")).toBeInTheDocument();
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
expect(screen.getByText("production")).toBeInTheDocument();
});
const manyTags: Tag[] = Array.from({ length: 4 }, (_, i) => ({
id: `t${i + 1}`, name: `tag${i + 1}`, color: "#3b82f6", created_at: "",
}));
const connWith4Tags = { ...conn, tag_ids: manyTags.map((t) => t.id) };
it("renders a scrollable tag row when there are 4+ tags", () => {
const { container } = render(
<ConnectionCard connection={connWith4Tags} tags={manyTags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row).not.toBeNull();
expect(row?.className).toContain("overflow-x-auto");
});
it("does not scroll the tag row when there are 3 or fewer tags", () => {
const { container } = render(
<ConnectionCard connection={conn} tags={tags} />,
{ wrapper: Wrapper },
);
const row = container.querySelector('[data-testid="tag-row"]');
expect(row?.className).not.toContain("overflow-x-auto");
});
});
@@ -0,0 +1,171 @@
import { memo } from "react";
import type { Connection, ConnectionInput, Tag } from "../../lib/types";
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
import { TagBadge } from "../tags/TagBadge";
import { ConnectionCardMenu } from "./ConnectionCardMenu";
import { Check, GripVertical } from "lucide-react";
import { useUiStore } from "../../stores/uiStore";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
interface ConnectionCardProps {
connection: Connection;
tags: Tag[];
onTagToggle?: (id: string) => void;
onOpenDbViewer?: (connectionId: string) => void;
onEdit?: (connection: Connection) => void;
onDuplicate?: (connection: Connection) => void;
onDelete?: (connection: Connection) => void;
}
export function buildConfigFromConnection(conn: Connection, password: string | null): ConnectionInput {
return {
name: conn.name,
db_type: conn.db_type,
host: conn.host,
port: conn.port,
username: conn.username,
folder_id: conn.folder_id,
tag_ids: conn.tag_ids,
password,
database: conn.database ?? null,
environment: conn.environment ?? null,
ssh_host: conn.ssh_host ?? null,
ssh_port: conn.ssh_port ?? null,
ssh_user: conn.ssh_user ?? null,
ssh_auth_method: (conn.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
ssh_private_key_path: conn.ssh_private_key_path ?? null,
ssh_password: null,
ssh_passphrase: null,
ssl_mode: (conn.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
ssl_ca_path: conn.ssl_ca_path ?? null,
ssl_cert_path: conn.ssl_cert_path ?? null,
ssl_key_path: conn.ssl_key_path ?? null,
};
}
function ConnectionCardBase({
connection,
tags,
onTagToggle,
onOpenDbViewer,
onEdit,
onDuplicate,
onDelete,
}: ConnectionCardProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: connection.id,
data: { type: "connection", connection },
});
const style: React.CSSProperties = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? "grabbing" : "default",
};
const tagMap = new Map(tags.map((t) => [t.id, t]));
const cardTags = connection.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as Tag[];
const hostLabel = connection.port
? `${connection.host}:${connection.port}`
: connection.host;
const isSelected = selectedItemIds.includes(connection.id);
const handleClick = () => {
if (selectedItemIds.length > 0) {
// Something already selected — toggle this item in the selection
toggleItemSelection(connection.id);
} else {
// Nothing selected — open the connection
onOpenDbViewer?.(connection.id);
}
};
return (
<div
ref={setNodeRef}
style={style}
onClick={handleClick}
className={`relative group rounded-xl border transition-colors cursor-pointer ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
}`}
>
<div
{...listeners}
{...attributes}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
aria-label="Drag to move connection"
>
<GripVertical size={14} className="text-text-muted" />
</div>
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center overflow-hidden">
<DbIcon type={connection.db_type} size={20} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold truncate text-text">
{connection.name}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{DB_LABELS[connection.db_type] ??
connection.db_type}
</span>
{connection.environment && (
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-none ${ENV_COLORS[connection.environment] ?? "bg-surface-raised border-border text-text-muted"}`}
>
{ENV_LABELS[connection.environment] ??
connection.environment}
</span>
)}
</div>
</div>
</div>
<div className="text-xs text-text-muted mb-2 font-mono truncate">
{hostLabel}
</div>
<div
data-testid="tag-row"
className={`flex gap-1 ${cardTags.length > 3 ? "overflow-x-auto" : "flex-wrap"} max-h-[28px] whitespace-nowrap`}
>
{cardTags.map((t) => (
<span key={t.id} className="shrink-0 whitespace-nowrap">
<TagBadge tag={t} onToggle={onTagToggle} />
</span>
))}
</div>
</div>
<ConnectionCardMenu
connection={connection}
onEdit={() => onEdit?.(connection)}
onDuplicate={() => onDuplicate?.(connection)}
onDelete={() => onDelete?.(connection)}
/>
<button
onClick={(e) => {
e.stopPropagation();
toggleItemSelection(connection.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && <Check size={12} className="text-white" />}
</button>
</div>
);
}
export const ConnectionCard = memo(ConnectionCardBase);
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionCardMenu } from "./ConnectionCardMenu";
import { useConnectionStore } from "../../stores/connectionStore";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
vi.mock("../../lib/commands", () => ({
getConnectionPassword: vi.fn(),
testConnection: vi.fn(),
}));
const conn: Connection = {
id: "c1",
name: "Prod DB",
db_type: "postgresql",
host: "prod.example.com",
port: 5432,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
};
function renderMenu() {
const onEdit = vi.fn();
const onDuplicate = vi.fn();
const onDelete = vi.fn();
const utils = render(
<ConnectionCardMenu
connection={conn}
onEdit={onEdit}
onDuplicate={onDuplicate}
onDelete={onDelete}
/>,
);
return { ...utils, onEdit, onDuplicate, onDelete };
}
async function openMenu() {
await userEvent.click(screen.getByLabelText("Connection actions"));
}
describe("ConnectionCardMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders a kebab trigger and opens the menu on click", async () => {
renderMenu();
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
await openMenu();
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
expect(screen.getByText("Test connection")).toBeInTheDocument();
expect(screen.getByText("Manage")).toBeInTheDocument();
});
it("favorite label reflects connection.favorite", async () => {
const { unmount } = renderMenu();
await openMenu();
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
unmount();
render(
<ConnectionCardMenu
connection={{ ...conn, favorite: true }}
onEdit={vi.fn()}
onDuplicate={vi.fn()}
onDelete={vi.fn()}
/>,
);
await openMenu();
expect(screen.getByText("Remove from favorites")).toBeInTheDocument();
});
it("clicking favorite calls toggleFavorite and closes the menu", async () => {
const spy = vi
.spyOn(useConnectionStore.getState(), "toggleFavorite")
.mockResolvedValue(undefined);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Add to favorites"));
expect(spy).toHaveBeenCalledWith("c1");
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("test connection keeps the menu open and shows online status", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({
ok: true,
server_version: "15.2",
latency_ms: 12,
} as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
// Menu stays open while the check runs
expect(screen.getByText("Manage")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByText(/online/i)).toBeInTheDocument(),
);
expect(screen.getByText(/15\.2/)).toBeInTheDocument();
expect(screen.getByText(/12ms/)).toBeInTheDocument();
});
it("test connection shows just Online when no version/latency reported", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({ ok: true } as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
await waitFor(() =>
expect(
screen.getByText("Online", { exact: true }),
).toBeInTheDocument(),
);
expect(screen.queryByText(/unknown/)).not.toBeInTheDocument();
expect(screen.queryByText(/0ms/)).not.toBeInTheDocument();
});
it("test connection shows the offline error text", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
vi.mocked(commands.testConnection).mockResolvedValue({
ok: false,
error: "connection refused",
} as any);
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Test connection"));
await waitFor(() =>
expect(screen.getByText(/connection refused/i)).toBeInTheDocument(),
);
});
it("Manage expands to reveal Edit/Duplicate/Delete and Edit calls onEdit + closes", async () => {
const { onEdit } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
expect(screen.getByText("Edit…")).toBeInTheDocument();
expect(screen.getByText("Duplicate")).toBeInTheDocument();
expect(screen.getByText("Delete…")).toBeInTheDocument();
await userEvent.click(screen.getByText("Edit…"));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Edit…")).not.toBeInTheDocument();
});
it("Duplicate calls onDuplicate + closes the menu", async () => {
const { onDuplicate } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
await userEvent.click(screen.getByText("Duplicate"));
expect(onDuplicate).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Delete is rendered in red, calls onDelete + closes the menu", async () => {
const { onDelete } = renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Manage"));
const deleteItem = screen.getByText("Delete…");
expect(deleteItem.className).toContain("text-red");
await userEvent.click(deleteItem);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("closes on outside mousedown", async () => {
renderMenu();
await openMenu();
expect(screen.getByText("Manage")).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("closes on Escape", async () => {
renderMenu();
await openMenu();
expect(screen.getByText("Manage")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Copy connection URL fetches the password and copies the full URL", async () => {
vi.mocked(commands.getConnectionPassword).mockResolvedValue("s3cret");
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Copy connection URL"));
expect(commands.getConnectionPassword).toHaveBeenCalledWith("c1");
expect(writeText).toHaveBeenCalledWith(
"postgresql://:s3cret@prod.example.com:5432",
);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
it("Copy connection URL (no password) does not fetch the password", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderMenu();
await openMenu();
await userEvent.click(screen.getByText("Copy connection URL (no password)"));
expect(commands.getConnectionPassword).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledWith(
"postgresql://prod.example.com:5432",
);
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from "react";
import {
Activity,
ChevronRight,
CircleCheck,
CircleX,
Copy,
EyeOff,
Loader2,
MoreVertical,
Pencil,
Star,
Trash2,
} from "lucide-react";
import type { Connection } from "../../lib/types";
import { useConnectionStore } from "../../stores/connectionStore";
import { buildConfigFromConnection } from "./ConnectionCard";
import { buildConnectionUrl } from "../../lib/connectionString";
import { useConnectionStatus } from "./useConnectionStatus";
interface ConnectionCardMenuProps {
connection: Connection;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
const menuItemClass =
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text hover:bg-surface transition-colors cursor-pointer";
/**
* Kebab (⋮) actions menu for a connection card. Hosts the favorite toggle,
* on-demand connection test (inline status), and a Manage submenu
* (Edit… / Duplicate / Delete…). Closes on outside mousedown, Escape, and
* after selecting an action.
*/
export function ConnectionCardMenu({
connection,
onEdit,
onDuplicate,
onDelete,
}: ConnectionCardMenuProps) {
const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { state, info, check } = useConnectionStatus(
connection.id,
(pw) => buildConfigFromConnection(connection, pw),
);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
setManageOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setManageOpen(false);
}
};
// Capture phase so we fire before other stopPropagation handlers
document.addEventListener("mousedown", handleMouseDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const toggleMenu = () => {
setOpen((prev) => {
const next = !prev;
if (!next) setManageOpen(false);
return next;
});
};
const close = () => {
setOpen(false);
setManageOpen(false);
};
const copyUrl = async (withPassword: boolean) => {
let password: string | null = null;
if (withPassword) {
password = await useConnectionStore
.getState()
.getConnectionPassword(connection.id)
.catch(() => null);
}
const url = buildConnectionUrl(connection, password);
await navigator.clipboard.writeText(url).catch(() => {});
close();
};
const statusLabel =
state === "checking"
? "Testing…"
: state === "online"
? `Online${info ? ` · ${info}` : ""}`
: state === "offline"
? info
: "Test connection";
const statusIcon =
state === "checking" ? (
<Loader2 size={14} className="animate-spin text-text-muted" />
) : state === "online" ? (
<CircleCheck size={14} className="shrink-0 text-green-500" />
) : state === "offline" ? (
<CircleX size={14} className="shrink-0 text-red-500" />
) : (
<Activity size={14} className="text-text-muted" />
);
return (
<div className="absolute top-1/2 -translate-y-1/2 right-0.5 z-10" ref={menuRef}>
<button
type="button"
aria-label="Connection actions"
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggleMenu();
}}
className={`rounded-md p-1 text-text-muted hover:text-text hover:bg-surface transition-colors cursor-pointer ${
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
}`}
>
<MoreVertical size={16} />
</button>
{open && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute top-full right-0 mt-1 w-64 rounded-md border border-border bg-canvas shadow-lg z-20 py-1 text-xs"
>
<button
type="button"
onClick={() => {
void useConnectionStore
.getState()
.toggleFavorite(connection.id)
.catch(() => {});
close();
}}
className={menuItemClass}
>
<Star
size={14}
className={
connection.favorite
? "text-amber-400 fill-amber-400"
: "text-text-muted"
}
/>
<span className="truncate">
{connection.favorite
? "Remove from favorites"
: "Add to favorites"}
</span>
</button>
<button
type="button"
onClick={() => {
void check();
}}
className={menuItemClass}
>
{statusIcon}
<span
className={
state === "offline"
? "whitespace-normal break-words text-red-500"
: "truncate"
}
>
{statusLabel}
</span>
</button>
<button
type="button"
onClick={() => {
void copyUrl(true);
}}
className={menuItemClass}
>
<Copy size={14} className="text-text-muted" />
<span className="truncate">Copy connection URL</span>
</button>
<button
type="button"
onClick={() => {
void copyUrl(false);
}}
className={menuItemClass}
>
<EyeOff size={14} className="text-text-muted" />
<span className="truncate">Copy connection URL (no password)</span>
</button>
<button
type="button"
onClick={() => setManageOpen((m) => !m)}
className={`${menuItemClass} justify-between`}
>
<span>Manage</span>
<ChevronRight
size={14}
className={`text-text-muted transition-transform ${
manageOpen ? "rotate-90" : ""
}`}
/>
</button>
{manageOpen && (
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
onEdit();
close();
}}
className={`${menuItemClass} pl-6`}
>
<Pencil size={14} className="text-text-muted" />
Edit…
</button>
<button
type="button"
onClick={() => {
onDuplicate();
close();
}}
className={`${menuItemClass} pl-6`}
>
<Copy size={14} className="text-text-muted" />
Duplicate
</button>
<button
type="button"
onClick={() => {
onDelete();
close();
}}
className={`${menuItemClass} pl-6 !text-red-500 hover:!text-red-400`}
>
<Trash2 size={14} className="text-red-500" />
Delete…
</button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,55 @@
import { ChevronLeft } from "lucide-react";
import { Button } from "../ui/Button";
import type { ReactNode } from "react";
interface ConnectionFormShellProps {
onBack: () => void;
onTest: () => void;
onSave: () => void;
testLoading?: boolean;
saveLoading?: boolean;
children: ReactNode;
}
export function ConnectionFormShell({
onBack,
onTest,
onSave,
testLoading,
saveLoading,
children,
}: ConnectionFormShellProps) {
return (
<div className="h-full overflow-y-auto bg-canvas">
<div className="max-w-lg mx-auto p-8">
<Button
variant="ghost"
onClick={onBack}
className="mb-4 -ml-3 justify-start gap-1 px-3"
>
<ChevronLeft size={16} /> Back
</Button>
<div className="space-y-4">{children}</div>
<div className="flex gap-3 mt-8">
<Button
variant="secondary"
onClick={onTest}
disabled={testLoading}
className="flex-1"
>
{testLoading ? "Testing..." : "Test Connection"}
</Button>
<Button
onClick={onSave}
disabled={saveLoading}
className="flex-1"
>
{saveLoading ? "Saving..." : "Save Connection"}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionGrid } from "./ConnectionGrid";
import { useUiStore } from "../../stores/uiStore";
import type { Connection, Folder, Tag } from "../../lib/types";
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
username: null, folder_id, keychain_ref: null, tag_ids: [],
created_at: "", updated_at: "", environment: null, favorite: false,
});
const folders: Folder[] = [
{ id: "f1", name: "Work", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Personal", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f3", name: "Client A", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
];
describe("ConnectionGrid", () => {
beforeEach(() => {
useUiStore.setState({ activeTagIds: [], activeDbTypes: [], activeEnvironment: null });
});
it("renders empty state when no connections and no folders", () => {
render(<ConnectionGrid connections={[]} tags={[]} />);
expect(screen.getByText(/no connections yet/i)).toBeInTheDocument();
});
it("renders cards for each connection", () => {
const conns = [makeConn("1"), makeConn("2")];
render(<ConnectionGrid connections={conns} tags={[]} />);
expect(screen.getByText("Conn 1")).toBeInTheDocument();
expect(screen.getByText("Conn 2")).toBeInTheDocument();
});
it("renders no-results state when filtered empty", () => {
render(<ConnectionGrid connections={[]} tags={[]} hasSearch />);
expect(screen.getByText(/no connections match/i)).toBeInTheDocument();
});
it("renders only top-level folders at root", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
expect(screen.queryByText("Client A")).not.toBeInTheDocument();
});
it("renders only children of active folder", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" />);
expect(screen.getByText("Client A")).toBeInTheDocument();
expect(screen.queryByText("Personal")).not.toBeInTheDocument();
});
it("calls onFolderSelect with folder id on click", async () => {
const fn = vi.fn();
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} onFolderSelect={fn} />);
await userEvent.click(screen.getByText("Work"));
expect(fn).toHaveBeenCalledWith("f1");
});
it("breadcrumb navigates to root", async () => {
const fn = vi.fn();
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} activeFolderId="f1" onFolderSelect={fn} />);
await userEvent.click(screen.getByText(/all connections/i));
expect(fn).toHaveBeenCalledWith(null);
});
it("shows folder cards", () => {
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("hides folders that match no tags and contain no matching connections", () => {
useUiStore.setState({ activeTagIds: ["t1"] });
const taggedFolders: Folder[] = [
{ id: "f1", name: "Tagged Folder", parent_id: null, tag_ids: ["t1"], created_at: "", updated_at: "" },
{ id: "f2", name: "Untagged Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
render(<ConnectionGrid connections={[]} tags={tags} folders={taggedFolders} />);
expect(screen.getByText("Tagged Folder")).toBeInTheDocument();
expect(screen.queryByText("Untagged Folder")).not.toBeInTheDocument();
});
it("shows folder when it contains a matching connection even if untagged", () => {
useUiStore.setState({ activeTagIds: ["t1"] });
const foldersWithConn: Folder[] = [
{ id: "f1", name: "Parent", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns = [makeConn("c1", "f1")];
conns[0] = { ...conns[0], tag_ids: ["t1"] };
const tags: Tag[] = [{ id: "t1", name: "prod", color: "#f00", created_at: "" }];
render(<ConnectionGrid connections={conns} tags={tags} folders={foldersWithConn} />);
expect(screen.getByText("Parent")).toBeInTheDocument();
});
it("shows all folders when no tag filter is active", () => {
useUiStore.setState({ activeTagIds: [] });
render(<ConnectionGrid connections={[]} tags={[]} folders={folders} />);
expect(screen.getByText("Work")).toBeInTheDocument();
expect(screen.getByText("Personal")).toBeInTheDocument();
});
it("hides folders whose connections don't match the DB type filter", () => {
useUiStore.setState({ activeDbTypes: ["sqlite"] });
const typedFolders: Folder[] = [
{ id: "f1", name: "PG Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "SQLite Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns: Connection[] = [
{ ...makeConn("c1", "f1"), db_type: "postgresql" },
{ ...makeConn("c2", "f2"), db_type: "sqlite" },
];
render(<ConnectionGrid connections={conns} tags={[]} folders={typedFolders} />);
expect(screen.queryByText("PG Folder")).not.toBeInTheDocument();
expect(screen.getByText("SQLite Folder")).toBeInTheDocument();
});
it("hides folders whose connections don't match the environment filter", () => {
useUiStore.setState({ activeEnvironment: "production" });
const envFolders: Folder[] = [
{ id: "f1", name: "Prod Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Dev Folder", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns: Connection[] = [
{ ...makeConn("c1", "f1"), environment: "production" },
{ ...makeConn("c2", "f2"), environment: "development" },
];
render(<ConnectionGrid connections={conns} tags={[]} folders={envFolders} />);
expect(screen.getByText("Prod Folder")).toBeInTheDocument();
expect(screen.queryByText("Dev Folder")).not.toBeInTheDocument();
});
it("shows search results from all folders as if at root", () => {
useUiStore.setState({ activeFolderId: "f1", searchQuery: "conn" });
const searchFolders: Folder[] = [
{ id: "f1", name: "Folder 1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
{ id: "f2", name: "Folder 2", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
];
const conns = [makeConn("c1", "f1"), makeConn("c2", "f2")];
render(
<ConnectionGrid
connections={conns}
tags={[]}
folders={searchFolders}
activeFolderId="f1"
hasSearch
/>
);
expect(screen.getByText("Conn c2")).toBeInTheDocument();
expect(screen.queryByText("Folder 1")).not.toBeInTheDocument();
expect(screen.queryByText("Folder 2")).not.toBeInTheDocument();
expect(screen.getByText("Showing Search Results")).toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import type { Connection, Folder, Tag } from "../../lib/types";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
import { getChildFolders, getDescendantFolderIds } from "../../lib/utils";
import { useUiStore } from "../../stores/uiStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { TagBadge } from "../tags/TagBadge";
interface DroppableFolderCardProps {
folder: Folder;
isSelected: boolean;
count: number;
subfolderCount: number;
folderTags: Tag[];
onFolderClick: (id: string) => void;
onToggleSelection: (id: string) => void;
}
function DroppableFolderCard({
folder,
isSelected,
count,
subfolderCount,
folderTags,
onFolderClick,
onToggleSelection,
}: DroppableFolderCardProps) {
const { setNodeRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div
ref={setNodeRef}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<button
onClick={() => onFolderClick(folder.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{folder.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 && subfolderCount > 0 && " · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelection(folder.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
);
}
interface ConnectionGridProps {
connections: Connection[];
tags: Tag[];
folders?: Folder[];
activeFolderId?: string | null;
onFolderSelect?: (id: string | null) => void;
hasSearch?: boolean;
onTagToggle?: (id: string) => void;
onEditFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onOpenDbViewer?: (connectionId: string) => void;
onEditConnection?: (conn: Connection) => void;
onDuplicateConnection?: (conn: Connection) => void;
onDeleteConnection?: (conn: Connection) => void;
}
export function ConnectionGrid({
connections,
tags,
folders = [],
activeFolderId = null,
onFolderSelect,
hasSearch = false,
onTagToggle,
onEditFolder,
onDeleteFolder,
onOpenDbViewer,
onEditConnection,
onDuplicateConnection,
onDeleteConnection,
}: ConnectionGridProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const clearSelection = useUiStore((s) => s.clearSelection);
const activeTagIds = useUiStore((s) => s.activeTagIds);
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
const currentFolderId = hasSearch
? null
: activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
? activeFolderId
: null;
const hasActiveFilters =
activeTagIds.length > 0 ||
activeDbTypes.length > 0 ||
(activeEnvironment !== null && activeEnvironment !== undefined);
const connectionMatchesFilters = (c: Connection) => {
if (activeTagIds.length > 0 && !c.tag_ids.some((id) => activeTagIds.includes(id))) {
return false;
}
if (activeDbTypes.length > 0 && !activeDbTypes.includes(c.db_type)) {
return false;
}
if (activeEnvironment !== null && activeEnvironment !== undefined && c.environment !== activeEnvironment) {
return false;
}
return true;
};
const visibleFolders = hasSearch
? []
: getChildFolders(folders, currentFolderId).filter((f) => {
if (!hasActiveFilters) return true;
const folderMatchesTags = f.tag_ids.some((id) => activeTagIds.includes(id));
if (folderMatchesTags) return true;
const subIds = new Set(getDescendantFolderIds(folders, f.id));
return connections.some(
(c) => c.folder_id !== null && subIds.has(c.folder_id) && connectionMatchesFilters(c),
);
});
const directConnections = hasSearch
? connections
: connections.filter((c) => c.folder_id === currentFolderId);
const allStoreConnections = useConnectionStore((s) => s.connections);
const allStoreFolders = useConnectionStore((s) => s.folders);
// Check if any direct connections OR any subfolder has connections anywhere below
const hasItems = visibleFolders.length > 0 || directConnections.length > 0 ||
(currentFolderId && allStoreConnections.some((c) => {
const allowed = new Set(getDescendantFolderIds(allStoreFolders, currentFolderId));
return c.folder_id !== null && allowed.has(c.folder_id);
}));
const isSelecting = selectedItemIds.length > 0;
const activeFolder = currentFolderId
? (folders.find((f) => f.id === currentFolderId) ?? null)
: null;
const handleFolderClick = (folderId: string) => {
if (isSelecting) {
toggleItemSelection(folderId);
} else {
onFolderSelect?.(folderId);
}
};
const handleBreadcrumbNavigate = (folderId: string | null) => {
clearSelection();
onFolderSelect?.(folderId);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FolderBreadcrumb
folders={folders}
activeFolderId={currentFolderId}
onNavigate={handleBreadcrumbNavigate}
hasSearch={hasSearch}
onClearSearch={() => useUiStore.getState().clearFilters()}
/>
{activeFolder && (
<div className="flex items-center gap-1">
<button
onClick={() => onEditFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Pencil size={12} /> Edit
</button>
<button
onClick={() => onDeleteFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs !text-red-400 hover:!text-red-300 transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Trash2 size={12} /> Delete
</button>
</div>
)}
</div>
{!hasItems ? (
<div className="text-center w-full py-16 text-text-muted">
{hasSearch
? "No connections match your search."
: activeFolderId
? "This folder is empty. Add a connection or subfolder."
: "No connections yet. Create one to get started."}
</div>
) : (
<div
className="grid gap-3"
style={{
gridTemplateColumns:
"repeat(auto-fill, minmax(260px, 1fr))",
}}
>
{visibleFolders.map((f) => {
const isSelected = selectedItemIds.includes(f.id);
// Count all connections in this subfolder (including nested descendants)
const subIds = new Set(getDescendantFolderIds(folders, f.id));
const count = allStoreConnections.filter(
(c) => c.folder_id !== null && subIds.has(c.folder_id),
).length;
const subfolderCount = getChildFolders(
folders,
f.id,
).length;
const tagMap = new Map(tags.map((t) => [t.id, t]));
const folderTags = f.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as Tag[];
return (
<DroppableFolderCard
key={f.id}
folder={f}
isSelected={isSelected}
count={count}
subfolderCount={subfolderCount}
folderTags={folderTags}
onFolderClick={handleFolderClick}
onToggleSelection={toggleItemSelection}
/>
);
})}
{directConnections.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
tags={tags}
onTagToggle={onTagToggle}
onOpenDbViewer={onOpenDbViewer}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
onDelete={onDeleteConnection}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "", environment: null, folder_id: null, tag_ids: [],
connection_string: "", db_type: "postgresql", host: "", port: 5432,
username: null, password: null, database: null, use_keychain: true, ssh_password: null,
};
describe("ConnectionMetadataRow", () => {
it("renders a Connection Label input and emits name changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
function Wrapper() {
const [form, setForm] = useState(BASE_FORM);
return (
<ConnectionMetadataRow
form={form}
onChange={(updates) => {
onChange(updates);
setForm((prev) => ({ ...prev, ...updates }));
}}
/>
);
}
render(<Wrapper />);
const label = screen.getByLabelText(/connection label/i);
expect(label).toBeInTheDocument();
await user.type(label, "My DB");
expect(onChange).toHaveBeenLastCalledWith({ name: "My DB" });
});
it("does not render tag/env/folder controls", () => {
render(<ConnectionMetadataRow form={BASE_FORM} onChange={vi.fn()} />);
expect(screen.queryByRole("button", { name: /add tags/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /set env/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("environment-section")).not.toBeInTheDocument();
expect(screen.queryByTestId("folder-section")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { Input } from "../ui/Input";
import type { ConnectionFormData } from "./connectionFormData";
interface ConnectionMetadataRowProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
return (
<div className="space-y-3">
<label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
aria-label="Connection Label"
/>
</div>
);
}
@@ -0,0 +1,24 @@
import { forwardRef } from "react";
import type { KeyboardEvent } from "react";
interface ConnectionStringInputProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export const ConnectionStringInput = forwardRef<HTMLInputElement, ConnectionStringInputProps>(
function ConnectionStringInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
return (
<input
ref={ref}
className={`w-full rounded-lg bg-surface border border-border px-4 py-3 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer font-mono ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
/>
);
}
);
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import type { ConnectionFormData } from "./connectionFormData";
import type { DetailedConnectionFormProps } from "./DetailedConnectionForm";
const BASE_FORM: ConnectionFormData = {
name: "",
environment: null,
folder_id: null,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: 5432,
username: null,
password: null,
database: null,
use_keychain: true,
ssh_password: null,
};
function StatefulForm(
props: Omit<DetailedConnectionFormProps, "form" | "onChange"> & {
onChange?: (updates: Partial<ConnectionFormData>) => void;
folders?: unknown;
tags?: unknown;
},
) {
const [form, setForm] = useState<ConnectionFormData>(BASE_FORM);
return (
<DetailedConnectionForm
{...props}
form={form}
onChange={(updates) => {
setForm((prev) => ({ ...prev, ...updates }));
props.onChange?.(updates);
}}
/>
);
}
describe("DetailedConnectionForm", () => {
it("updates host and port", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm onChange={onChange} />);
await user.type(screen.getByLabelText(/host/i), "localhost");
await user.clear(screen.getByLabelText(/port/i));
await user.type(screen.getByLabelText(/port/i), "5432");
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ port: 5432 }));
});
it("renders General, Tags & Env, and SSH / SSL tabs", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={vi.fn()} />);
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /tags & env/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
});
it("renders the metadata row (Connection Label) above the tabs", () => {
render(<StatefulForm folders={[]} tags={[]} onChange={() => {}} />);
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
});
it("updates host via the General tab", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulForm folders={[]} tags={[]} onChange={onChange} />);
await user.type(screen.getByLabelText(/host/i), "localhost");
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "localhost" }));
});
});
@@ -0,0 +1,36 @@
import { useState } from "react";
import { GeneralTab } from "./GeneralTab";
import { SshSslTab } from "./SshSslTab";
import { TagsEnvTab } from "./TagsEnvTab";
import { ConnectionMetadataRow } from "./ConnectionMetadataRow";
import type { ConnectionFormData } from "./connectionFormData";
export interface DetailedConnectionFormProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
return (
<div className="space-y-4">
<ConnectionMetadataRow form={form} onChange={onChange} />
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
<button type="button" onClick={() => setActiveTab("tagsEnv")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "tagsEnv" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>Tags & Env</button>
<button type="button" onClick={() => setActiveTab("ssh")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>SSH / SSL</button>
</div>
{activeTab === "general" ? (
<GeneralTab form={form} onChange={onChange} managedPreset={managedPreset} />
) : activeTab === "tagsEnv" ? (
<TagsEnvTab form={form} onChange={onChange} />
) : (
<SshSslTab form={form as unknown as Record<string, unknown>} onChange={onChange as (u: Record<string, unknown>) => void} />
)}
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { SelectDropdown } from "../ui/SelectDropdown";
export type Environment = "production" | "staging" | "development" | null;
interface EnvironmentSelectProps {
value: Environment;
onChange: (value: Environment) => void;
}
const OPTIONS: { value: Environment; label: string }[] = [
{ value: null, label: "None" },
{ value: "production", label: "Production" },
{ value: "staging", label: "Staging" },
{ value: "development", label: "Development" },
];
export function EnvironmentSelect({ value, onChange }: EnvironmentSelectProps) {
return (
<SelectDropdown
value={value ?? ""}
onChange={(next) =>
onChange(next === "" ? null : (next as Environment))
}
options={OPTIONS.map((opt) => ({
value: opt.value ?? "",
label: opt.label,
}))}
placeholder="None"
/>
);
}
@@ -0,0 +1,28 @@
import { SelectDropdown } from "../ui/SelectDropdown";
import type { Folder } from "../../lib/types";
import { getFolderPathLabel } from "../../lib/utils";
interface FolderSelectProps {
folders: Folder[];
value: string | null;
onChange: (value: string | null) => void;
}
export function FolderSelect({ folders, value, onChange }: FolderSelectProps) {
const options = [
{ value: "", label: "None" },
...folders.map((folder) => ({
value: folder.id,
label: getFolderPathLabel(folders, folder.id),
})),
];
return (
<SelectDropdown
value={value ?? ""}
onChange={(next) => onChange(next === "" ? null : next)}
options={options}
placeholder="None"
/>
);
}
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { GeneralTab } from "./GeneralTab";
import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "My DB", environment: null, folder_id: null, tag_ids: [],
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
use_keychain: true, ssh_password: null,
};
describe("GeneralTab", () => {
it("renders the Connection URI input (not Name)", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
});
it("renders the OR divider, host, port, user, password, database, and keychain", () => {
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
expect(screen.getByTestId("or-divider")).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toBeInTheDocument();
expect(screen.getByLabelText("Authentication")).toBeInTheDocument();
expect(screen.getByLabelText("User")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText(/database/i)).toBeInTheDocument();
expect(screen.getByLabelText(/keychain/i)).toBeInTheDocument();
});
it("emits host changes", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Host"), { target: { value: "newhost" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ host: "newhost" }));
});
it("shows SqlitePathInput (File Path) and hides Host/Port for sqlite", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "sqlite", host: "/data/x.db", port: null }} onChange={() => {}} />);
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Host")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Port")).not.toBeInTheDocument();
});
it("emits a connection_string change when the URI field is edited", () => {
const onChange = vi.fn();
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/connection uri/i), { target: { value: "postgresql://u@h/db" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ connection_string: "postgresql://u@h/db" }));
});
it("shows an SSL hint when a managed-PG preset is active", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("defaults the keychain toggle to ON (opt-out)", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("shows the DB-password-only tooltip text", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
expect(screen.getByText(/DB password only/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,92 @@
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import { SqlitePathInput } from "./SqlitePathInput";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionFormData } from "./connectionFormData";
export interface GeneralTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | null;
}
const AUTH_OPTIONS = ["User & Password"];
export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
const isSqlite = form.db_type === "sqlite";
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">{isSqlite ? "File Path" : "Connection URI"}</label>
{isSqlite ? (
<SqlitePathInput value={form.host} onChange={(value) => onChange({ host: value })} />
) : (
<textarea
value={form.connection_string}
onChange={(e) => onChange({ connection_string: e.target.value })}
placeholder="postgresql://user:password@host:5432/database"
aria-label="Connection URI"
rows={1}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors resize-none overflow-x-auto whitespace-nowrap`}
/>
)}
{managedPreset && (
<p className="text-xs text-accent-muted mt-1.5">
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL.
</p>
)}
</div>
{!isSqlite && (
<>
<div className="flex items-center gap-3 my-1" data-testid="or-divider">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-sm text-text mb-1.5">Host</label>
<Input value={form.host} onChange={(value) => onChange({ host: value })} placeholder="localhost" aria-label="Host" />
</div>
<div className="w-28">
<label className="block text-sm text-text mb-1.5">Port</label>
<Input type="number" value={form.port?.toString() ?? ""} onChange={(value) => onChange({ port: value === "" ? null : Number(value) })} placeholder="5432" aria-label="Port" className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]" />
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Authentication</label>
<select
value={AUTH_OPTIONS[0]}
disabled
aria-label="Authentication"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text opacity-70 cursor-not-allowed`}
>
{AUTH_OPTIONS.map((opt) => <option key={opt}>{opt}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-text mb-1.5">User</label>
<Input value={form.username ?? ""} onChange={(value) => onChange({ username: value || null })} placeholder="postgres" aria-label="User" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Password</label>
<PasswordInput value={form.password ?? ""} onChange={(value) => onChange({ password: value || null })} placeholder="••••••••" aria-label="Password" />
</div>
<div>
<label className="block text-sm text-text mb-1.5">Database (optional)</label>
<Input value={form.database ?? ""} onChange={(value) => onChange({ database: value || null })} placeholder="database" aria-label="Database" />
</div>
</>
)}
<label className="flex items-center gap-2 text-sm text-text cursor-pointer">
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
Enable keychain
</label>
<p className="text-xs text-text-muted -mt-1 mb-2">
Saves the DB password to the OS keychain (DB password only; SSH secrets always use the keychain). Uncheck to never persist the password — you'll re-enter it each session.
</p>
</div>
);
}
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MoveToFolderDialog } from "./MoveToFolderDialog";
import type { Folder } from "../../lib/types";
const makeFolder = (id: string, name: string): Folder => ({
id,
name,
parent_id: null,
tag_ids: [],
created_at: "",
updated_at: "",
});
describe("MoveToFolderDialog", () => {
it("lists folders + Root and calls onConfirm with the chosen id", () => {
const onConfirm = vi.fn();
const onClose = vi.fn();
render(
<MoveToFolderDialog
open
folders={[makeFolder("f1", "Prod")]}
selectedCount={3}
onConfirm={onConfirm}
onClose={onClose}
/>,
);
expect(screen.getByText(/3 items/)).toBeInTheDocument();
fireEvent.click(screen.getByText("Prod"));
fireEvent.click(screen.getByRole("button", { name: /move/i }));
expect(onConfirm).toHaveBeenCalledWith("f1");
});
it("Root option passes null", () => {
const onConfirm = vi.fn();
render(
<MoveToFolderDialog
open
folders={[]}
selectedCount={1}
onConfirm={onConfirm}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByText(/root/i));
fireEvent.click(screen.getByRole("button", { name: /move/i }));
expect(onConfirm).toHaveBeenCalledWith(null);
});
it("Cancel button calls onClose", () => {
const onClose = vi.fn();
render(
<MoveToFolderDialog
open
folders={[]}
selectedCount={2}
onConfirm={vi.fn()}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it("Move button is disabled until an option is chosen", () => {
render(
<MoveToFolderDialog
open
folders={[makeFolder("f1", "Prod")]}
selectedCount={1}
onConfirm={vi.fn()}
onClose={vi.fn()}
/>,
);
const move = screen.getByRole("button", { name: /move/i });
expect(move).toBeDisabled();
fireEvent.click(screen.getByText("Prod"));
expect(move).not.toBeDisabled();
});
});
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import { Folder as FolderIcon, Check } from "lucide-react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import type { Folder } from "../../lib/types";
interface MoveToFolderDialogProps {
open: boolean;
folders: Folder[];
selectedCount: number;
onConfirm: (targetFolderId: string | null) => void;
onClose: () => void;
}
export function MoveToFolderDialog({
open,
folders,
selectedCount,
onConfirm,
onClose,
}: MoveToFolderDialogProps) {
const [target, setTarget] = useState<string | null>(null);
const [selected, setSelected] = useState(false);
// Reset on close so a fresh open starts unselected (null is a valid target = Root).
useEffect(() => {
if (!open) {
setTarget(null);
setSelected(false);
}
}, [open]);
const handleConfirm = () => {
onConfirm(target);
};
const isSelected = (id: string | null) => target === id;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-[360px]">
<h3 className="font-heading text-lg text-text mb-4">
Move {selectedCount} item{selectedCount !== 1 ? "s" : ""} to folder
</h3>
<div className="max-h-[300px] overflow-y-auto space-y-1 pr-1">
<button
type="button"
onClick={() => {
setTarget(null);
setSelected(true);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
isSelected(null)
? "bg-accent/10 text-text"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<FolderIcon size={14} />
<span className="flex-1">Root (no folder)</span>
{isSelected(null) && <Check size={14} className="text-accent" />}
</button>
{folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => {
setTarget(folder.id);
setSelected(true);
}}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
isSelected(folder.id)
? "bg-accent/10 text-text"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<FolderIcon size={14} />
<span className="flex-1 truncate">{folder.name}</span>
{isSelected(folder.id) && <Check size={14} className="text-accent" />}
</button>
))}
</div>
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selected}>
Move
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NewConnectionScreen } from "./NewConnectionScreen";
import { useSettingsStore } from "../../stores/settingsStore";
const { createConnection, notify, testConnection } = vi.hoisted(() =>
({
createConnection: vi.fn().mockResolvedValue({}),
notify: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../../stores/connectionStore", () => ({
createConnection,
useConnectionStore: (selector: (s: { createConnection: typeof createConnection }) => unknown) =>
selector({ createConnection }),
}));
vi.mock("../../stores/notificationStore", () => ({
notify,
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("../../lib/commands", () => ({
testConnection,
}));
describe("NewConnectionScreen", () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ settings: null, loading: false, error: null });
});
it("entry stage: renders Connection URI input and provider grid, not the full form", () => {
render(<NewConnectionScreen />);
expect(screen.getByLabelText(/connection uri/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.queryByLabelText(/connection label/i)).not.toBeInTheDocument();
});
it("paste a recognized URL reveals the form and fills fields", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("u");
});
it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Port")).toHaveValue(3306);
});
it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /SQLite/i }));
expect(screen.getByLabelText(/file path/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/connection uri/i)).not.toBeInTheDocument();
});
it("shows validation error when saving an empty configured form", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /PostgreSQL/i }));
await user.click(screen.getByText("Save Connection"));
expect(notify).toHaveBeenCalledWith("name is required", "error");
expect(createConnection).not.toHaveBeenCalled();
});
it("saves a connection with label + parsed URL", async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
render(<NewConnectionScreen onSaved={onSaved} />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(expect.objectContaining({
name: "Local DB", db_type: "postgresql", host: "localhost", port: 5432,
username: "u", password: "p", database: "db",
}));
expect(notify).toHaveBeenCalledWith("Connection saved", "success");
expect(onSaved).toHaveBeenCalled();
});
it("calls testConnection when Test Connection is clicked", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "Local DB");
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
});
it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
render(<NewConnectionScreen onCancel={onCancel} />);
await user.click(screen.getByRole("button", { name: "Back" }));
expect(onCancel).toHaveBeenCalled();
});
it("prefills from prefilledConnectionString and reveals the form", async () => {
render(<NewConnectionScreen prefilledConnectionString="postgresql://u:p@localhost:5432/db" />);
expect(screen.getByLabelText(/connection uri/i)).toHaveValue("postgresql://u:p@localhost:5432/db");
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByLabelText("Host")).toHaveValue("localhost");
});
});
@@ -0,0 +1,340 @@
import { useState, useEffect, useCallback } from "react";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { ConnectionFormShell } from "./ConnectionFormShell";
import { DetailedConnectionForm } from "./DetailedConnectionForm";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
import {
parseConnectionString,
detectProviderFromHost,
} from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils";
import { testConnection } from "../../lib/commands";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
import type { ConnectionInput, Folder, Tag } from "../../lib/types";
import type { ConnectionFormData } from "./connectionFormData";
interface NewConnectionScreenProps {
defaultFolderId?: string | null;
prefilledConnectionString?: string;
folders?: Folder[];
tags?: Tag[];
onSaved?: () => void;
onCancel?: () => void;
}
type Stage = "entry" | "configured";
const FALLBACK_PORTS: Record<string, number> = {
postgresql: 5432,
mysql: 3306,
redis: 6379,
};
function createEmptyForm(
defaultFolderId: string | null = null,
defaultPorts?: Record<string, number | null>,
): ConnectionFormData {
return {
name: "",
environment: null,
folder_id: defaultFolderId,
tag_ids: [],
connection_string: "",
db_type: "postgresql",
host: "",
port: defaultPorts?.postgresql ?? 5432,
username: null,
password: null,
database: null,
use_keychain: true,
ssh_password: null,
};
}
function getDefaultPort(dbType: string): number {
return (
useSettingsStore.getState().settings?.default_ports?.[dbType] ??
FALLBACK_PORTS[dbType] ??
5432
);
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
folders: _folders,
tags: _tags,
onSaved,
onCancel,
}: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | null
>(null);
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(
defaultFolderId,
useSettingsStore.getState().settings?.default_ports,
),
);
const [testLoading, setTestLoading] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => ({ ...prev, ...updates }));
setStage("configured");
},
[],
);
const handleConnectionStringChange = useCallback((value: string) => {
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}));
} else {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
}
}, []);
useEffect(() => {
if (
stage === "entry" &&
form.connection_string &&
parseConnectionString(form.connection_string)
) {
setStage("configured");
}
}, [form.connection_string, stage]);
useEffect(() => {
if (prefilledConnectionString) {
handleConnectionStringChange(prefilledConnectionString);
}
}, [prefilledConnectionString, handleConnectionStringChange]);
const handleSelectProvider = useCallback(
(id: ProviderId) => {
const provider = getProviderById(id)!;
setManagedPreset(
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
);
revealConfigured({
db_type: provider.dbType,
port:
provider.dbType === "sqlite"
? null
: getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}),
});
},
[revealConfigured],
);
const updateForm = useCallback(
(updates: Partial<ConnectionFormData>) => {
setForm((prev) => {
if (
"connection_string" in updates &&
updates.connection_string !== undefined
) {
const value = updates.connection_string;
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
return {
...prev,
...updates,
db_type: parsed.db_type,
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
};
}
return { ...prev, ...updates };
}
return { ...prev, ...updates };
});
},
[],
);
const buildPayload = useCallback((): ConnectionInput => {
return {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
folder_id: form.folder_id,
tag_ids: form.tag_ids,
connection_string: form.connection_string,
environment: form.environment,
password: form.password,
database: form.database,
use_keychain: form.use_keychain,
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null,
};
}, [form]);
const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload());
return result.ok ? null : result.error;
}, [buildPayload]);
const handleSave = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setSaveLoading(true);
try {
await createConnection(buildPayload());
notify("Connection saved", "success");
onSaved?.();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Failed to save connection: ${message}`, "error");
} finally {
setSaveLoading(false);
}
}, [validate, notify, createConnection, buildPayload, onSaved]);
const handleTest = useCallback(async () => {
const error = validate();
if (error) {
notify(error, "error");
return;
}
setTestLoading(true);
try {
const result = await testConnection(buildPayload());
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
notify(`Connection test failed: ${message}`, "error");
} finally {
setTestLoading(false);
}
}, [validate, notify, testConnection, buildPayload]);
const isEntry = stage === "entry";
const showEntryUri = isEntry || form.db_type !== "sqlite";
return (
<ConnectionFormShell
onBack={() => onCancel?.()}
onTest={handleTest}
onSave={handleSave}
testLoading={testLoading}
saveLoading={saveLoading}
>
<div>
{isEntry && showEntryUri && (
<label className="block text-sm text-text mb-1.5">
Connection URI
</label>
)}
{isEntry && form.db_type === "sqlite" ? (
<div>
<label className="block text-sm text-text mb-1.5">
File Path
</label>
<input
value={form.host}
onChange={(e) =>
setForm((prev) => ({
...prev,
host: e.target.value,
}))
}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
</div>
) : (
<textarea
value={form.connection_string}
onChange={(e) =>
handleConnectionStringChange(e.target.value)
}
placeholder="postgresql://user:password@host:5432/database"
aria-label={isEntry ? "Connection URI" : undefined}
rows={1}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-3 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors resize-none overflow-x-auto whitespace-nowrap ${
isEntry ? "" : "sr-only"
}`}
/>
)}
{isEntry && showEntryUri && (
<p className="text-xs text-text-muted mt-1.5">
Paste a connection string to auto-detect, or pick a
provider below.
</p>
)}
</div>
{isEntry ? (
<>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted">OR</span>
<div className="flex-1 h-px bg-border" />
</div>
<ProviderTabsGrid onSelect={handleSelectProvider} />
</>
) : (
<>
{managedPreset && (
<ProviderSetupGuide provider={managedPreset} />
)}
<DetailedConnectionForm
form={form}
onChange={updateForm}
/>
</>
)}
</ConnectionFormShell>
);
}
@@ -0,0 +1,38 @@
import { useState, forwardRef } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { KeyboardEvent } from "react";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface PasswordInputProps {
value?: string;
placeholder?: string;
className?: string;
onChange?: (value: string) => void;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput({ onChange, onKeyDown, className = "", ...rest }, ref) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<input
ref={ref}
type={visible ? "text" : "password"}
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 pr-10 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer ${className}`}
onChange={(e) => onChange?.(e.target.value)}
onKeyDown={(e) => onKeyDown?.(e)}
{...rest}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text transition-colors cursor-pointer"
aria-label={visible ? "Hide password" : "Show password"}
>
{visible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
);
}
);
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderSetupGuide } from "./ProviderSetupGuide";
describe("ProviderSetupGuide", () => {
it("renders Supabase SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
expect(screen.getByText(/SSL is required by Supabase/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
it("renders Neon SSL note immediately and steps after expanding", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="neon" />);
expect(screen.getByText(/Neon requires SSL/i)).toBeInTheDocument();
expect(screen.queryByText(/Open the Neon Console/i)).not.toBeInTheDocument();
const toggle = screen.getByRole("button", { name: /how to connect/i });
await user.click(toggle);
expect(screen.getByText(/Open the Neon Console/i)).toBeInTheDocument();
});
it("starts collapsed and expands on toggle", async () => {
const user = userEvent.setup();
render(<ProviderSetupGuide provider="supabase" />);
const toggle = screen.getByRole("button", { name: /how to connect/i });
expect(screen.queryByText(/Open the Supabase Dashboard/i)).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText(/Open the Supabase Dashboard/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { SETUP_GUIDES } from "../../lib/providers";
interface ProviderSetupGuideProps {
provider: "supabase" | "neon";
}
const SSL_NOTE: Record<"supabase" | "neon", string> = {
supabase: "SSL is required by Supabase.",
neon: "Neon requires SSL.",
};
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
const [open, setOpen] = useState(false);
const guide = SETUP_GUIDES[provider];
return (
<div className="mt-2 border border-border rounded-lg bg-surface">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label="How to connect"
className="w-full flex items-center justify-between px-3 py-2 text-xs text-text-muted hover:text-text cursor-pointer transition-colors"
>
<span>How to connect</span>
<ChevronDown
size={14}
className={open ? "rotate-180 transition-transform" : "transition-transform"}
aria-hidden="true"
/>
</button>
{guide.sslRequired && (
<p className="px-3 pb-2 text-xs text-accent-muted">{SSL_NOTE[provider]}</p>
)}
{open && (
<ol className="px-3 pb-3 space-y-2 list-decimal list-inside text-xs text-text-muted">
{guide.steps.map((step, i) => (
<li key={i} className="space-y-0.5">
<span className="text-text font-medium">{step.title}</span>
<p className="text-text-muted">{step.detail}</p>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProviderTabsGrid } from "./ProviderTabsGrid";
describe("ProviderTabsGrid", () => {
it("renders exactly 6 provider cards in order", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByRole("button", { name: /PostgreSQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /MySQL/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /SQLite/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Redis/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Supabase/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /NeonDB/i })).toBeInTheDocument();
});
it("applies the 2-column grid layout class", () => {
const { container } = render(<ProviderTabsGrid onSelect={() => {}} />);
const grid = container.querySelector('[data-testid="provider-grid"]');
expect(grid?.className).toContain("grid-cols-2");
});
it("calls onSelect with the provider id when a card is clicked", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(<ProviderTabsGrid onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /MySQL/i }));
expect(onSelect).toHaveBeenCalledWith("mysql");
});
it("highlights the selected provider", () => {
render(<ProviderTabsGrid onSelect={() => {}} selectedId="mysql" />);
const mysqlBtn = screen.getByRole("button", { name: /MySQL/i });
expect(mysqlBtn.className).toContain("border-accent");
});
it("renders setup-guide content on Supabase and NeonDB cards", () => {
render(<ProviderTabsGrid onSelect={() => {}} />);
expect(screen.getByText(/managed postgresql/i)).toBeInTheDocument();
expect(screen.getByText(/serverless postgres/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { PROVIDER_TABS, SETUP_GUIDES, type ProviderId } from "../../lib/providers";
import { DbIcon, ProviderIcon } from "../../lib/dbIcons";
interface ProviderTabsGridProps {
selectedId?: ProviderId | null;
onSelect: (id: ProviderId) => void;
}
export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps) {
return (
<div data-testid="provider-grid" className="grid grid-cols-2 gap-3">
{PROVIDER_TABS.map((p) => {
const isSelected = selectedId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => onSelect(p.id)}
aria-label={p.label}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border bg-surface transition-colors cursor-pointer ${
isSelected
? "border-accent text-text"
: "border-border text-text-muted hover:border-accent/50 hover:text-text"
}`}
>
<span className="w-8 h-8 flex items-center justify-center">
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
</span>
<span className="text-sm font-medium">{p.label}</span>
{p.isManagedPreset && (
<span className="text-[10px] text-text-muted text-center leading-tight">
{SETUP_GUIDES[p.id as keyof typeof SETUP_GUIDES].blurb}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { RecentConnectionsStrip } from "./RecentConnectionsStrip";
import type { Connection } from "../../lib/types";
const makeConn = (id: string): Connection => ({
id,
name: id.toUpperCase(),
db_type: "postgresql",
host: "h",
port: 5432,
username: null,
folder_id: null,
keychain_ref: null,
tag_ids: [],
created_at: "",
updated_at: "",
favorite: false,
});
describe("RecentConnectionsStrip", () => {
it("renders up to 8 recent connections and calls onOpen on click", () => {
const onOpen = vi.fn();
const recents = Array.from({ length: 10 }, (_, i) => makeConn(`c${i}`));
render(<RecentConnectionsStrip recents={recents} onOpen={onOpen} />);
expect(screen.getAllByRole("button")).toHaveLength(8);
fireEvent.click(screen.getByText("C0"));
expect(onOpen).toHaveBeenCalledWith("c0");
});
it("renders nothing when the list is empty", () => {
const { container } = render(
<RecentConnectionsStrip recents={[]} onOpen={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { DbIcon } from "../../lib/dbIcons";
import type { Connection } from "../../lib/types";
interface RecentConnectionsStripProps {
recents: Connection[];
onOpen: (id: string) => void;
}
export function RecentConnectionsStrip({
recents,
onOpen,
}: RecentConnectionsStripProps) {
if (recents.length === 0) return null;
const visible = recents.slice(0, 8);
return (
<div className="mb-4">
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-2">
Recent
</h3>
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{visible.map((connection) => (
<button
key={connection.id}
type="button"
onClick={() => onOpen(connection.id)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface border border-border hover:border-border-hover text-sm text-text transition-colors cursor-pointer whitespace-nowrap"
>
<DbIcon type={connection.db_type} size={14} />
<span className="truncate max-w-[180px]">{connection.name}</span>
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { SqlitePathInput } from "./SqlitePathInput";
function StatefulInput(props: Omit<React.ComponentProps<typeof SqlitePathInput>, "onChange"> & {
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(props.value ?? "");
return (
<SqlitePathInput
{...props}
value={value}
onChange={(v) => {
setValue(v);
props.onChange?.(v);
}}
/>
);
}
const open = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
describe("SqlitePathInput", () => {
it("emits typed path changes", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<StatefulInput value="" onChange={onChange} />);
const input = screen.getByLabelText(/file path/i);
await user.type(input, "/Users/me/data.db");
expect(onChange).toHaveBeenLastCalledWith("/Users/me/data.db");
expect(input).toHaveValue("/Users/me/data.db");
});
it("opens the file dialog on Browse and emits the chosen path", async () => {
const user = userEvent.setup();
open.mockResolvedValue("/chosen/path.db");
const onChange = vi.fn();
render(<SqlitePathInput value="" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(open).toHaveBeenCalledWith({ multiple: false, directory: false });
expect(onChange).toHaveBeenCalledWith("/chosen/path.db");
});
it("does not emit when the dialog is cancelled", async () => {
const user = userEvent.setup();
open.mockResolvedValue(null);
const onChange = vi.fn();
render(<SqlitePathInput value="/existing.db" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /browse/i }));
expect(onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { open } from "@tauri-apps/plugin-dialog";
import { INPUT_ROUNDING } from "../../lib/uiConstants";
interface SqlitePathInputProps {
value: string;
onChange: (value: string) => void;
}
export function SqlitePathInput({ value, onChange }: SqlitePathInputProps) {
const handleBrowse = async () => {
try {
const path = await open({ multiple: false, directory: false });
if (path) onChange(path as string);
} catch {
// dialog unavailable (e.g., web preview) — no-op; user can type the path
}
};
return (
<div className="flex gap-2">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="/path/to/database.sqlite"
aria-label="File Path"
className={`w-full ${INPUT_ROUNDING} bg-surface border border-border px-4 py-2 text-sm text-text font-mono placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors`}
/>
<button
type="button"
onClick={handleBrowse}
aria-label="Browse"
className={`shrink-0 ${INPUT_ROUNDING} bg-surface-raised border border-border px-3 py-2 text-sm text-text hover:border-accent/50 cursor-pointer transition-colors`}
>
Browse…
</button>
</div>
);
}
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SshFields } from "./SshFields";
const notify = vi.fn();
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SshFields", () => {
it("renders SSH host, port, and user fields", () => {
render(<SshFields values={{}} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.getByLabelText("SSH Port")).toBeInTheDocument();
expect(screen.getByLabelText("SSH User")).toBeInTheDocument();
});
it("renders auth method dropdown", () => {
render(<SshFields values={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "Auth Method" })).toBeInTheDocument();
});
it("shows private key and passphrase fields when auth method is key", () => {
render(<SshFields values={{ ssh_auth_method: "key" }} onChange={() => {}} />);
expect(screen.getByLabelText("Private Key")).toBeInTheDocument();
expect(screen.getByLabelText("Passphrase")).toBeInTheDocument();
expect(screen.queryByLabelText("SSH Password")).not.toBeInTheDocument();
});
it("shows password field when auth method is password", () => {
render(<SshFields values={{ ssh_auth_method: "password" }} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Password")).toBeInTheDocument();
expect(screen.queryByLabelText("Private Key")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Passphrase")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,119 @@
import { open } from "@tauri-apps/plugin-dialog";
import { Input } from "../ui/Input";
import { PasswordInput } from "./PasswordInput";
import { SelectDropdown } from "../ui/SelectDropdown";
import { useNotificationStore } from "../../stores/notificationStore";
export interface SshFieldsProps {
values: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
const AUTH_METHOD_OPTIONS = [
{ value: "password", label: "Password" },
{ value: "key", label: "Private Key" },
];
export function SshFields({ values, onChange }: SshFieldsProps) {
const notify = useNotificationStore((s) => s.notify);
const authMethod = (values.ssh_auth_method as string) ?? "password";
const handlePickFile = async (field: string) => {
try {
const path = await open({ multiple: false, directory: false });
if (path) {
onChange({ [field]: path });
}
} catch {
notify("File picker not available", "error");
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">SSH Host</label>
<Input
value={(values.ssh_host as string) ?? ""}
onChange={(value) => onChange({ ssh_host: value })}
placeholder="bastion.example.com"
aria-label="SSH Host"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">SSH Port</label>
<Input
type="number"
value={(values.ssh_port as number)?.toString() ?? "22"}
onChange={(value) => onChange({ ssh_port: value === "" ? null : Number(value) })}
placeholder="22"
aria-label="SSH Port"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">SSH User</label>
<Input
value={(values.ssh_user as string) ?? ""}
onChange={(value) => onChange({ ssh_user: value })}
placeholder="ssh-user"
aria-label="SSH User"
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Auth Method</label>
<SelectDropdown
value={authMethod}
onChange={(value) => onChange({ ssh_auth_method: value })}
options={AUTH_METHOD_OPTIONS}
aria-label="Auth Method"
/>
</div>
{authMethod === "key" ? (
<>
<div>
<label className="block text-sm text-text mb-1.5">Private Key</label>
<div className="flex gap-2">
<Input
value={(values.ssh_private_key as string) ?? ""}
onChange={(value) => onChange({ ssh_private_key: value })}
placeholder="/path/to/key"
aria-label="Private Key"
/>
<button
type="button"
onClick={() => handlePickFile("ssh_private_key")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Passphrase</label>
<PasswordInput
value={(values.ssh_passphrase as string) ?? ""}
onChange={(value) => onChange({ ssh_passphrase: value })}
placeholder="••••••••"
aria-label="Passphrase"
/>
</div>
</>
) : (
<div>
<label className="block text-sm text-text mb-1.5">SSH Password</label>
<PasswordInput
value={(values.ssh_password as string) ?? ""}
onChange={(value) => onChange({ ssh_password: value })}
placeholder="••••••••"
aria-label="SSH Password"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SshSslTab } from "./SshSslTab";
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: () => ({
notify: vi.fn(),
}),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SshSslTab", () => {
it("renders SSH and SSL sub-tab buttons", () => {
render(<SshSslTab form={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "SSH" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "SSL" })).toBeInTheDocument();
});
it("toggles between SSH and SSL content", async () => {
const user = userEvent.setup();
render(<SshSslTab form={{}} onChange={() => {}} />);
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "SSL" }));
expect(screen.queryByLabelText("SSH Host")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "SSH" }));
expect(screen.getByLabelText("SSH Host")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "SSL Mode" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,39 @@
import { useState } from "react";
import { SshFields } from "./SshFields";
import { SslFields } from "./SslFields";
export interface SshSslTabProps {
form: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
export function SshSslTab({ form, onChange }: SshSslTabProps) {
const [activeSubTab, setActiveSubTab] = useState<"ssh" | "ssl">("ssh");
return (
<div>
<div className="flex gap-4 border-b border-border mb-4">
<button
type="button"
onClick={() => setActiveSubTab("ssh")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeSubTab === "ssh" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSH
</button>
<button
type="button"
onClick={() => setActiveSubTab("ssl")}
className={`pb-2 text-sm cursor-pointer transition-colors ${
activeSubTab === "ssl" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"
}`}
>
SSL
</button>
</div>
{activeSubTab === "ssh" ? <SshFields values={form} onChange={onChange} /> : <SslFields values={form} onChange={onChange} />}
</div>
);
}
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { SslFields } from "./SslFields";
const notify = vi.fn();
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (selector: (s: { notify: typeof notify }) => unknown) =>
selector({ notify }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
describe("SslFields", () => {
it("renders SSL mode dropdown", () => {
render(<SslFields values={{}} onChange={() => {}} />);
expect(screen.getByRole("button", { name: "SSL Mode" })).toBeInTheDocument();
});
it("shows file pickers for verify-full mode", () => {
render(<SslFields values={{ ssl_mode: "verify-full" }} onChange={() => {}} />);
expect(screen.getByLabelText("CA Certificate")).toBeInTheDocument();
expect(screen.getByLabelText("Client Certificate")).toBeInTheDocument();
expect(screen.getByLabelText("Client Key")).toBeInTheDocument();
});
it("hides file pickers for disable mode", () => {
render(<SslFields values={{ ssl_mode: "disable" }} onChange={() => {}} />);
expect(screen.queryByLabelText("CA Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,114 @@
import { open } from "@tauri-apps/plugin-dialog";
import { Input } from "../ui/Input";
import { SelectDropdown } from "../ui/SelectDropdown";
import { useNotificationStore } from "../../stores/notificationStore";
export interface SslFieldsProps {
values: Record<string, unknown>;
onChange: (updates: Record<string, unknown>) => void;
}
const SSL_MODE_OPTIONS = [
{ value: "disable", label: "Disable" },
{ value: "require", label: "Require" },
{ value: "verify-ca", label: "Verify CA" },
{ value: "verify-full", label: "Verify Full" },
];
export function SslFields({ values, onChange }: SslFieldsProps) {
const notify = useNotificationStore((s) => s.notify);
const mode = (values.ssl_mode as string) ?? "disable";
const showCertFields = mode === "verify-ca" || mode === "verify-full";
const handlePickFile = async (field: string) => {
try {
const path = await open({ multiple: false, directory: false });
if (path) {
onChange({ [field]: path });
}
} catch {
notify("File picker not available", "error");
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">SSL Mode</label>
<SelectDropdown
value={mode}
onChange={(value) => onChange({ ssl_mode: value })}
options={SSL_MODE_OPTIONS}
aria-label="SSL Mode"
/>
</div>
{mode === "require" && (
<p className="text-sm text-warning bg-warning/10 border border-warning/20 rounded-lg px-3 py-2">
Require mode is vulnerable to man-in-the-middle attacks because it does not verify the server certificate.
</p>
)}
{showCertFields && (
<>
<div>
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_ca_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_cert: value })}
placeholder="/path/to/ca-cert.pem"
aria-label="CA Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_ca_cert")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_client_cert: value })}
placeholder="/path/to/client-cert.pem"
aria-label="Client Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_cert")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Client Key</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_key as string) ?? ""}
onChange={(value) => onChange({ ssl_client_key: value })}
placeholder="/path/to/client-key.pem"
aria-label="Client Key"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_key")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
</button>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,51 @@
import { EnvironmentSelect } from "../connections/EnvironmentSelect";
import { FolderSelect } from "../connections/FolderSelect";
import { SearchableTagPicker } from "../tags/SearchableTagPicker";
import { useConnectionStore } from "../../stores/connectionStore";
import type { ConnectionFormData } from "../connections/connectionFormData";
interface TagsEnvTabProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
}
export function TagsEnvTab({ form, onChange }: TagsEnvTabProps) {
const folders = useConnectionStore((s) => s.folders);
const tags = useConnectionStore((s) => s.tags);
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-text mb-1.5">Environment</label>
<EnvironmentSelect
value={form.environment}
onChange={(value) => onChange({ environment: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Folder</label>
<FolderSelect
folders={folders}
value={form.folder_id ?? null}
onChange={(value) => onChange({ folder_id: value })}
/>
</div>
<div>
<label className="block text-sm text-text mb-1.5">Tags</label>
<SearchableTagPicker
tags={tags}
selectedTagIds={form.tag_ids ?? []}
onToggle={(tagId) => {
const current = form.tag_ids ?? [];
const next = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onChange({ tag_ids: next });
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import type { DbType } from "../../lib/types";
import type { Environment } from "./EnvironmentSelect";
export interface ConnectionFormData {
name: string;
environment: Environment;
folder_id: string | null;
tag_ids: string[];
connection_string: string;
db_type: DbType;
host: string;
port: number | null;
username: string | null;
password: string | null;
database: string | null;
use_keychain: boolean;
// SSH tunnel fields (non-secret flat fields are optional; SshFields writes
// the key path under ssh_private_key, which submit handlers map to
// ssh_private_key_path on ConnectionInput)
ssh_host?: string | null;
ssh_port?: number | null;
ssh_user?: string | null;
ssh_auth_method?: "password" | "key" | null;
ssh_private_key?: string | null;
ssh_password: string | null;
ssh_passphrase?: string | null;
}
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useConnectionStatus } from "./useConnectionStatus";
import * as commands from "../../lib/commands";
describe("useConnectionStatus", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts idle and becomes online with server_version/latency info after a successful check", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({
ok: true,
server_version: "15.2",
latency_ms: 12,
} as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
password: "pw",
} as any)),
);
expect(result.current.state).toBe("idle");
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("online");
expect(result.current.info).toContain("15.2");
expect(result.current.info).toContain("12ms");
});
it("becomes offline with error info when testConnection fails", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({
ok: false,
error: "timeout",
} as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
} as any)),
);
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("offline");
expect(result.current.info).toContain("timeout");
});
it("online with no version/latency reported keeps info empty (no unknown/0ms fallback)", async () => {
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
username: "u",
} as any)),
);
await act(async () => {
await result.current.check();
});
expect(result.current.state).toBe("online");
expect(result.current.info).toBe("");
});
it("debounces: rapid check() calls run one check; a re-check is allowed after 2s", async () => {
vi.useFakeTimers({ now: 100_000 });
try {
const pw = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
const { result } = renderHook(() =>
useConnectionStatus("c1", () => ({
name: "P",
db_type: "postgresql",
host: "h",
port: 5432,
} as any)),
);
await act(async () => {
const p1 = result.current.check();
const p2 = result.current.check();
await Promise.all([p1, p2]);
});
expect(pw).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(2000);
await act(async () => {
await result.current.check();
});
expect(pw).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectionInput, ConnectionTestResult } from "../../lib/types";
import * as cmd from "../../lib/commands";
export type ConnectionStatusState = "idle" | "checking" | "online" | "offline";
const DEBOUNCE_MS = 2000;
const RESET_MS = 5000;
/**
* Extracted from the former status-dot UI: runs a connection test on
* demand, debounced to once per 2s, and auto-resets to "idle" 5s after the
* last check. `check()` is stable (useCallback) and safe to hand to menu
* handlers; it no-ops while a check is already in flight or within the
* debounce window.
*/
export function useConnectionStatus(
connectionId: string,
buildConfig: (password: string | null) => ConnectionInput,
): { state: ConnectionStatusState; info: string; check: () => Promise<void> } {
const [state, setState] = useState<ConnectionStatusState>("idle");
const [info, setInfo] = useState<string>("");
const timeoutRef = useRef<number | null>(null);
const lastCheckRef = useRef<number>(0);
// Mirror `state` in a ref so `check` stays stable while still being able to
// skip a second run while one is already in flight.
const stateRef = useRef<ConnectionStatusState>("idle");
const setStateBoth = useCallback((next: ConnectionStatusState) => {
stateRef.current = next;
setState(next);
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, []);
const check = useCallback(async () => {
const now = Date.now();
if (stateRef.current === "checking") return;
if (now - lastCheckRef.current < DEBOUNCE_MS) return;
lastCheckRef.current = now;
setStateBoth("checking");
setInfo("");
try {
const password = await cmd.getConnectionPassword(connectionId);
const input = buildConfig(password);
const result: ConnectionTestResult = await cmd.testConnection(input);
if (result.ok) {
const parts: string[] = [];
if (result.server_version) parts.push(result.server_version);
if (result.latency_ms != null) parts.push(`${result.latency_ms}ms`);
setInfo(parts.join(" · "));
setStateBoth("online");
} else {
setInfo(result.error ?? "offline");
setStateBoth("offline");
}
} catch (e) {
setInfo(e instanceof Error ? e.message : "offline");
setStateBoth("offline");
}
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
timeoutRef.current = window.setTimeout(() => {
setStateBoth("idle");
setInfo("");
}, RESET_MS);
}, [connectionId, buildConfig, setStateBoth]);
return { state, info, check };
}