DB viewer + query editor enhancements (home-screen-ux-query-editor) (#4)

* feat: add query_history table migration (v5) (Task 1)

* feat: add isDestructiveQuery utility (Task 2)

* feat: add tabType discriminator and openQueryTab to dbViewerStore (Task 3)

* feat: add execute_query command with pagination and query history (Task 4)

* feat: add typed wrappers for executeQuery, getQueryHistory, clearQueryHistory (Task 5)

* fix: global search bypasses folder scope when filters active (Task 6)

* feat: add TagFilterDropdown with checkboxes and empty state (Task 7)

* feat: add DbTypeFilterDropdown with checkboxes and clear all (Task 8)

* feat: wire TagFilterDropdown/DbTypeFilterDropdown into ActionRow, add inline tag creation (Task 9)

* feat: add Name input to GeneralTab for connection editing (Task 10)

* feat: add QueryEditor Monaco wrapper with SQL mode and Cmd+Enter (Task 11)

* feat: add DestructiveQueryDialog with SQL preview and confirmation (Task 12)

* feat: integrate query tabs, Monaco editor, destructive guard into DbViewerScreen (Task 13)

* fix: harden moveConnection against race conditions on rapid drags (Task 14)

* docs: update AGENTS.md implementation status for Home Screen UX + Query Editor (Task 15)

* feat: switch tag filter to OR semantics, add environment filter (F-T16)

* feat: add activeEnvironment filter state to uiStore and useFilteredConnections (F-T17)

* feat: add environment filter select to Filters dropdown (F-T18)

* feat: filter folder cards by tag match or contained connections (F-T19)

* fix: keep grid header width to content, border last column

* fix: hide select-all checkbox and empty-state when no table open

* fix: filter folder cards by any active filter, show global search results (F-T20)

* feat: show 'Showing Search Results' breadcrumb with clear button (F-T21)

* docs: update README + AGENTS.md for Query Editor, filters, and planned AI integration (BYOK)

* feat: refresh indicator with spinning icon and pulse, defer auto-refresh on tab switch

* feat: smart default schema selection, refresh schemas on database switch

* fix: auto-refresh waits for in-flight refresh to complete before next tick

* style: shrink db viewer sidebar nav icons from 20px to 16px

* style: shrink db viewer sidebar nav buttons to 32px (8px padding)

* style: make Tables panel title xs, regular weight, muted

* style: bump Tables panel title back to sm, keep regular weight and muted

* feat: export schema diagram as PNG/JPEG/SVG (entire schema or viewport)

* chore: lockfile for html-to-image

* fix: raise schema visualizer toolbar above legend so export menu isn't hidden

* feat: schema export via save dialog, transparent background option, save notification

* fix: render nothing in tab bar when no tabs are open

* style: reduce tab bar height from 40px to 36px

* style: reduce tab bar height to 32px

* style: revert tab bar height to 36px

* feat: split tab bar with fixed +Query and Changes actions on the right

* style: blue play-icon Query button in tab bar

* refactor: remove sidebar New Query button (now in tab bar)

* style: conditional bottom padding in sidebar toolbar when nothing is below

* feat: distinguish table and query tabs with icons

* style: tab icons follow active/inactive state, muted colors

* feat: query tab toolbar (run/format/dialect badge) + bare transparent editor

* style: blue rounded Run Query button in query toolbar

* feat: smart platform-aware shortcut tooltip on Run Query (⌘+⏎ / Ctrl+Enter)

* style: show only the shortcut in the Run Query tooltip

* fix: Cmd+Enter keybinding stale closure; add run pulse to query toolbar; bundle monaco locally (offline)

* feat: show placeholder text in empty query editor

* feat: SQL autocomplete — keywords + table names from active schema

* feat: per-table column autocomplete on 'table.' + docs update

* feat: query-variant result toolbar — export/refresh/columns left, smart-unit execution time right

* fix: populate execution_time_ms on query results so the toolbar can show time taken

* fix: re-measure monaco fonts after async font load to stop cursor drift

* feat: resizable + collapsible query results panel

* refactor: move results caret onto the resize handle (centered), bottom caret when collapsed

* style: thin drag strip with caret on its own centered pill

* refactor: remove Queue button from table toolbar (Changes lives in tab bar)

* style: changes button becomes bordered rounded icon with count badge

* docs: mark tab-bar Changes queue button in AGENTS.md and README
This commit is contained in:
2026-08-01 05:56:47 +08:00
committed by GitHub
parent 4f18993e70
commit 80962d7d11
66 changed files with 5681 additions and 509 deletions
@@ -28,6 +28,23 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/users/i)).toBeInTheDocument();
});
it("toggle button flips the store expanded state", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "update",
schema: "public",
table: "users",
primaryKey: { id: 1 },
oldData: { name: "Bob" },
newData: { name: "Alice" },
});
render(<ChangesQueuePanel />);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
await user.click(screen.getByText(/1 pending change/i));
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
});
it("cancel button changes status", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useCallback } from "react";
import { X, Check, ChevronUp, ChevronDown } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore";
@@ -58,7 +58,10 @@ export function ChangesQueuePanel() {
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
const notify = useNotificationStore((state) => state.notify);
const [expanded, setExpanded] = useState(true);
const expanded = useDbViewerStore((state) => state.changesPanelExpanded);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
const handleCommitAll = useCallback(async () => {
const connectionId = useUiStore.getState().activeConnectionId;
@@ -114,7 +117,7 @@ export function ChangesQueuePanel() {
<div className="border-t border-border bg-surface">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
onClick={() => toggleChangesPanel()}
className="flex w-full items-center justify-between px-4 py-2 text-sm text-text hover:bg-surface-raised/50 cursor-pointer"
>
<div className="flex items-center gap-2">
+398 -20
View File
@@ -1,28 +1,406 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { DbViewerScreen } from "./DbViewerScreen";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => {},
}),
vi.mock("../../hooks/useDbConnection", () => ({
useDbConnection: (_connectionId: string) => ({
connectionError: null,
connect: vi.fn(),
}),
}));
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.setState({
tabs: [], activeTabId: null, changesQueue: [],
databases: ["mydb"], schemas: ["public"],
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
currentDatabase: "mydb", currentSchema: "public",
});
});
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: () => ({
getVirtualItems: () => [],
getTotalSize: () => 0,
measureElement: () => {},
}),
}));
it("renders the sidebar", () => {
render(<DbViewerScreen connectionId="c1" onHome={() => {}} onSettings={() => {}} />);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
const { registeredActions } = vi.hoisted(() => ({
registeredActions: [] as Array<{ run: () => void }>,
}));
// monaco-editor's global font re-measure — stub so jsdom stays light
vi.mock("monaco-editor", () => ({
editor: { remeasureFonts: vi.fn() },
}));
vi.mock("@monaco-editor/react", async () => {
const { useEffect } = await import("react");
return {
default: ({ value, onChange, onMount }: any) => {
useEffect(() => {
onMount?.({
addAction: (action: any) => registeredActions.push(action),
getValue: () => value,
setValue: (v: string) => onChange?.(v),
focus: () => {},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div data-testid="monaco-editor">
<textarea
data-testid="monaco-textarea"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</div>
);
},
};
});
const mockQueryResult = {
columns: [
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
},
],
rows: [[1]],
total_rows: 1,
page: 1,
page_size: 50,
execution_time_ms: 42,
};
describe("DbViewerScreen", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
databases: ["mydb"],
schemas: ["public"],
tables: [
{ name: "users", schema: "public", table_type: "TABLE" },
],
currentDatabase: "mydb",
currentSchema: "public",
});
vi.resetAllMocks();
});
it("renders the sidebar", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
});
it("renders the New Query button", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
});
it("opens a query tab when New Query is clicked", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
expect(screen.getByRole("tab", { name: "Query" })).toBeInTheDocument();
});
it("renders the query editor inside a query tab", async () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
await waitFor(() => {
expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /run query/i })).toBeInTheDocument();
});
it("executes a non-destructive query when Run is clicked", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith("c1", "SELECT 1", 1, 50),
);
// query variant toolbar shows the execution time from the result
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
});
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue({
columns: [],
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
} as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "DELETE FROM users" },
});
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => {
expect(screen.getByText("Destructive Query")).toBeInTheDocument();
});
expect(executeQuery).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Execute"));
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith(
"c1",
"DELETE FROM users",
1,
50,
),
);
});
it("formats the query SQL when Auto format is clicked", async () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "select * from users where id = 1" },
});
fireEvent.click(screen.getByRole("button", { name: /auto format/i }));
await waitFor(() => {
expect((textarea as HTMLTextAreaElement).value).toMatch(/\n/);
});
});
it("runs the current query when the Cmd+Enter action fires", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
registeredActions.length = 0;
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, {
target: { value: "SELECT 42" },
});
expect(registeredActions).toHaveLength(1);
registeredActions[0].run();
await waitFor(() =>
expect(executeQuery).toHaveBeenCalledWith("c1", "SELECT 42", 1, 50),
);
});
it("shows the pulse while a query is running and hides it after", async () => {
let resolveRun!: (v: unknown) => void;
const pending = new Promise<unknown>((r) => {
resolveRun = r;
});
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() =>
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
);
resolveRun(mockQueryResult);
await waitFor(() =>
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
);
});
it("collapses and re-expands the query results via the caret", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
expect(screen.getByTestId("query-results")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/hide results/i));
expect(screen.queryByTestId("query-results")).toBeNull();
expect(screen.queryByText("42.00ms")).toBeNull();
fireEvent.click(screen.getByLabelText(/show results/i));
expect(screen.getByTestId("query-results")).toBeInTheDocument();
expect(executeQuery).toHaveBeenCalledTimes(1);
});
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
);
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /new query/i }));
const textarea = await waitFor(() =>
screen.getByTestId("monaco-textarea"),
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(screen.getByText("42.00ms")).toBeInTheDocument());
const results = screen.getByTestId("query-results");
const initial = parseFloat(results.style.height);
const handle = screen.getByTestId("query-results-resize");
// Drag up: results grow
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(document, { clientY: 100 });
fireEvent.mouseUp(document);
await waitFor(() =>
expect(parseFloat(results.style.height)).toBeGreaterThan(initial),
);
// Drag far down: clamps to the 120px minimum
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(document, { clientY: 5000 });
fireEvent.mouseUp(document);
await waitFor(() => expect(parseFloat(results.style.height)).toBe(120));
});
it("re-fetches the active table and shows the refresh indicator when refresh is clicked", async () => {
let resolveFetch!: (v: unknown) => void;
const pendingFetch = new Promise<unknown>((r) => {
resolveFetch = r;
});
const getTableData = vi
.spyOn(commands, "getTableData")
.mockReturnValue(pendingFetch as any);
useDbViewerStore.setState({
tabs: [
{
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: mockQueryResult,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
},
],
activeTabId: "tab-1",
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
// Tab already has data and is not loading → no fetch on mount
expect(getTableData).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText(/refresh table/i));
// Refetch triggered for the active tab
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
// Indicator visible while the fetch is in flight
await waitFor(() =>
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument(),
);
act(() => {
resolveFetch({ ...mockQueryResult });
});
await waitFor(() =>
expect(
screen.queryByTestId("refresh-pulse"),
).not.toBeInTheDocument(),
);
});
});
+442 -67
View File
@@ -1,7 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery } from "../../lib/utils";
import { executeQuery } from "../../lib/commands";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
import { QueryToolbar } from "../editor/QueryToolbar";
const DestructiveQueryDialog = lazy(() =>
import("../editor/DestructiveQueryDialog").then((m) => ({ default: m.DestructiveQueryDialog })),
);
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { TabBar } from "./TabBar";
@@ -39,6 +49,7 @@ export function DbViewerScreen({
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
const connections = useConnectionStore((s) => s.connections);
const currentConnection =
connections.find((c) => c.id === connectionId) ?? null;
@@ -72,6 +83,7 @@ export function DbViewerScreen({
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
@@ -105,6 +117,57 @@ export function DbViewerScreen({
[connectionId, setTabData, setTabError],
);
async function executeQueryForTab(tabId: string, sql: string) {
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
if (!tab) return;
setTabLoading(tabId, true);
try {
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result);
} catch (e) {
setTabError(tabId, e instanceof Error ? e.message : String(e));
}
}
// Read the active tab from the store directly so the Monaco keybinding action
// (which keeps the first onRun closure) always sees the latest query text.
const handleRunQuery = useCallback(() => {
const state = useDbViewerStore.getState();
const tab = state.tabs.find((t) => t.id === state.activeTabId);
if (!tab || tab.tabType !== "query") return;
const sql = tab.query?.trim() ?? "";
if (!sql) return;
if (isDestructiveQuery(sql)) {
setDestructiveQuery(sql);
} else {
executeQueryForTab(tab.id, sql);
}
}, []);
// Auto-format the active query tab's SQL
const handleFormatQuery = useCallback(() => {
const state = useDbViewerStore.getState();
const tab = state.tabs.find((t) => t.id === state.activeTabId);
if (!tab || tab.tabType !== "query") return;
const dbType = currentConnection?.db_type ?? "postgresql";
const language =
dbType === "mysql"
? "mysql"
: dbType === "sqlite"
? "sqlite"
: "postgresql";
try {
const formatted = formatSql(tab.query ?? "", { language });
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tab.id ? { ...t, query: formatted } : t,
),
}));
} catch {
// leave the query untouched if formatting fails
}
}, [currentConnection?.db_type]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
@@ -116,6 +179,7 @@ export function DbViewerScreen({
});
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
if (!activeTab.loading) return;
if (activeTab.error) return;
fetchData(activeTab);
@@ -124,6 +188,7 @@ export function DbViewerScreen({
// Smart default sort: apply once when data first loads for a tab
useEffect(() => {
if (!activeTab) return;
if (activeTab.tabType !== "table") return;
if (activeTab.loading) return;
if (!activeTab.data) return;
if (activeTab.smartSortApplied) return;
@@ -370,10 +435,17 @@ export function DbViewerScreen({
}
}, [filterRules, activeTab, clearColumnFilter]);
// Refresh: clear data so auto-fetch effect re-fetches
// Refresh: clear data so auto-fetch effect re-fetches; for query tabs, re-run the stored query
const handleRefresh = useCallback(() => {
const tabId = useDbViewerStore.getState().activeTabId;
if (!tabId) return;
const tab = useDbViewerStore.getState().tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.tabType === "query") {
const sql = tab.query?.trim() ?? "";
if (sql) executeQueryForTab(tabId, sql);
return;
}
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, loading: true, error: null } : t,
@@ -416,6 +488,47 @@ export function DbViewerScreen({
[tablePanelWidth],
);
// Query results panel: collapsible + resizable (min 120px, max 80% of column)
const queryColumnRef = useRef<HTMLDivElement>(null);
const resultsResizeRef = useRef<{ startY: number; startH: number } | null>(
null,
);
const [resultsHeight, setResultsHeight] = useState(() =>
Math.round(
(typeof window !== "undefined" ? window.innerHeight : 800) * 0.4,
),
);
const [resultsCollapsed, setResultsCollapsed] = useState(false);
const onResultsResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
resultsResizeRef.current = {
startY: e.clientY,
startH: resultsHeight,
};
const columnH =
queryColumnRef.current?.clientHeight ||
(typeof window !== "undefined" ? window.innerHeight : 800);
const maxH = Math.max(120, Math.round(columnH * 0.8));
const onMove = (ev: MouseEvent) => {
if (!resultsResizeRef.current) return;
const h =
resultsResizeRef.current.startH +
(resultsResizeRef.current.startY - ev.clientY);
setResultsHeight(Math.max(120, Math.min(maxH, h)));
};
const onUp = () => {
resultsResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[resultsHeight],
);
const handleNavigate = useCallback(
(view: string) => {
if (view === "home") onHome();
@@ -479,73 +592,335 @@ export function DbViewerScreen({
/>
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
toggleHiddenColumn(activeTab!.id, col)
{activeTab?.tabType === "query" ? (
<Suspense
fallback={
<div className="p-4 text-text-muted">
Loading editor...
</div>
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={(rules) =>
setFilterRules(activeTab!.id, rules)
}
sortRules={sortRules}
onSortChange={(rules) =>
setSortRules(activeTab!.id, rules)
}
defaultRefreshRate={
settings?.table_refresh_rate ?? 0
}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter(
(_, i) => selectedRows.has(i),
)}
onClearSelection={() =>
setSelectedRows(new Set())
}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(rowIndex))
next.delete(rowIndex);
else next.add(rowIndex);
return next;
});
}}
onToggleAll={() => {
setSelectedRows((prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length > 0
) {
return new Set();
>
<div ref={queryColumnRef} className="flex flex-col flex-1 min-h-0 overflow-hidden">
<QueryToolbar
onRun={handleRunQuery}
onFormat={handleFormatQuery}
dbType={currentConnection?.db_type}
/>
<div className="flex-1 min-h-0 overflow-hidden">
<QueryEditor
value={activeTab.query ?? ""}
onChange={(value) =>
useDbViewerStore.setState(
(s) => ({
tabs: s.tabs.map(
(t) =>
t.id ===
activeTab.id
? {
...t,
query: value,
}
: t,
),
}),
)
}
onRun={handleRunQuery}
/>
</div>
{!resultsCollapsed ? (
<>
<div className="relative shrink-0">
<div
data-testid="query-results-resize"
aria-label="Resize results"
onMouseDown={
onResultsResizeStart
}
onDoubleClick={() =>
setResultsHeight(
Math.round(
(typeof window !==
"undefined"
? window
.innerHeight
: 800) *
0.4,
),
)
}
className="h-1 cursor-row-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50"
/>
{/* caret pill, centered on the drag strip */}
<button
type="button"
onClick={() =>
setResultsCollapsed(
true,
)
}
aria-label="Hide results"
onMouseDown={(e) =>
e.stopPropagation()
}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
>
<ChevronDown size={12} />
</button>
</div>
<div
data-testid="query-results"
style={{
height: resultsHeight,
}}
className="flex flex-col min-h-0 shrink-0"
>
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={
hiddenColumns
}
onToggleColumn={(
col,
) =>
toggleHiddenColumn(
activeTab!.id,
col,
)
}
onRefresh={
handleRefresh
}
filterRules={
filterRules
}
onFilterChange={(
rules,
) =>
setFilterRules(
activeTab!.id,
rules,
)
}
sortRules={
sortRules
}
onSortChange={(
rules,
) =>
setSortRules(
activeTab!.id,
rules,
)
}
defaultRefreshRate={
settings?.table_refresh_rate ??
0
}
selectedCount={
selectedRows.size
}
selectedRows={processedRows.filter(
(_, i) =>
selectedRows.has(
i,
),
)}
onClearSelection={() =>
setSelectedRows(
new Set(),
)
}
variant="query"
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
const next =
new Set(
prev,
);
if (
next.has(
rowIndex,
)
)
next.delete(
rowIndex,
);
else
next.add(
rowIndex,
);
return next;
},
);
}}
onToggleAll={() => {
setSelectedRows(
(prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length >
0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) =>
i,
),
);
},
);
}}
/>
</div>
</div>
</>
) : (
<>
{/* collapsed caret pill, pinned to the bottom of the editor */}
<div className="flex shrink-0 justify-center py-1">
<button
type="button"
onClick={() =>
setResultsCollapsed(
false,
)
}
aria-label="Show results"
className="flex items-center justify-center rounded-full border border-border bg-surface px-2 py-0.5 text-text-muted hover:text-text hover:bg-surface-raised shadow-sm transition-colors cursor-pointer"
>
<ChevronUp size={12} />
</button>
</div>
</>
)}
<DestructiveQueryDialog
open={destructiveQuery !== null}
query={destructiveQuery ?? ""}
onConfirm={() => {
if (
destructiveQuery &&
activeTab
) {
executeQueryForTab(
activeTab.id,
destructiveQuery,
);
}
setDestructiveQuery(null);
}}
onCancel={() =>
setDestructiveQuery(null)
}
return new Set(
processedRows.map(
(_, i) => i,
),
);
});
}}
/>
</div>
/>
</div>
</Suspense>
) : (
<>
{activeTab?.data && (
<TableControls
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
columns={columns}
rows={rawRows}
hiddenColumns={hiddenColumns}
onToggleColumn={(col) =>
toggleHiddenColumn(
activeTab!.id,
col,
)
}
onRefresh={handleRefresh}
filterRules={filterRules}
onFilterChange={(rules) =>
setFilterRules(
activeTab!.id,
rules,
)
}
sortRules={sortRules}
onSortChange={(rules) =>
setSortRules(
activeTab!.id,
rules,
)
}
defaultRefreshRate={
settings?.table_refresh_rate ??
0
}
selectedCount={selectedRows.size}
selectedRows={processedRows.filter(
(_, i) =>
selectedRows.has(i),
)}
onClearSelection={() =>
setSelectedRows(new Set())
}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
rows={processedRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
onToggleRow={(rowIndex) => {
setSelectedRows((prev) => {
const next = new Set(
prev,
);
if (next.has(rowIndex))
next.delete(
rowIndex,
);
else next.add(rowIndex);
return next;
});
}}
onToggleAll={() => {
setSelectedRows((prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length >
0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) => i,
),
);
});
}}
/>
</div>
</>
)}
</div>
</div>
) : currentView === "functions" ? (
+13 -13
View File
@@ -31,43 +31,43 @@ export function DbViewerSidebar({
onNavigate,
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
{
id: "schema-visualizer",
label: "Schema Visualizer",
icon: <Grid2x2 size={20} />,
icon: <Grid2x2 size={16} />,
},
{
id: "functions",
label: "Functions",
icon: <FunctionSquare size={20} />,
icon: <FunctionSquare size={16} />,
},
{ id: "triggers", label: "Triggers", icon: <GitBranch size={20} /> },
{ id: "triggers", label: "Triggers", icon: <GitBranch size={16} /> },
{
id: "sequences",
label: "Sequences",
icon: <ListOrdered size={20} />,
icon: <ListOrdered size={16} />,
},
{ id: "enums", label: "Enums", icon: <Tag size={20} /> },
{ id: "extensions", label: "Extensions", icon: <Puzzle size={20} /> },
{ id: "backup", label: "Backup", icon: <Download size={20} /> },
{ id: "restore", label: "Restore", icon: <Upload size={20} /> },
{ id: "enums", label: "Enums", icon: <Tag size={16} /> },
{ id: "extensions", label: "Extensions", icon: <Puzzle size={16} /> },
{ id: "backup", label: "Backup", icon: <Download size={16} /> },
{ id: "restore", label: "Restore", icon: <Upload size={16} /> },
{
id: "sync",
label: "DB Sync",
icon: <ArrowLeftRight size={20} />,
icon: <ArrowLeftRight size={16} />,
},
];
const bottomItems: NavItem[] = [
{ id: "home", label: "Home", icon: <Home size={20} /> },
{ id: "settings", label: "Settings", icon: <Settings size={20} /> },
{ 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-10 h-10 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
"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";
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { TooltipProvider } from "../ui/Tooltip";
@@ -42,6 +42,38 @@ describe("DbViewerToolbar", () => {
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>
+7 -2
View File
@@ -94,10 +94,15 @@ export function DbViewerToolbar({
}
}, [connectionId, refreshing, populate]);
const hasBelow =
searchOpen || databases.length > 1 || schemas.length > 1;
return (
<div className="p-3 border-b border-border space-y-2">
<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-semibold text-text">Tables</span>
<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">
@@ -1,8 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
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", () => ({
@@ -19,7 +39,9 @@ vi.mock("./SchemaVisualizerNode", () => ({
describe("SchemaVisualizerPage", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
useNotificationStore.setState({ notifications: [] });
useDbViewerStore.setState({
schemas: ["public", "auth"],
currentSchema: "public",
@@ -77,4 +99,348 @@ describe("SchemaVisualizerPage", () => {
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);
});
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
MiniMap,
@@ -6,18 +6,24 @@ import {
Background,
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 } from "lucide-react";
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 };
@@ -27,6 +33,20 @@ 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;
}
@@ -59,6 +79,8 @@ function layoutGraph(
position: { x: 0, y: 0 },
data: { table, isExternal: false },
style: { width: CARD_WIDTH },
width: CARD_WIDTH,
height,
});
}
@@ -148,6 +170,134 @@ export function SchemaVisualizerPage({
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);
@@ -240,7 +390,7 @@ export function SchemaVisualizerPage({
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-10">
<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
@@ -276,10 +426,90 @@ export function SchemaVisualizerPage({
<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">
<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>
+81 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabBar } from "./TabBar";
import { useDbViewerStore } from "../../stores/dbViewerStore";
@@ -11,9 +11,11 @@ describe("TabBar", () => {
useDbViewerStore.getState().reset();
});
it("shows empty state when no tabs", () => {
it("renders the fixed Query and Changes actions when no tabs are open", () => {
render(<TabBar />);
expect(screen.getByText(/No tables open/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /changes queue/i })).toBeInTheDocument();
expect(screen.queryAllByRole("tab")).toHaveLength(0);
});
it("renders open tab names", () => {
@@ -36,6 +38,82 @@ describe("TabBar", () => {
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/i });
expect(within(changesButton).getByText("1")).toBeInTheDocument();
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("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/i });
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/i });
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);
+91 -43
View File
@@ -1,4 +1,4 @@
import { X } from "lucide-react";
import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
import { useDbViewerStore } from "../../stores/dbViewerStore";
export function TabBar() {
@@ -6,52 +6,100 @@ export function TabBar() {
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 changesQueue = useDbViewerStore((state) => state.changesQueue);
const toggleChangesPanel = useDbViewerStore(
(state) => state.toggleChangesPanel,
);
if (tabs.length === 0) {
return (
<div className="flex h-10 items-center border-b border-border px-3 text-sm text-text-muted">
No tables open
</div>
);
}
const pendingCount = changesQueue.filter(
(c) => c.status === "pending",
).length;
return (
<div
className="flex flex-nowrap h-10 items-stretch overflow-x-auto border-b border-border"
role="tablist"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<span className="flex-1 text-left select-none">
{tab.table}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
aria-label={`Close ${tab.table}`}
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
<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"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
role="tab"
aria-selected={isActive}
onClick={() => setActiveTab(tab.id)}
className={[
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-pointer",
isActive
? "bg-canvas text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
<span className="flex-1 text-left select-none">
{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"
/>
) : (
<Table2
data-testid="tab-icon-table"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)}
{tab.table}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
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>
);
})}
</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>
<button
type="button"
onClick={() => {
if (changesQueue.length > 0) toggleChangesPanel();
}}
aria-label="Changes queue"
className={[
"flex items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
pendingCount > 0
? "text-amber-400 border-amber-500/40 hover:bg-surface-raised"
: "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>
</div>
</div>
);
}
@@ -0,0 +1,317 @@
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 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,
},
];
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);
});
});
+255 -226
View File
@@ -112,6 +112,17 @@ function exportData(
URL.revokeObjectURL(url);
}
/**
* 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({
@@ -473,6 +484,8 @@ interface TableControlsProps {
selectedRows: unknown[][];
onClearSelection: () => void;
defaultRefreshRate?: number;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
}
export function TableControls({
@@ -492,33 +505,38 @@ export function TableControls({
selectedRows,
onClearSelection,
defaultRefreshRate = 0,
variant = "table",
}: TableControlsProps) {
const isQuery = variant === "query";
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 changesQueue = useDbViewerStore((s) => s.changesQueue);
const cancelChange = useDbViewerStore((s) => s.cancelChange);
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 [queueOpen, setQueueOpen] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
// auto-refresh timer
// 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;
const id = setInterval(onRefresh, autoRefresh);
return () => clearInterval(id);
}, [autoRefresh, onRefresh]);
// 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;
@@ -559,149 +577,237 @@ export function TableControls({
setExportOpen(false);
};
return (
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
{/* ── left side ──────────────────────────────── */}
<div className="flex items-center gap-1">
{/* 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>
const executionTimeMs = activeTab?.data?.execution_time_ms ?? null;
{/* Refresh */}
<Tooltip content="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="Refresh"
>
<RefreshCw size={14} />
</button>
</Tooltip>
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>
);
{/* Auto-refresh */}
<div className="relative">
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
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={() => 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"
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"
>
<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"
<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"
}`}
>
{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>
)}
{!hiddenColumns.has(col.name) && (
<Check size={10} className="text-white" />
)}
</span>
<span className="truncate">{col.name}</span>
</button>
</Tooltip>
<FilterModal
columns={columns}
rules={filterRules}
onChange={onFilterChange}
open={filterOpen}
setOpen={setFilterOpen}
/>
))}
</div>
</DropdownMenu>
</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>
{/* Export */}
<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) => (
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}
</>
) : (
<>
{/* Insert Row */}
<Tooltip content="Insert row" side="bottom">
<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"
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"
>
{fmt.label}
<Plus size={14} />
</button>
))}
</DropdownMenu>
</div>
</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 ──────────────────────────────────── */}
@@ -709,63 +815,18 @@ export function TableControls({
{/* ── right side ─────────────────────────────── */}
<div className="flex items-center gap-2">
{/* Action queue button */}
<div className="relative">
<button
type="button"
onClick={() => setQueueOpen((v) => !v)}
className={`relative flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors cursor-pointer ${
changesQueue.some((c) => c.status === "pending")
? "text-amber-400 hover:bg-surface-raised"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
aria-label="Action queue"
>
<span className="text-xs font-medium">Queue</span>
{changesQueue.filter((c) => c.status === "pending").length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 text-[10px] font-bold text-white px-1">
{changesQueue.filter((c) => c.status === "pending").length}
</span>
)}
</button>
<DropdownMenu open={queueOpen} setOpen={setQueueOpen} align="right">
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
Changes Queue ({changesQueue.filter((c) => c.status === "pending").length} pending)
</div>
<div className="max-h-64 overflow-y-auto">
{changesQueue.length === 0 && (
<div className="px-3 py-2 text-xs text-text-muted">No changes queued</div>
)}
{changesQueue.map((item) => (
<div
key={item.id}
className={`flex items-center justify-between px-3 py-1.5 text-xs ${
item.status === "pending" ? "text-text" : "text-text-muted/50"
}`}
>
<span className="truncate flex-1">
<span className={`inline-block w-2 h-2 rounded-full mr-1.5 ${
item.status === "pending" ? "bg-amber-500"
: item.status === "committed" ? "bg-emerald-500"
: "bg-red-500"
}`} />
{item.type.toUpperCase()} {item.table}
{item.description && <span className="ml-1 text-text-muted/50"> {item.description}</span>}
</span>
{item.status === "pending" && (
<button
type="button"
onClick={() => cancelChange(item.id)}
className="text-text-muted hover:text-red-400 ml-2 shrink-0 cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</DropdownMenu>
</div>
{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 && (
<>
@@ -791,39 +852,7 @@ export function TableControls({
</>
)}
{/* Columns toggle */}
<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="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>
{!isQuery && columnsControl}
<div className="w-px h-4 bg-border" />