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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user