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

- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
2026-08-16 19:31:53 +08:00
parent d752642b4d
commit 6854d889c5
400 changed files with 857 additions and 149 deletions
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import App from "./App";
import type { Settings } from "./lib/types";
import { useConnectionStore } from "./stores/connectionStore";
import { useSettingsStore } from "./stores/settingsStore";
import { useUiStore } from "./stores/uiStore";
vi.mock("./lib/commands", () => ({
getConnections: vi.fn().mockResolvedValue([]),
getFolders: vi.fn().mockResolvedValue([]),
getTags: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({
confirm_before_delete: true,
default_folder_id: null,
theme: "dark",
font_size: "medium",
default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null },
tag_order: null,
table_refresh_rate: 30,
table_page_size: 50,
shortcuts: {},
accent_color: "#2563EB",
editor_font_size: 13,
editor_font_family: "Space Mono",
editor_word_wrap: "off",
editor_minimap: false,
editor_tab_size: 4,
} satisfies Settings),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
}));
beforeEach(() => {
useConnectionStore.setState({
connections: [],
folders: [],
tags: [],
loading: false,
error: null,
});
useSettingsStore.setState({ settings: null, loading: false, error: null });
useUiStore.setState({ activeView: "home" });
useUiStore.setState({ activeFolderId: null });
vi.clearAllMocks();
});
describe("App", () => {
it("renders home view on mount", async () => {
render(<App />);
expect(await screen.findByPlaceholderText(/search connections/i)).toBeInTheDocument();
});
it("shows empty state when no connections", async () => {
render(<App />);
expect(await screen.findByText(/no connections/i)).toBeInTheDocument();
});
it("loads data on mount", async () => {
render(<App />);
await screen.findByPlaceholderText(/search connections/i);
expect(useConnectionStore.getState().loading).toBe(false);
});
it("renders settings page when activeView is settings", async () => {
useUiStore.setState({ activeView: "settings" });
render(<App />);
expect(screen.getByRole("heading", { name: /general/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument();
});
it("renders new connection form when activeView is new-connection", async () => {
useUiStore.setState({ activeView: "new-connection" });
render(<App />);
expect(await screen.findByText("Save Connection")).toBeInTheDocument();
});
it("shows error banner when connectionStore has error", async () => {
const { getConnections } = await import("./lib/commands");
vi.mocked(getConnections).mockRejectedValueOnce(new Error("Storage error"));
useConnectionStore.setState({ connections: [], loading: false, error: null });
render(<App />);
expect(await screen.findByText(/storage error/i)).toBeInTheDocument();
});
it("sets the active folder from default_folder_id on startup", async () => {
const { getFolders, getSettings } = await import("./lib/commands");
vi.mocked(getFolders).mockResolvedValueOnce([
{
id: "folder-1",
name: "Projects",
parent_id: null,
tag_ids: [],
created_at: "",
updated_at: "",
},
]);
// Resolve settings only after folders have loaded so the default-folder
// effect doesn't race HomeScreen's "reset missing folder" effect.
let resolveSettings!: (value: Settings) => void;
vi.mocked(getSettings).mockImplementationOnce(
() =>
new Promise<Settings>((resolve) => {
resolveSettings = resolve;
}),
);
useUiStore.setState({ activeFolderId: null });
render(<App />);
await screen.findByPlaceholderText(/search connections/i);
resolveSettings({
confirm_before_delete: true,
default_folder_id: "folder-1",
theme: "dark",
font_size: "medium",
default_ports: { postgresql: 5432, mysql: 3306, redis: 6379, sqlite: null },
tag_order: null,
table_refresh_rate: 30,
table_page_size: 50,
shortcuts: {},
accent_color: "#2563EB",
editor_font_size: 13,
editor_font_family: "Space Mono",
editor_word_wrap: "off",
editor_minimap: false,
editor_tab_size: 4,
});
await waitFor(() => {
expect(useUiStore.getState().activeFolderId).toBe("folder-1");
});
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useEffect } from "react";
import { useConnectionStore } from "./stores/connectionStore";
import { useSettingsStore } from "./stores/settingsStore";
import { useUiStore } from "./stores/uiStore";
import { useBackupStore } from "./stores/backupStore";
import { HomeScreen } from "./components/layout/HomeScreen";
import { SettingsPage } from "./components/settings/SettingsPage";
import { NewConnectionScreen } from "./components/connections/NewConnectionScreen";
import { ErrorBanner } from "./components/ui/ErrorBanner";
import { ToastContainer } from "./components/ui/Toast";
import { DbViewerScreen } from "./components/db-viewer/DbViewerScreen";
import { useAppearance } from "./hooks/useAppearance";
import { isMacOS } from "./lib/platform";
import { getCurrentWindow } from "@tauri-apps/api/window";
const VIEW_TITLES: Record<string, string> = {
home: "Gridline",
settings: "Settings",
"new-connection": "New Connection",
"db-viewer": "",
};
export default function App() {
const activeView = useUiStore((s) => s.activeView);
const setActiveView = useUiStore((s) => s.setActiveView);
const settingsReturnView = useUiStore((s) => s.settingsReturnView);
const openSettings = useUiStore((s) => s.openSettings);
const loadConnections = useConnectionStore((s) => s.loadAll);
const loadSettings = useSettingsStore((s) => s.load);
const settings = useSettingsStore((s) => s.settings);
const connectionError = useConnectionStore((s) => s.error);
const activeFolderId = useUiStore((s) => s.activeFolderId);
const folders = useConnectionStore((s) => s.folders);
const tags = useConnectionStore((s) => s.tags);
const prefilledConnectionString = useUiStore((s) => s.prefilledConnectionString);
const clearPrefilledConnectionString = useUiStore((s) => s.clearPrefilledConnectionString);
useAppearance(settings?.theme ?? "system", settings?.font_size ?? "medium", settings?.accent_color ?? "#2563EB");
useEffect(() => {
loadConnections();
loadSettings();
// Init backup event listener (noop outside Tauri)
useBackupStore.getState().initListener().catch(() => {});
}, [loadConnections, loadSettings]);
// If the user hasn't navigated anywhere yet, start in the configured default folder
const setActiveFolderId = useUiStore((s) => s.setActiveFolderId);
const defaultFolderId = settings?.default_folder_id;
useEffect(() => {
if (defaultFolderId && useUiStore.getState().activeFolderId === null) {
setActiveFolderId(defaultFolderId);
}
}, [defaultFolderId, setActiveFolderId]);
useEffect(() => {
let title = VIEW_TITLES[activeView] ?? "Gridline";
if (activeView === "db-viewer") {
const conn = useConnectionStore.getState().connections.find(
(c) => c.id === useUiStore.getState().activeConnectionId
);
if (conn) title = conn.name;
}
document.title = title;
try {
getCurrentWindow().setTitle(title).catch(() => {
// Ignore environments where the Tauri API is unavailable (tests, browser)
});
} catch {
// getCurrentWindow can throw outside of a Tauri runtime
}
}, [activeView]);
const keepDbViewerMounted =
activeView === "db-viewer" ||
(activeView === "settings" && settingsReturnView === "db-viewer");
const dbViewerVisible = activeView === "db-viewer";
return (
<div className="h-svh bg-canvas select-none flex flex-col overflow-hidden">
{typeof window !== "undefined" &&
"__TAURI_INTERNALS__" in window &&
isMacOS() && (
// macOS "Overlay" title bar: in-flow strip the window can be
// dragged by; traffic lights float over it. Only in Tauri.
<div
data-tauri-drag-region
aria-hidden
className="h-7 shrink-0 bg-canvas select-none"
/>
)}
<div className="flex-1 min-h-0">
{connectionError && (
<div className="px-6 pt-4">
<ErrorBanner
error={connectionError}
onRetry={loadConnections}
/>
</div>
)}
{activeView === "settings" && <SettingsPage />}
{activeView === "new-connection" && (
<NewConnectionScreen
defaultFolderId={activeFolderId}
prefilledConnectionString={prefilledConnectionString ?? ""}
folders={folders}
tags={tags}
onSaved={() => {
clearPrefilledConnectionString();
setActiveView("home");
}}
onCancel={() => {
clearPrefilledConnectionString();
setActiveView("home");
}}
/>
)}
{activeView === "home" && <HomeScreen />}
{keepDbViewerMounted && (
<div className={dbViewerVisible ? "contents" : "hidden"}>
<DbViewerScreen
connectionId={useUiStore.getState().activeConnectionId ?? ""}
onHome={() => setActiveView("home")}
onSettings={openSettings}
/>
</div>
)}
</div>
<ToastContainer />
</div>
);
}
@@ -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 };
}
@@ -0,0 +1,222 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgDump } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface BackupDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const FORMAT_OPTIONS = [
{ value: "plain", label: "Plain SQL" },
{ value: "custom", label: "Custom Archive" },
{ value: "tar", label: "Tarball" },
{ value: "directory", label: "Directory" },
];
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) {
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const extensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
const picked = await save({
defaultPath: `backup.${format === "custom" ? "dump" : format === "plain" ? "sql" : "tar"}`,
filters: [{ name: "Backup", extensions: extensions[format] }],
});
if (picked) setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, [format]);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `dump-${Date.now()}`;
startJob(jobId, "dump");
try {
await pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
notify("Backup completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Backup failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Backup Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_dump not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Format selector */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<Select
value={format}
onChange={(v) => setFormat(v as BackupFormat)}
options={FORMAT_OPTIONS}
/>
</div>
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Output File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* No owner toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={noOwner}
onChange={(e) => setNoOwner(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
No Owner (--no-owner flag)
</label>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="dump"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartBackup} disabled={running || !filePath}>
{running ? "Backing up..." : "Start Backup"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:")) {
const [, ...rest] = msg.split("pg_dump:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions for the output path.`;
}
return msg;
}
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { BackupPage } from "./BackupPage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
describe("BackupPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
});
it("hides install instructions when tools are bundled", async () => {
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: "bundled",
pg_restore_source: "bundled",
});
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
});
});
describe("BackupPage DB-aware", () => {
beforeEach(() => {
mockConnections.length = 0;
});
it("shows a single SQL format for MySQL (no custom/tar/directory)", async () => {
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
});
render(<BackupPage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText("Tarball")).toBeNull();
expect(screen.queryByText("Directory")).toBeNull();
expect(screen.getByText("Plain SQL")).toBeTruthy();
});
it("renders a plain file-picker backup for SQLite (no format selector, no tool card)", () => {
mockConnections.push({ id: "c2", db_type: "sqlite", name: "s" });
render(<BackupPage connectionId="c2" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_dump/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
it("still lists Custom Archive for PostgreSQL (regression guard)", async () => {
mockConnections.push({ id: "c3", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
});
render(<BackupPage connectionId="c3" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
});
@@ -0,0 +1,546 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Download, FolderOpen, HardDrive } from "lucide-react";
import { save } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore";
import {
detectPgTools,
pgDump,
getSchemas,
detectMysqlTools,
mysqlDump,
sqliteDump,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface BackupPageProps {
connectionId: string;
}
type BackupFormat = "plain" | "custom" | "tar" | "directory";
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
};
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysqldump is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return map.darwin;
if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return map.win32;
return map.linux;
}
export function BackupPage({ connectionId }: BackupPageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [format, setFormat] = useState<BackupFormat>("custom");
const [filePath, setFilePath] = useState("");
const [schema, setSchema] = useState("");
const [noOwner, setNoOwner] = useState(true);
const [singleTransaction, setSingleTransaction] = useState(true);
const [noData, setNoData] = useState(false);
const [routines, setRoutines] = useState(true);
const [triggers, setTriggers] = useState(true);
const [events, setEvents] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
// Track our job ID so we only react to jobs we started
const pendingJobRef = useRef<string | null>(null);
// React to job completion/failure via store events
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Backup completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Backup failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => {
let defaultPath = "backup";
let extensions: string[] = [];
if (isPg) {
const pgExtensions: Record<BackupFormat, string[]> = {
plain: ["sql"],
custom: ["dump", "custom"],
tar: ["tar"],
directory: [],
};
extensions = pgExtensions[format];
defaultPath = `backup.${
format === "custom"
? "dump"
: format === "plain"
? "sql"
: "tar"
}`;
} else if (isMysql) {
extensions = ["sql"];
defaultPath = "backup.sql";
} else {
extensions = ["db", "sqlite", "sql"];
defaultPath = "backup.db";
}
const picked = await save({
defaultPath,
filters: [{ name: "Backup", extensions }],
});
if (picked) setFilePath(picked);
}, [format, isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
// Command returns the job ID immediately — completion
// comes via Tauri events handled by the backupStore
await action();
} catch (e) {
// If the command itself fails (e.g. connection not found),
// the event won't fire — handle here
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartBackup = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
await runWithProgress("dump", () => {
if (isPg) {
return pgDump(connectionId, {
format,
filePath,
schema: schema || undefined,
tables: undefined,
noOwner,
});
}
if (isMysql) {
return mysqlDump(connectionId, {
database: database!,
filePath,
singleTransaction,
noData,
routines,
triggers,
events,
});
}
return sqliteDump(connectionId, { filePath });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
schema,
noOwner,
singleTransaction,
noData,
routines,
triggers,
events,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_dump_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled"
: false;
const checkingMessage = isPg
? "Checking for pg_dump..."
: isMysql
? "Checking for mysqldump..."
: null;
const headerDescription = isPg
? "Create a database backup via pg_dump"
: isMysql
? "Create a database backup via mysqldump"
: "Create a database backup";
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<HardDrive size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Backup</span>
<span className="text-[11px] text-text-muted">
{headerDescription}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
{checkingMessage}
</p>
</div>
)}
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_dump not found" : "mysqldump not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format */}
{isPg ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(
e.target.value as BackupFormat,
)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
) : isMysql ? (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<div className="text-sm text-text py-2">
Plain SQL
</div>
</div>
) : null}
{/* Output file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Output File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FolderOpen size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* PostgreSQL: no-owner toggle */}
{isPg && (
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noOwner}
onChange={(e) =>
setNoOwner(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Owner{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-owner
</code>
</span>
</label>
)}
{/* MySQL: option toggles */}
{isMysql && (
<div className="space-y-2">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={singleTransaction}
onChange={(e) =>
setSingleTransaction(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Single Transaction{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--single-transaction
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={noData}
onChange={(e) =>
setNoData(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
No Data{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--no-data
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={routines}
onChange={(e) =>
setRoutines(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Routines{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--routines
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={triggers}
onChange={(e) =>
setTriggers(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Triggers{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--triggers
</code>
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={events}
onChange={(e) =>
setEvents(e.target.checked)
}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Events{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
--events
</code>
</span>
</label>
</div>
)}
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="dump"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartBackup}
disabled={isRunning || !filePath}
>
<Download size={14} className="mr-1.5" />
{isRunning ? "Backing up..." : "Start Backup"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { Button } from "../ui/Button";
interface BackupProgressProps {
progress: number;
jobType: string;
status: "running" | "completed" | "failed" | "cancelled";
errorMessage?: string | null;
onCancel?: () => void;
}
export function BackupProgress({
progress,
jobType,
status,
errorMessage,
onCancel,
}: BackupProgressProps) {
const isRunning = status === "running";
return (
<div data-testid="backup-progress" className="w-full space-y-3">
{/* Header row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text capitalize">
{jobType}
</span>
<span className="text-xs text-text-muted">
{status === "running" && `In progress...`}
{status === "completed" && "Completed"}
{status === "failed" && "Failed"}
{status === "cancelled" && "Cancelled"}
</span>
</div>
{isRunning && onCancel && (
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
</div>
{/* Progress bar */}
<div className="relative w-full h-2 bg-surface-raised rounded-full overflow-hidden">
<div
data-testid="progress-bar-fill"
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-300 ${
status === "failed" ? "bg-red-500" : "bg-accent"
}`}
style={{
width: `${Math.min(100, Math.max(0, progress))}%`,
}}
/>
</div>
{/* Error message */}
{errorMessage && status === "failed" && (
<p className="text-sm text-red-400">{errorMessage}</p>
)}
</div>
);
}
@@ -0,0 +1,256 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
describe("ChangesQueuePanel", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
useUiStore.setState({ activeConnectionId: "c1" });
vi.resetAllMocks();
});
it("shows nothing when queue is empty", () => {
const { container } = render(<ChangesQueuePanel />);
expect(container.textContent).toBe("");
});
it("shows the pending-changes header and a change card", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
expect(screen.getByText(/update/i)).toBeInTheDocument();
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
});
it("shows the old → new value diff on update cards", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
// the diff renders old (struck) → new (accent) as separate spans
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("revert removes the change from the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
description: "Update row in users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /revert/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("labels bulk_insert / empty_table / drop_table cards", () => {
useDbViewerStore.getState().addChange({
type: "bulk_insert",
schema: "public",
table: "t",
columns: ["a"],
rows: [[1]],
description: "Import 2 rows into public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "empty_table",
schema: "public",
table: "t",
description: "Empty Table: public.t",
} as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument();
expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument();
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
});
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText("DDL")).toBeInTheDocument();
expect(screen.getByText("Drop index i")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
});
it("commit calls executeChange with buildChangePayload output for insert", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { id: 1, name: "Alice" },
description: "Insert row into t",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(exec).toHaveBeenCalled());
expect(exec.mock.calls[0][0]).toBe("c1");
expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({
type: "insert",
schema: "public",
table: "t",
data: expect.any(String),
}));
});
it("refreshes the schema tree after committing a drop_table change", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().addChange({
type: "drop_table",
schema: "public",
table: "t",
description: "Drop Table: public.t",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("refreshes the schema tree after committing a schema-modifying ddl change", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().addChange({
type: "ddl",
schema: "public",
table: "products",
sql: 'CREATE TABLE "public"."products" ("id" integer NOT NULL)',
description: "Create Table",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("SQL toggle shows the generated SQL", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { name: "Alice" },
description: "Insert row into users",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument();
});
it("Cmd+S commits all pending changes", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel />);
fireEvent.keyDown(document, { key: "s", metaKey: true });
await waitFor(() => expect(exec).toHaveBeenCalled());
});
it("Clear All empties the queue", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel />);
await user.click(screen.getByRole("button", { name: /clear all/i }));
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("calls onCommitted after a successful commit cycle", async () => {
const onCommitted = vi.fn();
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "t",
newData: { a: 1 },
description: "Insert row into t",
} as any);
render(<ChangesQueuePanel onCommitted={onCommitted} />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
});
it("shows a green check on committed changes after Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument());
});
it("auto-closes tabs for a table dropped via Commit All", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any);
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => {
const tabs = useDbViewerStore.getState().tabs;
expect(tabs.some((t) => t.table === "users")).toBe(false);
expect(tabs.some((t) => t.table === "posts")).toBe(true);
});
});
it("renders a rebuild_table change with an amber REBUILD badge and SQL preview", () => {
useDbViewerStore.getState().addChange({
type: "rebuild_table",
schema: "public",
table: "users",
sql: "BEGIN; ALTER TABLE \"public\".\"users\" ...; COMMIT;",
description: "Rebuild public.users",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText("REBUILD")).toBeInTheDocument();
expect(screen.getByText(/rebuild public\.users/i)).toBeInTheDocument();
expect(screen.getByText(/BEGIN;/)).toBeInTheDocument();
});
});
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useState } from "react";
import { Check, X, RotateCcw } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as cmd from "../../lib/commands";
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
import { isSchemaModifyingQuery } from "../../lib/utils";
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
const statusBg: Record<QueueStatus, string> = {
pending: "bg-accent/5",
committed: "bg-green-500/5",
failed: "bg-red-500/5",
cancelled: "bg-surface-raised/50",
};
function formatChangeLabel(change: QueueItem): string {
const schema = change.schema ?? "";
const table = change.table ?? "";
const fullName = schema ? `${schema}.${table}` : table;
switch (change.type) {
case "bulk_insert":
return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`;
case "empty_table":
return `Empty Table: ${fullName}`;
case "drop_table":
return `Drop Table: ${fullName}`;
case "rebuild_table":
return change.description ?? `Rebuild ${fullName}`;
case "ddl":
return change.description ?? "DDL";
default:
return change.table ?? "-";
}
}
/** Render the old → new value change for update queue items. */
function formatValueDiff(change: QueueItem): string | null {
if (change.type !== "update" || !change.newData) return null;
const colName = Object.keys(change.newData)[0];
if (!colName) return null;
const oldVal =
change.oldData && change.oldData[colName] !== undefined
? String(change.oldData[colName])
: "NULL";
const newVal =
change.newData[colName] === null || change.newData[colName] === undefined
? "NULL"
: String(change.newData[colName]);
return `${colName}: ${oldVal}${newVal}`;
}
function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
function badgeLabel(type: string): string {
if (type === "ddl") return "DDL";
if (type === "rebuild_table") return "REBUILD";
return capitalizeType(type);
}
function tableRef(change: QueueItem): string {
if (change.schema && change.table) return `${change.schema}.${change.table}`;
return change.table ?? "-";
}
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const removeChange = useDbViewerStore((state) => state.removeChange);
const clearChanges = useDbViewerStore((state) => state.clearChanges);
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const [view, setView] = useState<"visual" | "sql">("visual");
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
if (!connectionId) {
notify("No active connection", "error");
return;
}
const pending = useDbViewerStore.getState().changesQueue.filter(
(c) => c.status === "pending",
);
if (pending.length === 0) return;
let committedCount = 0;
let treeDirty = false;
for (const change of pending) {
try {
const payload = buildChangePayload(change);
await cmd.executeChange(connectionId, payload);
markChangeCommitted(change.id);
committedCount++;
if (change.type === "drop_table") {
treeDirty = true;
const st = useDbViewerStore.getState();
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
} else if (
change.type === "rebuild_table" ||
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
) {
treeDirty = true;
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
markChangeFailed(change.id, msg);
notify(`Change failed: ${msg}`, "error");
break;
}
}
if (committedCount > 0) {
onCommitted?.();
notify(`${committedCount} change(s) committed`, "success");
}
if (treeDirty) {
const st = useDbViewerStore.getState();
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
}
}, [markChangeCommitted, markChangeFailed, notify]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
void handleCommitAll();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [handleCommitAll]);
if (changesQueue.length === 0) {
return null;
}
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
return (
<div className="flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="font-medium text-sm text-text">Pending Changes</span>
<div className="flex rounded-md border border-border overflow-hidden">
<button
type="button"
aria-label="Visual"
onClick={() => setView("visual")}
className={[
"px-2 py-0.5 text-xs transition-colors",
view === "visual"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
Visual
</button>
<button
type="button"
aria-label="SQL"
onClick={() => setView("sql")}
className={[
"px-2 py-0.5 text-xs transition-colors",
view === "sql"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto px-2 py-2 space-y-2">
{view === "visual" ? (
changesQueue.map((change) => (
<div
key={change.id}
className={`rounded-lg border border-border bg-surface-raised/40 px-3 py-2 ${statusBg[change.status]}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={[
"rounded px-1.5 py-0.5 text-xs font-medium",
change.type === "rebuild_table"
? "bg-amber-500/10 text-amber-400"
: "bg-surface-raised text-text-muted",
].join(" ")}
>
{badgeLabel(change.type)}
</span>
<span className="text-sm text-text truncate">
{tableRef(change)}
</span>
</div>
{change.status === "pending" ? (
<button
type="button"
aria-label="Revert change"
title="Revert change"
onClick={() => removeChange(change.id)}
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer shrink-0"
>
<RotateCcw className="h-4 w-4" />
</button>
) : change.status === "committed" ? (
<span title="Committed" className="shrink-0 text-green-500">
<Check className="h-4 w-4" />
</span>
) : change.status === "failed" ? (
<span title="Failed" className="shrink-0 text-red-500">
<X className="h-4 w-4" />
</span>
) : null}
</div>
{change.type === "rebuild_table" ? (
<>
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</div>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
{buildChangeSql(change)}
</pre>
</>
) : (
<>
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</div>
{formatValueDiff(change) && (
<div className="mt-0.5 font-mono text-xs text-text">
<span className="text-text-muted line-through">
{formatValueDiff(change)!.split(" → ")[0]}
</span>
<span className="mx-1 text-text-muted"></span>
<span className="text-accent">
{formatValueDiff(change)!.split(" → ")[1]}
</span>
</div>
)}
</>
)}
</div>
))
) : (
changesQueue.map((change) => (
<pre
key={change.id}
className="text-xs text-text-muted whitespace-pre-wrap rounded-md bg-canvas px-3 py-2 font-mono border border-border"
>
{buildChangeSql(change)}
</pre>
))
)}
</div>
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<button
type="button"
onClick={clearChanges}
className="text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-md px-2 py-1 transition-colors cursor-pointer"
>
Clear All
</button>
<button
type="button"
disabled={pendingCount === 0}
onClick={handleCommitAll}
className="inline-flex items-center rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Commit All ({pendingCount})
<kbd className="ml-1.5 rounded bg-surface-raised px-1 text-[10px]">S</kbd>
</button>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
describe("ConnectionDropBanner", () => {
it("shows error message", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByText("Connection lost")).toBeInTheDocument();
});
it("shows reconnect button", () => {
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument();
});
it("calls onRetry when reconnect clicked", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={onRetry}
onDismiss={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /reconnect/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("calls onDismiss when close button clicked", async () => {
const onDismiss = vi.fn();
const user = userEvent.setup();
render(
<ConnectionDropBanner
error="Connection lost"
onRetry={() => {}}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /dismiss/i }));
expect(onDismiss).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,35 @@
import { AlertTriangle, X } from "lucide-react";
interface ConnectionDropBannerProps {
error: string;
onRetry: () => void;
onDismiss?: () => void;
}
export function ConnectionDropBanner({ error, onRetry, onDismiss }: ConnectionDropBannerProps) {
return (
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/20 rounded-md px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<AlertTriangle size={18} className="text-red-400 shrink-0" />
<span className="text-red-300 text-sm truncate">{error}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={onRetry}
className="text-sm px-3 py-1.5 rounded-md bg-red-500/20 text-red-200 hover:bg-red-500/30 transition-colors cursor-pointer"
>
Reconnect
</button>
<button
type="button"
aria-label="Dismiss error"
onClick={onDismiss}
className="p-1.5 rounded-md text-red-300 hover:bg-red-500/20 transition-colors cursor-pointer"
>
<X size={16} />
</button>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
const C = "#3b82f6";
const S = 10;
const G = 4;
export function CrowsFootEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
style,
}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX, sourceY, sourcePosition,
targetX, targetY, targetPosition,
borderRadius: 8,
});
const sm = (data as any)?.startMarker as string;
const em = (data as any)?.endMarker as string;
// Use ORIGINAL layout direction stored in edge data — never re-compute.
// Prevents symbols from flipping when user drags tables around.
const origRight = (data as any)?.origRight as boolean | undefined;
const right = origRight !== undefined ? origRight : targetX < sourceX ? false : true;
const sOff = right ? 1 : -1;
const tOff = right ? -1 : 1;
const sDir = right ? 1 : -1;
const tDir = right ? -1 : 1;
const edgeColor = (style as any)?.stroke as string || C;
return (
<g>
<BaseEdge id={id} path={edgePath} style={{ stroke: edgeColor, strokeWidth: 1.5, ...style }} />
{sm && <Mark type={sm} cx={sourceX + sOff * G} cy={sourceY} dir={sDir} color={edgeColor} />}
{em && <Mark type={em} cx={targetX + tOff * G} cy={targetY} dir={tDir} color={edgeColor} />}
</g>
);
}
function Mark({ type, cx, cy, dir, color }: { type: string; cx: number; cy: number; dir: number; color: string }) {
if (type === "one") {
return <line x1={cx} y1={cy - S} x2={cx} y2={cy + S} stroke={color} strokeWidth={2} strokeLinecap="round" />;
}
if (type === "many") {
const sp = 6;
const tx = cx + dir * S;
return (
<g>
<line x1={cx} y1={cy - sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
<line x1={cx} y1={cy} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
<line x1={cx} y1={cy + sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
</g>
);
}
return null;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { TooltipProvider } from "../ui/Tooltip";
import { DB_CAPABILITIES } from "../../lib/dbCapabilities";
describe("DbViewerSidebar", () => {
it("renders all navigation icons", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
});
it("calls onNavigate when home is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/home/i));
expect(onNavigate).toHaveBeenCalledWith("home");
});
it("calls onNavigate when settings is clicked", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
</TooltipProvider>
);
await user.click(screen.getByLabelText(/settings/i));
expect(onNavigate).toHaveBeenCalledWith("settings");
});
it("renders Schema Visualizer nav item (not coming soon)", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
// Should find the label WITHOUT "coming soon"
const btn = screen.getByLabelText(/schema visualizer/i);
expect(btn).toBeInTheDocument();
expect(btn).not.toBeDisabled();
});
it("renders Queries nav item", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
});
it("renders Tools nav item", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
});
it("shows all 5 tools for PostgreSQL (default capabilities)", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.getByLabelText(/objects/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
});
it("shows Tools but hides Objects for SQLite", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
});
it("shows Tools but hides Objects and Visualizer for MySQL", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
</TooltipProvider>
);
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
});
it("shows no top nav items for Redis (unsupported browsing)", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.redis} />
</TooltipProvider>
);
expect(screen.queryByLabelText(/explorer/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText("Queries")).not.toBeInTheDocument();
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,98 @@
import {
Boxes,
Clock,
Database,
DatabaseBackup,
Home,
Settings,
Share2,
} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { DB_CAPABILITIES, type DbCapabilities } from "../../lib/dbCapabilities";
export interface DbViewerSidebarProps {
currentView: string;
onNavigate: (view: string) => void;
capabilities?: DbCapabilities;
}
export const NAV_CAPABILITY_KEY: Record<string, keyof DbCapabilities> = {
"db-viewer": "explorer",
queries: "queries",
"schema-visualizer": "visualizer",
objects: "objects",
tools: "tools",
};
interface NavItem {
id: string;
label: string;
icon: React.ReactNode;
stub?: boolean;
}
export function DbViewerSidebar({
currentView,
onNavigate,
capabilities = DB_CAPABILITIES.postgresql,
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
{
id: "queries",
label: "Queries",
icon: <Clock size={16} />,
},
{
id: "schema-visualizer",
label: "Schema Visualizer",
icon: <Share2 size={16} />,
},
{ id: "objects", label: "Objects", icon: <Boxes size={16} /> },
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
];
const visibleTopItems = topItems.filter(
(item) => capabilities[NAV_CAPABILITY_KEY[item.id]]
);
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={16} /> },
{ id: "settings", label: "Settings", icon: <Settings size={16} /> },
];
function renderItem(item: NavItem) {
const isActive = currentView === item.id;
const baseClass =
"w-8 h-8 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
const activeClass = "text-accent";
const inactiveClass =
"text-text-muted hover:text-text hover:bg-surface-raised";
const stubClass = "opacity-40 cursor-not-allowed";
return (
<Tooltip key={item.id} content={item.label} side="right">
<button
type="button"
aria-label={item.label}
disabled={item.stub}
onClick={() => onNavigate(item.id)}
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
>
{item.icon}
</button>
</Tooltip>
);
}
return (
<div className="w-14 h-full bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
<div className="flex flex-col gap-2 flex-1">
{visibleTopItems.map(renderItem)}
</div>
<div className="flex flex-col gap-2">
{bottomItems.map(renderItem)}
</div>
</div>
);
}
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
const defaultProps = {
databases: [] as string[],
currentDatabase: null as string | null,
setCurrentDatabase: () => {},
schemas: [] as string[],
currentSchema: null as string | null,
setCurrentSchema: () => {},
searchQuery: "",
onSearchChange: () => {},
};
describe("DbViewerToolbar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders Tables label", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByText("Tables")).toBeInTheDocument();
});
it("renders database dropdown when multiple databases", () => {
render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
databases={["mydb", "otherdb"]}
currentDatabase="mydb"
/>
</TooltipProvider>,
);
expect(screen.getByText("mydb")).toBeInTheDocument();
});
it("omits bottom padding when nothing is rendered below the title row", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(container.firstElementChild!.className).not.toContain("pb-3");
});
it("keeps bottom padding when selectors are rendered below", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
databases={["mydb", "otherdb"]}
currentDatabase="mydb"
/>
</TooltipProvider>,
);
expect(container.firstElementChild!.className).toContain("pb-3");
});
it("keeps bottom padding while the search input is open", () => {
const { container } = render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
fireEvent.click(screen.getByLabelText(/search tables/i));
expect(container.firstElementChild!.className).toContain("pb-3");
});
it("renders refresh and create table buttons", () => {
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
});
it("opens a create table form tab when Create Table is clicked", () => {
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} currentSchema="public" />
</TooltipProvider>,
);
fireEvent.click(screen.getByLabelText(/create table/i));
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "create" }));
});
it("shows a disabled schema loading indicator while schema tree is loading", () => {
useDbViewerStore.setState({ schemaTreeLoading: true });
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} />
</TooltipProvider>,
);
expect(screen.getByLabelText(/select schema/i)).toBeDisabled();
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
it("renders schema options when not loading and multiple schemas exist", () => {
useDbViewerStore.setState({ schemaTreeLoading: false });
render(
<TooltipProvider>
<DbViewerToolbar
{...defaultProps}
schemas={["public", "app"]}
currentSchema="public"
/>
</TooltipProvider>,
);
expect(screen.getByText("public")).toBeInTheDocument();
});
});
@@ -0,0 +1,262 @@
import {
RefreshCw,
Plus,
Search,
Pencil,
Check,
AlertCircle,
X,
} from "lucide-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu";
import { getCapabilities } from "../../lib/dbCapabilities";
import type { DbType } from "../../lib/types";
export function DbViewerToolbar({
databases,
currentDatabase,
setCurrentDatabase,
schemas,
currentSchema,
setCurrentSchema,
onEdit,
connectionId,
dbType,
searchQuery,
onSearchChange,
}: {
databases: string[];
currentDatabase: string | null;
setCurrentDatabase: (db: string | null) => void;
schemas: string[];
currentSchema: string | null;
setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void;
connectionId?: string;
dbType?: DbType;
searchQuery: string;
onSearchChange: (q: string) => void;
}) {
const [searchOpen, setSearchOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [result, setResult] = useState<"idle" | "success" | "error">("idle");
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const populate = useDbViewerStore((s) => s.populate);
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) onSearchChange(""); // clear when closing
return next;
});
}, [onSearchChange]);
// Cleanup result timer on unmount
useEffect(() => {
return () => {
if (resultTimer.current) clearTimeout(resultTimer.current);
};
}, []);
const handleCreateTable = useCallback(() => {
const schema = currentSchema ?? "public";
useDbViewerStore.getState().openFormTab({
kind: "table",
schema,
name: "",
title: "Create Table",
description: "Create Table",
mode: "create",
params: { schema, name: "", action: { op: "create", columns: [] } },
});
}, [currentSchema]);
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return;
setRefreshing(true);
setResult("idle");
try {
const dbs = await cmd.getDatabases(connectionId);
const scs = await cmd.getSchemas(connectionId);
const tbls = await cmd.getTables(connectionId);
populate(dbs, scs, tbls);
setResult("success");
} catch {
setResult("error");
} finally {
setRefreshing(false);
resultTimer.current = setTimeout(() => setResult("idle"), 1500);
}
}, [connectionId, refreshing, populate]);
const hasBelow =
searchOpen || databases.length > 1 || schemas.length > 1 || schemaTreeLoading;
return (
<div
className={`px-3 pt-3 border-b border-border space-y-2 ${hasBelow ? "pb-3" : ""}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-normal text-text-muted">Tables</span>
<div className="flex items-center gap-1">
{onEdit && (
<Tooltip content="Edit Connection" side="bottom">
<button
aria-label="Edit Connection"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Pencil size={14} />
</button>
</Tooltip>
)}
<Tooltip
content={
result === "success"
? "Refreshed"
: result === "error"
? "Refresh failed"
: "Refresh Database"
}
side="bottom"
>
<button
aria-label="Refresh"
onClick={handleRefresh}
disabled={refreshing}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
{refreshing ? (
<RefreshCw size={14} className="animate-spin" />
) : result === "success" ? (
<Check size={14} className="text-emerald-400" />
) : result === "error" ? (
<AlertCircle
size={14}
className="text-red-400"
/>
) : (
<RefreshCw size={14} />
)}
</button>
</Tooltip>
{showCreateTable && (
<Tooltip content="Create Table" side="bottom">
<button
aria-label="Create Table"
onClick={handleCreateTable}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Plus size={14} />
</button>
</Tooltip>
)}
<Tooltip content="Search Tables" side="bottom">
<button
aria-label="Search Tables"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</Tooltip>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search
size={12}
className="absolute left-2.5 text-text-muted pointer-events-none"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter tables…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => onSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({
value: d,
label: d,
}))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && (
<span className="text-border">|</span>
)}
{(schemas.length > 1 || schemaTreeLoading) && (
<SelectDropdown
value={schemaTreeLoading ? "" : (currentSchema ?? "")}
onChange={schemaTreeLoading ? () => {} : setCurrentSchema}
options={
schemaTreeLoading
? [{ value: "", label: "Loading…" }]
: schemas.map((s) => ({ value: s, label: s }))
}
placeholder={schemaTreeLoading ? "Loading…" : "Select schema"}
aria-label="Select schema"
variant="ghost"
disabled={schemaTreeLoading}
/>
)}
<SchemaMenu
connectionId={connectionId ?? ""}
schema={currentSchema ?? undefined}
onRefresh={handleRefresh}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DependencyDialog } from "./DependencyDialog";
describe("DependencyDialog", () => {
it("empty list shows no-dependencies message", () => {
render(<DependencyDialog open deps={[]} onProceed={() => {}} onCancel={() => {}} />);
expect(screen.getByText(/no dependencies/i)).toBeTruthy();
});
it("non-empty lists rows and requires checkbox to proceed", () => {
const deps = [{ deptype: "n", class: "pg_class", name: "v_orders" }];
render(<DependencyDialog open deps={deps} onProceed={() => {}} onCancel={() => {}} />);
expect(screen.getByText("v_orders")).toBeTruthy();
expect(screen.getByRole("button", { name: /proceed/i })).toBeDisabled();
fireEvent.click(screen.getByRole("checkbox"));
expect(screen.getByRole("button", { name: /proceed/i })).toBeEnabled();
});
});
@@ -0,0 +1,62 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import type { DependencyInfo } from "../../lib/types";
interface DependencyDialogProps {
open: boolean;
deps: DependencyInfo[];
onProceed: () => void;
onCancel: () => void;
}
export function DependencyDialog({ open, deps, onProceed, onCancel }: DependencyDialogProps) {
const [ack, setAck] = useState(false);
const hasDeps = deps.length > 0;
return (
<AnimatedModal open={open} onClose={onCancel}>
<div className="w-[420px]">
<h3 className="font-heading text-text text-lg mb-3">Dependencies</h3>
{hasDeps ? (
<>
<p className="text-sm text-red-400 mb-2">
The following depend on this object and will be removed with CASCADE:
</p>
<ul className="max-h-48 overflow-auto my-2 space-y-1 pr-1">
{deps.map((d, i) => (
<li key={i} className="text-sm text-text">
{d.name}{" "}
<span className="text-text-subtle">({d.class})</span>
</li>
))}
</ul>
<label className="flex items-center gap-2 text-sm text-text-muted mt-3 cursor-pointer">
<input
type="checkbox"
checked={ack}
onChange={(e) => setAck(e.target.checked)}
className="accent-accent h-4 w-4"
/>
I understand these will be dropped.
</label>
</>
) : (
<p className="text-sm text-text-muted">No dependencies safe to drop.</p>
)}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
variant="primary"
onClick={onProceed}
disabled={hasDeps && !ack}
>
Proceed
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { EditConnectionModal } from "./EditConnectionModal";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
const { updateConnection, loadAll } = vi.hoisted(() => ({
updateConnection: vi.fn().mockResolvedValue({}),
loadAll: vi.fn(),
}));
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
}));
vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>();
return {
...actual,
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
deleteConnectionPassword: vi.fn(),
};
});
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
}));
const baseConn: Connection = {
id: "c1",
name: "Prod",
db_type: "postgresql",
host: "localhost",
port: 5432,
username: "u",
folder_id: null,
keychain_ref: null,
tag_ids: [],
favorite: false,
created_at: "",
updated_at: "",
database: "db",
};
describe("EditConnectionModal", () => {
beforeEach(() => vi.clearAllMocks());
it("renders the Connection Label field and General/SSH tabs", () => {
render(
<EditConnectionModal
connection={baseConn}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
});
it("shows the Supabase SSL hint when editing a Supabase-host connection", () => {
const supa = { ...baseConn, host: "db.abcdefghijklmnopqrst.supabase.co" };
render(
<EditConnectionModal
connection={supa}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("saves to keychain when use_keychain=true", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
);
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
});
it("purges keychain when use_keychain=false", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: false }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
);
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,149 @@
import { useState, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
interface EditConnectionModalProps {
connection: Connection;
open: boolean;
onClose: () => void;
onSaved: (updated: Connection) => void;
}
export function EditConnectionModal({
connection,
open,
onClose,
onSaved,
}: EditConnectionModalProps) {
const managedPreset = detectProviderFromHost(connection.host);
const [form, setForm] = useState<ConnectionFormData>(() => ({
name: connection.name,
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
folder_id: connection.folder_id,
tag_ids: [...connection.tag_ids],
connection_string: "",
db_type: connection.db_type,
host: connection.host,
port: connection.port,
username: connection.username,
password: null,
database: connection.database ?? null,
use_keychain: connection.use_keychain ?? true,
ssh_host: connection.ssh_host ?? null,
ssh_port: connection.ssh_port ?? null,
ssh_user: connection.ssh_user ?? null,
ssh_auth_method:
(connection.ssh_auth_method as "password" | "key" | null | undefined) ??
null,
ssh_private_key: connection.ssh_private_key_path ?? null,
ssh_password: null,
ssh_passphrase: null,
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const loadAll = useConnectionStore((s) => s.loadAll);
const notify = useNotificationStore((s) => s.notify);
const handleSave = useCallback(async () => {
if (!form.name.trim()) return;
setSaving(true);
try {
const input: ConnectionInput = {
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password: form.password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
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,
};
const updated = await updateConnection(connection.id, input);
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
// Persist SSH secrets to the OS keychain (not SQLite)
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
}
if (form.ssh_host && form.ssh_passphrase) {
await saveConnectionSshPassphrase(connection.id, form.ssh_passphrase).catch(() => {});
}
notify("Connection updated", "success");
onSaved(updated);
onClose();
loadAll();
} catch (e) {
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setSaving(false);
}
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
const handleTest = useCallback(async () => {
setTesting(true);
try {
// Fetch password from keychain if not provided in form
let password = form.password;
if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
}
const result = await testConnection({
name: form.name,
db_type: form.db_type,
host: form.host,
port: form.port,
username: form.username,
password,
database: form.database,
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
ssh_password: form.ssh_password ?? null,
});
if (result.ok) {
notify("Connection successful", "success");
} else {
notify(result.error ?? "Connection failed", "error");
}
} catch (e) {
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
} finally {
setTesting(false);
}
}, [form, notify, connection.id]);
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} managedPreset={managedPreset} />
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={handleTest} disabled={testing}>
{testing ? "Testing..." : "Test"}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,27 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBuilder } from "./FilterBuilder";
import type { ColumnInfo } from "../../lib/types";
const cols: ColumnInfo[] = [
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
];
describe("FilterBuilder", () => {
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
const onChange = vi.fn();
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("name"));
expect(onChange).toHaveBeenCalledWith([
expect.objectContaining({ column: "name", operator: "contains" }),
]);
});
it("removing a rule calls onChange without it", () => {
const onChange = vi.fn();
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Remove filter name"));
expect(onChange).toHaveBeenCalledWith([]);
});
});
@@ -0,0 +1,132 @@
import { useState } from "react";
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
import { X } from "lucide-react";
import { defaultFilterOperator } from "../grid/gridEditability";
import type { ColumnInfo } from "../../lib/types";
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
interface Props {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
}
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: `col-${col.name}`,
data: { column: col },
});
return (
<button
ref={setNodeRef}
{...attributes}
{...listeners}
onClick={onAdd}
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
isDragging ? "opacity-50" : ""
}`}
>
{col.name}
</button>
);
}
const OPERATORS: FilterOperator[] = [
"eq",
"neq",
"contains",
"starts",
"ends",
"gt",
"lt",
"null",
"notnull",
];
export function FilterBuilder({ columns, rules, onChange }: Props) {
const [val, setVal] = useState<Record<string, string>>({});
const addRule = (col: ColumnInfo) => {
const op = defaultFilterOperator(col.data_type);
onChange([
...rules,
{
id: `f-${Date.now()}-${col.name}`,
column: col.name,
operator: op,
value: "",
},
]);
};
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
const update = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<DndContext
onDragEnd={(e) => {
const id = e.active.id as string;
const colName = id.replace(/^col-/, "");
const col = columns.find((c) => c.name === colName);
if (col && e.over?.id === "filter-dropzone") addRule(col);
}}
>
<div className="flex flex-wrap gap-1 mb-2">
{columns.map((c) => (
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
))}
</div>
<div
ref={setNodeRef}
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
isOver ? "border-accent bg-surface" : "border-border"
}`}
>
{rules.length === 0 && (
<span className="text-xs text-text-muted">
Drop columns here to add filters
</span>
)}
{rules.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-xs">
<span className="text-text font-semibold">{r.column}</span>
<select
value={r.operator}
onChange={(e) =>
update(r.id, { operator: e.target.value as FilterOperator })
}
className="bg-surface border border-border rounded px-1"
>
{OPERATORS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{!["null", "notnull"].includes(r.operator) && (
<input
value={val[r.id] ?? r.value}
onChange={(e) => {
setVal({ ...val, [r.id]: e.target.value });
update(r.id, { value: e.target.value });
}}
className="bg-surface border border-border rounded px-1 flex-1"
placeholder="value"
/>
)}
<button
aria-label={`Remove filter ${r.column}`}
onClick={() => remove(r.id)}
className="text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
</div>
))}
</div>
</DndContext>
);
}
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
import * as cmd from "../../lib/commands";
import type { QueryResult } from "../../lib/types";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { DataTypeIcon } from "../ui/DataTypeIcon";
interface FkPreviewPopoverProps {
connectionId: string;
schema: string;
table: string;
column: string;
value: string;
anchorRect: DOMRect | null;
onClose: () => void;
}
export function FkPreviewPopover({
connectionId,
schema,
table,
column,
value,
anchorRect,
onClose,
}: FkPreviewPopoverProps) {
const [data, setData] = useState<QueryResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const openTab = useDbViewerStore((s) => s.openTab);
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
const handleOpen = () => {
openTab(schema, table);
// Find the newly created tab and apply the column filter
const newTab = useDbViewerStore.getState().tabs.find(
(t) => t.schema === schema && t.table === table,
);
if (newTab) {
setColumnFilter(newTab.id, column, value);
}
onClose();
};
// Fetch the referenced row
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
cmd
.getFkPreview(connectionId, schema, table, column, value)
.then((result) => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch((e: any) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [connectionId, schema, table, column, value]);
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay to avoid closing immediately from the same click that opened it
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
// Compute position to keep popover within viewport
const popoverWidth = 360;
const popoverMaxHeight = 320;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
// Flip horizontally if off-screen
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
// Flip vertically if not enough space below
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Key size={12} className="text-amber-400 shrink-0" />
<span className="text-xs font-heading text-text truncate">
{schema}.{table}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleOpen}
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
title="Open table in new tab"
>
<ExternalLink size={11} />
<span>Open</span>
</button>
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
<Loader2 size={14} className="animate-spin" />
Loading...
</div>
)}
{error && (
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
{error}
</div>
)}
{data && data.rows.length === 0 && !loading && (
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
No matching row found
</div>
)}
{data && data.rows.length > 0 && (
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
<tbody>
{data.columns.map((col, ci) => {
const cell = data.rows[0][ci];
const isNull = cell === null || cell === undefined;
return (
<tr
key={col.name}
className="border-b border-border last:border-0 hover:bg-surface/30"
>
<td
className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap overflow-hidden align-top"
style={{ width: 100, maxWidth: 100 }}
>
<div className="flex items-center gap-1 min-w-0">
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
<span className="truncate">{col.name}</span>
<DataTypeIcon
dataType={col.data_type}
size={9}
className="text-text-muted/60 shrink-0"
/>
</div>
</td>
<td className="px-3 py-1.5 text-text align-top break-all">
{isNull ? (
<span className="italic text-text-muted">NULL</span>
) : (
<span className="break-all">{String(cell)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,33 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { ImportDialog } from "./ImportDialog";
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/f.csv") }));
vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn().mockResolvedValue("a,b\n1,2\n3,4") }));
describe("ImportDialog", () => {
it("parses CSV and stages a bulk_insert change", async () => {
const addChange = vi.fn();
render(<ImportDialog open schema="public" table="t" columns={["a", "b"]} onStage={addChange} onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(screen.getByText(/preview/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /stage import/i }));
await waitFor(() => {
expect(addChange).toHaveBeenCalledWith(expect.objectContaining({
type: "bulk_insert", schema: "public", table: "t",
columns: ["a", "b"],
}));
expect(addChange.mock.calls[0][0].rows.length).toBe(2);
});
});
it("rejects files over the row cap", async () => {
vi.mocked(readTextFile).mockResolvedValue("a\n" + "1\n".repeat(100_001));
const onStage = vi.fn();
render(<ImportDialog open schema="public" table="t" columns={["a"]} onStage={onStage} onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(screen.getByText(/limit/i)).toBeInTheDocument());
expect(onStage).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,187 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { Select } from "../ui/Select";
import { normalizeImport, coerceRow } from "../../lib/importNormalize";
const MAX_ROWS = 100_000;
const MAX_BYTES = 100 * 1024 * 1024;
const SKIP = "<skip>";
export interface ImportDialogProps {
open: boolean;
schema: string;
table: string;
columns: string[];
onStage: (change: {
type: "bulk_insert";
schema: string;
table: string;
columns: string[];
rows: unknown[][];
description: string;
}) => void;
onClose: () => void;
}
export function ImportDialog({
open: isOpen,
schema,
table,
columns,
onStage,
onClose,
}: ImportDialogProps) {
const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const chooseFile = async () => {
try {
const path = await open({
filters: [{ name: "Data", extensions: ["csv", "json"] }],
});
if (!path || Array.isArray(path)) return;
const text = await readTextFile(path as string);
if (text.length > MAX_BYTES) {
setError("File exceeds 100 MB limit");
return;
}
const { headers, rows } = normalizeImport(text);
if (rows.length > MAX_ROWS) {
setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`);
return;
}
setParsed({ headers, rows });
setMapping(
Object.fromEntries(
headers.map((h, i) => [h, columns[i] ?? SKIP]),
),
);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const stage = () => {
if (!parsed) return;
const selected = parsed.headers
.map((header) => ({ header, col: mapping[header] }))
.filter(({ col }) => col && col !== SKIP);
const targetColumns = selected.map(({ col }) => col);
const dataRows = parsed.rows.map((row) =>
selected.map(({ header }) => {
const idx = parsed.headers.indexOf(header);
return coerceRow(row[idx]);
}),
);
onStage({
type: "bulk_insert",
schema,
table,
columns: targetColumns,
rows: dataRows,
description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`,
});
onClose();
};
const mappingOptions = [
{ value: SKIP, label: "<skip>" },
...columns.map((c) => ({ value: c, label: c })),
];
const previewRows = parsed ? parsed.rows.slice(0, 100) : [];
return (
<AnimatedModal open={isOpen} onClose={onClose}>
<div className="w-full min-w-md max-w-2xl max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">
Import into {schema}.{table}
</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button onClick={chooseFile}>Choose file</Button>
<span className="text-xs text-text-muted">CSV or JSON, up to 100 MB / 100,000 rows</span>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-md px-4 py-3">
<span className="text-red-300 text-sm">{error}</span>
</div>
)}
{parsed && (
<div className="space-y-3">
<p className="text-sm text-text-muted">
Preview ({parsed.rows.length.toLocaleString()} rows × {parsed.headers.length} columns)
</p>
<div className="space-y-2">
{parsed.headers.map((header) => (
<div key={header} className="flex items-center gap-3">
<span className="text-sm text-text w-24 truncate" title={header}>{header}</span>
<span className="text-xs text-text-muted"></span>
<Select
value={mapping[header] ?? SKIP}
onChange={(v) =>
setMapping((prev) => ({ ...prev, [header]: v }))
}
options={mappingOptions}
label={`Map ${header}`}
/>
</div>
))}
</div>
<div className="overflow-auto max-h-64 rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-surface sticky top-0">
<tr className="border-b border-border">
{parsed.headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-text-muted font-heading whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={ri} className="border-b border-border last:border-0 hover:bg-surface/30">
{row.map((cell, ci) => (
<td key={ci} className="px-3 py-1.5 text-text whitespace-nowrap">
{cell === "" ? (
<span className="italic text-text-muted/50">null</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={stage} disabled={!parsed}>Stage import</Button>
</div>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,163 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Braces, Copy, Check, X } from "lucide-react";
interface JsonCellPopoverProps {
value: unknown;
anchorRect: DOMRect | null;
onClose: () => void;
}
function safeJsonParse(value: unknown): object | null {
if (typeof value === "object" && value !== null) return value as object;
if (typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
return typeof parsed === "object" && parsed !== null ? parsed : null;
} catch {
return null;
}
}
function formatJson(obj: object): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
const [copied, setCopied] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const parsed = safeJsonParse(value);
const rawText = typeof value === "string" ? value : JSON.stringify(value);
const formattedText = parsed ? formatJson(parsed) : rawText;
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
return () => {
clearTimeout(id);
document.removeEventListener("mousedown", onClick);
};
}, [onClose]);
if (!anchorRect) return null;
const popoverWidth = 420;
const popoverMaxHeight = 360;
const gap = 8;
let left = anchorRect.left;
let top = anchorRect.bottom + gap;
if (left + popoverWidth > window.innerWidth - 16) {
left = Math.max(16, window.innerWidth - popoverWidth - 16);
}
if (top + popoverMaxHeight > window.innerHeight - 16) {
top = anchorRect.top - popoverMaxHeight - gap;
if (top < 16) top = 16;
}
const handleCopy = async () => {
const text = tab === "formatted" ? formattedText : rawText;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
ref={popoverRef}
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
style={{
left,
top,
width: popoverWidth,
maxHeight: popoverMaxHeight,
}}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
<div className="flex items-center gap-1.5 min-w-0">
<Braces size={12} className="text-accent shrink-0" />
<span className="text-xs font-heading text-text">JSON</span>
</div>
<div className="flex items-center gap-1">
{/* Tabs */}
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
<button
onClick={() => setTab("formatted")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Formatted
</button>
<button
onClick={() => setTab("raw")}
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
}`}
>
Raw
</button>
</div>
{/* Copy */}
<button
onClick={handleCopy}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
title="Copy to clipboard"
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
{/* Close */}
<button
onClick={onClose}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
{/* Body */}
<div
className="overflow-auto p-3"
style={{ maxHeight: popoverMaxHeight - 41 }}
>
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
{tab === "formatted" ? formattedText : rawText}
</pre>
</div>
</div>,
document.body,
);
}
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
const parsed = safeJsonParse(value);
if (!parsed) return { label: "", isJson: false };
if (Array.isArray(parsed)) {
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
}
const keys = Object.keys(parsed);
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
}
@@ -0,0 +1,392 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
describe("ObjectExplorerPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
});
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
});
it("renders functions by default", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() =>
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
);
});
it("functions type filters to kind === 'f'", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
});
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
const user = userEvent.setup();
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
{
name: "idx_users_email",
schema: "public",
table: "users",
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
is_unique: true,
method: "btree",
columns: ["email"],
size_bytes: 8192,
tablespace: null,
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
);
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("idx_users_email"));
await waitFor(() => {
expect(screen.getByText("Index")).toBeInTheDocument();
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("8192")).toBeInTheDocument();
});
});
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
const user = userEvent.setup();
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
{
name: "chk_users_positive",
schema: "public",
table: "users",
contype: "CHECK",
definition: "CHECK (age > 0)",
deferrable: false,
validated: true,
columns: ["age"],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Constraints"));
await waitFor(() =>
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
);
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("chk_users_positive"));
await waitFor(() => {
expect(screen.getByText("Constraint")).toBeInTheDocument();
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Deferrable")).toBeInTheDocument();
});
});
it("shows 'No indexes found' when getIndexes returns []", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
);
});
it("shows an error message when getIndexes rejects", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("boom")).toBeInTheDocument(),
);
});
it("procedures type filters getFunctions to kind === 'p'", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Procedures"));
await waitFor(() =>
expect(screen.getByText("do_thing")).toBeInTheDocument(),
);
expect(screen.queryByText("calc")).not.toBeInTheDocument();
});
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["a"] },
]);
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
fireEvent.click(screen.getByText(/copy ddl/i));
await waitFor(() =>
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
);
expect(writeText).toHaveBeenCalledWith("CREATE TYPE ...");
});
it("View dependencies opens DependencyDialog", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([
{ deptype: "n", class: "pg_class", name: "v" },
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => screen.getByText("add_one(int)"));
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
fireEvent.click(screen.getByText(/dependencies/i));
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
});
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
expect(screen.getByText("Drop…")).toBeInTheDocument();
fireEvent.click(screen.getByText("Edit…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("edit");
});
it("right-click on a list row opens the context menu", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
const row = await screen.findByText("role");
fireEvent.contextMenu(row);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
});
it("header + create button opens an objectForm create tab for the current type", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("create");
expect(st.tabs[0].form?.kind).toBe("enum");
expect(st.tabs[0].form?.params?.schema).toBe("public");
});
it("refetches the current object list after a ddl commit succeeds", async () => {
const getFunctions = vi
.spyOn(commands, "getFunctions")
.mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
useDbViewerStore.getState().markChangeCommitted("ch-1");
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
});
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN RETURN 1; END",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
await waitFor(() =>
expect(screen.getByText("add")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("add"));
const st = useDbViewerStore.getState();
expect(
st.tabs.some(
(t) => t.tabType === "object" && t.table === "add",
),
).toBe(true);
});
it("sidebarMode: does not render the inline detail pane", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() =>
expect(screen.getByText("role")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("role"));
// clicking opened an object tab (no inline detail selected)
expect(
useDbViewerStore
.getState()
.tabs.some((t) => t.tabType === "object"),
).toBe(true);
// detail pane is gone; only the list remains
expect(
screen.queryByText("Select an enum to view details"),
).not.toBeInTheDocument();
});
it("roles fetch ignores the schema filter", async () => {
const user = userEvent.setup();
const getRoles = vi.spyOn(commands, "getRoles").mockResolvedValue([
{
name: "app",
superuser: false,
inherit: true,
create_db: false,
create_role: false,
can_login: true,
replication: false,
bypass_rls: false,
connection_limit: -1,
valid_until: null,
memberships: [],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Roles"));
await waitFor(() =>
expect(screen.getByText("app")).toBeInTheDocument(),
);
// Roles are cluster-scoped: no schema argument is ever passed, even when
// the schema changes (no schema-change refetch for roles).
expect(getRoles).toHaveBeenCalledTimes(1);
expect(getRoles).toHaveBeenCalledWith("c1");
expect(getRoles).not.toHaveBeenCalledWith("c1", "public");
useDbViewerStore.setState({ currentSchema: "analytics" });
await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(1));
expect(getRoles).toHaveBeenCalledWith("c1");
});
it("preselects type from store on mount", () => {
useDbViewerStore.setState({ selectedObjectType: "sequences" });
render(<ObjectExplorerPage connectionId="c1" />);
expect(screen.getByText("Sequences")).toBeTruthy();
});
});
@@ -0,0 +1,631 @@
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
import { ChevronRight, Plus, Search, X, RefreshCw } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { SelectDropdown } from "../ui/SelectDropdown";
import { DependencyDialog } from "./DependencyDialog";
import { ObjectContextMenu } from "./objects/ObjectContextMenu";
import {
ObjectDetail,
OBJECT_ICONS,
TYPE_LABELS,
SINGULAR_LABELS,
type AnyObject,
} from "./objects/ObjectDetail";
import { initialCrudParams, type ObjectKind } from "../../lib/objectCrud";
import * as cmd from "../../lib/commands";
import type { ObjectType, DependencyInfo } from "../../lib/types";
interface ObjectExplorerPageProps {
connectionId: string;
sidebarMode?: boolean;
}
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
(t) => ({ value: t, label: TYPE_LABELS[t] }),
);
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
function emptyPlural(type: ObjectType): string {
const singular = SINGULAR_LABELS[type];
const irregulars: Record<string, string> = {
index: "indexes",
constraint: "constraints",
};
return irregulars[singular] ?? `${singular}s`;
}
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
function itemKey(item: AnyObject): string {
const name = (item as any).name as string;
if ("argument_types" in item && Array.isArray(item.argument_types)) {
return `${name}(${item.argument_types.join(",")})`;
}
return name;
}
/** Display name for the tree list. Functions show their argument signature. */
function itemLabel(item: AnyObject): string {
const name = (item as any).name as string;
if (
"argument_types" in item &&
Array.isArray(item.argument_types) &&
item.argument_types.length > 0
) {
return `${name}(${item.argument_types.join(", ")})`;
}
return name;
}
function schemaOf(item: AnyObject): string {
// Roles are cluster-scoped and carry no schema — default to "".
return (item as { schema?: string }).schema ?? "";
}
function objectName(item: AnyObject): string {
return item.name;
}
function typeToDdlType(type: ObjectType): string {
const map: Record<ObjectType, string> = {
functions: "function",
procedures: "procedure",
triggers: "trigger",
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
roles: "role",
};
return map[type];
}
export function ObjectExplorerPage({
connectionId,
sidebarMode = false,
}: ObjectExplorerPageProps) {
const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType);
const [type, setType] = useState<ObjectType>(selectedObjectType ?? "functions");
const [panelWidth, setPanelWidth] = useState(280);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
);
const onPanelResizeStart = useCallback(
(e: React.MouseEvent) => {
panelResizeRef.current = { startX: e.clientX, startW: panelWidth };
const onMove = (ev: MouseEvent) => {
if (!panelResizeRef.current) return;
const delta = ev.clientX - panelResizeRef.current.startX;
const next = Math.max(
180,
Math.min(500, panelResizeRef.current.startW + delta),
);
setPanelWidth(next);
};
const onUp = () => {
panelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[panelWidth],
);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const schemas = useDbViewerStore((s) => s.schemas);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const changesQueue = useDbViewerStore((s) => s.changesQueue);
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
const refreshTree = useDbViewerStore((s) => s.refreshTree);
// Use store for persistence, but allow re-fetching when schema changes
const [items, setItems] = useState<AnyObject[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null);
const [openKey, setOpenKey] = useState<string | null>(null);
const [depOpen, setDepOpen] = useState(false);
const [depDeps, setDepDeps] = useState<DependencyInfo[]>([]);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
// Track last-fetched-schema so we know when to re-fetch
const lastSchemaRef = useRef<string | undefined>(undefined);
// Track the last committed ddl change so a commit triggers exactly one
// refetch (no duplicate refetches across re-renders).
const lastCommittedDdlRef = useRef<string>("");
// Fetch on mount and when schema changes
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
let result: AnyObject[];
if (type === "roles") {
// Roles are cluster-scoped — no schema filter.
result = await cmd.getRoles(connectionId);
} else if (type === "extensions") {
result = await cmd.getExtensions(connectionId);
} else if (type === "functions") {
result = (await cmd.getFunctions(
connectionId,
currentSchema ?? undefined,
)).filter((f) => f.kind === "f");
} else if (type === "procedures") {
result = (await cmd.getFunctions(
connectionId,
currentSchema ?? undefined,
)).filter((f) => f.kind === "p");
} else if (type === "triggers") {
result = await cmd.getTriggers(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "sequences") {
result = await cmd.getSequences(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "enums") {
result = await cmd.getEnums(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "indexes") {
result = await cmd.getIndexes(
connectionId,
currentSchema ?? undefined,
);
} else if (type === "constraints") {
result = await cmd.getConstraints(
connectionId,
currentSchema ?? undefined,
);
} else {
result = [];
}
setItems(result);
setSelectedItem(null);
lastSchemaRef.current = currentSchema ?? undefined;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setItems(null);
} finally {
setLoading(false);
}
}, [type, connectionId, currentSchema]);
useEffect(() => {
// Only re-fetch if schema actually changed (or first load). Roles are
// cluster-scoped — skip the refetch when the schema changes (the mount
// fetch still runs via lastSchemaRef === undefined).
const schema = currentSchema ?? undefined;
const schemaChanged = lastSchemaRef.current !== schema;
const skipForRoles =
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
if (schemaChanged && !skipForRoles) {
fetch();
}
}, [currentSchema, fetch]);
// After a commit containing ddl items succeeds, refetch the current
// object-type list + refresh the schema tree so newly created/dropped
// objects show up without a manual refresh.
useEffect(() => {
const committedDdl = changesQueue.filter(
(c) => c.type === "ddl" && c.status === "committed",
);
const lastCommitted = committedDdl[committedDdl.length - 1];
if (lastCommitted && lastCommitted.id !== lastCommittedDdlRef.current) {
lastCommittedDdlRef.current = lastCommitted.id;
fetch();
void refreshTree(connectionId, currentSchema ?? undefined);
}
}, [changesQueue, connectionId, currentSchema, fetch, refreshTree]);
// Search toggle handling
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
const handleSearchBlur = useCallback(() => {
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) setSearchQuery("");
return next;
});
}, []);
const q = searchQuery.toLowerCase().trim();
const filtered = useMemo(() => {
if (!items) return [];
if (!q) return items;
return items.filter((item) => {
const label = itemLabel(item).toLowerCase();
return label.includes(q);
});
}, [items, q]);
const icon = OBJECT_ICONS[type];
const label = TYPE_LABELS[type];
const singular = SINGULAR_LABELS[type];
// Header create button: open an objectForm tab prefilled with create state for the current type.
const openCreate = useCallback(() => {
const kind = typeToDdlType(type) as ObjectKind;
const schema = currentSchema ?? "public";
const singular = SINGULAR_LABELS[type];
useDbViewerStore.getState().openFormTab({
kind,
schema,
name: "",
title: `Create ${singular}`,
description: `Create ${singular}`,
mode: "create",
params: initialCrudParams(kind, { schema, name: "" }, "create"),
});
}, [type, currentSchema]);
// Switching object type: reset selection/search, clear the stale list so
// the loading state renders (no flash of the previous type's objects), and
// reset the last-fetched-schema marker so the fetch effect re-runs.
const handleTypeChange = useCallback((next: ObjectType) => {
setType(next);
setSearchQuery("");
setSelectedItem(null);
setOpenKey(null);
setItems(null);
setLoading(true);
lastSchemaRef.current = undefined;
}, []);
// Consume any object-type preselection from the Cmd+K palette.
useEffect(() => {
if (selectedObjectType && selectedObjectType !== type) {
handleTypeChange(selectedObjectType);
}
if (selectedObjectType) {
useDbViewerStore.getState().setSelectedObjectType(null);
}
}, [selectedObjectType, type, handleTypeChange]);
return (
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left panel: toolbar + object list */}
<div
className={
sidebarMode
? "flex flex-col flex-1 min-h-0 overflow-hidden"
: "border-r border-border flex flex-col shrink-0"
}
style={sidebarMode ? undefined : { width: panelWidth }}
>
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<SelectDropdown
value={type}
onChange={(v) => handleTypeChange(v as ObjectType)}
options={OBJECT_TYPE_OPTIONS}
variant="ghost"
aria-label="Object type"
/>
<div className="flex items-center gap-1">
<button
aria-label={`Create ${singular}`}
onClick={openCreate}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Plus size={14} />
</button>
<button
aria-label="Refresh"
onClick={fetch}
disabled={loading}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
>
<RefreshCw
size={14}
className={loading ? "animate-spin" : ""}
/>
</button>
<button
aria-label={`Search ${label}`}
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
>
<Search size={14} />
</button>
</div>
</div>
{/* Search input */}
<div
ref={searchContainerRef}
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search
size={12}
className="absolute left-2.5 text-text-muted pointer-events-none"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
placeholder={`Filter ${label.toLowerCase()}`}
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{/* Database/Schema dropdowns */}
{(databases.length > 1 || schemas.length > 1) && (
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={(v) =>
setCurrentDatabase(v || null)
}
options={databases.map((d) => ({
value: d,
label: d,
}))}
placeholder="Select database"
aria-label="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 1 && (
<span className="text-border">|</span>
)}
{schemas.length > 1 && (
<SelectDropdown
value={currentSchema ?? ""}
onChange={(v) =>
setCurrentSchema(v || null)
}
options={schemas.map((s) => ({
value: s,
label: s,
}))}
placeholder="Select schema"
aria-label="Select schema"
variant="ghost"
/>
)}
</div>
)}
</div>
{/* Object list */}
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
{loading && (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<RefreshCw
size={14}
className="animate-spin mr-2"
/>
Loading {label.toLowerCase()}...
</div>
)}
{error && (
<div className="px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{!loading && !error && filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{searchQuery
? `No ${emptyPlural(type)} matching "${searchQuery}"`
: `No ${emptyPlural(type)} found`}
</div>
)}
{!loading &&
filtered.map((item) => {
const name = itemLabel(item);
const key = itemKey(item);
const isSelected =
selectedItem !== null &&
itemKey(selectedItem) === itemKey(item);
const handleCopyDdl = async () => {
setOpenKey(null);
const ddl = await cmd.getObjectDdl(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
try {
await navigator.clipboard?.writeText(ddl);
} catch {
// Ignore clipboard errors.
}
};
const handleViewDependencies = async () => {
setOpenKey(null);
const deps = await cmd.getObjectDependencies(
connectionId,
schemaOf(item),
typeToDdlType(type),
objectName(item),
);
setDepDeps(deps);
setDepOpen(true);
};
return (
<div
key={key}
onClick={() =>
sidebarMode
? openObjectTab(
type,
schemaOf(item),
item.name,
item,
)
: setSelectedItem(item)
}
onContextMenu={(e) => {
e.preventDefault();
setOpenKey(key);
}}
className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${
isSelected
? "bg-accent/10 text-accent"
: "text-text hover:bg-surface-raised"
}`}
>
{icon}
<span className="flex-1 text-sm truncate">
{name}
</span>
<div
onClick={(e) => e.stopPropagation()}
className="relative shrink-0"
>
<ObjectContextMenu
connectionId={connectionId}
objectType={
typeToDdlType(type) as ObjectKind
}
item={{
...item,
schema: schemaOf(item),
}}
onRefresh={fetch}
open={openKey === key}
onOpenChange={(o) =>
setOpenKey(o ? key : null)
}
extraItems={[
{
id: "copy-ddl",
label: "Copy DDL",
onClick: handleCopyDdl,
},
{
id: "dependencies",
label: "Dependencies",
onClick:
handleViewDependencies,
},
]}
/>
</div>
<ChevronRight
size={14}
className={`text-text-muted shrink-0 transition-transform ${
isSelected ? "rotate-90" : ""
}`}
/>
</div>
);
})}
</div>
</div>
{!sidebarMode && (
<>
{/* Panel resize handle */}
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setPanelWidth(280)}
/>
{/* Right panel: detail view */}
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-y-auto overflow-x-hidden">
{selectedItem ? (
<>
{/* Header */}
<div className="px-4 py-2 flex flex-row items-center justify-between border-b border-border">
<h2 className="text-lg font-semibold text-text font-mono">
{itemLabel(selectedItem)}
</h2>
<p className="text-xs text-text-muted capitalize">
{singular}
{"schema" in selectedItem
? ` · ${(selectedItem as any).schema}`
: ""}
</p>
</div>
{/* Detail content */}
<div style={{ overflowX: "auto", width: "100%" }}>
<ObjectDetail
connectionId={connectionId}
type={type}
item={selectedItem}
/>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<div className="flex justify-center">
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
</div>
<p className="text-sm">
Select a {singular} to view details
</p>
<p className="text-xs text-text-subtle">
{filtered.length} {label.toLowerCase()}{" "}
available
</p>
</div>
</div>
)}
</div>
</>
)}
<DependencyDialog
open={depOpen}
deps={depDeps}
onProceed={() => setDepOpen(false)}
onCancel={() => setDepOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ObjectSearchPalette } from "./ObjectSearchPalette";
import * as cmd from "../../lib/commands";
import type { ObjectSearchHit } from "../../lib/types";
vi.mock("../../lib/commands");
const mockSetObjectSearchOpen = vi.fn();
const mockSetCurrentSchema = vi.fn();
const mockSetSelectedObjectType = vi.fn();
const mockSetRequestedView = vi.fn();
const mockOpenTab = vi.fn();
const baseMockState = {
objectSearchOpen: true,
setObjectSearchOpen: mockSetObjectSearchOpen,
currentSchema: "public" as string | null,
setCurrentSchema: mockSetCurrentSchema,
setSelectedObjectType: mockSetSelectedObjectType,
setRequestedView: mockSetRequestedView,
openTab: mockOpenTab,
};
let mockState: typeof baseMockState = { ...baseMockState };
vi.mock("../../stores/dbViewerStore", () => ({
useDbViewerStore: (selector: unknown) => {
return typeof selector === "function"
? (selector as (s: typeof mockState) => unknown)(mockState)
: mockState[selector as keyof typeof mockState];
},
}));
describe("ObjectSearchPalette", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState = { ...baseMockState };
});
it("renders nothing when closed", () => {
mockState = { ...baseMockState, objectSearchOpen: false };
const { container } = render(<ObjectSearchPalette connectionId="c1" />);
expect(container.firstChild).toBeNull();
});
it("debounces and groups results by type", async () => {
const hits: ObjectSearchHit[] = [
{ name: "users", schema: "public", object_type: "TABLE" },
{ name: "get_user", schema: "public", object_type: "FUNCTION" },
];
vi.mocked(cmd.searchObjects).mockResolvedValue(hits);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "user" },
});
await waitFor(() =>
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "user"),
);
await waitFor(() => {
expect(screen.getByText("TABLE")).toBeInTheDocument();
expect(screen.getByText("FUNCTION")).toBeInTheDocument();
});
});
it("uses currentSchema fallback when store value is null", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
mockState = { ...baseMockState, currentSchema: null };
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "x" },
});
await waitFor(() =>
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "x"),
);
});
it("Esc closes the palette", () => {
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.keyDown(window, { key: "Escape" });
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("backdrop click closes the palette", () => {
render(<ObjectSearchPalette connectionId="c1" />);
const backdrop = screen.getByLabelText(/search objects/i).closest(
"div[class*='fixed inset-0']",
) as HTMLElement;
fireEvent.click(backdrop);
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("selecting a table opens a tab and closes", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "users", schema: "public", object_type: "TABLE" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "users" },
});
await waitFor(() => screen.getByText("users"));
fireEvent.click(screen.getByText("users"));
expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
expect(mockSetCurrentSchema).not.toHaveBeenCalled();
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
});
it("selecting a view opens a tab and closes", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "active_users", schema: "public", object_type: "VIEW" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "active" },
});
await waitFor(() => screen.getByText("active_users"));
fireEvent.click(screen.getByText("active_users"));
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
});
it("selecting a matview opens a tab and closes", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "mv_users", schema: "public", object_type: "MATERIALIZED VIEW" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "mv" },
});
await waitFor(() => screen.getByText("mv_users"));
fireEvent.click(screen.getByText("mv_users"));
expect(mockOpenTab).toHaveBeenCalledWith("public", "mv_users");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it.each([
["FUNCTION", "functions"],
["PROCEDURE", "procedures"],
["TRIGGER", "triggers"],
["SEQUENCE", "sequences"],
["ENUM", "enums"],
["EXTENSION", "extensions"],
["INDEX", "indexes"],
["CONSTRAINT", "constraints"],
] as const)(
"selecting a %s switches the objects view and closes",
async (objectType, mappedType) => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "item", schema: "app", object_type: objectType },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "item" },
});
await waitFor(() => screen.getByText("item"));
fireEvent.click(screen.getByText("item"));
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
expect(mockSetRequestedView).toHaveBeenCalledWith("objects");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
expect(mockOpenTab).not.toHaveBeenCalled();
},
);
it("shows an empty state when no results match", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "nomatch" },
});
await waitFor(() => expect(cmd.searchObjects).toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText(/no matches/i)).toBeInTheDocument(),
);
});
it("clears query and results when reopened", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "users", schema: "public", object_type: "TABLE" },
]);
const { rerender } = render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "users" },
});
await waitFor(() => screen.getByText("users"));
mockState = { ...baseMockState, objectSearchOpen: false };
rerender(<ObjectSearchPalette connectionId="c1" />);
mockState = { ...baseMockState, objectSearchOpen: true };
rerender(<ObjectSearchPalette connectionId="c1" />);
expect(screen.queryByText("users")).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
});
it("ArrowDown then Enter selects the second hit", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "first", schema: "public", object_type: "FUNCTION" },
{ name: "second", schema: "public", object_type: "ENUM" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "s" },
});
await waitFor(() => screen.getByText("second"));
fireEvent.keyDown(window, { key: "ArrowDown" });
fireEvent.keyDown(window, { key: "Enter" });
// highlight started on the first hit; one ArrowDown moved to the second
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("enums");
expect(mockSetCurrentSchema).toHaveBeenCalledWith("public");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("ArrowUp wraps from the first hit to the last", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([
{ name: "first", schema: "public", object_type: "FUNCTION" },
{ name: "last", schema: "public", object_type: "SEQUENCE" },
]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "s" },
});
await waitFor(() => screen.getByText("last"));
fireEvent.keyDown(window, { key: "ArrowUp" });
fireEvent.keyDown(window, { key: "Enter" });
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("sequences");
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
});
it("Enter with no results does nothing", async () => {
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
render(<ObjectSearchPalette connectionId="c1" />);
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
target: { value: "nope" },
});
await waitFor(() => screen.getByText(/no matches/i));
fireEvent.keyDown(window, { key: "Enter" });
expect(mockOpenTab).not.toHaveBeenCalled();
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
expect(mockSetObjectSearchOpen).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,221 @@
import { useEffect, useRef, useState } from "react";
import { Search } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import type { ObjectSearchHit, ObjectType } from "../../lib/types";
const TYPE_TO_OBJECTS: Record<string, ObjectType> = {
FUNCTION: "functions",
PROCEDURE: "procedures",
TRIGGER: "triggers",
SEQUENCE: "sequences",
ENUM: "enums",
EXTENSION: "extensions",
INDEX: "indexes",
CONSTRAINT: "constraints",
};
interface ObjectSearchPaletteProps {
connectionId: string;
}
export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps) {
const open = useDbViewerStore((s) => s.objectSearchOpen);
const setOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
const storeSchema = useDbViewerStore((s) => s.currentSchema);
const currentSchema = storeSchema ?? "public";
const openTab = useDbViewerStore((s) => s.openTab);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
const setRequestedView = useDbViewerStore((s) => s.setRequestedView);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<ObjectSearchHit[]>([]);
const [loading, setLoading] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Clear the transient state whenever the palette is closed so it reopens
// with an empty search, no stale results, and the highlight reset.
useEffect(() => {
if (!open) {
setQuery("");
setHits([]);
setHighlightedIndex(0);
}
}, [open]);
// Debounced search against the current schema.
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (!query.trim()) {
setHits([]);
setLoading(false);
return;
}
timerRef.current = setTimeout(async () => {
setLoading(true);
try {
const results = await cmd.searchObjects(
connectionId,
currentSchema,
query,
);
setHits(results);
setHighlightedIndex(0);
} catch {
setHits([]);
} finally {
setLoading(false);
}
}, 150);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [query, connectionId, currentSchema]);
// Select: open a table/view tab (switching to the DB viewer view so the
// tab is actually visible), or jump to the Objects view with the type
// preselected. The view switch goes through the store's requestedView
// mechanism — currentView is local state in DbViewerScreen, which watches
// requestedView and clears it after navigating.
const handleSelect = (hit: ObjectSearchHit) => {
if (
hit.object_type === "TABLE" ||
hit.object_type === "VIEW" ||
hit.object_type === "MATERIALIZED VIEW"
) {
setRequestedView("db-viewer");
openTab(hit.schema, hit.name);
} else {
setCurrentSchema(hit.schema);
setSelectedObjectType(TYPE_TO_OBJECTS[hit.object_type] ?? "functions");
setRequestedView("objects");
}
setOpen(false);
};
// Keyboard navigation: ↓/↑ move the highlight (wrapping), Enter picks,
// Esc closes. Registered on window so it works even after the input blurs.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!open) return;
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
if (hits.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % hits.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + hits.length) % hits.length);
} else if (e.key === "Enter") {
e.preventDefault();
const hit = hits[highlightedIndex];
if (hit) handleSelect(hit);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, hits, highlightedIndex]);
// Scroll the highlighted row into view inside the results list.
// `?.()` guards environments without scrollIntoView (jsdom) so the ref
// callback never throws during commit.
const onHighlightedRef = (el: HTMLButtonElement | null) => {
el?.scrollIntoView?.({ block: "nearest" });
};
if (!open) return null;
const grouped = hits.reduce<Record<string, ObjectSearchHit[]>>((acc, hit) => {
const list = acc[hit.object_type] ?? [];
list.push(hit);
acc[hit.object_type] = list;
return acc;
}, {});
// Flattened index → hit, so keyboard navigation matches the visible order.
// (Highlight index is tracked against the flat result list; grouped output
// below increments it in render order.)
let flatIndex = -1;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
onClick={() => setOpen(false)}
role="dialog"
aria-label="Search objects"
>
<div
className="w-[520px] max-h-[60vh] overflow-auto rounded-xl border border-border bg-surface shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Search size={14} className="text-text-muted" />
<input
ref={inputRef}
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search objects in current schema…"
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
/>
{loading && (
<span className="text-xs text-text-muted">loading</span>
)}
</div>
{Object.entries(grouped).map(([type, list]) => (
<div key={type}>
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
{type}
</div>
{list.map((hit) => {
flatIndex += 1;
const index = flatIndex;
const highlighted = index === highlightedIndex;
return (
<button
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
type="button"
ref={highlighted ? onHighlightedRef : undefined}
onClick={() => handleSelect(hit)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
highlighted
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text"
}`}
>
<span className="truncate">{hit.name}</span>
<span className="text-[10px] text-text-subtle">
{hit.schema}
</span>
</button>
);
})}
</div>
))}
{!loading && query.trim() && hits.length === 0 && (
<div className="px-3 py-4 text-sm text-text-muted">No matches</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,329 @@
import { useEffect, useState, useMemo } from "react";
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands";
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
interface ObjectTreeProps {
type: ObjectType;
connectionId: string;
}
const TYPE_LABELS: Record<ObjectType, string> = {
functions: "functions",
triggers: "triggers",
sequences: "sequences",
enums: "enums",
extensions: "extensions",
};
const ICONS: Record<ObjectType, React.ReactNode> = {
functions: <FunctionSquare size={14} className="text-text-muted shrink-0" />,
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
};
function SourceCode({ source }: { source: string }) {
const [expanded, setExpanded] = useState(false);
const maxLen = 500;
const truncated = source.length > maxLen && !expanded;
const display = truncated ? source.slice(0, maxLen) : source;
return (
<div className="mt-1">
<pre className="text-xs text-text-muted bg-surface-raised rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
{display}
{truncated && <span className="text-text-subtle">...</span>}
</pre>
{source.length > maxLen && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
className="text-xs text-accent hover:underline mt-1"
>
{expanded ? "Show less" : "Show more"}
</button>
)}
</div>
);
}
export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const functions = useDbViewerStore((s) => s.functions);
const triggers = useDbViewerStore((s) => s.triggers);
const sequences = useDbViewerStore((s) => s.sequences);
const enums = useDbViewerStore((s) => s.enums);
const extensions = useDbViewerStore((s) => s.extensions);
const setFunctions = useDbViewerStore((s) => s.setFunctions);
const setTriggers = useDbViewerStore((s) => s.setTriggers);
const setSequences = useDbViewerStore((s) => s.setSequences);
const setEnums = useDbViewerStore((s) => s.setEnums);
const setExtensions = useDbViewerStore((s) => s.setExtensions);
const [loading, setLoading] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
// Determine which store accessors to use
const data = useMemo(() => {
switch (type) {
case "functions": return functions;
case "triggers": return triggers;
case "sequences": return sequences;
case "enums": return enums;
case "extensions": return extensions;
}
}, [type, functions, triggers, sequences, enums, extensions]);
const setter = useMemo(() => {
switch (type) {
case "functions": return setFunctions;
case "triggers": return setTriggers;
case "sequences": return setSequences;
case "enums": return setEnums;
case "extensions": return setExtensions;
}
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
// Fetch on mount if not in store
useEffect(() => {
if (data !== null) return;
let cancelled = false;
setLoading(true);
const fetchData = async () => {
try {
if (type === "extensions") {
const result = await cmd.getExtensions(connectionId);
if (!cancelled) (setExtensions as (v: ExtensionInfo[]) => void)(result);
} else if (type === "functions") {
const result = await cmd.getFunctions(connectionId, currentSchema ?? undefined);
if (!cancelled) (setFunctions as (v: FunctionInfo[]) => void)(result);
} else if (type === "triggers") {
const result = await cmd.getTriggers(connectionId, currentSchema ?? undefined);
if (!cancelled) (setTriggers as (v: TriggerInfo[]) => void)(result);
} else if (type === "sequences") {
const result = await cmd.getSequences(connectionId, currentSchema ?? undefined);
if (!cancelled) (setSequences as (v: SequenceInfo[]) => void)(result);
} else if (type === "enums") {
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
}
} catch {
// Silently fail — store remains null, we show the empty state
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
const q = search.toLowerCase().trim();
const list = useMemo(() => {
if (!data) return [];
if (!q) return data;
return data.filter((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
return name.toLowerCase().includes(q);
});
}, [data, q]);
const toggle = (key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const icon = ICONS[type];
const pluralLabel = TYPE_LABELS[type];
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="px-3 py-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder={`Search ${pluralLabel}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2 py-1 text-xs bg-surface-raised border border-border rounded text-text placeholder:text-text-subtle focus:outline-none focus:border-accent/50"
/>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto py-2" style={{ overscrollBehavior: "none" }}>
{loading && (
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
<div className="w-4 h-4 border-2 border-text-muted border-t-accent rounded-full animate-spin mr-2" />
Loading {pluralLabel}...
</div>
)}
{!loading && list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`}
</div>
)}
{!loading && list.map((item) => {
const name = "name" in item ? (item as { name: string }).name : "";
const key = name;
const isExpanded = expandedKeys.has(key);
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => toggle(key)}
>
<button
type="button"
aria-label={isExpanded ? "Collapse" : "Expand"}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{icon}
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{name}
</span>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{type === "functions" && (() => {
const f = item as FunctionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Returns:</span> {f.return_type}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Language:</span> {f.language}
</div>
{f.argument_names.length > 0 && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Args:</span>{" "}
{f.argument_names.map((a, i) => (
<span key={i}>
{f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && (
<span className="text-amber-400">{f.argument_modes[i]} </span>
)}
{a} <span className="text-text-subtle">({f.argument_types?.[i] || "unknown"})</span>
{i < f.argument_names.length - 1 && ", "}
</span>
))}
</div>
)}
{f.source && <SourceCode source={f.source} />}
</>
);
})()}
{type === "triggers" && (() => {
const t = item as TriggerInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Table:</span> {t.table_schema}.{t.table_name}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Event:</span> {t.event_manipulation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Timing:</span> {t.action_timing}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Orientation:</span> {t.action_orientation}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Enabled:</span> {t.enabled}
</div>
{t.action_statement && <SourceCode source={t.action_statement} />}
</>
);
})()}
{type === "sequences" && (() => {
const s = item as SequenceInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Current:</span> {s.current_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Increment:</span> {s.increment}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Min:</span> {s.min_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Max:</span> {s.max_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Start:</span> {s.start_value}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Cycle:</span> {s.cycle ? "Yes" : "No"}
</div>
</>
);
})()}
{type === "enums" && (() => {
const e = item as EnumInfo;
return (
<div className="flex flex-wrap gap-1 mt-1">
{e.labels.map((label) => (
<span
key={label}
className="inline-block px-1.5 py-0.5 text-[10px] rounded bg-surface-raised text-text-muted border border-border"
>
{label}
</span>
))}
</div>
);
})()}
{type === "extensions" && (() => {
const e = item as ExtensionInfo;
return (
<>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Version:</span> {e.version}
</div>
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Schema:</span> {e.schema}
</div>
{e.comment && (
<div className="text-xs text-text-muted">
<span className="text-text-subtle">Comment:</span> {e.comment}
</div>
)}
</>
);
})()}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PasswordPromptDialog } from "./PasswordPromptDialog";
describe("PasswordPromptDialog", () => {
it("renders nothing when closed", () => {
const { container } = render(
<PasswordPromptDialog
open={false}
connectionName="n"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("shows the connection name and a password field when open", () => {
render(
<PasswordPromptDialog
open={true}
connectionName="Prod DB"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
it("calls onConnect with the typed value", () => {
const onConnect = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={onConnect}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/password/i), {
target: { value: "p@ss" },
});
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
expect(onConnect).toHaveBeenCalledWith("p@ss");
});
it("calls onCancel on cancel", () => {
const onCancel = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={() => {}}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
interface Props {
open: boolean;
connectionName: string;
onConnect: (password: string) => void;
onCancel: () => void;
}
export function PasswordPromptDialog({
open,
connectionName,
onConnect,
onCancel,
}: Props) {
const [pw, setPw] = useState("");
if (!open) return null;
return (
<AnimatedModal open={open} onClose={onCancel}>
<div className="p-5 w-80">
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
<p className="text-xs text-text-muted mb-3">
{connectionName} has keychain disabled. Enter the password for this
session (it will not be saved).
</p>
<input
type="password"
autoFocus
aria-label="Password"
placeholder="Password"
value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && pw) onConnect(pw);
}}
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
>
Cancel
</button>
<button
type="button"
onClick={() => pw && onConnect(pw)}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
>
Connect
</button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, pgRestore } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface RestoreDialogProps {
open: boolean;
connectionId: string;
onClose: () => void;
}
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
function getPlatformInstructions(): string {
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
}
export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProps) {
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handlePickFile = useCallback(async () => {
try {
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const picked = await openDialog({
multiple: false,
filters: [{ name: "Backup Files", extensions: ["dump", "sql", "tar", "custom", "gz"] }],
});
if (picked && typeof picked === "string") setFilePath(picked);
} catch {
// dialog not available (non-Tauri env), use manual path input
}
}, []);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
setRunning(true);
const jobId = `restore-${Date.now()}`;
startJob(jobId, "restore");
try {
await pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
notify("Restore completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Restore failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [filePath, format, clean, schema, connectionId, startJob, notify, onClose]);
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
const canStart = filePath && confirmed && !running;
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Restore Database</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">pg_restore not found</p>
<p className="text-amber-200/80 text-xs">
The PostgreSQL client tools are required for backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
{getPlatformInstructions()}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* File path */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Backup File</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="/path/to/backup.dump"
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
<Button variant="secondary" onClick={handlePickFile}>
Browse
</Button>
</div>
</div>
{/* Format */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Format</label>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">Custom Archive</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">Directory</option>
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Clean toggle */}
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
<input
type="checkbox"
checked={clean}
onChange={(e) => setClean(e.target.checked)}
className="rounded bg-surface border-border accent-accent"
/>
Clean (DROP before CREATE)
</label>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="restore"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartRestore} disabled={!canStart}>
{running ? "Restoring..." : "Start Restore"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_restore:")) {
const [, ...rest] = msg.split("pg_restore:");
return rest.join(":").trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { RestorePage } from "./RestorePage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("RestorePage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
});
it("shows format selector for PostgreSQL", async () => {
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
render(<RestorePage connectionId="c1" />);
await waitFor(() => expect(screen.queryByText(/checking for pg_restore/i)).not.toBeInTheDocument());
expect(screen.getByText("Custom Archive")).toBeTruthy();
});
it("renders a plain MySQL restore (no format selector)", async () => {
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<RestorePage connectionId="c2" />);
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/Plain SQL restores run via psql/i)).toBeNull();
});
it("renders a plain SQLite restore (no format selector, no tool card)", () => {
mockConnections.push({ id: "c3", db_type: "sqlite", name: "s" });
render(<RestorePage connectionId="c3" />);
expect(screen.queryByText("Custom Archive")).toBeNull();
expect(screen.queryByText(/pg_restore/i)).toBeNull();
expect(screen.queryByText(/mysqldump/i)).toBeNull();
});
});
@@ -0,0 +1,449 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { FileSearch, Upload } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { useConnectionStore } from "../../stores/connectionStore";
import {
detectPgTools,
pgRestore,
getSchemas,
detectMysqlTools,
mysqlRestore,
sqliteRestore,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
interface RestorePageProps {
connectionId: string;
}
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install libpq",
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
};
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
darwin: "brew install mysql-client",
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
};
function getPlatformInstructions(map: Record<string, string>): string {
const platform =
typeof navigator !== "undefined"
? navigator.platform.toLowerCase()
: "";
if (platform.includes("mac") || platform.includes("darwin"))
return map.darwin;
if (platform.includes("linux")) return map.linux;
if (platform.includes("win")) return map.win32;
return map.linux;
}
export function RestorePage({ connectionId }: RestorePageProps) {
const connection = useConnectionStore((s) =>
s.connections.find((c) => c.id === connectionId),
);
const dbType = connection?.db_type ?? "postgresql";
const database = connection?.database ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
const isSqlite = dbType === "sqlite";
const [filePath, setFilePath] = useState("");
const [format, setFormat] = useState("custom");
const [clean, setClean] = useState(true);
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(true);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Restore completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Restore failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
useEffect(() => {
setCheckingTools(true);
setConfirmed(false);
setPgToolStatus(null);
setMysqlToolStatus(null);
setAvailableSchemas([]);
if (isPg) {
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else if (isMysql) {
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
getSchemas(connectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
} else {
setCheckingTools(false);
}
}, [connectionId, isPg, isMysql]);
const handlePickFile = useCallback(async () => {
const extensions = isPg
? ["dump", "sql", "tar", "custom", "gz"]
: isMysql
? ["sql"]
: ["db", "sqlite", "sql"];
const picked = await open({
multiple: false,
filters: [
{
name: "Backup Files",
extensions,
},
],
});
if (picked && typeof picked === "string") setFilePath(picked);
}, [isPg, isMysql, isSqlite]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartRestore = useCallback(async () => {
if (!filePath) {
notify("Please select a file path", "error");
return;
}
if (isMysql && !database) {
notify("MySQL connection has no database selected", "error");
return;
}
await runWithProgress("restore", () => {
if (isPg) {
return pgRestore(connectionId, {
format,
filePath,
clean,
schema: schema || undefined,
});
}
if (isMysql) {
return mysqlRestore(connectionId, {
database: database!,
filePath,
clean,
});
}
return sqliteRestore(connectionId, { filePath, clean });
});
}, [
filePath,
database,
isPg,
isMysql,
isSqlite,
format,
clean,
schema,
connectionId,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus && !pgToolStatus.pg_restore_found
: isMysql
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart = filePath && confirmed && !isRunning;
const checkingMessage = isPg
? "Checking for pg_restore..."
: isMysql
? "Checking for mysql..."
: null;
const headerDescription = isPg
? "Restore a database from a backup file"
: isMysql
? "Restore a database from a SQL dump"
: "Restore a database from a backup file";
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<Upload size={14} className="text-accent" />
<span className="text-xs font-medium text-text">Restore</span>
<span className="text-[11px] text-text-muted">
{headerDescription}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
{checkingMessage}
</p>
</div>
)}
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
{isPg ? "pg_restore not found" : "mysql client not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
backup/restore operations. Install them using:
</p>
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
{getPlatformInstructions(
isPg
? PG_INSTALL_INSTRUCTIONS
: MYSQL_INSTALL_INSTRUCTIONS,
)}
</pre>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Format (PostgreSQL only) */}
{isPg && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Format
</label>
<select
value={format}
onChange={(e) =>
setFormat(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="custom">
Custom Archive
</option>
<option value="plain">Plain SQL</option>
<option value="tar">Tarball</option>
<option value="directory">
Directory
</option>
</select>
</div>
)}
{/* Backup file */}
<div className="space-y-1 w-full">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Backup File
</label>
<div className="flex gap-2">
<input
type="text"
value={filePath}
onChange={(e) =>
setFilePath(e.target.value)
}
placeholder="/path/to/backup.dump"
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
/>
<button
type="button"
onClick={handlePickFile}
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
aria-label="Browse for file"
>
<FileSearch size={15} />
</button>
</div>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">All schemas</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Clean toggle */}
{(isPg || isMysql || isSqlite) && (
<label
className={`flex items-center gap-2.5 cursor-pointer group ${
isPg && format === "plain"
? "opacity-40 pointer-events-none"
: ""
}`}
>
<input
type="checkbox"
checked={clean}
onChange={(e) =>
setClean(e.target.checked)
}
disabled={isPg && format === "plain"}
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
/>
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
Clean{" "}
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
DROP before CREATE
</code>
</span>
</label>
)}
{isPg && format === "plain" && (
<p className="text-[11px] text-text-muted/70 -mt-3">
Plain SQL restores run via psql and don't
support DROP-before-CREATE. Use Custom
Archive for clean restores.
</p>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="restore-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="restore"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartRestore}
disabled={!canStart}
>
<Upload size={14} className="mr-1.5" />
{isRunning
? "Restoring..."
: "Start Restore"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu";
vi.mock("../../lib/commands");
describe("SchemaMenu", () => {
beforeEach(() => {
vi.mocked(cmd.createSchema).mockReset();
vi.mocked(cmd.renameSchema).mockReset();
vi.mocked(cmd.dropSchema).mockReset();
vi.mocked(cmd.getObjectDependencies).mockReset();
});
it("create happy path calls createSchema then refreshTree", async () => {
vi.mocked(cmd.createSchema).mockResolvedValue(undefined);
const refresh = vi.fn();
render(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
fireEvent.click(screen.getByLabelText(/new schema/i));
fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } });
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema"));
await waitFor(() => expect(refresh).toHaveBeenCalled());
});
it("drop non-empty requires cascade checkbox + typed confirm", async () => {
vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]);
vi.mocked(cmd.dropSchema).mockResolvedValue(undefined);
const refresh = vi.fn();
render(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
fireEvent.click(screen.getByLabelText(/schema menu/i));
fireEvent.click(screen.getByText(/drop/i));
await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s"));
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
fireEvent.click(screen.getByRole("checkbox"));
fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } });
fireEvent.click(screen.getByRole("button", { name: /drop schema/i }));
await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true));
await waitFor(() => expect(refresh).toHaveBeenCalled());
});
});
@@ -0,0 +1,295 @@
import { useState, useRef, useEffect } from "react";
import { Plus, MoreVertical } from "lucide-react";
import * as cmd from "../../lib/commands";
import type { DependencyInfo } from "../../lib/types";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { DependencyDialog } from "./DependencyDialog";
interface SchemaMenuProps {
connectionId: string;
schema?: string;
onRefresh: () => void;
}
export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [renaming, setRenaming] = useState(false);
const [newName, setNewName] = useState("");
const [dropOpen, setDropOpen] = useState(false);
const [deps, setDeps] = useState<DependencyInfo[]>([]);
const [typed, setTyped] = useState("");
const [err, setErr] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [menuOpen]);
const resetErrors = () => setErr(null);
const create = async () => {
resetErrors();
try {
await cmd.createSchema(connectionId, name);
setCreating(false);
setName("");
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const rename = async () => {
resetErrors();
if (!schema) return;
try {
await cmd.renameSchema(connectionId, schema, newName);
setRenaming(false);
setNewName("");
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const startDrop = async () => {
setMenuOpen(false);
if (!schema) return;
try {
const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema);
setDeps(d);
setDropOpen(true);
setTyped("");
setErr(null);
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const confirmDrop = async (cascade: boolean) => {
if (!schema) return;
try {
await cmd.dropSchema(connectionId, schema, cascade);
setDropOpen(false);
setTyped("");
setDeps([]);
onRefresh();
} catch (e) {
setErr((e as Error)?.message ?? String(e));
}
};
const hasDeps = deps.length > 0;
const inputClass =
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
return (
<div className="flex items-center gap-1" ref={menuRef}>
<button
aria-label="New schema"
onClick={() => {
setCreating(true);
setName("");
setErr(null);
}}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Plus size={14} />
</button>
{schema && (
<button
aria-label="Schema menu"
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<MoreVertical size={14} />
</button>
)}
{menuOpen && (
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
<button
type="button"
onClick={() => {
setMenuOpen(false);
setRenaming(true);
setNewName("");
setErr(null);
}}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
>
Rename
</button>
<button
type="button"
onClick={startDrop}
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
>
Drop
</button>
</div>
)}
{/* New Schema */}
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
<input
placeholder="Schema name"
value={name}
onChange={(e) => setName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setCreating(false)}>
Cancel
</Button>
<Button variant="primary" onClick={create}>
Create
</Button>
</div>
</div>
</AnimatedModal>
{/* Rename Schema */}
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
<input
placeholder="New name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button variant="ghost" onClick={() => setRenaming(false)}>
Cancel
</Button>
<Button variant="primary" onClick={rename}>
Rename
</Button>
</div>
</div>
</AnimatedModal>
{/* Dependency warning */}
{dropOpen && (
<DependencyDialog
open={dropOpen}
deps={deps}
onCancel={() => {
setDropOpen(false);
setTyped("");
setDeps([]);
setErr(null);
}}
onProceed={() => {}}
/>
)}
{/* Typed-name confirmation for CASCADE drop */}
{dropOpen && hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">
Type the schema name to confirm the CASCADE drop.
</p>
<input
placeholder="Type the schema name"
value={typed}
onChange={(e) => {
setTyped(e.target.value);
if (err) setErr(null);
}}
className={inputClass}
/>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setTyped("");
setErr(null);
}}
>
Cancel
</Button>
<Button
variant="primary"
onClick={() => {
if (typed !== schema) {
setErr("Name does not match");
return;
}
void confirmDrop(true);
}}
>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
{/* Empty-schema confirmation (no deps) */}
{dropOpen && !hasDeps && (
<AnimatedModal
open={dropOpen}
onClose={() => {
setDropOpen(false);
setErr(null);
}}
>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
<p className="text-sm text-text-muted mb-3">No dependencies drop this empty schema?</p>
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
<div className="flex justify-end gap-2 mt-5">
<Button
variant="ghost"
onClick={() => {
setDropOpen(false);
setErr(null);
}}
>
Cancel
</Button>
<Button variant="primary" onClick={() => void confirmDrop(false)}>
Drop Schema
</Button>
</div>
</div>
</AnimatedModal>
)}
</div>
);
}
@@ -0,0 +1,98 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ReactFlowProvider } from "@xyflow/react";
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
import type { TableNode } from "../../lib/types";
// React Flow custom nodes must be wrapped in ReactFlowProvider
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ReactFlowProvider>{children}</ReactFlowProvider>
);
const sampleTable: TableNode = {
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
],
};
describe("SchemaVisualizerNode", () => {
it("renders table name in header", () => {
render(
<SchemaVisualizerNode
id="node-1"
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
selected={false}
/>,
{ wrapper },
);
expect(screen.getByText("public.users")).toBeInTheDocument();
});
it("renders all columns by default", () => {
render(
<SchemaVisualizerNode
id="node-1"
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
selected={false}
/>,
{ wrapper },
);
expect(screen.getByText("id")).toBeInTheDocument();
expect(screen.getByText("name")).toBeInTheDocument();
expect(screen.getByText("email")).toBeInTheDocument();
});
it("collapses non-key columns on chevron click", () => {
render(
<SchemaVisualizerNode
id="node-1"
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
selected={false}
/>,
{ wrapper },
);
// Find collapse button
const collapseBtn = screen.getByRole("button", { name: /collapse/i });
fireEvent.click(collapseBtn);
// After collapse, non-key columns should be hidden
// name is non-key, should not be visible
expect(screen.queryByText(/^name$/)).not.toBeInTheDocument();
// id and email (PK/UNIQUE) should still be visible
expect(screen.getByText(/^id$/)).toBeInTheDocument();
expect(screen.getByText(/^email$/)).toBeInTheDocument();
});
it("renders in dimmed style when isExternal is true", () => {
const { container } = render(
<SchemaVisualizerNode
id="node-ext"
data={{ table: sampleTable, isExternal: true, onExpandExternal: vi.fn() }}
selected={false}
/>,
{ wrapper },
);
const card = container.firstElementChild;
expect(card?.className).toContain("opacity-50");
});
it("calls onExpandExternal when external node is clicked", () => {
const onExpand = vi.fn();
render(
<SchemaVisualizerNode
id="node-ext"
data={{ table: sampleTable, isExternal: true, onExpandExternal: onExpand }}
selected={false}
/>,
{ wrapper },
);
const card = screen.getByText("public.users").closest("div");
fireEvent.click(card!);
expect(onExpand).toHaveBeenCalledWith("public", "users");
});
});
@@ -0,0 +1,124 @@
import { memo, useState } from "react";
import { Handle, Position } from "@xyflow/react";
import { Table2, Eye, ChevronUp, ChevronDown, Key, ArrowRight } from "lucide-react";
import type { TableNode as TableNodeType } from "../../lib/types";
import { abbreviateType } from "../../lib/utils";
export interface SchemaVisualizerNodeData {
table: TableNodeType;
isExternal: boolean;
onExpandExternal?: (schema: string, table: string) => void;
}
interface SchemaVisualizerNodeProps {
id: string;
data: SchemaVisualizerNodeData;
selected: boolean;
}
export const SchemaVisualizerNode = memo(function SchemaVisualizerNode({
data,
selected,
}: SchemaVisualizerNodeProps) {
const { table, isExternal, onExpandExternal } = data;
const [collapsed, setCollapsed] = useState(false);
const isView = table.table_type === "VIEW";
const visibleColumns = collapsed
? table.columns.filter((c) => c.is_pk || c.is_fk || c.is_unique)
: table.columns;
const handleClick = () => {
if (isExternal && onExpandExternal) {
onExpandExternal(table.schema, table.name);
}
};
const cardClass = [
"rounded-none border bg-surface min-w-[220px] text-xs font-mono",
selected ? "border-accent shadow-lg shadow-accent/10" : "border-border",
isExternal ? "opacity-50 border-dashed cursor-pointer" : "",
].join(" ");
return (
<div className={cardClass} onClick={handleClick}>
{/* Header */}
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border bg-surface-raised">
<div className="flex items-center gap-1.5">
{isView ? (
<Eye size={12} className="text-text-muted" />
) : (
<Table2 size={12} className="text-text-muted" />
)}
<span className="font-semibold text-text truncate max-w-[160px]">
{table.schema}.{table.name}
</span>
</div>
{!isExternal && (
<button
type="button"
aria-label={collapsed ? "Expand columns" : "Collapse columns"}
className="p-0.5 rounded hover:bg-surface-hover text-text-muted"
onClick={(e) => {
e.stopPropagation();
setCollapsed(!collapsed);
}}
>
{collapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
)}
</div>
{/* Column rows */}
<div>
{visibleColumns.map((col) => (
<div
key={col.name}
className="flex items-center justify-between px-2 py-1 border-b border-border last:border-b-0 hover:bg-surface-hover relative"
>
{/* Left side: badges + name */}
<div className="flex items-center gap-1">
{col.is_pk && <Key size={10} className="text-amber-400 shrink-0" />}
{col.is_fk && !col.is_pk && (
<ArrowRight size={10} className="text-accent shrink-0" />
)}
<span className="text-text truncate max-w-[120px]">{col.name}</span>
</div>
{/* Right side: type */}
<span className="text-text-muted text-[10px] shrink-0 ml-2 max-w-[60px] truncate inline-block align-middle">
{abbreviateType(col.data_type)}
</span>
{/* FK source handle */}
{col.is_fk && (
<Handle
type="source"
position={Position.Right}
id={`fk-${col.name}`}
className="!w-2 !h-2 !bg-accent !border-2 !border-canvas"
style={{ top: "50%", right: -5 }}
/>
)}
{/* PK target handle */}
{col.is_pk && (
<Handle
type="target"
position={Position.Left}
id={`pk-${col.name}`}
className="!w-2 !h-2 !bg-amber-400 !border-2 !border-canvas"
style={{ top: "50%", left: -5 }}
/>
)}
</div>
))}
</div>
{/* Show collapsed count */}
{collapsed && table.columns.length > visibleColumns.length && (
<div className="px-2 py-1 text-[10px] text-text-muted border-t border-border">
+{table.columns.length - visibleColumns.length} more columns
</div>
)}
</div>
);
});
@@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Mock } from "vitest";
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { TooltipProvider } from "../ui/Tooltip";
import { toPng } from "html-to-image";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
// Mock html-to-image so exports don't hit real DOM capture in jsdom
vi.mock("html-to-image", () => ({
toPng: vi.fn().mockResolvedValue("data:image/png;base64,AAAA"),
toJpeg: vi.fn().mockResolvedValue("data:image/jpeg;base64,AAAA"),
toSvg: vi.fn().mockResolvedValue("data:image/svg+xml;base64,AAAA"),
}));
// Mock the Tauri save dialog and fs write
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn().mockResolvedValue("/tmp/export.png"),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
}));
// Mock the Tauri invoke call
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockResolvedValue({
tables: [],
relationships: [],
}),
}));
// Mock the SchemaVisualizerNode to avoid React Flow complexity in tests
vi.mock("./SchemaVisualizerNode", () => ({
SchemaVisualizerNode: () => <div data-testid="mock-node">Node</div>,
}));
describe("SchemaVisualizerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
useNotificationStore.setState({ notifications: [] });
useDbViewerStore.setState({
schemas: ["public", "auth"],
currentSchema: "public",
});
});
it("renders the legend panel", () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
expect(screen.getByText(/one-to-one/i)).toBeInTheDocument();
expect(screen.getByText(/one-to-many/i)).toBeInTheDocument();
expect(screen.getByText(/many-to-many/i)).toBeInTheDocument();
});
it("shows loading state initially", () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
expect(screen.getByText(/loading schema/i)).toBeInTheDocument();
});
it("renders Reset Layout button", () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
expect(screen.getByText(/reset layout/i)).toBeInTheDocument();
});
it("shows error message when introspection fails", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockRejectedValueOnce(new Error("Connection lost"));
render(
<TooltipProvider>
<SchemaVisualizerPage connectionId="conn-1" onSchemaChange={() => {}} />
</TooltipProvider>,
);
const errorMsg = await screen.findByText(/failed to load schema/i);
expect(errorMsg).toBeInTheDocument();
});
it("renders the Export menu with a scope selector and format options", () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByText(/export/i));
expect(screen.getByLabelText("Export scope")).toBeInTheDocument();
expect(screen.getByText("PNG")).toBeInTheDocument();
expect(screen.getByText("JPEG")).toBeInTheDocument();
expect(screen.getByText("SVG")).toBeInTheDocument();
});
it("exports the viewport as PNG when Viewport scope is selected", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
// pick Viewport scope, then PNG
fireEvent.click(screen.getByLabelText("Export scope"));
fireEvent.click(screen.getByText("Viewport"));
fireEvent.click(screen.getByText("PNG"));
expect(toPng).toHaveBeenCalledTimes(1);
const [, options] = (toPng as Mock).mock.calls[0];
// Viewport export keeps the current view — no transform override
expect(options.style).toBeUndefined();
expect(options.width).toBeGreaterThan(0);
});
it("exports the entire schema as PNG with a computed transform", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG")); // scope defaults to Entire Schema
expect(toPng).toHaveBeenCalledTimes(1);
const [, options] = (toPng as Mock).mock.calls[0];
expect(options.style.transform).toContain("scale(");
});
it("shows an export error when the schema has no nodes", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({ tables: [], relationships: [] });
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
// default mock resolves an empty graph
await screen.findByText(/no tables found/i);
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
expect(await screen.findByText(/export failed/i)).toBeInTheDocument();
});
it("saves through the dialog and notifies with the file path", async () => {
(save as Mock).mockResolvedValue("/Users/me/Pictures/public-export.png");
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(writeFile).toHaveBeenCalledTimes(1));
expect(writeFile).toHaveBeenCalledWith(
"/Users/me/Pictures/public-export.png",
expect.any(Uint8Array),
);
const notification = useNotificationStore
.getState()
.notifications.find((n) => n.message.includes("exported to"));
expect(notification).toBeTruthy();
expect(notification!.message).toContain(
"/Users/me/Pictures/public-export.png",
);
});
it("defaults the filename to the db name plus a locale timestamp", async () => {
(save as Mock).mockResolvedValue(null);
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(save).toHaveBeenCalled());
const { defaultPath } = (save as Mock).mock.calls[0][0];
// schema is "public" here; timestamp is locale-formatted then sanitized
expect(defaultPath).toMatch(/^public-.*\.png$/);
});
it("exports a transparent PNG when a transparent background is selected", async () => {
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByLabelText("Export background"));
fireEvent.click(screen.getByText("Transparent"));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(toPng).toHaveBeenCalled());
const [, options] = (toPng as Mock).mock.calls[0];
expect(options.backgroundColor).toBeUndefined();
});
it("hides the JPEG option when a transparent background is selected", async () => {
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByText(/export/i));
// JPEG is available with an opaque background by default
expect(screen.getByText("JPEG")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Export background"));
fireEvent.click(screen.getByText("Transparent"));
expect(screen.queryByText("JPEG")).not.toBeInTheDocument();
expect(screen.getByText("PNG")).toBeInTheDocument();
expect(screen.getByText("SVG")).toBeInTheDocument();
});
it("does not write the file when the save dialog is cancelled", async () => {
(save as Mock).mockResolvedValue(null);
const { invoke } = await import("@tauri-apps/api/core");
(invoke as any).mockReset();
(invoke as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{
name: "id",
data_type: "integer",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
});
render(
<TooltipProvider>
<SchemaVisualizerPage
connectionId="conn-1"
onSchemaChange={() => {}}
/>
</TooltipProvider>,
);
await screen.findByText("1 table");
fireEvent.click(screen.getByText(/export/i));
fireEvent.click(screen.getByText("PNG"));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(writeFile).not.toHaveBeenCalled();
expect(
useNotificationStore
.getState()
.notifications.some((n) => n.message.includes("exported to")),
).toBe(false);
});
});
@@ -0,0 +1,629 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
MiniMap,
Controls,
Background,
BackgroundVariant,
useNodesState,
useEdgesState,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "dagre";
import { RotateCcw, ChevronUp, ChevronDown, Download, Loader2 } from "lucide-react";
import { toPng, toJpeg, toSvg } from "html-to-image";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
import { CrowsFootEdge } from "./CrowsFootEdge";
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
import { LEGEND_ITEMS } from "./legendHelpers";
import { SelectDropdown } from "../ui/SelectDropdown";
import { getSchemaGraph } from "../../lib/commands";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import type { SchemaGraph, TableNode as TableNodeType } from "../../lib/types";
const nodeTypes = { tableNode: SchemaVisualizerNode };
const edgeTypes = { crowsfoot: CrowsFootEdge };
const CARD_WIDTH = 240;
const ROW_HEIGHT = 28;
const HEADER_HEIGHT = 32;
// Export size for "Entire Schema" renders
const EXPORT_WIDTH = 1600;
const EXPORT_HEIGHT = 1000;
/**
* Decode an html-to-image data URL (base64 or URL-encoded) into bytes so it
* can be written to disk via the Tauri fs plugin.
*/
function dataUrlToBytes(dataUrl: string): Uint8Array {
const [meta, payload] = dataUrl.split(",");
const raw = /;base64/i.test(meta) ? atob(payload) : decodeURIComponent(payload);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
function getNodeHeight(colCount: number): number {
return HEADER_HEIGHT + colCount * ROW_HEIGHT + 4;
}
function layoutGraph(
tables: TableNodeType[],
relationships: { source_table: string; target_table: string; source_column: string; target_column: string; cardinality: string }[],
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 100, marginx: 40, marginy: 40 });
const cardinalityMap = new Map<string, string>();
for (const rel of relationships) {
cardinalityMap.set(
`${rel.source_table}.${rel.source_column}->${rel.target_table}.${rel.target_column}`,
rel.cardinality,
);
}
const nodes: Node[] = [];
const edges: Edge[] = [];
for (const table of tables) {
const height = getNodeHeight(table.columns.length);
g.setNode(table.name, { width: CARD_WIDTH, height });
nodes.push({
id: table.name,
type: "tableNode",
position: { x: 0, y: 0 },
data: { table, isExternal: false },
style: { width: CARD_WIDTH },
width: CARD_WIDTH,
height,
});
}
for (const table of tables) {
for (const col of table.columns) {
if (col.fk_ref) {
const [refSchema, refTable, refColumn] = col.fk_ref;
if (tables.some((t) => t.name === refTable && t.schema === refSchema)) {
const edgeKey = `${table.name}.${col.name}->${refTable}.${refColumn}`;
const cardinality = cardinalityMap.get(edgeKey) ?? "1:N";
const markers = getEdgeMarkers(cardinality);
g.setEdge(table.name, refTable, {});
edges.push({
id: edgeKey,
source: table.name,
target: refTable,
sourceHandle: `fk-${col.name}`,
targetHandle: `pk-${refColumn}`,
type: "crowsfoot",
label: cardinality,
data: { cardinality, startMarker: markers.markerStart, endMarker: markers.markerEnd, origRight: true },
style: { stroke: "#3b82f6", strokeWidth: 1.5 },
labelStyle: { fill: "#9ca3af", fontSize: 9 },
labelBgStyle: { fill: "#1f2937", fillOpacity: 0.85 },
labelBgPadding: [3, 1],
labelBgBorderRadius: 0,
});
}
}
}
}
dagre.layout(g);
for (const node of nodes) {
const dagreNode = g.node(node.id);
if (dagreNode) {
node.position = {
x: dagreNode.x - CARD_WIDTH / 2,
y: dagreNode.y - (dagreNode as any).height / 2,
};
}
}
return { nodes, edges };
}
function getEdgeMarkers(cardinality: string): { markerStart: string; markerEnd: string } {
switch (cardinality) {
case "1:1":
return { markerStart: "one", markerEnd: "one" };
case "0..1:0..1":
return { markerStart: "one", markerEnd: "one" };
case "1:N":
return { markerStart: "many", markerEnd: "one" };
case "0..N":
return { markerStart: "many", markerEnd: "one" };
case "N:M":
return { markerStart: "many", markerEnd: "many" };
default:
return { markerStart: "", markerEnd: "" };
}
}
export interface SchemaVisualizerPageProps {
connectionId: string;
onSchemaChange?: (schema: string) => void;
}
export function SchemaVisualizerPage({
connectionId,
onSchemaChange,
}: SchemaVisualizerPageProps) {
const databases = useDbViewerStore((s) => s.databases);
const schemas = useDbViewerStore((s) => s.schemas);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tableCount, setTableCount] = useState(0);
const [legendOpen, setLegendOpen] = useState(true);
const [highlightedEdge, setHighlightedEdge] = useState<string | null>(null);
// Export state
const containerRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const [exportScope, setExportScope] = useState<"schema" | "viewport">(
"schema",
);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportBackground, setExportBackground] = useState<
"opaque" | "transparent"
>("opaque");
const transparent = exportBackground === "transparent";
const notify = useNotificationStore((s) => s.notify);
// Close the export menu on outside click (ignoring the trigger button)
useEffect(() => {
if (!exportOpen) return;
const close = (e: MouseEvent) => {
const target = e.target as Element | null;
if (exportButtonRef.current?.contains(target)) return;
if (exportMenuRef.current?.contains(target)) return;
setExportOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [exportOpen]);
const handleExport = useCallback(
async (scope: "schema" | "viewport", format: "png" | "jpeg" | "svg") => {
const element = document.querySelector<HTMLElement>(
".react-flow__viewport",
);
if (!element) return;
setExporting(true);
setExportError(null);
try {
let width: number;
let height: number;
let style: Partial<CSSStyleDeclaration> | undefined;
if (scope === "viewport") {
const container = containerRef.current;
width = container?.clientWidth || 1024;
height = container?.clientHeight || 768;
} else {
width = EXPORT_WIDTH;
height = EXPORT_HEIGHT;
const bounds = getNodesBounds(nodes);
if (bounds.width === 0 && bounds.height === 0) {
throw new Error("Nothing to export");
}
const viewport = getViewportForBounds(
bounds,
width,
height,
0.5,
2,
0.05,
);
style = {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
};
}
const options = {
// JPEG has no alpha channel; transparency only applies to PNG/SVG
...(transparent && format !== "jpeg"
? {}
: { backgroundColor: "#0a0a0b" }),
width,
height,
style,
pixelRatio: 2,
};
const dataUrl =
format === "png"
? await toPng(element, options)
: format === "jpeg"
? await toJpeg(element, { ...options, quality: 0.95 })
: await toSvg(element, options);
// Filename: <db name>-<locale timestamp>.<ext>
const dbName = currentDatabase ?? currentSchema ?? "schema";
const timestamp = new Date()
.toLocaleString()
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, "-");
const ext = format === "jpeg" ? "jpg" : format;
const filename = `${dbName}-${timestamp}.${ext}`;
const bytes = dataUrlToBytes(dataUrl);
let savedPath: string | null = null;
try {
const path = await save({
defaultPath: filename,
filters: [
{ name: format.toUpperCase(), extensions: [ext] },
],
});
if (path) {
await writeFile(path, bytes);
savedPath = path;
}
} catch {
// Not running in Tauri (e.g. plain browser dev): fall back to the
// webview's default download handler.
const a = document.createElement("a");
a.href = dataUrl;
a.download = filename;
a.click();
}
if (savedPath) {
notify(`Schema exported to ${savedPath}`, "success");
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setExportError(`Export failed: ${msg}`);
} finally {
setExporting(false);
setExportOpen(false);
}
},
[nodes, currentSchema, currentDatabase, exportBackground, notify],
);
const fetchGraph = useCallback(async () => {
if (!currentSchema) return;
setLoading(true);
setError(null);
try {
const graph: SchemaGraph = await getSchemaGraph(connectionId, currentSchema);
if (graph.tables.length === 0) {
setNodes([]);
setEdges([]);
setTableCount(0);
} else {
const { nodes: layoutedNodes, edges: layoutedEdges } = layoutGraph(graph.tables, graph.relationships);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
if (graph.tables.length > 200) {
const proceed = window.confirm(
`This schema has ${graph.tables.length} tables. Rendering the full diagram may be slow. Continue?`,
);
if (!proceed) {
setLoading(false);
return;
}
}
setTableCount(graph.tables.length);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
} finally {
setLoading(false);
}
}, [connectionId, currentSchema, setNodes, setEdges]);
useEffect(() => {
fetchGraph();
}, [fetchGraph]);
const handleResetLayout = useCallback(() => {
fetchGraph();
setHighlightedEdge(null);
}, [fetchGraph]);
const handleEdgeClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
setHighlightedEdge(edge.id === highlightedEdge ? null : edge.id);
},
[highlightedEdge],
);
const handlePaneClick = useCallback(() => {
setHighlightedEdge(null);
}, []);
// Derive edges with highlighting applied
const displayEdges = useMemo(() => {
if (!highlightedEdge) return edges;
return edges.map((e) => {
if (e.id === highlightedEdge) {
return {
...e,
zIndex: 1000,
style: { ...e.style, stroke: "#f59e0b", strokeWidth: 2.5, opacity: 1 },
labelStyle: { ...e.labelStyle, fill: "#f59e0b" },
labelBgStyle: { ...e.labelBgStyle, fill: "#1f2937", fillOpacity: 0.95 },
};
}
return { ...e, style: { ...e.style, opacity: 0.15 } };
});
}, [edges, highlightedEdge]);
const highlightedCardinality = useMemo(() => {
if (!highlightedEdge) return null;
const edge = edges.find((e) => e.id === highlightedEdge);
return (edge?.data as any)?.cardinality as string | null;
}, [edges, highlightedEdge]);
const handleSchemaChange = useCallback(
(schema: string) => {
setCurrentSchema(schema);
onSchemaChange?.(schema);
},
[setCurrentSchema, onSchemaChange],
);
const schemaOptions = useMemo(
() => schemas.map((s) => ({ value: s, label: s })),
[schemas],
);
return (
<div className="flex-1 flex flex-col min-h-0 bg-canvas">
{/* Toolbar */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-20">
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
value={currentDatabase ?? ""}
onChange={setCurrentDatabase}
options={databases.map((d) => ({ value: d, label: d }))}
placeholder="Select database"
variant="ghost"
/>
)}
{databases.length > 1 && schemas.length > 0 && (
<span className="text-border">|</span>
)}
{schemas.length > 0 && (
<SelectDropdown
value={currentSchema ?? ""}
options={schemaOptions}
onChange={handleSchemaChange}
placeholder="Select schema"
variant="ghost"
/>
)}
</div>
<div className="flex-1" />
<span className="text-xs text-text-muted">
{tableCount} {tableCount === 1 ? "table" : "tables"}
</span>
<button
type="button"
onClick={handleResetLayout}
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised"
>
<RotateCcw size={12} />
Reset Layout
</button>
{/* Export */}
<div className="flex items-center gap-2">
{exportError && (
<span className="text-[11px] text-red-400 max-w-56 truncate">
{exportError}
</span>
)}
<div className="relative">
<button
type="button"
ref={exportButtonRef}
onClick={() => setExportOpen((v) => !v)}
disabled={exporting}
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised disabled:opacity-50"
>
{exporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} />
)}
{exporting ? "Exporting…" : "Export"}
<ChevronDown size={12} />
</button>
{exportOpen && !exporting && (
<div
ref={exportMenuRef}
className="absolute right-0 top-full mt-1 z-30 w-52 rounded-lg bg-surface border border-border shadow-lg py-2 px-2"
>
<div className="px-1 pb-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Scope
</div>
<SelectDropdown
value={exportScope}
onChange={(v) =>
setExportScope(v as "schema" | "viewport")
}
options={[
{ value: "schema", label: "Entire Schema" },
{ value: "viewport", label: "Viewport" },
]}
aria-label="Export scope"
variant="pill"
/>
<div className="px-1 pb-1.5 pt-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Background
</div>
<SelectDropdown
value={exportBackground}
onChange={(v) =>
setExportBackground(v as "opaque" | "transparent")
}
options={[
{ value: "opaque", label: "Opaque" },
{ value: "transparent", label: "Transparent" },
]}
aria-label="Export background"
variant="pill"
/>
<div className="border-t border-border my-1.5" />
{[
{ format: "png" as const, label: "PNG" },
{ format: "jpeg" as const, label: "JPEG" },
{ format: "svg" as const, label: "SVG" },
]
.filter((f) => !(transparent && f.format === "jpeg"))
.map(({ format, label }) => (
<button
key={format}
type="button"
onClick={() => handleExport(exportScope, format)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* Canvas */}
<div className="flex-1 min-h-0 relative" ref={containerRef}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-canvas/80">
<p className="text-text-muted text-sm">Loading schema...</p>
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 bg-canvas/80 gap-3">
<p className="text-red-400 text-sm">Failed to load schema: {error}</p>
<button
type="button"
onClick={fetchGraph}
className="px-3 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text"
>
Retry
</button>
</div>
)}
{!loading && !error && tableCount === 0 && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<p className="text-text-muted text-sm">
No tables found in schema &quot;{currentSchema}&quot;
</p>
</div>
)}
<ReactFlow
nodes={nodes}
edges={displayEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onEdgeClick={handleEdgeClick}
onPaneClick={handlePaneClick}
fitView
minZoom={0.1}
maxZoom={2}
className="bg-canvas"
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
color="var(--color-border)"
/>
<MiniMap
position="bottom-right"
nodeStrokeWidth={2}
nodeClassName="!fill-accent/20 !stroke-accent"
maskColor="rgba(18,18,24,0.85)"
maskStrokeColor="var(--color-border)"
maskStrokeWidth={1}
className="!bg-surface !border !border-border !rounded-none !shadow-lg"
/>
<Controls
position="bottom-left"
className="!rounded-none !shadow-lg [&_button]:!bg-surface [&_button]:!text-text-muted [&_button]:!border-border [&_button]:hover:!bg-surface-raised [&_button]:hover:!text-text [&_button]:!shadow-none"
/>
</ReactFlow>
{/* Legend */}
<div className="absolute top-3 right-3 z-10 bg-surface border border-border rounded-none px-3 py-2 text-xs shadow-lg">
<button
type="button"
onClick={() => setLegendOpen(!legendOpen)}
className="flex items-center gap-1 font-semibold text-text w-full"
>
{legendOpen ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
Relationships
</button>
{legendOpen && (
<div className="mt-2 space-y-1.5">
{LEGEND_ITEMS.map((item) => {
const isActive = highlightedCardinality === item.cardinality;
return (
<div key={item.cardinality} className={`flex items-center gap-2.5 transition-opacity ${highlightedCardinality && !isActive ? "opacity-20" : ""}`}>
<svg width="36" height="12" className="shrink-0">
<line x1={6} y1={6} x2={30} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} />
{/* Start marker */}
{item.markerStart === "one" ? (
<line x1={6} y1={2} x2={6} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
) : (
<>
<line x1={6} y1={3} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
<line x1={6} y1={6} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
<line x1={6} y1={9} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
</>
)}
{/* End marker */}
{item.markerEnd === "one" ? (
<line x1={30} y1={2} x2={30} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
) : (
<>
<line x1={30} y1={3} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
<line x1={30} y1={6} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
<line x1={30} y1={9} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
</>
)}
</svg>
<span className={`text-[11px] ${isActive ? "text-amber-400 font-medium" : "text-text-muted"}`}>{item.label}</span>
</div>
)})}
</div>
)}
</div>
{/* Powered by React Flow */}
<div className="absolute top-0 left-0 z-0 text-[10px] text-text-muted/50 bg-surface/80 px-2 py-0.5 rounded-none pointer-events-none">
Powered by React Flow
</div>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
import { useState, useEffect, useCallback } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { detectPgTools, dbSync } from "../../lib/commands";
import type { PgToolStatus } from "../../lib/types";
interface SyncDialogProps {
open: boolean;
onClose: () => void;
}
export function SyncDialog({ open, onClose }: SyncDialogProps) {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [running, setRunning] = useState(false);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
useEffect(() => {
if (!open) return;
setCheckingTools(true);
setConfirmed(false);
detectPgTools()
.then((status) => setToolStatus(status))
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
.finally(() => setCheckingTools(false));
}, [open]);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
setRunning(true);
const jobId = `sync-${Date.now()}`;
startJob(jobId, "sync");
try {
await dbSync({
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
dbType: "postgresql",
});
notify("Sync completed successfully", "success");
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
notify(`Sync failed: ${parseError(msg)}`, "error");
} finally {
setRunning(false);
}
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify, onClose]);
const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
const canStart = sourceConnectionId && targetConnectionId && confirmed && !running;
const postgresqlConnections = connections.filter((c) => c.db_type === "postgresql");
return (
<AnimatedModal open={open} onClose={onClose}>
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
<h3 className="font-heading text-text text-lg mb-4">Sync Databases</h3>
{checkingTools && (
<p className="text-sm text-text-muted mb-4">Checking for pg_dump/pg_restore...</p>
)}
{toolsMissing && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
<p className="text-amber-300 text-sm font-medium">PostgreSQL tools not found</p>
<p className="text-amber-200/80 text-xs">
Both pg_dump and pg_restore are required for database sync.
</p>
{!toolStatus?.pg_dump_found && (
<p className="text-amber-200/80 text-xs">pg_dump is missing.</p>
)}
{!toolStatus?.pg_restore_found && (
<p className="text-amber-200/80 text-xs">pg_restore is missing.</p>
)}
</div>
)}
{!checkingTools && !toolsMissing && (
<div className="space-y-4">
{/* Source connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Source Connection</label>
<select
value={sourceConnectionId}
onChange={(e) => setSourceConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select source...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === targetConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Target connection */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Target Connection</label>
<select
value={targetConnectionId}
onChange={(e) => setTargetConnectionId(e.target.value)}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">Select target...</option>
{postgresqlConnections.map((c) => (
<option key={c.id} value={c.id} disabled={c.id === sourceConnectionId}>
{c.name}
</option>
))}
</select>
</div>
{/* Schema filter */}
<div className="space-y-1">
<label className="text-xs text-text-muted">Schema (optional)</label>
<input
type="text"
value={schema}
onChange={(e) => setSchema(e.target.value)}
placeholder="public"
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
/>
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
className="mt-0.5 rounded bg-surface border-border accent-red-500"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300">
I understand this will overwrite data on the target database. This action cannot be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob?.status === "running" && (
<BackupProgress
progress={50}
jobType="sync"
status="running"
/>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={running}>
Cancel
</Button>
<Button onClick={handleStartSync} disabled={!canStart}>
{running ? "Syncing..." : "Start Sync"}
</Button>
</div>
</div>
)}
</div>
</AnimatedModal>
);
}
function parseError(msg: string): string {
if (msg.includes("pg_dump:") || msg.includes("pg_restore:")) {
const parts = msg.split(/pg_(dump|restore):/);
return parts[parts.length - 1]?.trim() || msg;
}
if (msg.includes("No such file or directory")) {
return `File not found. Check the output path and try again.`;
}
if (msg.includes("Permission denied")) {
return `Permission denied. Check file permissions.`;
}
return msg;
}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { SyncPage } from "./SyncPage";
import { useBackupStore } from "../../stores/backupStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as commands from "../../lib/commands";
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
const mockConnections: any[] = [];
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
}));
const pgToolsOk = {
pg_dump_found: true,
pg_restore_found: true,
pg_dump_version: "16",
pg_restore_version: "16",
pg_dump_source: "system",
pg_restore_source: "system",
};
const mysqlToolsOk = {
mysqldumpFound: true,
mysqlFound: true,
mysqldumpVersion: "8.0",
mysqlVersion: "8.0",
mysqldumpSource: "system",
mysqlSource: "system",
};
describe("SyncPage DB-aware", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockConnections.length = 0;
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
useNotificationStore.setState({ notifications: [] });
});
it("filters target connections to the same db_type as source (mysql)", async () => {
mockConnections.push(
{ id: "pg1", db_type: "postgresql", name: "Postgres 1" },
{ id: "my1", db_type: "mysql", name: "MySQL 1" },
{ id: "my2", db_type: "mysql", name: "MySQL 2" },
{ id: "sq1", db_type: "sqlite", name: "SQLite 1" },
);
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
render(<SyncPage />);
await waitFor(() => expect(screen.queryByText(/checking for/i)).not.toBeInTheDocument());
const [sourceSelect, targetSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
fireEvent.change(sourceSelect, { target: { value: "my1" } });
const options = Array.from(targetSelect.options).map((o) => o.value);
expect(options).toContain("my1");
expect(options).toContain("my2");
expect(options).not.toContain("pg1");
expect(options).not.toContain("sq1");
});
});
@@ -0,0 +1,423 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { ArrowLeftRight, Database } from "lucide-react";
import { Button } from "../ui/Button";
import { BackupProgress } from "./BackupProgress";
import { useBackupStore } from "../../stores/backupStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import {
detectPgTools,
dbSync,
getSchemas,
detectMysqlTools,
mysqlSync,
sqliteSync,
} from "../../lib/commands";
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
export function SyncPage() {
const [sourceConnectionId, setSourceConnectionId] = useState("");
const [targetConnectionId, setTargetConnectionId] = useState("");
const [schema, setSchema] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
const [checkingTools, setCheckingTools] = useState(false);
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
const connections = useConnectionStore((s) => s.connections);
const activeJobId = useBackupStore((s) => s.activeJobId);
const jobs = useBackupStore((s) => s.jobs);
const startJob = useBackupStore((s) => s.startJob);
const notify = useNotificationStore((s) => s.notify);
const activeJob = jobs.find((j) => j.id === activeJobId);
const isRunning = activeJob?.status === "running";
const pendingJobRef = useRef<string | null>(null);
const sourceConnection = connections.find(
(c) => c.id === sourceConnectionId,
);
const dbType: DbType | null = sourceConnection?.db_type ?? null;
const isPg = dbType === "postgresql";
const isMysql = dbType === "mysql";
useEffect(() => {
if (!pendingJobRef.current || !activeJob) return;
if (activeJob.id !== pendingJobRef.current) return;
if (activeJob.status === "completed") {
notify("Sync completed successfully", "success");
pendingJobRef.current = null;
} else if (activeJob.status === "failed") {
notify(
`Sync failed: ${activeJob.error_message || "Unknown error"}`,
"error",
);
pendingJobRef.current = null;
}
}, [activeJob, notify]);
// Tool detection: depends on the selected source connection's DB type
useEffect(() => {
setPgToolStatus(null);
setMysqlToolStatus(null);
setConfirmed(false);
if (isPg) {
setCheckingTools(true);
detectPgTools()
.then((status) => setPgToolStatus(status))
.catch(() =>
setPgToolStatus({
pg_dump_found: false,
pg_restore_found: false,
pg_dump_version: null,
pg_restore_version: null,
pg_dump_source: null,
pg_restore_source: null,
}),
)
.finally(() => setCheckingTools(false));
} else if (isMysql) {
setCheckingTools(true);
detectMysqlTools()
.then((status) => setMysqlToolStatus(status))
.catch(() =>
setMysqlToolStatus({
mysqldumpFound: false,
mysqlFound: false,
mysqldumpVersion: null,
mysqlVersion: null,
mysqldumpSource: null,
mysqlSource: null,
}),
)
.finally(() => setCheckingTools(false));
}
}, [isPg, isMysql]);
// Fetch schemas from the source connection when it changes
useEffect(() => {
if (!sourceConnectionId) {
setAvailableSchemas([]);
setSchema("");
return;
}
if (!isPg && !isMysql) {
setAvailableSchemas([]);
setSchema("");
return;
}
getSchemas(sourceConnectionId)
.then((schemas) => setAvailableSchemas(schemas))
.catch(() => setAvailableSchemas([]));
}, [sourceConnectionId, isPg, isMysql]);
const runWithProgress = useCallback(
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
const jobId = `${type}-${Date.now()}`;
startJob(jobId, type);
pendingJobRef.current = jobId;
try {
await action();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
useBackupStore.getState().failJob(jobId, msg);
}
},
[startJob],
);
const handleStartSync = useCallback(async () => {
if (!sourceConnectionId || !targetConnectionId) {
notify("Please select both source and target connections", "error");
return;
}
if (sourceConnectionId === targetConnectionId) {
notify("Source and target must be different", "error");
return;
}
if (!dbType) {
notify("Unable to determine database type for sync", "error");
return;
}
const options = {
sourceConnectionId,
targetConnectionId,
schema: schema || undefined,
tables: undefined,
dbType,
};
await runWithProgress("sync", () => {
if (isPg) return dbSync(options);
if (isMysql) return mysqlSync(options);
return sqliteSync(options);
});
}, [
sourceConnectionId,
targetConnectionId,
dbType,
isPg,
isMysql,
schema,
notify,
runWithProgress,
]);
const toolsMissing = isPg
? pgToolStatus &&
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
: isMysql
? mysqlToolStatus &&
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
: false;
const toolsBundled = isPg
? pgToolStatus?.pg_dump_source === "bundled" &&
pgToolStatus?.pg_restore_source === "bundled"
: isMysql
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
mysqlToolStatus?.mysqlSource === "bundled"
: false;
const canStart =
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
const checkingMessage = isPg
? "Checking for pg_dump / pg_restore..."
: isMysql
? "Checking for mysqldump / mysql..."
: null;
const targetConnections = dbType
? connections.filter((c) => c.db_type === dbType)
: [];
return (
<div className="flex flex-col h-full">
{/* Toolbar header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
<ArrowLeftRight size={14} className="text-accent" />
<span className="text-xs font-medium text-text">DB Sync</span>
<span className="text-[11px] text-text-muted">
Transfer data between databases via pipe
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
{/* Tool check */}
{checkingTools && checkingMessage && (
<div className="glass p-4 text-center">
<p className="text-sm text-text-muted">
{checkingMessage}
</p>
</div>
)}
{toolsMissing && !toolsBundled && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
<p className="text-amber-300 text-sm font-semibold">
{isPg
? "PostgreSQL tools not found"
: "MySQL tools not found"}
</p>
<p className="text-amber-200/80 text-xs leading-relaxed">
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
database sync.
</p>
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
{isPg && !pgToolStatus?.pg_dump_found && (
<li>pg_dump is missing.</li>
)}
{isPg && !pgToolStatus?.pg_restore_found && (
<li>pg_restore is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
<li>mysqldump is missing.</li>
)}
{isMysql && !mysqlToolStatus?.mysqlFound && (
<li>mysql is missing.</li>
)}
</ul>
</div>
)}
{!checkingTools && !toolsMissing && (
<>
{/* Configuration card */}
<div className="p-5 space-y-5">
{/* Source & Target connection pickers */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Source
</label>
<select
value={sourceConnectionId}
onChange={(e) => {
setSourceConnectionId(
e.target.value,
);
setTargetConnectionId("");
}}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
>
<option value="">
Select source...
</option>
{connections.map((c) => (
<option
key={c.id}
value={c.id}
>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
<Database size={11} />
Target
</label>
<select
value={targetConnectionId}
onChange={(e) =>
setTargetConnectionId(
e.target.value,
)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "Select target..."
: "Select a source first"}
</option>
{targetConnections.map((c) => (
<option
key={c.id}
value={c.id}
disabled={
c.id === sourceConnectionId
}
>
{c.name}
</option>
))}
</select>
</div>
</div>
{/* Schema (optional) */}
{(isPg || isMysql) && (
<div className="space-y-1">
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
Schema{" "}
<span className="font-normal normal-case tracking-normal">
(optional)
</span>
</label>
<select
value={schema}
onChange={(e) =>
setSchema(e.target.value)
}
disabled={!sourceConnectionId}
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">
{sourceConnectionId
? "All schemas"
: "Select a source first"}
</option>
{availableSchemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
)}
{/* Flow indicator */}
{sourceConnectionId && targetConnectionId && (
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="font-medium text-text">
{connections.find(
(c) =>
c.id === sourceConnectionId,
)?.name ?? sourceConnectionId}
</span>
<ArrowLeftRight
size={12}
className="text-accent shrink-0"
/>
<span className="font-medium text-text">
{connections.find(
(c) =>
c.id === targetConnectionId,
)?.name ?? targetConnectionId}
</span>
</div>
)}
</div>
{/* Destructive confirmation */}
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
onChange={(e) =>
setConfirmed(e.target.checked)
}
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
data-testid="sync-confirm-checkbox"
/>
<span className="text-sm text-red-300/90 leading-relaxed">
I understand this will overwrite data on
the target database. This action cannot
be undone.
</span>
</label>
</div>
{/* Progress */}
{activeJob && (
<div className="px-4">
<BackupProgress
progress={activeJob.status === "completed" ? 100 : 50}
jobType="sync"
status={activeJob.status}
errorMessage={activeJob.error_message ?? undefined}
/>
</div>
)}
{/* Actions */}
<div className="flex justify-end pb-2 pr-2">
<Button
onClick={handleStartSync}
disabled={!canStart}
>
<ArrowLeftRight
size={14}
className="mr-1.5"
/>
{isRunning ? "Syncing..." : "Start Sync"}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,341 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "./TabBar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
const user = userEvent.setup();
describe("TabBar", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders the fixed Query and Changes actions when no tabs are open", () => {
render(<TabBar />);
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Changes queue" })).toBeInTheDocument();
});
it("renders open tab names", () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
render(<TabBar />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("posts")).toBeInTheDocument();
});
it("sets active tab when clicked", async () => {
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
await user.click(screen.getByText("users"));
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
});
it("opens a new query tab when Query is clicked", async () => {
const user = userEvent.setup();
render(<TabBar />);
await user.click(screen.getByRole("button", { name: /new query/i }));
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0].tabType).toBe("query");
expect(state.activeTabId).toBe(state.tabs[0].id);
});
it("shows the pending change count and toggles the changes panel", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.setState({ changesPanelExpanded: false });
render(<TabBar />);
const changesButton = screen.getByRole("button", { name: "Changes queue" });
await user.click(changesButton);
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
await user.click(changesButton);
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
});
it("renders a table icon on table tabs", () => {
useDbViewerStore.getState().openTab("public", "users");
render(<TabBar />);
expect(screen.getByTestId("tab-icon-table")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-query")).not.toBeInTheDocument();
});
it("renders a query icon on query tabs", () => {
useDbViewerStore.getState().openQueryTab();
render(<TabBar />);
expect(screen.getByTestId("tab-icon-query")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders the per-type icon on an object tab", () => {
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
render(<TabBar />);
const icon = screen.getByLabelText(/object icon: functions/i);
const svg = icon.querySelector("svg");
expect(svg).toBeTruthy();
// Regression: the icon must use the SAME handling as the query/table icons —
// the svg itself is display:inline with the shared optical-centering classes.
// That defeats preflight svg{display:block} (no stacking) and lets
// vertical-align:middle center it with the tab name.
const cls = svg!.getAttribute("class") ?? "";
expect(cls).toContain("inline");
expect(cls).toContain("-mt-0.5");
expect(screen.getByText("add")).toBeInTheDocument();
});
it("renders a view icon on view tabs", () => {
useDbViewerStore.getState().openTab("main", "order_summary");
useDbViewerStore.setState({
tables: [
{ name: "order_summary", schema: "main", table_type: "VIEW" },
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-view")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a create icon and title on an objectForm create tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "",
title: "Create sequence",
description: "Create sequence",
mode: "create",
params: { schema: "public", name: "", action: { op: "create" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
expect(screen.getByText("Create sequence")).toBeInTheDocument();
});
it("renders an edit icon on an objectForm edit tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "s",
title: "Edit sequence",
description: "Edit sequence",
mode: "edit",
params: { schema: "public", name: "s", action: { op: "alter" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
});
it("renders a layers icon on materialized view tabs", () => {
useDbViewerStore.getState().openTab("public", "mv_products");
useDbViewerStore.setState({
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-matview")).toBeInTheDocument();
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("shows the changes count as an icon with a badge", () => {
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "posts",
newData: { title: "hi" },
});
render(<TabBar />);
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.querySelector("svg")).not.toBeNull();
expect(within(button).getByText("2")).toBeInTheDocument();
expect(screen.queryByText("Changes")).toBeNull();
});
it("hides the count badge when there are no pending changes", () => {
render(<TabBar />);
const button = screen.getByRole("button", { name: "Changes queue" });
expect(within(button).queryByText(/\d/)).toBeNull();
});
it("closes tab when close button clicked", async () => {
useDbViewerStore.getState().openTab("public", "users");
useDbViewerStore.getState().openTab("public", "posts", true);
const firstTabId = useDbViewerStore.getState().tabs[0].id;
render(<TabBar />);
const closeButton = screen.getByRole("button", {
name: /close users/i,
});
await user.click(closeButton);
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
expect(
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
).toBeUndefined();
});
it("opens the changes popover when the button is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
useDbViewerStore.setState({ changesPanelExpanded: false });
render(<TabBar />);
expect(screen.queryByText(/pending changes/i)).toBeNull();
await user.click(screen.getByRole("button", { name: "Changes queue" }));
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /commit all/i })).toBeInTheDocument();
});
it("closes the changes popover on Escape", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 1 },
description: "Insert row into users",
});
useDbViewerStore.setState({ changesPanelExpanded: true });
render(<TabBar />);
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByText(/pending changes/i)).toBeNull();
});
it("turns the button border amber when there are pending changes", () => {
useDbViewerStore.getState().addChange({
type: "insert",
schema: "public",
table: "users",
newData: { id: 1 },
description: "Insert row into users",
});
render(<TabBar />);
const button = screen.getByRole("button", { name: "Changes queue" });
expect(button.className).toContain("border-amber-500");
});
// ─────────────────────────────────────────────────────────────────────────
// Drag & drop reorder — keep this test LAST in this file.
//
// The vitest config does not enable `globals: true`, so RTL's auto-cleanup
// never unmounts components between tests. A dnd-kit drag leaves its DndContext
// (and document-level listeners) mounted, which silently breaks userEvent/fireEvent
// clicks in any LATER test. The drag itself is fully verified here; placing it
// last isolates the pollution.
// ─────────────────────────────────────────────────────────────────────────
it("reorders tabs via drag and drop (horizontal axis only)", async () => {
const { act, fireEvent } = await import("@testing-library/react");
const store = useDbViewerStore.getState();
store.openTab("public", "users");
store.openTab("public", "posts", true);
store.openTab("public", "comments", true);
// jsdom reports zero-sized rects and non-primary pointers by default,
// which breaks dnd-kit collision detection + pointer activation.
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () {
const text = this.textContent ?? "";
const index = text.includes("posts")
? 1
: text.includes("comments")
? 2
: 0;
const x = index * 100;
return {
x,
y: 0,
width: 100,
height: 30,
left: x,
right: x + 100,
top: 0,
bottom: 30,
toJSON: () => ({}),
} as DOMRect;
};
try {
render(<TabBar />);
const usersTab = screen.getByRole("tab", { name: "users" });
// pointerDown lifts the tab (distance constraint >= 4px on move), then
// moves it over the last tab and drops.
await act(async () => {
fireEvent.pointerDown(usersTab, {
pointerId: 1,
clientX: 50,
clientY: 15,
button: 0,
isPrimary: true,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 160,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerMove(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
await act(async () => {
fireEvent.pointerUp(usersTab, {
pointerId: 1,
clientX: 260,
clientY: 15,
});
});
// Flush dnd-kit's post-drag rAF focus-restore so it cannot leak into
// later tests (userEvent clicks are order-sensitive in jsdom).
await act(async () => {});
} finally {
Element.prototype.getBoundingClientRect = original;
}
expect(
useDbViewerStore.getState().tabs.map((t) => t.table),
).toEqual(["posts", "comments", "users"]);
});
});
+291
View File
@@ -0,0 +1,291 @@
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { OBJECT_ICONS } from "./objects/ObjectDetail";
function SortableTab({
tab,
isActive,
icon,
onSelect,
onClose,
}: {
tab: ViewerTab;
isActive: boolean;
icon: ReactNode;
onSelect: () => void;
onClose: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform: rawTransform,
transition,
isDragging,
} = useSortable({ id: tab.id });
// dnd-kit scales the dragged item to the width of whichever tab it is
// hovering over (adjustScale). Tabs have different widths, which would warp
// the text — always render at scale 1 and let the horizontal strategy handle
// positioning.
const transform = rawTransform
? { ...rawTransform, scaleX: 1, scaleY: 1 }
: null;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
{...attributes}
role="tab"
aria-selected={isActive}
aria-label={tab.table}
{...listeners}
onClick={onSelect}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-grab active:cursor-grabbing select-none",
isActive ? "bg-canvas text-text" : "text-text-muted hover:text-text",
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : "",
].join(" ")}
>
<span className="flex-1 text-left select-none">{icon}{tab.table}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
const tabs = useDbViewerStore((state) => state.tabs);
const tables = useDbViewerStore((state) => state.tables);
const activeTabId = useDbViewerStore((state) => state.activeTabId);
const closeTab = useDbViewerStore((state) => state.closeTab);
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
const reorderTab = useDbViewerStore((state) => state.reorderTab);
const changesQueue = useDbViewerStore((state) => state.changesQueue);
const changesPanelExpanded = useDbViewerStore(
(state) => state.changesPanelExpanded,
);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
// Drag threshold so a click still selects the tab; a deliberate drag (>= 4px)
// starts a reorder. Keyboard sorting uses arrow keys, one axis only.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Keep the dragged tab on the tab strip: zero out any vertical movement so
// dragging is constrained to the horizontal axis only.
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
if (from >= 0 && to >= 0) reorderTab(from, to);
};
const pendingCount = changesQueue.filter(
(c) => c.status === "pending",
).length;
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!changesPanelExpanded) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
toggleChangesPanel();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
toggleChangesPanel();
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [changesPanelExpanded, toggleChangesPanel]);
return (
<div className="flex h-9 items-stretch border-b border-border">
{/* Left: open tabs (scrollable) */}
<div
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
role="tablist"
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex items-stretch">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const objectType =
tab.tabType === "table"
? tables.find(
(t) =>
t.schema === tab.schema && t.name === tab.table,
)?.table_type
: undefined;
const icon =
tab.tabType === "query" ? (
<Terminal
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : tab.tabType === "object" ? (
<span
aria-label={`object icon: ${tab.objectType}`}
className="contents"
>
{cloneElement(
OBJECT_ICONS[tab.objectType!] as ReactElement<{
className?: string;
}>,
{
// Same handling as the query/table icons: the svg
// itself is display:inline (preflight vertical-align:
// middle centers it with the text) with the same
// optical-centering nudge. `display: contents` on the
// labelled span renders no box, so the geometry is
// identical to the bare Terminal/Table2 icons.
className:
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
},
)}
</span>
) : tab.tabType === "objectForm" ? (
tab.form?.mode === "create" ? (
<Plus
data-testid="tab-icon-form-create"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Pencil
data-testid="tab-icon-form-edit"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)
) : objectType === "VIEW" ? (
<Eye
data-testid="tab-icon-view"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : objectType === "MATERIALIZED VIEW" ? (
<Layers
data-testid="tab-icon-matview"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
);
return (
<SortableTab
key={tab.id}
tab={tab}
isActive={isActive}
icon={icon}
onSelect={() => setActiveTab(tab.id)}
onClose={() => closeTab(tab.id)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
{/* Right: fixed actions */}
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
<button
type="button"
onClick={openQueryTab}
aria-label="New query tab"
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
>
<Play className="h-3 w-3 fill-current" />
Query
</button>
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => {
if (changesQueue.length > 0) toggleChangesPanel();
}}
aria-label="Changes queue"
className={[
"flex items-center gap-1.5 rounded-md border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
pendingCount > 0
? "text-amber-400 border-amber-500 bg-amber-500/10 hover:bg-amber-500/20"
: "border-border text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<ListChecks className="h-3.5 w-3.5" />
{pendingCount > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
{pendingCount}
</span>
)}
</button>
{changesPanelExpanded && (
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
<ChangesQueuePanel onCommitted={onCommitted} />
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,360 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { ComponentProps } from "react";
import { TableControls, formatDuration } from "./TableControls";
import { TooltipProvider } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as exportData from "../../lib/exportData";
import type { ViewerTab } from "../../stores/dbViewerStore";
const columns = [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
];
function makeTab(overrides: Partial<ViewerTab> = {}): ViewerTab {
return {
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50 },
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
...overrides,
};
}
function seed(tabs: ViewerTab[], activeTabId: string) {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({ tabs, activeTabId });
}
function renderControls(
props: Partial<ComponentProps<typeof TableControls>> = {},
) {
return render(
<TooltipProvider>
<TableControls
connectionId="c1"
schema="public"
table="users"
columns={columns}
rows={[[1]]}
hiddenColumns={new Set()}
onToggleColumn={() => {}}
onRefresh={() => {}}
filterRules={[]}
onFilterChange={() => {}}
sortRules={[]}
onSortChange={() => {}}
selectedCount={0}
selectedRows={[]}
onClearSelection={() => {}}
{...props}
/>
</TooltipProvider>,
);
}
describe("formatDuration", () => {
it("formats milliseconds with two decimals", () => {
expect(formatDuration(15)).toBe("15.00ms");
});
it("formats seconds with one decimal once past a second", () => {
expect(formatDuration(1500)).toBe("1.5s");
expect(formatDuration(3200)).toBe("3.2s");
});
it("formats minutes for long-running queries", () => {
expect(formatDuration(90000)).toBe("1.5m");
});
it("returns an empty string when there is no timing", () => {
expect(formatDuration(null)).toBe("");
expect(formatDuration(undefined)).toBe("");
});
});
describe("TableControls query variant", () => {
it("shows Export, Re-run, and Columns on the left", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.getByLabelText(/export/i)).toBeInTheDocument();
expect(screen.getByLabelText(/re-run query/i)).toBeInTheDocument();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
});
it("hides table-only controls and the queue", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
expect(screen.queryByLabelText(/auto-refresh/i)).toBeNull();
expect(screen.queryByLabelText(/column filters/i)).toBeNull();
expect(screen.queryByLabelText(/sort rules/i)).toBeNull();
expect(screen.queryByRole("button", { name: /action queue/i })).toBeNull();
});
it("shows the execution time from the result with a clock", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
expect(screen.getByText("15.00ms")).toBeInTheDocument();
});
it("keeps the row count and pagination", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [[1]],
total_rows: 42,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByText(/of 42/)).toBeInTheDocument();
expect(screen.getByLabelText(/next page/i)).toBeInTheDocument();
});
it("table variant has no queue button (moved to tab bar) and no execution time", () => {
seed(
[
makeTab({
data: {
columns,
rows: [[1]],
total_rows: 1,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({});
expect(
screen.queryByRole("button", { name: /action queue/i }),
).toBeNull();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
expect(screen.queryByText("15.00ms")).toBeNull();
});
});
describe("TableControls", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onRefresh when the refresh button is clicked", () => {
seed([makeTab()], "tab-1");
const onRefresh = vi.fn();
renderControls({ onRefresh });
fireEvent.click(screen.getByLabelText(/refresh table/i));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("does not spin the refresh icon or show the pulse when idle", () => {
seed([makeTab()], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("spins the refresh icon and shows the pulse overlay while the tab is loading", () => {
seed([makeTab({ loading: true })], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
});
it("shows the refreshing indicators when auto-refresh fires and clears them when done", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
const { container } = renderControls({
onRefresh,
defaultRefreshRate: 5000,
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
// Once the fetch completes the indicators disappear
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("waits for an in-flight refresh to complete before restarting the timer", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// First interval fires the refresh
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// While the refresh is still in flight, the timer must NOT fire again
act(() => {
vi.advanceTimersByTime(15000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// Once the refresh completes, a fresh countdown starts
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(2);
});
it("defers auto-refresh when the user switches tabs (resets the timer)", () => {
vi.useFakeTimers();
const onRefresh = vi.fn();
seed([makeTab(), makeTab({ id: "tab-2", table: "orders" })], "tab-1");
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// Not yet a full interval
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// User switches to another tab → countdown restarts
act(() => {
useDbViewerStore.setState({ activeTabId: "tab-2" });
});
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// Full interval after the switch finally fires
act(() => {
vi.advanceTimersByTime(1000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("hides the Insert Row button for a materialized view", () => {
seed([makeTab()], "tab-1");
renderControls({ isMatview: true });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
});
it("renders the FilterBuilder inside the filter popover", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/column filters/i));
expect(
screen.getByText("Drop columns here to add filters"),
).toBeInTheDocument();
});
it("export dropdown includes the Excel option", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
expect(screen.getByText("CSV")).toBeInTheDocument();
expect(screen.getByText("SQL")).toBeInTheDocument();
expect(screen.getByText("Markdown")).toBeInTheDocument();
expect(screen.getByText("Excel")).toBeInTheDocument();
});
it("notifies after a successful export", () => {
seed([makeTab()], "tab-1");
useNotificationStore.getState().notifications.length = 0;
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
fireEvent.click(screen.getByText("Excel"));
const st = useNotificationStore.getState();
expect(
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
).toBe(true);
});
});
@@ -0,0 +1,846 @@
import { useState, useRef, useEffect } from "react";
import {
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
ChevronDown, FileJson, FileText, Terminal,
} from "lucide-react";
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { FilterBuilder } from "./FilterBuilder";
import { Tooltip } from "../ui/Tooltip";
import { exportData } from "../../lib/exportData";
import type { ColumnInfo } from "../../lib/types";
const AUTO_REFRESH_OPTIONS = [
{ label: "Off", value: 0 },
{ label: "5s", value: 5000 },
{ label: "10s", value: 10_000 },
{ label: "30s", value: 30_000 },
{ label: "1m", value: 60_000 },
{ label: "5m", value: 300_000 },
] as const;
const PAGE_SIZES = [50, 100, 200, 500] as const;
const EXPORT_FORMATS = [
{ label: "JSON", ext: "json" },
{ label: "CSV", ext: "csv" },
{ label: "SQL", ext: "sql" },
{ label: "Markdown", ext: "md" },
{ label: "Excel", ext: "xlsx" },
] as const;
// ─── helpers ────────────────────────────────────────────
/**
* Format an execution duration using the most sensible unit:
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
*/
export function formatDuration(ms: number | null | undefined): string {
if (ms == null) return "";
if (ms < 1000) return `${ms.toFixed(2)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
// ─── sub-components ─────────────────────────────────────
function DropdownMenu({
open,
setOpen,
align,
children,
}: {
open: boolean;
setOpen: (v: boolean) => void;
align?: "left" | "right";
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
return (
<div
ref={ref}
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{children}
</div>
);
}
function FilterModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: FilterRule[];
onChange: (rules: FilterRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<FilterRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Column Filters</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.operator}
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="eq">=</option>
<option value="neq"></option>
<option value="contains">contains</option>
<option value="starts">starts with</option>
<option value="ends">ends with</option>
<option value="gt">&gt;</option>
<option value="lt">&lt;</option>
<option value="null">is null</option>
<option value="notnull">not null</option>
</select>
{rule.operator !== "null" && rule.operator !== "notnull" && (
<input
type="text"
value={rule.value}
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
placeholder="value"
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
/>
)}
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add filter
</button>
</div>
);
}
function SortModal({
columns,
rules,
onChange,
open,
setOpen,
}: {
columns: ColumnInfo[];
rules: SortRule[];
onChange: (rules: SortRule[]) => void;
open: boolean;
setOpen: (v: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open, setOpen]);
if (!open) return null;
const addRule = () => {
onChange([
...rules,
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
]);
};
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
const updateRule = (id: string, patch: Partial<SortRule>) =>
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
return (
<div
ref={ref}
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-text">Sort Rules</span>
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
<X size={14} />
</button>
</div>
{rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
<select
value={rule.column}
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
>
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
<select
value={rule.order}
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
>
<option value="asc">ASC</option>
<option value="desc">DESC</option>
</select>
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
<Trash2 size={14} />
</button>
</div>
))}
<button
type="button"
onClick={addRule}
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
>
+ Add sort
</button>
</div>
);
}
// ─── bulk actions dropdown ──────────────────────────────
function BulkActionsDropdown({
columns,
selectedRows,
schema,
table,
onClearSelection,
}: {
columns: ColumnInfo[];
selectedRows: unknown[][];
schema: string;
table: string;
onClearSelection: () => void;
}) {
const [open, setOpen] = useState(false);
const addChange = useDbViewerStore((s) => s.addChange);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
setOpen(false);
};
const handleCopyJSON = () => {
const json = selectedRows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
return obj;
});
copyToClipboard(JSON.stringify(json, null, 2));
};
const handleCopyCSV = () => {
const headers = columns.map((c) => c.name);
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
for (const row of selectedRows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
}
copyToClipboard(csvRows.join("\n"));
};
const handleCopySQL = () => {
const headers = columns.map((c) => c.name);
const lines: string[] = [];
for (const row of selectedRows) {
const vals = row.map((cell) =>
cell === null ? "NULL"
: typeof cell === "number" ? String(cell)
: `'${String(cell).replace(/'/g, "''")}'`,
);
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
}
copyToClipboard(lines.join("\n"));
};
const handleDeleteSelected = () => {
const pkCol = columns.find((c) => c.is_pk);
for (const row of selectedRows) {
const pk: Record<string, unknown> = {};
if (pkCol) {
const ci = columns.findIndex((c) => c.name === pkCol.name);
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
}
addChange({
type: "delete",
schema,
table,
primaryKey: pk,
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
description: `Delete row from ${table}`,
});
}
setOpen(false);
onClearSelection();
};
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
>
<span className="text-xs font-medium">Actions</span>
<ChevronDown size={12} />
</button>
<DropdownMenu open={open} setOpen={setOpen} align="right">
<button
type="button"
onClick={handleCopyJSON}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileJson size={13} className="text-text-muted" />
Copy as JSON
</button>
<button
type="button"
onClick={handleCopyCSV}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<FileText size={13} className="text-text-muted" />
Copy as CSV
</button>
<button
type="button"
onClick={handleCopySQL}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<Terminal size={13} className="text-text-muted" />
Copy as SQL INSERT
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={handleDeleteSelected}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
>
<Trash2 size={13} />
Delete selected rows
</button>
</DropdownMenu>
</div>
);
}
// ─── main component ─────────────────────────────────────
interface TableControlsProps {
connectionId: string;
schema: string;
table: string;
columns: ColumnInfo[];
rows: unknown[][];
hiddenColumns: Set<string>;
onToggleColumn: (col: string) => void;
onRefresh: () => void;
filterRules: FilterRule[];
onFilterChange: (rules: FilterRule[]) => void;
sortRules: SortRule[];
onSortChange: (rules: SortRule[]) => void;
selectedCount: number;
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
/** Hide data-modifying affordances (e.g. for materialized views). */
isMatview?: boolean;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
}
export function TableControls({
connectionId: _connectionId,
schema,
table,
columns,
rows,
hiddenColumns,
onToggleColumn,
onRefresh,
filterRules,
onFilterChange,
sortRules,
onSortChange,
selectedCount,
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
isMatview = false,
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
const notify = useNotificationStore((s) => s.notify);
const tabs = useDbViewerStore((s) => s.tabs);
const activeTabId = useDbViewerStore((s) => s.activeTabId);
const setPage = useDbViewerStore((s) => s.setPage);
const setPageSize = useDbViewerStore((s) => s.setPageSize);
const openTab = useDbViewerStore((s) => s.openTab);
const addChange = useDbViewerStore((s) => s.addChange);
const activeTab = tabs.find((t) => t.id === activeTabId);
const isRefreshing = activeTab?.loading ?? false;
// local state
const [filterOpen, setFilterOpen] = useState(false);
const [sortOpen, setSortOpen] = useState(false);
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
// Auto-refresh: a self-restarting timer that only counts down while the tab
// is idle. Fires a refresh, waits for it to complete (loading → false),
// then starts a fresh countdown. Also resets whenever the active tab changes
// so a freshly opened/reopened tab is not immediately refetched.
useEffect(() => {
if (autoRefresh === 0) return;
// While a refresh is in flight, wait for it to finish before counting down
if (isRefreshing) return;
const id = setTimeout(onRefresh, autoRefresh);
return () => clearTimeout(id);
}, [autoRefresh, onRefresh, isRefreshing, activeTabId]);
// pagination
const totalRows = activeTab?.data?.total_rows ?? rows.length;
const pageSize = activeTab?.pageSize ?? 50;
const currentPage = activeTab?.page ?? 1;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
const startRow = (clampedPage - 1) * pageSize + 1;
const endRow = Math.min(clampedPage * pageSize, totalRows);
const handlePrev = () => {
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
};
const handleNext = () => {
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
};
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
};
const handleInsertRow = () => {
const newData: Record<string, unknown> = {};
columns.forEach((c) => { newData[c.name] = null; });
addChange({
type: "insert",
schema,
table,
primaryKey: {},
newData,
description: `Insert row into ${table}`,
});
openTab(schema, table);
};
const handleExport = (format: string) => {
const label =
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
try {
exportData(rows, columns, format, table);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
}
setExportOpen(false);
};
const executionTimeMs = activeTab?.data?.execution_time_ms ?? null;
const refreshControl = (
<Tooltip
content={
isRefreshing
? isQuery
? "Running…"
: "Refreshing…"
: isQuery
? "Re-trigger query"
: "Refresh"
}
side="bottom"
>
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label={isQuery ? "Re-run query" : "Refresh table"}
>
<RefreshCw
size={14}
className={isRefreshing ? "animate-spin text-accent" : ""}
/>
</button>
</Tooltip>
);
const exportControl = (
<div className="relative">
<Tooltip content="Export" side="bottom">
<button
type="button"
onClick={() => setExportOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Export"
>
<Download size={14} />
</button>
</Tooltip>
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.ext}
type="button"
onClick={() => handleExport(fmt.ext)}
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{fmt.label}
</button>
))}
</DropdownMenu>
</div>
);
const columnsControl = (
<div className="relative">
<Tooltip content="Show/hide columns" side="bottom">
<button
type="button"
onClick={() => setColumnMenuOpen((v) => !v)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Toggle columns"
>
<Columns size={14} />
</button>
</Tooltip>
<DropdownMenu
open={columnMenuOpen}
setOpen={setColumnMenuOpen}
align={isQuery ? "left" : "right"}
>
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Visible columns
</div>
<div className="max-h-64 overflow-y-auto">
{columns.map((col) => (
<button
key={col.name}
type="button"
onClick={() => onToggleColumn(col.name)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
<span
className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
hiddenColumns.has(col.name)
? "border-border bg-transparent"
: "border-accent bg-accent"
}`}
>
{!hiddenColumns.has(col.name) && (
<Check size={10} className="text-white" />
)}
</span>
<span className="truncate">{col.name}</span>
</button>
))}
</div>
</DropdownMenu>
</div>
);
return (
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* refresh pulse: absolutely positioned so it never causes layout shifts */}
{isRefreshing && (
<div
data-testid="refresh-pulse"
aria-hidden="true"
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
/>
)}
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{isQuery ? (
<>
{exportControl}
{refreshControl}
<div className="w-px h-4 bg-border mx-1" />
{columnsControl}
</>
) : (
<>
{!isMatview && (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<button
type="button"
onClick={handleInsertRow}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
aria-label="Insert row"
>
<Plus size={14} />
</button>
</Tooltip>
</>
)}
{refreshControl}
{/* Auto-refresh */}
<div className="relative">
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
<button
type="button"
onClick={() => setAutoRefreshOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Auto-refresh"
>
<Clock size={14} />
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
</button>
</Tooltip>
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
{AUTO_REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
autoRefresh === opt.value ? "text-accent" : "text-text"
}`}
>
{autoRefresh === opt.value && <Check size={12} />}
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
</button>
))}
</DropdownMenu>
</div>
<div className="w-px h-4 bg-border mx-1" />
{/* Filter */}
<div className="relative">
<Tooltip content="Column filters" side="bottom">
<button
type="button"
onClick={() => setFilterOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
filterRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Column filters"
>
<Filter size={14} />
{filterRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{filterRules.length}
</span>
)}
</button>
</Tooltip>
<FilterModal
columns={columns}
rules={filterRules}
onChange={onFilterChange}
open={filterOpen}
setOpen={setFilterOpen}
/>
</div>
{/* Sort */}
<div className="relative">
<Tooltip content="Sort rules" side="bottom">
<button
type="button"
onClick={() => setSortOpen((v) => !v)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
sortRules.length > 0 ? "text-accent" : "hover:text-text"
}`}
aria-label="Sort rules"
>
<ArrowUpDown size={14} />
{sortRules.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{sortRules.length}
</span>
)}
</button>
</Tooltip>
<SortModal
columns={columns}
rules={sortRules}
onChange={onSortChange}
open={sortOpen}
setOpen={setSortOpen}
/>
</div>
{exportControl}
</>
)}
</div>
{/* ── spacer ──────────────────────────────────── */}
<div className="flex-1" />
{/* ── right side ─────────────────────────────── */}
<div className="flex items-center gap-2">
{isQuery && executionTimeMs != null && (
<>
<span
className="flex items-center gap-1.5 tabular-nums"
aria-label="Execution time"
>
<Clock size={12} className="text-text-muted" />
{formatDuration(executionTimeMs)}
</span>
<div className="w-px h-4 bg-border" />
</>
)}
{/* Selected count + bulk actions */}
{selectedCount > 0 && (
<>
<span className="text-accent font-medium tabular-nums">
{selectedCount} selected
</span>
<BulkActionsDropdown
columns={columns}
selectedRows={selectedRows}
schema={schema}
table={table}
onClearSelection={onClearSelection}
/>
<button
type="button"
onClick={onClearSelection}
className="text-text-muted hover:text-text transition-colors cursor-pointer"
aria-label="Clear selection"
>
<X size={14} />
</button>
<div className="w-px h-4 bg-border" />
</>
)}
{!isQuery && columnsControl}
<div className="w-px h-4 bg-border" />
{/* Row count */}
<span className="tabular-nums">
{startRow}-{endRow} of {totalRows}
</span>
{/* Page size */}
<select
value={pageSize}
onChange={handlePageSizeChange}
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{/* Pagination */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={handlePrev}
disabled={clampedPage <= 1}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Previous page"
>
<ChevronLeft size={14} />
</button>
<span className="tabular-nums min-w-[3rem] text-center">
{clampedPage}/{totalPages}
</span>
<button
type="button"
onClick={handleNext}
disabled={clampedPage >= totalPages}
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
aria-label="Next page"
>
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableOverflowMenu } from "./TableOverflowMenu";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import * as commands from "../../lib/commands";
import * as exportData from "../../lib/exportData";
describe("TableOverflowMenu", () => {
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn() },
configurable: true,
writable: true,
});
useDbViewerStore.getState().reset();
useUiStore.setState({ activeConnectionId: "c1" });
vi.resetAllMocks();
});
it("offers Create Index… and Create Constraint…", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Create Index…")).toBeInTheDocument();
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
});
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText("Create Index…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.kind).toBe("index");
expect(st.tabs[0].form?.mode).toBe("create");
useDbViewerStore.getState().reset();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
fireEvent.click(screen.getByText("Create Constraint…"));
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
});
it("renders menu trigger button", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
});
it("shows menu options on click", async () => {
const user = userEvent.setup();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
await user.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
});
it("fires onOpenTab when menu item clicked", async () => {
const user = userEvent.setup();
const onOpenTab = vi.fn().mockReturnValue("tab-1");
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
await user.click(screen.getByLabelText(/table options/i));
await user.click(screen.getByText("Open in new tab"));
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
});
it("Copy table schema calls getTableDdl and writes clipboard", async () => {
vi.spyOn(commands, "getTableDdl").mockResolvedValue("CREATE TABLE t (id int)");
const writeText = (navigator.clipboard as any).writeText as ReturnType<typeof vi.fn>;
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/copy table schema/i));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("CREATE TABLE t (id int)"));
});
it("Empty Table opens confirm then stages an empty_table change", async () => {
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/empty table/i));
fireEvent.click(screen.getByRole("button", { name: /empty table/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "empty_table", schema: "public", table: "t" }));
});
});
it("Delete Table opens confirm then stages a drop_table change", async () => {
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]);
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/delete table/i));
await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "drop_table", schema: "public", table: "t" }));
});
});
it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => {
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]);
render(<TableOverflowMenu schema="public" table="orders" connectionId="c1" onOpenTab={() => "tab-1"} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/delete table/i));
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalledWith("c1", "public", "table", "orders"));
await waitFor(() => expect(screen.getByText("v_orders")).toBeInTheDocument());
});
it("Export data calls exportData when rows and columns are provided", async () => {
const spy = vi.spyOn(exportData, "exportData");
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(csv\)/i));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
});
it("Excel export calls exportData and notifies", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(excel\)/i));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "xlsx", "public.t"));
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.toLowerCase().includes("exported"))).toBe(true);
});
it("tree kebab export fetches table data when rows are absent", async () => {
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
const getSpy = vi.spyOn(commands, "getTableData").mockResolvedValue({
columns: [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }],
rows: [[42]],
total_rows: 1,
page: 1,
page_size: 1000,
});
const { useNotificationStore } = await import("../../stores/notificationStore");
useNotificationStore.getState().notifications.length = 0;
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText(/export data \(excel\)/i));
await waitFor(() =>
expect(getSpy).toHaveBeenCalledWith("c1", "public", "t", 1, 1000),
);
await waitFor(() => expect(spy).toHaveBeenCalled());
const st = useNotificationStore.getState();
expect(st.notifications.some((n) => n.message.includes("Exported"))).toBe(true);
});
it("maintenance items are gated by capability and run via confirm", async () => {
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Table options"));
fireEvent.click(screen.getByText("VACUUM"));
fireEvent.click(screen.getByRole("button", { name: /vacuum/i }));
await screen.findByText(/completed/i);
expect(commands.runMaintenance).toHaveBeenCalledWith("c1", "public", "users", "vacuum");
});
it("Edit Table opens a table form tab", () => {
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" columns={[{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]} />);
fireEvent.click(screen.getByLabelText("Table options"));
fireEvent.click(screen.getByText("Edit Table…"));
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "edit" }));
});
});
@@ -0,0 +1,430 @@
import { useEffect, useRef, useState } from "react";
import { MoreVertical, RefreshCw } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { ImportDialog } from "./ImportDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities";
import { DependencyDialog } from "./DependencyDialog";
import { initialCrudParams } from "../../lib/objectCrud";
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
interface TableOverflowMenuProps {
schema: string;
table: string;
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
connectionId?: string;
columns?: ColumnInfo[];
rows?: unknown[][];
dbType?: string;
}
interface MenuItem {
id: string;
label?: string;
danger?: boolean;
divider?: boolean;
}
type MaintenanceAction = "vacuum" | "analyze" | "reindex";
const maintenanceLockCopy: Record<MaintenanceAction, string> = {
vacuum: "VACUUM blocks concurrent DDL only on this table.",
analyze: "ANALYZE blocks concurrent DDL only on this table.",
reindex: "REINDEX takes an ACCESS EXCLUSIVE lock — blocks reads and writes on this table until complete.",
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
export function TableOverflowMenu({
schema,
table,
onOpenTab,
connectionId: connectionIdProp,
columns,
rows,
dbType,
}: TableOverflowMenuProps) {
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
const connectionId = connectionIdProp ?? storeConnectionId;
const addChange = useDbViewerStore((s) => s.addChange);
const notify = useNotificationStore((s) => s.notify);
const caps = getCapabilities(dbType ?? "postgresql");
const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
const [importOpen, setImportOpen] = useState(false);
const [maintenance, setMaintenance] = useState<{ action: MaintenanceAction; lockCopy: string } | null>(null);
const [maintenanceRunning, setMaintenanceRunning] = useState(false);
const [maintenanceResult, setMaintenanceResult] = useState<
{ type: "success" | "error"; message: string; duration_ms: number } | null
>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleMaintenanceConfirm = async () => {
if (!connectionId || !maintenance) return;
setMaintenanceRunning(true);
setMaintenanceResult(null);
try {
const result: MaintenanceResult = await cmd.runMaintenance(
connectionId,
schema,
table,
maintenance.action,
);
setMaintenanceResult({ type: "success", message: result.message, duration_ms: result.duration_ms });
notify(`${result.message} · ${result.duration_ms}ms`, "success");
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setMaintenanceResult({ type: "error", message, duration_ms: 0 });
notify(message, "error");
} finally {
setMaintenanceRunning(false);
}
};
const handleAction = async (id: string) => {
switch (id) {
case "open":
onOpenTab(schema, table, true);
setOpen(false);
break;
case "copy-schema": {
if (!connectionId) break;
try {
const ddl = await cmd.getTableDdl(connectionId, schema, table);
if (navigator.clipboard) {
void navigator.clipboard.writeText(ddl);
}
} catch {
/* ignore copy failures */
}
setOpen(false);
break;
}
case "export-csv":
case "export-json":
case "export-sql":
case "export-md":
case "export-xlsx": {
const format = id.replace("export-", "");
const label =
format === "xlsx"
? "Excel"
: format === "md"
? "Markdown"
: format.toUpperCase();
try {
if (rows && rows.length > 0 && columns && columns.length > 0) {
exportData(rows, columns, format, `${schema}.${table}`);
notify(
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
"success",
);
} else if (connectionId) {
// Tree kebab: no rows are loaded here — fetch the table data
// first, then export (capped at 1000 rows per fetch).
const result = await cmd.getTableData(connectionId, schema, table, 1, 1000);
if (!result.rows.length) {
notify("Nothing to export", "info");
} else {
exportData(result.rows, result.columns, format, `${schema}.${table}`);
const truncated =
result.total_rows > result.rows.length
? ` (first ${result.rows.length} of ${result.total_rows})`
: "";
notify(
`Exported ${result.rows.length} row${result.rows.length === 1 ? "" : "s"} as ${label}${truncated}`,
"success",
);
}
} else {
notify("Nothing to export", "info");
}
} catch (e) {
notify(
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
"error",
);
}
setOpen(false);
break;
}
case "import":
setImportOpen(true);
setOpen(false);
break;
case "edit_table": {
const columnMeta = (columns ?? []).map((c) => ({
name: c.name,
type: c.data_type,
nullable: c.is_nullable,
default: c.default_value,
is_pk: c.is_pk,
}));
useDbViewerStore.getState().openFormTab({
kind: "table",
schema,
name: table,
title: "Edit Table",
description: `Edit ${schema}.${table}`,
mode: "edit",
params: {
schema,
name: table,
action: {
op: "edit",
columns: columnMeta,
old_columns: columnMeta,
},
},
});
setOpen(false);
break;
}
case "create_index":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "index",
schema,
name: "",
title: "Create Index",
description: `Create index on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "create_constraint":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "constraint",
schema,
name: "",
title: "Create Constraint",
description: `Create constraint on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "vacuum":
case "analyze":
case "reindex":
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
setOpen(false);
break;
case "empty":
setConfirmAction("empty");
setOpen(false);
break;
case "delete": {
if (!connectionId) break;
try {
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
setDropDeps(deps);
} catch {
setDropDeps([]);
}
setConfirmAction("delete");
setOpen(false);
break;
}
default:
break;
}
};
const items: MenuItem[] = [
{ id: "open", label: "Open in new tab" },
{ id: "copy-schema", label: "Copy table schema" },
{ id: "export-csv", label: "Export data (CSV)" },
{ id: "export-json", label: "Export data (JSON)" },
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "export-xlsx", label: "Export data (Excel)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" },
...(caps.tableManagement ? [{ id: "edit_table", label: "Edit Table…" }] : []),
...(caps.maintenance
? [
{ id: "maintenance-divider", divider: true },
{ id: "vacuum", label: "VACUUM" },
{ id: "analyze", label: "ANALYZE" },
{ id: "reindex", label: "REINDEX", danger: true },
]
: []),
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
return (
<div className="relative" ref={menuRef}>
<button
aria-label="Table options"
onClick={() => setOpen((o) => !o)}
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreVertical size={14} />
</button>
{open && (
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
{items.map((item) =>
item.divider ? (
<div key={item.id} className="border-t border-border my-1" />
) : (
<button
key={item.id}
type="button"
onClick={() => handleAction(item.id)}
className={[
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
].join(" ")}
>
<span>{item.label}</span>
</button>
),
)}
</div>
)}
{maintenance && (
<AnimatedModal open onClose={() => setMaintenance(null)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">
{maintenanceResult
? maintenanceResult.type === "success"
? "Maintenance Complete"
: "Maintenance Failed"
: `${capitalize(maintenance.action)}: ${schema}.${table}`}
</h3>
<p className="text-sm text-text-muted mb-4">
{maintenanceResult
? `${maintenanceResult.message} · ${maintenanceResult.duration_ms}ms`
: maintenance.lockCopy}
</p>
{maintenanceRunning && (
<div className="flex items-center gap-2 text-sm text-text-muted mb-4">
<RefreshCw size={14} className="animate-spin" />
<span>Running {maintenance.action}</span>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => setMaintenance(null)}
disabled={maintenanceRunning}
>
{maintenanceResult ? "Close" : "Cancel"}
</Button>
{!maintenanceResult && (
<Button
variant="primary"
onClick={handleMaintenanceConfirm}
disabled={maintenanceRunning}
>
{maintenanceRunning ? "Running…" : capitalize(maintenance.action)}
</Button>
)}
</div>
</div>
</AnimatedModal>
)}
{confirmAction === "delete" && dropDeps.length > 0 && (
<DependencyDialog
open
deps={dropDeps}
onProceed={() => setConfirmAction(null)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "empty" && (
<ConfirmDialog
open
title={`Empty Table: ${table}`}
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
confirmLabel="Empty Table"
onConfirm={() => {
addChange({
type: "empty_table",
schema,
table,
description: `Empty Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction === "delete" && (
<ConfirmDialog
open
title={`Delete Table: ${table}`}
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
confirmLabel="Delete Table"
onConfirm={() => {
addChange({
type: "drop_table",
schema,
table,
description: `Drop Table: ${schema}.${table}`,
});
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
<ImportDialog
open={importOpen}
schema={schema}
table={table}
columns={columns?.map((c) => c.name) ?? []}
onStage={(change) => {
addChange({
type: "bulk_insert",
schema: change.schema,
table: change.table,
columns: change.columns,
rows: change.rows,
description: change.description,
});
setImportOpen(false);
}}
onClose={() => setImportOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TableTree } from "./TableTree";
import { useDbViewerStore } from "../../stores/dbViewerStore";
describe("TableTree", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
});
it("renders table names from store", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{ name: "users", schema: "public", table_type: "TABLE" },
{ name: "orders", schema: "public", table_type: "TABLE" },
],
});
render(<TableTree />);
expect(screen.getByText("users")).toBeInTheDocument();
expect(screen.getByText("orders")).toBeInTheDocument();
});
it("shows a distinct icon and label for materialized views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "mv_products",
schema: "public",
table_type: "MATERIALIZED VIEW" as any,
},
],
});
render(<TableTree />);
expect(screen.getByText("mv_products")).toBeInTheDocument();
expect(screen.getByText("Materialized View")).toBeInTheDocument();
});
it("shows a distinct icon and label for views", () => {
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [
{
name: "order_summary",
schema: "public",
table_type: "VIEW",
},
],
});
render(<TableTree />);
expect(screen.getByText("order_summary")).toBeInTheDocument();
expect(screen.getByText("View")).toBeInTheDocument();
});
it("opens a tab when table is clicked", async () => {
const user = userEvent.setup();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
});
render(<TableTree />);
await user.click(screen.getByText("users"));
const state = useDbViewerStore.getState();
expect(state.tabs).toHaveLength(1);
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
});
it("shows Loading when schema tree is loading and no tables are present", () => {
useDbViewerStore.setState({ schemaTreeLoading: true });
render(<TableTree />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
expect(screen.queryByText("No tables")).not.toBeInTheDocument();
});
it("shows No tables when not loading and no tables are present", () => {
useDbViewerStore.setState({ schemaTreeLoading: false });
render(<TableTree />);
expect(screen.getByText("No tables")).toBeInTheDocument();
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,173 @@
import { useState } from "react";
import { ChevronRight, ChevronDown, Table2, Layers, Eye, Key, Type } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
import { TableOverflowMenu } from "./TableOverflowMenu";
import { abbreviateType } from "../../lib/utils";
import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery, dbType }: { searchQuery?: string; dbType?: string }) {
const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema);
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
const openTab = useDbViewerStore((s) => s.openTab);
const connectionId = useUiStore((s) => s.activeConnectionId);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [columnCache, setColumnCache] = useState<
Record<string, ColumnInfo[]>
>({});
const q = (searchQuery ?? "").toLowerCase().trim();
const filteredTables = (
currentSchema
? tables.filter((t) => t.schema === currentSchema)
: tables
).filter((t) => !q || t.name.toLowerCase().includes(q));
const toggle = async (key: string, schema: string, tableName: string) => {
const isExpanded = expanded.has(key);
setExpanded((prev) => {
const next = new Set(prev);
if (isExpanded) next.delete(key);
else next.add(key);
return next;
});
// Fetch columns if not cached
if (!isExpanded && !columnCache[key] && connectionId) {
try {
const result = await cmd.getTableData(
connectionId,
schema,
tableName,
1,
0,
);
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
} catch {
/* ignore, columns will remain unknowns */
}
}
};
const handleOpenTab = (
schema: string,
table: string,
forceNew?: boolean,
) => {
openTab(schema, table, forceNew);
return "tab";
};
return (
<div>
{filteredTables.length === 0 && (
<div className="px-3 py-2 text-sm text-text-muted">
{schemaTreeLoading ? "Loading…" : "No tables"}
</div>
)}
{filteredTables.map((table) => {
const key = `${table.schema}.${table.name}`;
const isExpanded = expanded.has(key);
const cols = columnCache[key] ?? table.columns ?? [];
const isMatView = table.table_type === "MATERIALIZED VIEW";
const isView = table.table_type === "VIEW";
const TypeIcon = isMatView ? Layers : isView ? Eye : Table2;
const typeLabel = isMatView
? "Materialized View"
: isView
? "View"
: null;
return (
<div key={key}>
<div
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
onClick={() => openTab(table.schema, table.name)}
>
<button
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
toggle(key, table.schema, table.name);
}}
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
<TypeIcon size={14} className="text-text-muted" />
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
{table.name}
</span>
{typeLabel && (
<span className="text-[10px] text-text-subtle shrink-0">
{typeLabel}
</span>
)}
<div onClick={(e) => e.stopPropagation()}>
<TableOverflowMenu
schema={table.schema}
table={table.name}
connectionId={connectionId ?? undefined}
dbType={dbType}
columns={cols}
onOpenTab={handleOpenTab}
/>
</div>
</div>
{isExpanded && (
<div className="pl-10 pr-3 py-1 space-y-1">
{cols.length === 0 && (
<div className="text-xs text-text-muted">
No columns
</div>
)}
{cols.map((col) => (
<div
key={col.name}
className="flex items-center gap-2 text-xs text-text-muted"
title={
col.is_fk && col.fk_ref
? `${col.data_type}${col.fk_ref[0]}.${col.fk_ref[1]}`
: col.data_type
}
>
{col.is_pk ? (
<Key
size={12}
className="text-accent shrink-0"
/>
) : col.is_fk ? (
<Key
size={12}
className="text-amber-400 shrink-0"
/>
) : (
<Type
size={12}
className="shrink-0"
/>
)}
<span className="truncate">
{col.name}
</span>
<span
className="text-text-subtle truncate"
title={col.data_type}
>
{abbreviateType(col.data_type)}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,39 @@
import { useState } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage";
type ToolOperation = "backup" | "restore" | "sync";
const OPERATION_OPTIONS = [
{ value: "backup", label: "Backup" },
{ value: "restore", label: "Restore" },
{ value: "sync", label: "DB Sync" },
];
export function ToolsPage({ connectionId }: { connectionId: string }) {
const [operation, setOperation] = useState<ToolOperation>("backup");
return (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
{/* Operation switcher toolbar */}
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0">
<SelectDropdown
value={operation}
onChange={(v) => setOperation(v as ToolOperation)}
options={OPERATION_OPTIONS}
variant="ghost"
aria-label="Operation"
/>
</div>
{/* Content BackupPage/RestorePage/SyncPage each render their own
toolbar header and flex-1 overflow-y-auto scroll container, so this
wrapper only provides a definite height (h-full resolves against it). */}
<div className="flex-1 min-h-0">
{operation === "backup" && <BackupPage connectionId={connectionId} />}
{operation === "restore" && <RestorePage connectionId={connectionId} />}
{operation === "sync" && <SyncPage />}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import {
getCardinalityColor,
getCardinalityLabel,
LEGEND_ITEMS,
} from "./legendHelpers";
describe("legendHelpers", () => {
it("getCardinalityColor returns correct colors", () => {
expect(getCardinalityColor("1:1")).toBe("#22c55e"); // green
expect(getCardinalityColor("1:N")).toBe("#3b82f6"); // blue
expect(getCardinalityColor("N:M")).toBe("#f59e0b"); // amber
});
it("getCardinalityColor returns fallback for unknown", () => {
expect(getCardinalityColor("unknown")).toBe("#6b7280"); // gray fallback
});
it("getCardinalityLabel returns human-readable labels", () => {
expect(getCardinalityLabel("1:1")).toBe("One-to-One");
expect(getCardinalityLabel("1:N")).toBe("One-to-Many");
expect(getCardinalityLabel("N:M")).toBe("Many-to-Many");
});
it("getCardinalityLabel returns raw value for unknown", () => {
expect(getCardinalityLabel("unknown")).toBe("unknown");
});
it("LEGEND_ITEMS has three entries", () => {
expect(LEGEND_ITEMS).toHaveLength(3);
expect(LEGEND_ITEMS[0]).toHaveProperty("cardinality");
expect(LEGEND_ITEMS[0]).toHaveProperty("color");
expect(LEGEND_ITEMS[0]).toHaveProperty("label");
});
});
@@ -0,0 +1,33 @@
export interface LegendItem {
cardinality: string;
color: string;
label: string;
markerStart: string;
markerEnd: string;
}
const CARDINALITY_COLORS: Record<string, string> = {
"1:1": "#22c55e",
"1:N": "#3b82f6",
"N:M": "#f59e0b",
};
const CARDINALITY_LABELS: Record<string, string> = {
"1:1": "One-to-One",
"1:N": "One-to-Many",
"N:M": "Many-to-Many",
};
export function getCardinalityColor(cardinality: string): string {
return CARDINALITY_COLORS[cardinality] ?? "#6b7280";
}
export function getCardinalityLabel(cardinality: string): string {
return CARDINALITY_LABELS[cardinality] ?? cardinality;
}
export const LEGEND_ITEMS: LegendItem[] = [
{ cardinality: "1:1", color: "#22c55e", label: "One-to-One", markerStart: "one", markerEnd: "one" },
{ cardinality: "1:N", color: "#3b82f6", label: "One-to-Many", markerStart: "many", markerEnd: "one" },
{ cardinality: "N:M", color: "#f59e0b", label: "Many-to-Many", markerStart: "many", markerEnd: "many" },
];
@@ -0,0 +1,26 @@
interface Props {
cols: string[];
selected: string[];
onToggle: (col: string) => void;
}
export function ColumnPicker({ cols, selected, onToggle }: Props) {
return (
<div className="flex flex-wrap gap-1" data-testid="column-picker">
{cols.map((c) => (
<button
key={c}
type="button"
onClick={() => onToggle(c)}
className={`text-xs px-2 py-1 rounded border transition-colors ${
selected.includes(c)
? "bg-accent text-white border-accent"
: "border-border text-text hover:border-text-muted"
}`}
>
{c}
</button>
))}
</div>
);
}
@@ -0,0 +1,168 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ConstraintForm } from "./ConstraintForm";
import * as cmd from "../../../lib/commands";
beforeEach(() => {
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
const graph = {
tables: [
{
name: "orders",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "user_id",
data_type: "int",
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: true,
fk_ref: ["public", "users", "id"],
},
],
},
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "id",
data_type: "int",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
};
describe("ConstraintForm", () => {
it("check: emits the expression", () => {
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "ck",
action: { op: "check", expression: "" },
}}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
target: { value: "amount > 0" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ expression: "amount > 0" }),
}),
);
});
it("foreign_key: picks referenced table + column", async () => {
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "fk",
action: {
op: "foreign_key",
columns: ["user_id"],
ref_schema: "",
ref_table: "",
ref_columns: [],
},
}}
onChange={onChange}
/>,
);
const kindSelect = screen.getByLabelText("Kind");
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
await screen.findByText("user_id");
const refTable = await screen.findByLabelText("Referenced table");
fireEvent.change(refTable, { target: { value: "public.users" } });
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({
ref_schema: "public",
ref_table: "users",
}),
}),
);
});
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
// mock getSchemaGraph to return tables across two schemas
(cmd.getSchemaGraph as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
],
},
{
name: "orgs",
schema: "auth",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
],
},
],
relationships: [],
} as any);
render(
<ConstraintForm
connectionId="c1"
schemas={["public", "auth"]}
params={{
schema: "public",
table: "orders",
name: "fk1",
action: {
op: "foreign_key",
columns: ["org_id"],
ref_schema: "auth",
ref_table: "orgs",
ref_columns: ["id"],
on_delete: "CASCADE",
on_update: "NO ACTION",
deferrable: false,
initially_deferred: false,
},
}}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
expect(screen.getByLabelText("On update")).toBeInTheDocument();
// referenced-table picker includes the auth.orgs option
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
await waitFor(() => {
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
});
});
});
@@ -0,0 +1,271 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import * as cmd from "../../../lib/commands";
import type { SchemaGraph } from "../../../lib/types";
import { ColumnPicker } from "./ColumnPicker";
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
const KINDS = ["check", "unique", "primary_key", "foreign_key"];
const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
function refKey(schema: string, table: string): string {
return `${schema}.${table}`;
}
function parseRefKey(key: string): { schema: string; table: string } {
const parts = key.split(".");
if (parts.length >= 2) return { schema: parts[0] ?? "", table: parts.slice(1).join(".") };
return { schema: "", table: key };
}
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const kind = (action.op as string) ?? "check";
const [graph, setGraph] = useState<SchemaGraph>({ tables: [], relationships: [] });
useEffect(() => {
cmd
.getSchemaGraph(connectionId, undefined)
.then((g: SchemaGraph) => setGraph(g))
.catch(() => setGraph({ tables: [], relationships: [] }));
}, [connectionId]);
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
const tableCols =
graph.tables
.find((t) => t.name === (p.table as string) && t.schema === (p.schema as string))
?.columns.map((c) => c.name) ?? [];
const refCols =
graph.tables
.find(
(t) =>
t.name === (action.ref_table as string) &&
t.schema === (action.ref_schema as string),
)
?.columns.map((c) => c.name) ?? [];
const selected: string[] = (action.columns as string[]) ?? [];
const refSelected: string[] = (action.ref_columns as string[]) ?? [];
const pick = (list: string[], c: string) =>
list.includes(c) ? list.filter((x) => x !== c) : [...list, c];
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(p.table as string) ?? ""}
onChange={(e) => onChange({ ...p, table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Constraint name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Kind">
<select
aria-label="Kind"
value={kind}
onChange={(e) => onChange({ ...p, action: { op: e.target.value } })}
className={controlClass}
>
{KINDS.map((k) => (
<option key={k} value={k}>
{k.replace(/_/g, " ")}
</option>
))}
</select>
</FormRow>
{(kind === "unique" || kind === "primary_key") && (
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
)}
{kind === "check" && (
<FormRow label="Expression">
<input
type="text"
placeholder="CHECK expression"
value={(action.expression as string) ?? ""}
onChange={(e) => setAction({ expression: e.target.value })}
className={monoInputClass}
/>
</FormRow>
)}
{kind === "foreign_key" && (
<>
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
<FormRow label="Referenced table">
<select
aria-label="Referenced table"
value={refKey(
(action.ref_schema as string) ?? "",
(action.ref_table as string) ?? "",
)}
onChange={(e) => {
const { schema, table } = parseRefKey(e.target.value);
const t = graph.tables.find(
(tbl) => tbl.name === table && tbl.schema === schema,
);
setAction({
ref_table: table,
ref_schema: schema,
ref_columns: [],
});
if (!t) return;
const pkCols = t.columns.filter((c) => c.is_pk).map((c) => c.name);
if (pkCols.length > 0 && selected.length > 0) {
setAction({
ref_table: table,
ref_schema: schema,
ref_columns: pkCols.slice(0, selected.length),
});
}
}}
className={controlClass}
>
<option value="" disabled>
Select a table
</option>
{graph.tables.map((t) => (
<option key={refKey(t.schema, t.name)} value={refKey(t.schema, t.name)}>
{t.schema}.{t.name}
</option>
))}
</select>
</FormRow>
<FormRow label="Referenced columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={refCols}
selected={refSelected}
onToggle={(c) => setAction({ ref_columns: pick(refSelected, c) })}
/>
</div>
</FormRow>
<FormRow label="On delete">
<select
aria-label="On delete"
value={(action.on_delete as string) ?? "NO ACTION"}
onChange={(e) => setAction({ on_delete: e.target.value })}
className={controlClass}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</FormRow>
<FormRow label="On update">
<select
aria-label="On update"
value={(action.on_update as string) ?? "NO ACTION"}
onChange={(e) => setAction({ on_update: e.target.value })}
className={controlClass}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</FormRow>
<FormRow label="Deferrable">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
aria-label="DEFERRABLE"
checked={!!action.deferrable}
onChange={(e) =>
setAction({
deferrable: e.target.checked,
initially_deferred: e.target.checked ? action.initially_deferred ?? false : false,
})
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>DEFERRABLE</span>
</label>
</FormRow>
{!!action.deferrable && (
<FormRow label="Initially deferred">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
aria-label="INITIALLY DEFERRED"
checked={!!action.initially_deferred}
onChange={(e) => setAction({ initially_deferred: e.target.checked })}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>INITIALLY DEFERRED</span>
</label>
</FormRow>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { EnumForm } from "./EnumForm";
describe("EnumForm", () => {
it("create: add/remove labels", () => {
const onChange = vi.fn();
render(
<EnumForm
params={{ schema: "public", name: "role", action: { op: "create", labels: ["admin"] } }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /add value/i }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ labels: ["admin", ""] }),
}),
);
});
it("add_value op shows value + position + the no-removal note", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "add_value", value: "orange", if_not_exists: false, before: null, after: null },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("New value")).toHaveValue("orange");
expect(screen.getByText(/no ALTER TYPE … DROP VALUE/i)).toBeInTheDocument();
});
it("rename_value op shows from + to", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "rename_value", from: "purple", to: "mauve" },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("From")).toHaveValue("purple");
expect(screen.getByPlaceholderText("To")).toHaveValue("mauve");
});
});

Some files were not shown because too many files have changed in this diff Show More