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:
@@ -1,13 +1,14 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
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 type { Connection, Folder } from "../../lib/types";
|
||||
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: "",
|
||||
created_at: "", updated_at: "", environment: null,
|
||||
});
|
||||
|
||||
const folders: Folder[] = [
|
||||
@@ -17,6 +18,10 @@ const folders: Folder[] = [
|
||||
];
|
||||
|
||||
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();
|
||||
@@ -66,4 +71,87 @@ describe("ConnectionGrid", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Connection, Folder, Tag } from "../../lib/types";
|
||||
import { useMemo } from "react";
|
||||
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
@@ -127,17 +126,47 @@ export function ConnectionGrid({
|
||||
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 =
|
||||
activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
|
||||
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);
|
||||
const directConnections = connections.filter(
|
||||
(c) => c.folder_id === currentFolderId,
|
||||
);
|
||||
: 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
|
||||
@@ -171,6 +200,8 @@ export function ConnectionGrid({
|
||||
folders={folders}
|
||||
activeFolderId={currentFolderId}
|
||||
onNavigate={handleBreadcrumbNavigate}
|
||||
hasSearch={hasSearch}
|
||||
onClearSearch={() => useUiStore.getState().clearFilters()}
|
||||
/>
|
||||
{activeFolder && (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { GeneralTab } from "./GeneralTab";
|
||||
import type { ConnectionFormData } from "./connectionFormData";
|
||||
|
||||
@@ -19,6 +19,18 @@ const BASE_FORM: ConnectionFormData = {
|
||||
};
|
||||
|
||||
describe("GeneralTab", () => {
|
||||
it("renders a Name input and passes value to onChange", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<GeneralTab form={BASE_FORM} onChange={onChange} />);
|
||||
|
||||
const nameInput = screen.getByLabelText("Name");
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
expect(nameInput).toHaveValue(BASE_FORM.name);
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: "My New Name" } });
|
||||
expect(onChange).toHaveBeenCalledWith({ name: "My New Name" });
|
||||
});
|
||||
|
||||
it("renders host, port, user, password, and database fields", () => {
|
||||
render(<GeneralTab form={BASE_FORM} onChange={() => {}} />);
|
||||
|
||||
|
||||
@@ -14,6 +14,16 @@ export function GeneralTab({ form, onChange }: GeneralTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text mb-1.5">Name</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(value) => onChange({ name: value })}
|
||||
placeholder="My Production Database"
|
||||
aria-label="Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isSqlite && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DestructiveQueryDialog } from "./DestructiveQueryDialog";
|
||||
|
||||
describe("DestructiveQueryDialog", () => {
|
||||
it("renders warning message with the SQL shown", () => {
|
||||
render(
|
||||
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={() => {}} />
|
||||
);
|
||||
expect(screen.getByText(/destructive/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/DROP TABLE users/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onConfirm when 'Execute' is clicked", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveQueryDialog open={true} query="DELETE FROM users" onConfirm={onConfirm} onCancel={() => {}} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Execute"));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onCancel when 'Cancel' is clicked", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<DestructiveQueryDialog open={true} query="DROP TABLE users" onConfirm={() => {}} onCancel={onCancel} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("truncates long queries in the dialog", () => {
|
||||
const longQuery = "DROP TABLE " + "x".repeat(500);
|
||||
render(
|
||||
<DestructiveQueryDialog open={true} query={longQuery} onConfirm={() => {}} onCancel={() => {}} />
|
||||
);
|
||||
const displayed = screen.getByText(/DROP TABLE/);
|
||||
expect(displayed.textContent!.length).toBeLessThan(longQuery.length + 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
|
||||
interface DestructiveQueryDialogProps {
|
||||
open: boolean;
|
||||
query: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DestructiveQueryDialog({ open, query, onConfirm, onCancel }: DestructiveQueryDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const truncated = query.length > 200 ? query.slice(0, 200) + "..." : query;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-canvas/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 shadow-2xl ring-1 ring-white/10 max-w-md w-full">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle size={20} className="text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-heading text-text text-lg">Destructive Query</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
This query will modify your database. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-canvas border border-border rounded-lg p-3 mb-4">
|
||||
<code className="text-xs text-text-muted font-mono break-all">{truncated}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={onConfirm} className="!bg-red-500 hover:!bg-red-600 !border-red-500">Execute</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { editor as monacoEditor } from "monaco-editor";
|
||||
|
||||
// Monaco editor loads from CDN — mock it for tests to avoid network dependency
|
||||
const { registeredActions } = vi.hoisted(() => ({
|
||||
registeredActions: [] as Array<{ id: string; keybindings: number[]; run: () => void }>,
|
||||
}));
|
||||
const { editorOptions } = vi.hoisted(() => ({ editorOptions: [] as Array<Record<string, unknown>> }));
|
||||
|
||||
// monaco-editor's global re-measure (font metrics) — stub so tests stay light
|
||||
vi.mock("monaco-editor", () => ({
|
||||
editor: { remeasureFonts: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
default: ({ value, onChange, onMount, options }: any) => {
|
||||
editorOptions.push(options);
|
||||
if (onMount) {
|
||||
onMount({
|
||||
addAction: (action: any) => registeredActions.push(action),
|
||||
getValue: () => value,
|
||||
setValue: (v: string) => onChange?.(v),
|
||||
focus: vi.fn(),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div data-testid="monaco-editor">
|
||||
<textarea
|
||||
data-testid="monaco-textarea"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("QueryEditor", () => {
|
||||
beforeEach(() => {
|
||||
registeredActions.length = 0;
|
||||
vi.mocked(monacoEditor.remeasureFonts).mockClear();
|
||||
});
|
||||
|
||||
it("renders a textarea editor", () => {
|
||||
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={() => {}} />);
|
||||
expect(screen.getByTestId("monaco-editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the provided value", () => {
|
||||
render(<QueryEditor value="SELECT * FROM users" onChange={() => {}} onRun={() => {}} />);
|
||||
const textarea = screen.getByTestId("monaco-textarea");
|
||||
expect(textarea).toHaveValue("SELECT * FROM users");
|
||||
});
|
||||
|
||||
it("calls onChange when text changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<QueryEditor value="" onChange={onChange} onRun={() => {}} />);
|
||||
fireEvent.change(screen.getByTestId("monaco-textarea"), { target: { value: "SELECT 1" } });
|
||||
expect(onChange).toHaveBeenCalledWith("SELECT 1");
|
||||
});
|
||||
|
||||
it("registers a Cmd+Enter action that runs the query", () => {
|
||||
const onRun = vi.fn();
|
||||
render(<QueryEditor value="SELECT 1" onChange={() => {}} onRun={onRun} />);
|
||||
expect(registeredActions).toHaveLength(1);
|
||||
expect(registeredActions[0].keybindings).toEqual([2048 | 3]);
|
||||
registeredActions[0].run();
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes a placeholder option to the editor", () => {
|
||||
editorOptions.length = 0;
|
||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||
expect(editorOptions[0]?.placeholder).toMatch(/Enter your SQL query/i);
|
||||
});
|
||||
|
||||
it("re-measures fonts after mount so the cursor stays aligned", () => {
|
||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||
expect(monacoEditor.remeasureFonts).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps the editor without padding, border, or rounding", () => {
|
||||
render(<QueryEditor value="" onChange={() => {}} onRun={() => {}} />);
|
||||
const wrapper = screen.getByTestId("query-editor");
|
||||
expect(wrapper.className).not.toMatch(/rounded|border|p-\d/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback } from "react";
|
||||
import Editor, { type OnMount, type BeforeMount } from "@monaco-editor/react";
|
||||
import * as monaco from "monaco-editor";
|
||||
|
||||
interface QueryEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onRun: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function QueryEditor({
|
||||
value,
|
||||
onChange,
|
||||
onRun,
|
||||
readOnly = false,
|
||||
}: QueryEditorProps) {
|
||||
const handleMount: OnMount = useCallback(
|
||||
(editor) => {
|
||||
editor.addAction({
|
||||
id: "run-query",
|
||||
label: "Run Query",
|
||||
keybindings: [2048 | 3], // Cmd/Ctrl+Enter
|
||||
run: () => onRun(),
|
||||
});
|
||||
editor.focus();
|
||||
|
||||
// Custom fonts (@fontsource Space Mono) load asynchronously. Monaco
|
||||
// measures glyph widths at creation, so if the font lands after that the
|
||||
// cursor/selection drift rightward the further along the line you are.
|
||||
// Re-measure now (fonts may already be ready) and again once fonts load.
|
||||
const reMeasure = () => monaco.editor.remeasureFonts();
|
||||
reMeasure();
|
||||
try {
|
||||
void document.fonts?.load('13px "Space Mono"').then(() => {
|
||||
requestAnimationFrame(reMeasure);
|
||||
// WebKit can settle a frame late; re-measure once more to be safe
|
||||
setTimeout(reMeasure, 200);
|
||||
});
|
||||
} catch {
|
||||
// fonts API unavailable — nothing more we can do
|
||||
}
|
||||
},
|
||||
[onRun],
|
||||
);
|
||||
|
||||
// Transparent editor background so the app's canvas shows through
|
||||
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
|
||||
monaco.editor.defineTheme("gridline-sql", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": "#00000000",
|
||||
"editorGutter.background": "#00000000",
|
||||
"editor.lineHighlightBackground": "#ffffff08",
|
||||
"editorLineNumber.foreground": "#5b5b5e",
|
||||
"editorLineNumber.activeForeground": "#a1a1a6",
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0" data-testid="query-editor">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
theme="gridline-sql"
|
||||
beforeMount={handleBeforeMount}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
onMount={handleMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: "'Space Mono', 'Fira Code', monospace",
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "off",
|
||||
readOnly,
|
||||
placeholder: "Enter your SQL query…",
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryToolbar, queryShortcut } from "./QueryToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
function renderToolbar(props: {
|
||||
onRun?: () => void;
|
||||
onFormat?: () => void;
|
||||
dbType?: "postgresql" | "mysql" | "sqlite" | "redis";
|
||||
}) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<QueryToolbar
|
||||
onRun={props.onRun ?? (() => {})}
|
||||
onFormat={props.onFormat ?? (() => {})}
|
||||
dbType={props.dbType}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("queryShortcut", () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "",
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the command symbol and enter glyph on mac", () => {
|
||||
expect(queryShortcut("MacIntel")).toEqual({ mod: "⌘", enter: "⏎" });
|
||||
});
|
||||
|
||||
it("uses Ctrl + Enter on other platforms", () => {
|
||||
expect(queryShortcut("Win32")).toEqual({ mod: "Ctrl", enter: "Enter" });
|
||||
expect(queryShortcut("Linux x86_64")).toEqual({
|
||||
mod: "Ctrl",
|
||||
enter: "Enter",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the mac shortcut in the run tooltip", async () => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "MacIntel",
|
||||
configurable: true,
|
||||
});
|
||||
renderToolbar({});
|
||||
fireEvent.mouseEnter(
|
||||
screen.getByRole("button", { name: /run query/i }).parentElement!,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("⌘")).toBeInTheDocument());
|
||||
expect(screen.getByText("⏎")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Ctrl + Enter in the run tooltip on non-mac", async () => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Linux x86_64",
|
||||
configurable: true,
|
||||
});
|
||||
renderToolbar({});
|
||||
fireEvent.mouseEnter(
|
||||
screen.getByRole("button", { name: /run query/i }).parentElement!,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("Ctrl")).toBeInTheDocument());
|
||||
expect(screen.getByText("Enter")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("QueryToolbar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders Run Query and the format icon button", () => {
|
||||
renderToolbar({});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /run query/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /auto format/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the SQL dialect on the right", () => {
|
||||
renderToolbar({ dbType: "postgresql" });
|
||||
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
|
||||
renderToolbar({ dbType: "mysql" });
|
||||
expect(screen.getByText("MySQL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the dialect badge when no db type is known", () => {
|
||||
renderToolbar({});
|
||||
expect(screen.queryByText(/postgresql|mysql|sqlite|redis/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs the query when Run Query is clicked", () => {
|
||||
const onRun = vi.fn();
|
||||
renderToolbar({ onRun });
|
||||
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onFormat when the format icon is clicked", () => {
|
||||
const onFormat = vi.fn();
|
||||
renderToolbar({ onFormat });
|
||||
fireEvent.click(screen.getByRole("button", { name: /auto format/i }));
|
||||
expect(onFormat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts with an unfilled play icon", () => {
|
||||
renderToolbar({});
|
||||
const playSvg = screen
|
||||
.getByRole("button", { name: /run query/i })
|
||||
.querySelector("svg");
|
||||
expect(playSvg).toHaveAttribute("fill", "none");
|
||||
});
|
||||
|
||||
it("shows the pulse while the active query tab is loading", () => {
|
||||
useDbViewerStore.getState().openQueryTab();
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) => ({ ...t, loading: true })),
|
||||
}));
|
||||
renderToolbar({});
|
||||
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the pulse when the query is idle", () => {
|
||||
useDbViewerStore.getState().openQueryTab();
|
||||
renderToolbar({});
|
||||
expect(screen.queryByTestId("query-run-pulse")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the format action as an icon only (no text label)", () => {
|
||||
renderToolbar({});
|
||||
const button = screen.getByRole("button", { name: /auto format/i });
|
||||
expect(button.textContent?.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Play, Wand2 } from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
const DB_TYPE_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
sqlite: "SQLite",
|
||||
redis: "Redis",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the run-query modifier key for the given platform string
|
||||
* (from `navigator.platform`). Mac platforms show ⌘ + the return glyph,
|
||||
* everything else shows Ctrl + Enter.
|
||||
*/
|
||||
export function queryShortcut(platform: string): {
|
||||
mod: string;
|
||||
enter: string;
|
||||
} {
|
||||
const isMac = /Mac|iPhone|iPad/.test(platform);
|
||||
return isMac
|
||||
? { mod: "⌘", enter: "⏎" }
|
||||
: { mod: "Ctrl", enter: "Enter" };
|
||||
}
|
||||
|
||||
interface QueryToolbarProps {
|
||||
onRun: () => void;
|
||||
onFormat: () => void;
|
||||
dbType?: DbType;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function QueryToolbar({
|
||||
onRun,
|
||||
onFormat,
|
||||
dbType,
|
||||
readOnly = false,
|
||||
}: QueryToolbarProps) {
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const isRunning = tabs.find((t) => t.id === activeTabId)?.loading ?? false;
|
||||
|
||||
const shortcut = queryShortcut(
|
||||
typeof navigator !== "undefined" ? navigator.platform : "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
|
||||
{/* run pulse: mirrors the table toolbar refresh pulse, absolutely positioned so it never shifts layout */}
|
||||
{isRunning && (
|
||||
<div
|
||||
data-testid="query-run-pulse"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */}
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
|
||||
{shortcut.mod}
|
||||
</kbd>
|
||||
<span className="text-text-muted">+</span>
|
||||
<kbd className="rounded border border-border bg-surface px-1 font-mono text-[10px] leading-none">
|
||||
{shortcut.enter}
|
||||
</kbd>
|
||||
</span>
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
disabled={readOnly}
|
||||
className="group flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Run query"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 transition-[fill] duration-150 group-hover:fill-current" />
|
||||
<span>Run Query</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Auto format: icon only with tooltip */}
|
||||
<Tooltip content="Auto format query" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFormat}
|
||||
disabled={readOnly}
|
||||
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Auto format query"
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{dbType && (
|
||||
<div className="ml-auto">
|
||||
<span className="rounded-md border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{DB_TYPE_LABELS[dbType]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,4 +34,17 @@ describe("FolderBreadcrumb", () => {
|
||||
await userEvent.click(screen.getByText("All Connections"));
|
||||
expect(fn).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows 'Showing Search Results' when hasSearch is true", () => {
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} hasSearch />);
|
||||
expect(screen.getByText("Showing Search Results")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All Connections")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClearSearch when Clear is clicked", async () => {
|
||||
const fn = vi.fn();
|
||||
render(<FolderBreadcrumb folders={folders} activeFolderId={null} onNavigate={() => {}} hasSearch onClearSearch={fn} />);
|
||||
await userEvent.click(screen.getByText("Clear"));
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,45 @@
|
||||
import type { Folder } from "../../lib/types";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
import { ChevronRight, Home, Search, X } from "lucide-react";
|
||||
import { getFolderPath } from "../../lib/utils";
|
||||
|
||||
interface FolderBreadcrumbProps {
|
||||
folders: Folder[];
|
||||
activeFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
hasSearch?: boolean;
|
||||
onClearSearch?: () => void;
|
||||
}
|
||||
|
||||
export function FolderBreadcrumb({ folders, activeFolderId, onNavigate }: FolderBreadcrumbProps) {
|
||||
export function FolderBreadcrumb({
|
||||
folders,
|
||||
activeFolderId,
|
||||
onNavigate,
|
||||
hasSearch = false,
|
||||
onClearSearch,
|
||||
}: FolderBreadcrumbProps) {
|
||||
const path = getFolderPath(folders, activeFolderId);
|
||||
|
||||
if (hasSearch) {
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-text-muted">
|
||||
<span className="flex items-center gap-1 px-2 py-1 text-text">
|
||||
<Search size={14} />
|
||||
<span>Showing Search Results</span>
|
||||
</span>
|
||||
{onClearSearch && (
|
||||
<button
|
||||
onClick={onClearSearch}
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-text-muted hover:text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} /> Clear
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm text-text-muted">
|
||||
<button
|
||||
|
||||
@@ -186,6 +186,60 @@ describe("VirtualDataGrid", () => {
|
||||
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps header width to content and borders the last column", () => {
|
||||
mockGetTotalSize.mockReturnValue(0);
|
||||
mockGetVirtualItems.mockReturnValue([]);
|
||||
|
||||
const { container } = render(
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
rows={[]}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Header must span exactly the total column width (40 checkbox + 2 × 200),
|
||||
// not stretch across the empty area to the right of the last column.
|
||||
const header = container.querySelector(".sticky > div");
|
||||
expect(header).not.toBeNull();
|
||||
expect((header as HTMLElement).style.width).toBe("440px");
|
||||
expect((header as HTMLElement).style.minWidth).toBe("");
|
||||
|
||||
// The last column header keeps a right border, matching body cells.
|
||||
const headerCells = container.querySelectorAll(".sticky > div:first-child > div");
|
||||
const lastCol = headerCells[headerCells.length - 1];
|
||||
expect(lastCol.className).toContain("border-r");
|
||||
expect(lastCol.className).not.toContain("border-r-0");
|
||||
});
|
||||
|
||||
it("hides the select-all checkbox and header when no columns are present", () => {
|
||||
mockGetTotalSize.mockReturnValue(0);
|
||||
mockGetVirtualItems.mockReturnValue([]);
|
||||
|
||||
const { container } = render(
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
rows={[]}
|
||||
columns={[]}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// No table open → no select-all checkbox, no header bar, no empty-state message.
|
||||
expect(screen.queryAllByRole("checkbox").length).toBe(0);
|
||||
expect(container.querySelector(".sticky")).toBeNull();
|
||||
expect(screen.queryByText(/no rows/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has resize handles on column headers", () => {
|
||||
mockGetTotalSize.mockReturnValue(0);
|
||||
mockGetVirtualItems.mockReturnValue([]);
|
||||
|
||||
@@ -35,6 +35,7 @@ export function VirtualDataGrid({
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
||||
const hasColumns = columns.length > 0;
|
||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -62,7 +63,9 @@ export function VirtualDataGrid({
|
||||
);
|
||||
|
||||
// Total width for horizontal scroll support
|
||||
const totalWidth = 40 + visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
|
||||
const totalWidth =
|
||||
(hasColumns ? 40 : 0) +
|
||||
visibleColumns.reduce((sum, c) => sum + getWidth(c.name), 0);
|
||||
|
||||
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null);
|
||||
|
||||
@@ -211,44 +214,46 @@ export function VirtualDataGrid({
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
|
||||
{/* ── sticky header ── */}
|
||||
<div className="sticky top-0 z-10">
|
||||
<div className="flex items-center border-b border-border bg-canvas" style={{ minWidth: totalWidth }}>
|
||||
<div style={{ width: 40, minWidth: 40 }} className="px-2 py-2 flex items-center justify-center border-r border-border self-stretch">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onToggleAll}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
{visibleColumns.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border last:border-r-0 self-stretch"
|
||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||
>
|
||||
<div className="truncate flex items-center gap-1">
|
||||
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||
<span className="text-text text-xs">{col.name}</span>
|
||||
<span className="text-[10px] text-text-muted/50 shrink-0" title={col.data_type}>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
|
||||
onMouseDown={(e) => startResize(col.name, e)}
|
||||
onDoubleClick={() => resetWidth(col.name)}
|
||||
{/* ── sticky header (hidden when no columns/table open) ── */}
|
||||
{hasColumns && (
|
||||
<div className="sticky top-0 z-10">
|
||||
<div className="flex items-center border-b border-border bg-canvas" style={{ width: totalWidth }}>
|
||||
<div style={{ width: 40, minWidth: 40 }} className="px-2 py-2 flex items-center justify-center border-r border-border self-stretch">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={onToggleAll}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{visibleColumns.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="group relative px-3 py-2 font-heading text-text-muted border-r border-border self-stretch"
|
||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||
>
|
||||
<div className="truncate flex items-center gap-1">
|
||||
{col.is_pk && <Key size={10} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||
<span className="text-text text-xs">{col.name}</span>
|
||||
<span className="text-[10px] text-text-muted/50 shrink-0" title={col.data_type}>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[6px] cursor-col-resize select-none bg-transparent hover:bg-accent/30 active:bg-accent/50"
|
||||
onMouseDown={(e) => startResize(col.name, e)}
|
||||
onDoubleClick={() => resetWidth(col.name)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── virtual body ── */}
|
||||
{rows.length === 0 ? (
|
||||
{/* ── virtual body (hidden when no columns/table open) ── */}
|
||||
{!hasColumns ? null : rows.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-text-muted">
|
||||
No rows in result set
|
||||
</div>
|
||||
@@ -279,14 +284,16 @@ export function VirtualDataGrid({
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleRow(virtualRow.index)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
{hasColumns && (
|
||||
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleRow(virtualRow.index)}
|
||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,9 +21,4 @@ describe("ActionRow", () => {
|
||||
await userEvent.click(screen.getByText(/settings/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
it("Tags button switches to settings view", async () => {
|
||||
render(<ActionRow />);
|
||||
await userEvent.click(screen.getByText(/^tags$/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Settings as SettingsIcon, Tag, Filter, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { ImportExportMenu } from "./ImportExportMenu";
|
||||
import { TagFilterDropdown } from "../tags/TagFilterDropdown";
|
||||
import { DbTypeFilterDropdown } from "./DbTypeFilterDropdown";
|
||||
|
||||
interface ActionRowProps {
|
||||
onImport?: () => void;
|
||||
@@ -13,7 +15,7 @@ interface ActionRowProps {
|
||||
visibleItemIds?: string[];
|
||||
}
|
||||
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const selectAllItems = useUiStore((s) => s.selectAllItems);
|
||||
@@ -44,12 +46,8 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters, onDelete
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="text-xs" onClick={() => setActiveView("settings")}>
|
||||
<Tag size={14} /> Tags
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-xs" onClick={onFilters ?? (() => {})}>
|
||||
<Filter size={14} /> Filters
|
||||
</Button>
|
||||
<TagFilterDropdown />
|
||||
<DbTypeFilterDropdown />
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
||||
<FolderPlus size={14} /> New Folder
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DbTypeFilterDropdown } from "./DbTypeFilterDropdown";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
describe("DbTypeFilterDropdown", () => {
|
||||
beforeEach(() => {
|
||||
useUiStore.setState({ activeDbTypes: [], activeEnvironment: null });
|
||||
});
|
||||
|
||||
it("renders a button with Filter icon", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
expect(screen.getByText("Filters")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown and shows 4 DB types", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
expect(screen.getByText("PostgreSQL")).toBeInTheDocument();
|
||||
expect(screen.getByText("MySQL")).toBeInTheDocument();
|
||||
expect(screen.getByText("SQLite")).toBeInTheDocument();
|
||||
expect(screen.getByText("Redis")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles DB type filter on click", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
fireEvent.click(screen.getByText("PostgreSQL"));
|
||||
expect(useUiStore.getState().activeDbTypes).toContain("postgresql");
|
||||
});
|
||||
|
||||
it("clears all filters with 'Clear all' button", () => {
|
||||
useUiStore.setState({ activeDbTypes: ["postgresql", "sqlite"] });
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
fireEvent.click(screen.getByText("Clear all"));
|
||||
expect(useUiStore.getState().activeDbTypes).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows environment select in dropdown", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
expect(screen.getByLabelText("Environment filter")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects environment via the select", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
fireEvent.click(screen.getByLabelText("Environment filter"));
|
||||
fireEvent.click(screen.getByText("Production"));
|
||||
expect(useUiStore.getState().activeEnvironment).toBe("production");
|
||||
});
|
||||
|
||||
it("'All' option clears environment filter", () => {
|
||||
useUiStore.setState({ activeEnvironment: "production" });
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
fireEvent.click(screen.getByLabelText("Environment filter"));
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
expect(useUiStore.getState().activeEnvironment).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'None' option for connections without environment", () => {
|
||||
render(<DbTypeFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Filters"));
|
||||
fireEvent.click(screen.getByLabelText("Environment filter"));
|
||||
fireEvent.click(screen.getByText("None"));
|
||||
expect(useUiStore.getState().activeEnvironment).toBe("none");
|
||||
});
|
||||
|
||||
it("environment filter counts toward badge", () => {
|
||||
useUiStore.setState({ activeDbTypes: [], activeEnvironment: "staging" });
|
||||
render(<DbTypeFilterDropdown />);
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Filter } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
const DB_TYPES: { value: DbType; label: string }[] = [
|
||||
{ value: "postgresql", label: "PostgreSQL" },
|
||||
{ value: "mysql", label: "MySQL" },
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "redis", label: "Redis" },
|
||||
];
|
||||
|
||||
export function DbTypeFilterDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
|
||||
const toggleDbType = useUiStore((s) => s.toggleDbType);
|
||||
const clearFilters = useUiStore((s) => s.clearFilters);
|
||||
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
|
||||
const setEnvironment = useUiStore((s) => s.setEnvironment);
|
||||
|
||||
const activeCount = activeDbTypes.length + (activeEnvironment ? 1 : 0);
|
||||
const hasActiveFilters = activeCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
clearFilters();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium border transition-all cursor-pointer ${
|
||||
hasActiveFilters
|
||||
? "bg-accent/10 border-accent text-accent"
|
||||
: "bg-transparent border-border text-text-muted hover:text-text hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<Filter size={14} />
|
||||
<span>Filters</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-0.5 flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 rounded-full text-[10px] bg-accent text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 mt-1 z-20 min-w-[12rem] rounded-xl bg-surface border border-border shadow-lg p-1.5">
|
||||
<div className="flex flex-col">
|
||||
<div className="py-1">
|
||||
{DB_TYPES.map((dbType) => {
|
||||
const checked = activeDbTypes.includes(dbType.value);
|
||||
return (
|
||||
<label
|
||||
key={dbType.value}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs cursor-pointer transition-colors ${
|
||||
checked ? "bg-accent/10" : "hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleDbType(dbType.value)}
|
||||
className="w-4 h-4 rounded border-border accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className={checked ? "text-accent" : "text-text"}>
|
||||
{dbType.label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-2 py-2">
|
||||
<label className="block text-[10px] uppercase tracking-wider text-text-muted mb-1">
|
||||
Environment
|
||||
</label>
|
||||
<SelectDropdown
|
||||
value={activeEnvironment ?? ""}
|
||||
onChange={(val) => setEnvironment(val === "" ? null : val)}
|
||||
options={[
|
||||
{ value: "", label: "All" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "development", label: "Development" },
|
||||
{ value: "none", label: "None" },
|
||||
]}
|
||||
placeholder="All"
|
||||
aria-label="Environment filter"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="w-full text-left px-2 py-1.5 text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Search, Command } from "lucide-react";
|
||||
import { Input } from "../ui/Input";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
@@ -15,12 +15,18 @@ interface SearchBarProps {
|
||||
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function SearchBar({ onDetectUrl }, ref) {
|
||||
const [value, setValue] = useState("");
|
||||
const setSearchQuery = useUiStore((s) => s.setSearchQuery);
|
||||
const searchQuery = useUiStore((s) => s.searchQuery);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}));
|
||||
|
||||
// Keep local input in sync with store (e.g. when Clear button resets it)
|
||||
useEffect(() => {
|
||||
setValue(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div className="relative w-full max-w-xl">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { SearchableTagPicker } from "./SearchableTagPicker";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
describe("SearchableTagPicker", () => {
|
||||
it("shows 'No tags yet' when tags array is empty", () => {
|
||||
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getByText("No tags yet.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Create first tag' button when empty", () => {
|
||||
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
|
||||
expect(screen.getByText("Create first tag")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows inline creation form when 'Create first tag' is clicked", () => {
|
||||
render(<SearchableTagPicker tags={[]} selectedTagIds={[]} onToggle={() => {}} />);
|
||||
fireEvent.click(screen.getByText("Create first tag"));
|
||||
expect(screen.getByPlaceholderText("Tag name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Tag } from "../../lib/types";
|
||||
import { Search } from "lucide-react";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
|
||||
interface SearchableTagPickerProps {
|
||||
tags: Tag[];
|
||||
@@ -28,7 +29,16 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-xs text-text-muted py-1">No tags found</div>
|
||||
<div className="py-2 text-xs text-text-muted">
|
||||
{tags.length === 0 ? (
|
||||
<>
|
||||
<p>No tags yet.</p>
|
||||
<InlineTagCreator onCreated={onToggle} />
|
||||
</>
|
||||
) : (
|
||||
<p>No tags match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((tag) => {
|
||||
const active = selectedTagIds.includes(tag.id);
|
||||
@@ -53,4 +63,68 @@ export function SearchableTagPicker({ tags, selectedTagIds, onToggle }: Searchab
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineTagCreator({ onCreated }: { onCreated: (tagId: string) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#8b5cf6");
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
if (!show) {
|
||||
return (
|
||||
<button onClick={() => setShow(true)} className="text-accent hover:underline cursor-pointer text-xs">
|
||||
Create first tag
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
await useConnectionStore.getState().createTag({ name: name.trim(), color });
|
||||
const tags = useConnectionStore.getState().tags;
|
||||
const created = tags.find((t) => t.name === name.trim());
|
||||
if (created) onCreated(created.id);
|
||||
setName("");
|
||||
setShow(false);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#d946ef", "#ec4899", "#6b7280"];
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-2 border border-border rounded-lg bg-canvas">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Tag name"
|
||||
className="w-full rounded-full bg-surface border border-border px-3 py-1.5 text-xs text-text placeholder-text-muted/60 focus:outline-none focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
if (e.key === "Escape") setShow(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-5 h-5 rounded-full border-2 transition-colors cursor-pointer ${color === c ? "border-text" : "border-transparent"}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="text-xs text-white bg-accent rounded-full px-3 py-1 cursor-pointer hover:bg-accent-hover transition-colors">
|
||||
Create
|
||||
</button>
|
||||
<button onClick={() => setShow(false)} className="text-xs text-text-muted cursor-pointer hover:text-text">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { TagFilterDropdown } from "./TagFilterDropdown";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import type { Tag } from "../../lib/types";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
const makeTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
id: "t1", name: "production", color: "#ef4444", created_at: "", ...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [], folders: [], tags: [
|
||||
makeTag({ id: "t1", name: "production", color: "#ef4444" }),
|
||||
makeTag({ id: "t2", name: "staging", color: "#f59e0b" }),
|
||||
],
|
||||
tagOrder: [], loading: false, error: null,
|
||||
});
|
||||
useUiStore.setState({ activeTagIds: [], activeView: "home" });
|
||||
});
|
||||
|
||||
describe("TagFilterDropdown", () => {
|
||||
it("renders a button with Tag icon", () => {
|
||||
render(<TagFilterDropdown />);
|
||||
expect(screen.getByText("Tags")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown on button click", () => {
|
||||
render(<TagFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Tags"));
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
expect(screen.getByText("staging")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows checkboxes for each tag", () => {
|
||||
render(<TagFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Tags"));
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("toggles tag filter when checkbox is clicked", () => {
|
||||
render(<TagFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Tags"));
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
expect(useUiStore.getState().activeTagIds).toContain("t1");
|
||||
fireEvent.click(screen.getAllByRole("checkbox")[0]);
|
||||
expect(useUiStore.getState().activeTagIds).not.toContain("t1");
|
||||
});
|
||||
|
||||
it("shows 'Manage tags' link that navigates to settings", () => {
|
||||
render(<TagFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Tags"));
|
||||
fireEvent.click(screen.getByText("Manage tags"));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
|
||||
it("shows empty state when no tags exist", () => {
|
||||
useConnectionStore.setState({ tags: [] });
|
||||
render(<TagFilterDropdown />);
|
||||
fireEvent.click(screen.getByText("Tags"));
|
||||
expect(screen.getByText("No tags yet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create in Settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Tag as TagIcon } from "lucide-react";
|
||||
import { useSortedTags } from "../../hooks/useSortedTags";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
export function TagFilterDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const tags = useSortedTags();
|
||||
const activeTagIds = useUiStore((s) => s.activeTagIds);
|
||||
const toggleTag = useUiStore((s) => s.toggleTag);
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
|
||||
const activeCount = activeTagIds.length;
|
||||
const hasActiveFilters = activeCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleManageTags = () => {
|
||||
setActiveView("settings");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium border transition-all cursor-pointer ${
|
||||
hasActiveFilters
|
||||
? "bg-accent/10 border-accent text-accent"
|
||||
: "bg-transparent border-border text-text-muted hover:text-text hover:border-border-hover"
|
||||
}`}
|
||||
>
|
||||
<TagIcon size={14} />
|
||||
<span>Tags</span>
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-0.5 flex items-center justify-center min-w-[1.125rem] h-[1.125rem] px-1 rounded-full text-[10px] bg-accent text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 mt-1 z-20 min-w-[12rem] rounded-xl bg-surface border border-border shadow-lg p-1.5">
|
||||
{tags.length === 0 ? (
|
||||
<div className="p-3 text-center">
|
||||
<div className="text-xs text-text-muted mb-2">No tags yet</div>
|
||||
<button
|
||||
onClick={handleManageTags}
|
||||
className="text-xs text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
Create in Settings
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{tags.map((tag) => {
|
||||
const checked = activeTagIds.includes(tag.id);
|
||||
return (
|
||||
<label
|
||||
key={tag.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-xs cursor-pointer transition-colors ${
|
||||
checked ? "bg-accent/10" : "hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTag(tag.id)}
|
||||
className="w-4 h-4 rounded border-border accent-accent cursor-pointer"
|
||||
/>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className={checked ? "text-accent" : "text-text"}>
|
||||
{tag.name}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={handleManageTags}
|
||||
className="w-full text-left px-2 py-1.5 text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-lg transition-colors cursor-pointer"
|
||||
>
|
||||
Manage tags
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useFilteredConnections } from "./useConnections";
|
||||
import { useConnectionStore } from "../stores/connectionStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import type { Connection } from "../lib/types";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
id: "c1",
|
||||
name: "Test DB",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
database: null,
|
||||
environment: null,
|
||||
ssh_host: null,
|
||||
ssh_port: null,
|
||||
ssh_user: null,
|
||||
ssh_auth_method: null,
|
||||
ssh_private_key_path: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Root DB", folder_id: null }),
|
||||
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1" }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
|
||||
],
|
||||
folders: [
|
||||
{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" },
|
||||
{ id: "f2", name: "F2", parent_id: "f1", tag_ids: [], created_at: "", updated_at: "" },
|
||||
],
|
||||
tags: [],
|
||||
tagOrder: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useUiStore.setState({
|
||||
searchQuery: "",
|
||||
activeFolderId: null,
|
||||
activeTagIds: [],
|
||||
activeDbTypes: [],
|
||||
activeEnvironment: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFilteredConnections", () => {
|
||||
it("returns all connections at root with no filters", () => {
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns folder-descendant connections when browsing a folder", () => {
|
||||
useUiStore.setState({ activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(2);
|
||||
expect(result.current.map((c) => c.id)).toEqual(["c2", "c3"]);
|
||||
});
|
||||
|
||||
it("returns ALL connections matching search regardless of active folder", () => {
|
||||
// c1 "Root DB" is at root (folder_id: null) — filtered out by
|
||||
// current folder-scope code when activeFolderId is set.
|
||||
useUiStore.setState({ searchQuery: "Root", activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c1");
|
||||
});
|
||||
|
||||
it("returns ALL connections matching tag filter regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Root DB", folder_id: null, tag_ids: ["t1"] }),
|
||||
makeConn({ id: "c2", name: "Nested DB", folder_id: "f1", tag_ids: [] }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2", tag_ids: [] }),
|
||||
],
|
||||
tags: [{ id: "t1", name: "prod", color: "#f00", created_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeTagIds: ["t1"], activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c1");
|
||||
});
|
||||
|
||||
it("returns ALL connections matching environment regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
|
||||
makeConn({ id: "c2", name: "Dev", db_type: "sqlite", folder_id: "f1", environment: "development" }),
|
||||
],
|
||||
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeEnvironment: "development", activeFolderId: null });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c2");
|
||||
});
|
||||
|
||||
it("environment filter counts as active filter (bypasses folder scope)", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "Prod", db_type: "postgresql", folder_id: null, environment: "production" }),
|
||||
makeConn({ id: "c2", name: "Prod2", db_type: "postgresql", folder_id: "f1", environment: "production" }),
|
||||
],
|
||||
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
useUiStore.setState({ activeEnvironment: "production", activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns ALL connections matching DB type filter regardless of folder", () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
makeConn({ id: "c1", name: "PG", db_type: "postgresql", folder_id: null }),
|
||||
makeConn({ id: "c2", name: "SQLite", db_type: "sqlite", folder_id: "f1" }),
|
||||
makeConn({ id: "c3", name: "Deep DB", folder_id: "f2" }),
|
||||
],
|
||||
});
|
||||
useUiStore.setState({ activeDbTypes: ["sqlite"], activeFolderId: "f1" });
|
||||
const { result } = renderHook(() => useFilteredConnections());
|
||||
expect(result.current).toHaveLength(1);
|
||||
expect(result.current[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
@@ -11,14 +11,22 @@ export function useFilteredConnections(): Connection[] {
|
||||
const activeFolderId = useUiStore((s) => s.activeFolderId);
|
||||
const activeTagIds = useUiStore((s) => s.activeTagIds);
|
||||
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
|
||||
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
|
||||
|
||||
const hasFilters =
|
||||
searchQuery.length > 0 ||
|
||||
activeTagIds.length > 0 ||
|
||||
activeDbTypes.length > 0 ||
|
||||
(activeEnvironment !== null && activeEnvironment !== undefined);
|
||||
|
||||
let filtered = filterConnections(connections, tags, {
|
||||
query: searchQuery,
|
||||
activeTagIds,
|
||||
activeDbTypes,
|
||||
activeEnvironment,
|
||||
});
|
||||
|
||||
if (activeFolderId) {
|
||||
if (!hasFilters && activeFolderId) {
|
||||
const allowed = new Set(getDescendantFolderIds(folders, activeFolderId));
|
||||
filtered = filtered.filter(
|
||||
(c) => c.folder_id !== null && allowed.has(c.folder_id),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { useDbConnection } from "./useDbConnection";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
vi.mock("../lib/commands", () => ({
|
||||
dbConnect: vi.fn().mockResolvedValue(undefined),
|
||||
dbDisconnect: vi.fn().mockResolvedValue(undefined),
|
||||
getDatabases: vi.fn().mockResolvedValue([]),
|
||||
getSchemas: vi.fn().mockResolvedValue([]),
|
||||
getTables: vi.fn().mockResolvedValue([]),
|
||||
getConnectionPassword: vi.fn().mockResolvedValue("pw"),
|
||||
}));
|
||||
|
||||
const mockCommands = vi.mocked(commands);
|
||||
|
||||
const mockConnection = {
|
||||
id: "c1",
|
||||
name: "Conn",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
database: "mydb",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
ssh_host: null,
|
||||
ssh_port: null,
|
||||
ssh_user: null,
|
||||
ssh_auth_method: null,
|
||||
ssh_private_key_path: null,
|
||||
ssl_mode: null,
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mock("../stores/connectionStore", () => ({
|
||||
useConnectionStore: {
|
||||
getState: () => ({
|
||||
connections: [mockConnection],
|
||||
getConnectionPassword: async () => "pw",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness() {
|
||||
const { connect } = useDbConnection("c1");
|
||||
return (
|
||||
<button type="button" onClick={() => connect()}>
|
||||
connect
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDbConnection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||
mockCommands.getTables.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("connects and smart-selects the public schema when available", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
// tables are fetched for the smart default schema
|
||||
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "public");
|
||||
});
|
||||
|
||||
it("falls back to the first schema when public is absent", async () => {
|
||||
mockCommands.getSchemas.mockResolvedValue(["analytics", "app"]);
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("analytics");
|
||||
});
|
||||
expect(mockCommands.getTables).toHaveBeenCalledWith("c1", "analytics");
|
||||
});
|
||||
|
||||
it("reconnects and refreshes schemas when the database changes", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["analytics", "public"]);
|
||||
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "otherdb" });
|
||||
});
|
||||
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "otherdb" }),
|
||||
);
|
||||
expect(useDbViewerStore.getState().schemas).toEqual([
|
||||
"analytics",
|
||||
"public",
|
||||
]);
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
// tables refetched for the new database's smart schema
|
||||
expect(mockCommands.getTables).toHaveBeenLastCalledWith("c1", "public");
|
||||
});
|
||||
|
||||
it("refreshes schemas when switching back to the initial database", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("connect"));
|
||||
await waitFor(() => {
|
||||
expect(useDbViewerStore.getState().currentDatabase).toBe("mydb");
|
||||
});
|
||||
|
||||
// Switch away to otherdb
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "otherdb" });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "otherdb" }),
|
||||
),
|
||||
);
|
||||
|
||||
// Switch back to mydb — must reconnect and refresh schemas again
|
||||
mockCommands.getSchemas.mockResolvedValue(["public", "reporting"]);
|
||||
await act(async () => {
|
||||
useDbViewerStore.setState({ currentDatabase: "mydb" });
|
||||
});
|
||||
|
||||
expect(mockCommands.dbConnect).toHaveBeenLastCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ database: "mydb" }),
|
||||
);
|
||||
expect(useDbViewerStore.getState().schemas).toEqual([
|
||||
"public",
|
||||
"reporting",
|
||||
]);
|
||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import { useConnectionStore } from "../stores/connectionStore";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../stores/notificationStore";
|
||||
import * as cmd from "../lib/commands";
|
||||
import type { ConnectionInput } from "../lib/types";
|
||||
import { pickDefaultSchema } from "../lib/utils";
|
||||
import type { ConnectionInput, TableInfo } from "../lib/types";
|
||||
|
||||
export function useDbConnection(connectionId: string) {
|
||||
const reset = useDbViewerStore((s) => s.reset);
|
||||
@@ -14,7 +15,10 @@ export function useDbConnection(connectionId: string) {
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const inputRef = useRef<ConnectionInput | null>(null);
|
||||
const initialDbRef = useRef<string | null>(null);
|
||||
// The database the pool is currently connected to. Unlike the selected
|
||||
// `currentDatabase`, this lets us reconnect whenever the selection drifts
|
||||
// from the live connection (including switching back to the first DB).
|
||||
const connectedDbRef = useRef<string | null>(null);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
const conn = useConnectionStore
|
||||
@@ -56,21 +60,34 @@ export function useDbConnection(connectionId: string) {
|
||||
await cmd.dbConnect(connectionId, input);
|
||||
setConnectionError(null);
|
||||
inputRef.current = input;
|
||||
connectedDbRef.current =
|
||||
input.db_type === "sqlite"
|
||||
? "main"
|
||||
: (input.database ?? "postgres");
|
||||
|
||||
// Load initial data
|
||||
// Load initial data, smart-selecting the default schema (e.g. `public`)
|
||||
const databases = await cmd
|
||||
.getDatabases(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
const tables = await cmd.getTables(connectionId);
|
||||
const defaultSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd.getTables(
|
||||
connectionId,
|
||||
defaultSchema ?? undefined,
|
||||
);
|
||||
populate(databases, schemas, tables);
|
||||
if (databases.length > 0) {
|
||||
setCurrentDatabase(databases[0]);
|
||||
initialDbRef.current = databases[0];
|
||||
// Prefer the connection's configured database, fall back to the first
|
||||
// available one so the dropdown matches what the pool is connected to.
|
||||
const preferred =
|
||||
input.database && databases.includes(input.database)
|
||||
? input.database
|
||||
: databases[0];
|
||||
setCurrentDatabase(preferred);
|
||||
}
|
||||
if (schemas.length > 0) setCurrentSchema(schemas[0]);
|
||||
setCurrentSchema(defaultSchema);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setConnectionError(msg);
|
||||
@@ -89,29 +106,42 @@ export function useDbConnection(connectionId: string) {
|
||||
};
|
||||
}, [connectionId, connect, reset]);
|
||||
|
||||
// Database switch effect: reconnect when user changes database from dropdown
|
||||
// Database switch effect: whenever the selected database drifts from the
|
||||
// pool's live connection, reconnect and refresh schemas/tables for that DB.
|
||||
useEffect(() => {
|
||||
if (!currentDatabase || !inputRef.current) return;
|
||||
if (currentDatabase === initialDbRef.current) return;
|
||||
if (currentDatabase === connectedDbRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
const reconnect = async () => {
|
||||
const input = { ...inputRef.current!, database: currentDatabase };
|
||||
try {
|
||||
await cmd.dbConnect(connectionId, input);
|
||||
const schemas = await cmd.getSchemas(connectionId);
|
||||
const tables = await cmd.getTables(connectionId);
|
||||
populate(
|
||||
useDbViewerStore.getState().databases,
|
||||
schemas,
|
||||
tables,
|
||||
);
|
||||
if (schemas.length > 0) setCurrentSchema(schemas[0]);
|
||||
} catch {
|
||||
/* silent */
|
||||
if (cancelled) return;
|
||||
connectedDbRef.current = currentDatabase;
|
||||
const schemas = await cmd
|
||||
.getSchemas(connectionId)
|
||||
.catch(() => [] as string[]);
|
||||
if (cancelled) return;
|
||||
const newSchema = pickDefaultSchema(schemas);
|
||||
const tables = await cmd
|
||||
.getTables(connectionId, newSchema ?? undefined)
|
||||
.catch(() => [] as TableInfo[]);
|
||||
if (cancelled) return;
|
||||
populate(useDbViewerStore.getState().databases, schemas, tables);
|
||||
setCurrentSchema(newSchema);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Revert the selection so the dropdown matches the live connection
|
||||
setCurrentDatabase(connectedDbRef.current);
|
||||
notify(`Failed to switch database: ${msg}`, "error");
|
||||
}
|
||||
};
|
||||
reconnect();
|
||||
}, [currentDatabase, connectionId, populate, setCurrentSchema]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDatabase, connectionId, populate, setCurrentSchema, notify]);
|
||||
|
||||
return { connectionError, connect };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
--color-accent-muted: #60A5FA;
|
||||
--font-family-heading: "Space Mono", monospace;
|
||||
--font-family-sans: "Outfit", sans-serif;
|
||||
|
||||
--animate-toolbar-pulse: toolbar-pulse 1.6s ease-in-out infinite;
|
||||
|
||||
@keyframes toolbar-pulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 0.12; }
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
|
||||
}));
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
testConnection,
|
||||
dbConnect,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
executeChange,
|
||||
refreshConnection,
|
||||
getSchemaGraph,
|
||||
executeQuery,
|
||||
getQueryHistory,
|
||||
clearQueryHistory,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
|
||||
@@ -67,4 +71,35 @@ describe("commands", () => {
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("query commands", () => {
|
||||
it("executeQuery calls invoke with correct args", async () => {
|
||||
const mockResult = { columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await executeQuery("conn-1", "SELECT 1", 1, 50);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("execute_query", {
|
||||
connectionId: "conn-1",
|
||||
query: "SELECT 1",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getQueryHistory calls invoke", async () => {
|
||||
const mockHistory = [{ id: "h1", connection_id: "c1", query_text: "SELECT 1", status: "success", executed_at: "2025-01-01" }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockHistory);
|
||||
const result = await getQueryHistory("conn-1", 10, 0);
|
||||
expect(invoke).toHaveBeenCalledWith("get_query_history", { connectionId: "conn-1", limit: 10, offset: 0 });
|
||||
expect(result).toEqual(mockHistory);
|
||||
});
|
||||
|
||||
it("clearQueryHistory calls invoke", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined);
|
||||
await clearQueryHistory("conn-1");
|
||||
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
|
||||
});
|
||||
});
|
||||
@@ -146,4 +146,38 @@ export async function getSchemaGraph(
|
||||
schema?: string,
|
||||
): Promise<SchemaGraph> {
|
||||
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
|
||||
}
|
||||
|
||||
// ─── Query History ──────────────────────────────────────────────
|
||||
|
||||
export interface QueryHistoryEntry {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
query_text: string;
|
||||
execution_time_ms: number | null;
|
||||
row_count: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export async function executeQuery(
|
||||
connectionId: string,
|
||||
query: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<QueryResult> {
|
||||
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
||||
}
|
||||
|
||||
export async function getQueryHistory(
|
||||
connectionId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<QueryHistoryEntry[]> {
|
||||
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
|
||||
}
|
||||
|
||||
export async function clearQueryHistory(connectionId: string): Promise<void> {
|
||||
return invoke<void>("clear_query_history", { connectionId });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Local Monaco setup — bundles monaco-editor with the app so no CDN is
|
||||
* needed and the editor works fully offline.
|
||||
*
|
||||
* Must be imported once, before any @monaco-editor/react <Editor /> mounts.
|
||||
*/
|
||||
import * as monaco from "monaco-editor";
|
||||
import { loader } from "@monaco-editor/react";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import { useDbViewerStore } from "../stores/dbViewerStore";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
parseTableRef,
|
||||
} from "./sqlCompletion";
|
||||
|
||||
// Use the bundled editor worker (SQL has no dedicated language worker)
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
// Hand the already-imported monaco instance to @monaco-editor/react so it
|
||||
// skips its CDN download entirely.
|
||||
loader.config({ monaco });
|
||||
|
||||
// SQL autocomplete:
|
||||
// - after `table.` (or `schema.table.`): suggest that table's columns
|
||||
// (fetched lazily via schema introspection and cached per schema)
|
||||
// - otherwise: keywords + table names from the active schema
|
||||
monaco.languages.registerCompletionItemProvider("sql", {
|
||||
provideCompletionItems: (
|
||||
model,
|
||||
position,
|
||||
): monaco.languages.CompletionList | Promise<monaco.languages.CompletionList> => {
|
||||
const { tables, currentSchema } = useDbViewerStore.getState();
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
);
|
||||
const withRange = (s: { label: string; insertText: string; kind: string }) => ({
|
||||
label: s.label,
|
||||
insertText: s.insertText,
|
||||
kind:
|
||||
s.kind === "keyword"
|
||||
? monaco.languages.CompletionItemKind.Keyword
|
||||
: s.kind === "table"
|
||||
? monaco.languages.CompletionItemKind.Struct
|
||||
: monaco.languages.CompletionItemKind.Field,
|
||||
range,
|
||||
});
|
||||
|
||||
const tableRef = parseTableRef(before);
|
||||
if (tableRef) {
|
||||
const schema = tableRef.schema ?? currentSchema;
|
||||
const connectionId = useUiStore.getState().activeConnectionId;
|
||||
const cached = schema ? getCachedColumns(schema, tableRef.table) : undefined;
|
||||
if (cached) {
|
||||
return {
|
||||
suggestions: buildColumnSuggestions(cached).map(withRange),
|
||||
};
|
||||
}
|
||||
// Not introspected yet: warm the cache in the background and ask Monaco
|
||||
// to re-request once the columns are available.
|
||||
void getColumnsForTable(connectionId, schema, tableRef.table);
|
||||
return { suggestions: [], incomplete: true };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: buildSqlSuggestions(tables, currentSchema).map(withRange),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
buildSqlSuggestions,
|
||||
SQL_KEYWORDS,
|
||||
parseTableRef,
|
||||
buildColumnSuggestions,
|
||||
getColumnsForTable,
|
||||
getCachedColumns,
|
||||
} from "./sqlCompletion";
|
||||
import type { TableInfo } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
vi.mock("./commands", () => ({
|
||||
getSchemaGraph: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetSchemaGraph = vi.mocked(getSchemaGraph);
|
||||
|
||||
const tables: TableInfo[] = [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
{ name: "audit_log", schema: "audit", table_type: "TABLE" },
|
||||
];
|
||||
|
||||
describe("parseTableRef", () => {
|
||||
it("parses a bare table before the cursor dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users.")).toEqual({
|
||||
schema: null,
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a schema-qualified table", () => {
|
||||
expect(parseTableRef("SELECT * FROM public.users.")).toEqual({
|
||||
schema: "public",
|
||||
table: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no trailing dot", () => {
|
||||
expect(parseTableRef("SELECT * FROM users WHERE id")).toBeNull();
|
||||
expect(parseTableRef("SELECT")).toBeNull();
|
||||
expect(parseTableRef("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildColumnSuggestions", () => {
|
||||
it("maps columns to column-kind suggestions", () => {
|
||||
const suggestions = buildColumnSuggestions([
|
||||
{ name: "id" },
|
||||
{ name: "email" },
|
||||
]);
|
||||
expect(suggestions).toEqual([
|
||||
{ label: "id", insertText: "id", kind: "column" },
|
||||
{ label: "email", insertText: "email", kind: "column" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getColumnsForTable", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSchemaGraph.mockReset();
|
||||
});
|
||||
|
||||
it("fetches columns from the schema graph and caches them", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
|
||||
const cols = await getColumnsForTable("c1", "public", "users");
|
||||
expect(cols.map((c) => c.name)).toEqual(["id"]);
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
|
||||
// cached: a second lookup does not refetch
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(mockGetSchemaGraph).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an empty list when the fetch fails", async () => {
|
||||
mockGetSchemaGraph.mockRejectedValue(new Error("boom"));
|
||||
const cols = await getColumnsForTable("c1", "public", "orders");
|
||||
expect(cols).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes cached columns synchronously", async () => {
|
||||
mockGetSchemaGraph.mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [{ name: "id" } as never],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as never);
|
||||
await getColumnsForTable("c1", "public", "users");
|
||||
expect(getCachedColumns("public", "users")?.map((c) => c.name)).toEqual([
|
||||
"id",
|
||||
]);
|
||||
expect(getCachedColumns("public", "missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSqlSuggestions", () => {
|
||||
it("includes core SQL keywords", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
for (const kw of ["SELECT", "FROM", "WHERE", "JOIN", "INSERT"]) {
|
||||
expect(labels).toContain(kw);
|
||||
}
|
||||
});
|
||||
|
||||
it("labels keywords as keyword kind", () => {
|
||||
const suggestions = buildSqlSuggestions([], null);
|
||||
const select = suggestions.find((s) => s.label === "SELECT");
|
||||
expect(select?.kind).toBe("keyword");
|
||||
expect(select?.insertText).toBe("SELECT");
|
||||
});
|
||||
|
||||
it("includes table names from the current schema", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("users");
|
||||
expect(labels).toContain("orders");
|
||||
expect(labels).not.toContain("audit_log");
|
||||
});
|
||||
|
||||
it("includes all tables when no schema is selected", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, null);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
expect(labels).toContain("audit_log");
|
||||
});
|
||||
|
||||
it("labels tables as table kind", () => {
|
||||
const suggestions = buildSqlSuggestions(tables, "public");
|
||||
const users = suggestions.find((s) => s.label === "users");
|
||||
expect(users?.kind).toBe("table");
|
||||
});
|
||||
|
||||
it("exports a non-empty keyword list", () => {
|
||||
expect(SQL_KEYWORDS.length).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { TableInfo, GraphColumn } from "./types";
|
||||
import { getSchemaGraph } from "./commands";
|
||||
|
||||
export const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"INSERT",
|
||||
"INTO",
|
||||
"VALUES",
|
||||
"UPDATE",
|
||||
"SET",
|
||||
"DELETE",
|
||||
"CREATE",
|
||||
"TABLE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"ADD",
|
||||
"COLUMN",
|
||||
"PRIMARY",
|
||||
"KEY",
|
||||
"FOREIGN",
|
||||
"REFERENCES",
|
||||
"INDEX",
|
||||
"UNIQUE",
|
||||
"JOIN",
|
||||
"INNER",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"AS",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"IS",
|
||||
"IN",
|
||||
"EXISTS",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"UNION",
|
||||
"ALL",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"WITH",
|
||||
"RETURNING",
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"EXPLAIN",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
] as const;
|
||||
|
||||
export type SqlSuggestionKind = "keyword" | "table" | "column";
|
||||
|
||||
export interface SqlSuggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: SqlSuggestionKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the text before the cursor into a `table.` reference. Returns the
|
||||
* table (and optional schema qualifier) when the text ends in a dot right
|
||||
* after an identifier, e.g. `users.` or `public.users.`.
|
||||
*/
|
||||
export function parseTableRef(
|
||||
text: string,
|
||||
): { schema: string | null; table: string } | null {
|
||||
const match = text.match(/(?:(\w+)\.)?(\w+)\.\s*$/);
|
||||
if (!match) return null;
|
||||
return { schema: match[1] ?? null, table: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build column suggestions (used after a `table.` reference).
|
||||
*/
|
||||
export function buildColumnSuggestions(
|
||||
columns: { name: string }[],
|
||||
): SqlSuggestion[] {
|
||||
return columns.map((c) => ({
|
||||
label: c.name,
|
||||
insertText: c.name,
|
||||
kind: "column" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// Cache of table columns, keyed by "schema.table".
|
||||
const columnCache = new Map<string, GraphColumn[]>();
|
||||
|
||||
/**
|
||||
* Synchronously read cached columns for a table, if the schema graph for its
|
||||
* schema has already been fetched.
|
||||
*/
|
||||
export function getCachedColumns(
|
||||
schema: string,
|
||||
table: string,
|
||||
): GraphColumn[] | undefined {
|
||||
return columnCache.get(`${schema}.${table}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch (and cache) the columns of a table via the schema-graph introspection
|
||||
* command. Caches the whole schema in one round trip. Returns [] on error so
|
||||
* autocomplete degrades gracefully.
|
||||
*/
|
||||
export async function getColumnsForTable(
|
||||
connectionId: string | null,
|
||||
schema: string | null,
|
||||
table: string,
|
||||
): Promise<GraphColumn[]> {
|
||||
const resolvedSchema = schema ?? "public";
|
||||
const key = `${resolvedSchema}.${table}`;
|
||||
const cached = columnCache.get(key);
|
||||
if (cached) return cached;
|
||||
if (!connectionId) return [];
|
||||
|
||||
try {
|
||||
const graph = await getSchemaGraph(connectionId, resolvedSchema);
|
||||
for (const t of graph.tables) {
|
||||
columnCache.set(`${resolvedSchema}.${t.name}`, t.columns);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnCache.get(key) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build editor suggestions for the SQL language: reserved keywords plus the
|
||||
* table names in the currently selected schema.
|
||||
*/
|
||||
export function buildSqlSuggestions(
|
||||
tables: TableInfo[],
|
||||
schema: string | null,
|
||||
): SqlSuggestion[] {
|
||||
const keywords: SqlSuggestion[] = SQL_KEYWORDS.map((kw) => ({
|
||||
label: kw,
|
||||
insertText: kw,
|
||||
kind: "keyword",
|
||||
}));
|
||||
|
||||
const tableNames = new Set(
|
||||
tables
|
||||
.filter((t) => !schema || t.schema === schema)
|
||||
.map((t) => t.name),
|
||||
);
|
||||
const tableSuggestions: SqlSuggestion[] = [...tableNames].map((name) => ({
|
||||
label: name,
|
||||
insertText: name,
|
||||
kind: "table",
|
||||
}));
|
||||
|
||||
return [...keywords, ...tableSuggestions];
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
filterConnections,
|
||||
getDescendantFolderIds,
|
||||
getFolderPathLabel,
|
||||
isDestructiveQuery,
|
||||
pickDefaultSchema,
|
||||
} from "./utils";
|
||||
import type { Connection, Folder, Tag } from "./types";
|
||||
|
||||
@@ -19,6 +21,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
...over,
|
||||
@@ -283,6 +286,39 @@ describe("filterConnections", () => {
|
||||
it("search matches tag name", () => {
|
||||
expect(filterConnections(conns, tags, { query: "cache" })).toEqual([conns[1]]);
|
||||
});
|
||||
|
||||
it("matches ANY selected tag (OR semantics)", () => {
|
||||
// c1 has t1, c2 has t2. Selecting both t1+t2 should return BOTH connections.
|
||||
const result = filterConnections(conns, tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches when connection has only one of multiple selected tags", () => {
|
||||
const c3 = makeConnection({ id: "c3", name: "Cache", host: "cache.local", db_type: "postgresql", tag_ids: ["t1"], folder_id: "f1", environment: "production" });
|
||||
const result = filterConnections([...conns, c3], tags, { query: "", activeTagIds: ["t1", "t2"] });
|
||||
// c3 only has t1 but should still show
|
||||
expect(result.map((c) => c.id)).toContain("c3");
|
||||
});
|
||||
|
||||
it("filters by environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "Dev", db_type: "postgresql", tag_ids: [], environment: "development" });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "production" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
|
||||
it("activeEnvironment 'none' filters to connections without environment", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const c2 = makeConnection({ id: "c2", name: "NoEnv", db_type: "postgresql", tag_ids: [], environment: null });
|
||||
const result = filterConnections([c1, c2], tags, { query: "", activeEnvironment: "none" });
|
||||
expect(result).toEqual([c2]);
|
||||
});
|
||||
|
||||
it("environment null/undefined means no filtering", () => {
|
||||
const c1 = makeConnection({ id: "c1", name: "Prod", db_type: "postgresql", tag_ids: [], environment: "production" });
|
||||
const result = filterConnections([c1], tags, { query: "" });
|
||||
expect(result).toEqual([c1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFolderPathLabel", () => {
|
||||
@@ -302,4 +338,80 @@ describe("getFolderPathLabel", () => {
|
||||
it("returns root label for non-existent folder", () => {
|
||||
expect(getFolderPathLabel(folders, "missing")).toBe("Root");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDestructiveQuery", () => {
|
||||
it("returns true for INSERT", () => {
|
||||
expect(isDestructiveQuery("INSERT INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns true for UPDATE", () => {
|
||||
expect(isDestructiveQuery("UPDATE users SET name = 'x'")).toBe(true);
|
||||
});
|
||||
it("returns true for DELETE", () => {
|
||||
expect(isDestructiveQuery("DELETE FROM users")).toBe(true);
|
||||
});
|
||||
it("returns true for DROP", () => {
|
||||
expect(isDestructiveQuery("DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for ALTER", () => {
|
||||
expect(isDestructiveQuery("ALTER TABLE users ADD COLUMN age int")).toBe(true);
|
||||
});
|
||||
it("returns true for TRUNCATE", () => {
|
||||
expect(isDestructiveQuery("TRUNCATE TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns true for CREATE", () => {
|
||||
expect(isDestructiveQuery("CREATE TABLE t (id int)")).toBe(true);
|
||||
});
|
||||
it("returns true for REPLACE", () => {
|
||||
expect(isDestructiveQuery("REPLACE INTO users VALUES (1)")).toBe(true);
|
||||
});
|
||||
it("returns false for SELECT", () => {
|
||||
expect(isDestructiveQuery("SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for EXPLAIN", () => {
|
||||
expect(isDestructiveQuery("EXPLAIN SELECT * FROM users")).toBe(false);
|
||||
});
|
||||
it("returns false for WITH (CTE SELECT)", () => {
|
||||
expect(isDestructiveQuery("WITH cte AS (SELECT 1) SELECT * FROM cte")).toBe(false);
|
||||
});
|
||||
it("returns false for SHOW", () => {
|
||||
expect(isDestructiveQuery("SHOW search_path")).toBe(false);
|
||||
});
|
||||
it("strips line comments before checking", () => {
|
||||
expect(isDestructiveQuery("-- harmless comment\nDROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("strips block comments before checking", () => {
|
||||
expect(isDestructiveQuery("/* harmless */ DROP TABLE users")).toBe(true);
|
||||
});
|
||||
it("returns false for empty string", () => {
|
||||
expect(isDestructiveQuery("")).toBe(false);
|
||||
});
|
||||
it("returns false for whitespace only", () => {
|
||||
expect(isDestructiveQuery(" \n\t ")).toBe(false);
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(isDestructiveQuery("drop table users")).toBe(true);
|
||||
expect(isDestructiveQuery("Drop Table users")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultSchema", () => {
|
||||
it("prefers the public schema when available", () => {
|
||||
expect(pickDefaultSchema(["app", "public"])).toBe("public");
|
||||
expect(pickDefaultSchema(["public"])).toBe("public");
|
||||
});
|
||||
|
||||
it("prefers the main schema (SQLite) when available", () => {
|
||||
expect(pickDefaultSchema(["main"])).toBe("main");
|
||||
expect(pickDefaultSchema(["other", "main"])).toBe("main");
|
||||
});
|
||||
|
||||
it("falls back to the first schema when no conventional one exists", () => {
|
||||
expect(pickDefaultSchema(["analytics", "app"])).toBe("analytics");
|
||||
expect(pickDefaultSchema(["zzz"])).toBe("zzz");
|
||||
});
|
||||
|
||||
it("returns null for an empty list", () => {
|
||||
expect(pickDefaultSchema([])).toBeNull();
|
||||
});
|
||||
});
|
||||
+55
-2
@@ -112,16 +112,29 @@ export function getChildFolders(folders: Folder[], parentId: string | null): Fol
|
||||
export function filterConnections(
|
||||
connections: Connection[],
|
||||
tags: Tag[],
|
||||
filter: { query: string; activeTagIds?: string[]; activeDbTypes?: DbType[] },
|
||||
filter: {
|
||||
query: string;
|
||||
activeTagIds?: string[];
|
||||
activeDbTypes?: DbType[];
|
||||
activeEnvironment?: string | null;
|
||||
},
|
||||
): Connection[] {
|
||||
const q = filter.query.trim().toLowerCase();
|
||||
const tagIds = filter.activeTagIds ?? [];
|
||||
const dbTypes = filter.activeDbTypes ?? [];
|
||||
const activeEnvironment = filter.activeEnvironment;
|
||||
const tagNameById = new Map(tags.map((t) => [t.id, t.name.toLowerCase()]));
|
||||
|
||||
return connections.filter((c) => {
|
||||
if (dbTypes.length > 0 && !dbTypes.includes(c.db_type)) return false;
|
||||
if (tagIds.length > 0 && !tagIds.every((id) => c.tag_ids.includes(id))) return false;
|
||||
if (tagIds.length > 0 && !tagIds.some((id) => c.tag_ids.includes(id))) return false;
|
||||
if (activeEnvironment !== undefined && activeEnvironment !== null && activeEnvironment !== "") {
|
||||
if (activeEnvironment === "none") {
|
||||
if (c.environment) return false;
|
||||
} else if (c.environment !== activeEnvironment) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (q.length > 0) {
|
||||
const tagNames = c.tag_ids.map((id) => tagNameById.get(id) ?? "").join(" ");
|
||||
const haystack = `${c.name} ${c.host} ${c.db_type} ${tagNames}`.toLowerCase();
|
||||
@@ -129,4 +142,44 @@ export function filterConnections(
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_KEYWORDS = new Set([
|
||||
"INSERT", "UPDATE", "DELETE", "DROP", "ALTER",
|
||||
"TRUNCATE", "CREATE", "REPLACE",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Detect whether `sql` is a data-modifying statement by checking the
|
||||
* first significant keyword after stripping comments and whitespace.
|
||||
*
|
||||
* This is a UX safety net, not a security boundary. The user is already
|
||||
* authenticated to their own database — the confirmation dialog prevents
|
||||
* accidental data loss, not malicious access.
|
||||
*/
|
||||
export function isDestructiveQuery(sql: string): boolean {
|
||||
// Strip block comments /* ... */
|
||||
let stripped = sql.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
// Strip line comments -- ...
|
||||
stripped = stripped.replace(/--[^\n]*/g, " ");
|
||||
// Collapse whitespace
|
||||
const tokens = stripped.trim().split(/\s+/);
|
||||
if (tokens.length === 0 || tokens[0].length === 0) return false;
|
||||
const first = tokens[0].toUpperCase();
|
||||
return DESTRUCTIVE_KEYWORDS.has(first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the smart default schema for a freshly loaded database.
|
||||
*
|
||||
* Prefers conventional schemas (`public` for PostgreSQL, `main` for SQLite)
|
||||
* and otherwise falls back to the first schema returned by the backend
|
||||
* (which already excludes system schemas and is ordered alphabetically).
|
||||
*/
|
||||
export function pickDefaultSchema(schemas: string[]): string | null {
|
||||
if (schemas.length === 0) return null;
|
||||
for (const preferred of ["public", "main"]) {
|
||||
if (schemas.includes(preferred)) return preferred;
|
||||
}
|
||||
return schemas[0];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./lib/monacoSetup";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
@@ -147,4 +147,45 @@ describe("moveConnection", () => {
|
||||
await useConnectionStore.getState().moveConnection("c1", null); // c1 is already null
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles rapid successive drags without stale state", async () => {
|
||||
const conn1 = makeConn({ id: "c1", folder_id: null });
|
||||
const conn2 = makeConn({ id: "c2", folder_id: null, name: "Other" });
|
||||
useConnectionStore.setState({
|
||||
connections: [conn1, conn2],
|
||||
folders: [{ id: "f1", name: "F1", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
|
||||
// First call hangs until we resolve it; second call resolves immediately
|
||||
let resolveFirst: (v: Connection) => void;
|
||||
const firstCall = new Promise<Connection>((r) => { resolveFirst = r; });
|
||||
let callCount = 0;
|
||||
vi.spyOn(commands, "updateConnection").mockImplementation(async (_id, input) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return firstCall;
|
||||
}
|
||||
return { ...conn2, folder_id: (input as any).folder_id } as Connection;
|
||||
});
|
||||
|
||||
// Start first drag (c1 → f1) — will be pending on updateConnection
|
||||
const move1 = useConnectionStore.getState().moveConnection("c1", "f1");
|
||||
// Immediately start second drag (c2 → f1) — should complete
|
||||
await useConnectionStore.getState().moveConnection("c2", "f1");
|
||||
|
||||
// Second drag should have applied optimistically
|
||||
expect(
|
||||
useConnectionStore.getState().connections.find((c) => c.id === "c2")?.folder_id
|
||||
).toBe("f1");
|
||||
|
||||
// Resolve the first drag's network call
|
||||
resolveFirst!({ ...conn1, folder_id: "f1" } as Connection);
|
||||
await move1;
|
||||
|
||||
// Both should be in f1 after both complete
|
||||
const state = useConnectionStore.getState();
|
||||
expect(state.connections.find((c) => c.id === "c1")?.folder_id).toBe("f1");
|
||||
expect(state.connections.find((c) => c.id === "c2")?.folder_id).toBe("f1");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -123,14 +123,19 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
if (!conn) return;
|
||||
if (conn.folder_id === newFolderId) return;
|
||||
|
||||
const previousConnections = [...state.connections];
|
||||
// Capture the snapshot atomically INSIDE the optimistic set()
|
||||
// to avoid stale closure issues on rapid successive drags.
|
||||
let previousState: { connections: Connection[] };
|
||||
|
||||
// Optimistic update
|
||||
set((s) => ({
|
||||
connections: s.connections.map((c) =>
|
||||
c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
|
||||
),
|
||||
}));
|
||||
set((s) => {
|
||||
previousState = { connections: [...s.connections] };
|
||||
return {
|
||||
connections: s.connections.map((c) =>
|
||||
c.id === connectionId ? { ...c, folder_id: newFolderId } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
// Build a minimal ConnectionInput with only folder_id changed
|
||||
@@ -156,7 +161,7 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
};
|
||||
await cmd.updateConnection(connectionId, input);
|
||||
} catch (e) {
|
||||
set({ connections: previousConnections });
|
||||
set({ connections: previousState!.connections });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -179,4 +179,46 @@ describe("dbViewerStore", () => {
|
||||
expect(state.schemas).toEqual(["public", "private"]);
|
||||
expect(state.tables).toEqual(tables);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabType discriminator", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
currentDatabase: "mydb",
|
||||
});
|
||||
});
|
||||
|
||||
it("openQueryTab creates a query-type tab with empty query", () => {
|
||||
useDbViewerStore.getState().openQueryTab();
|
||||
const state = useDbViewerStore.getState();
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
const tab = state.tabs[0];
|
||||
expect(tab.tabType).toBe("query");
|
||||
expect(tab.query).toBe("");
|
||||
expect(tab.schema).toBe("public");
|
||||
expect(tab.table).toBe("Query");
|
||||
expect(state.activeTabId).toBe(tab.id);
|
||||
});
|
||||
|
||||
it("creates sequential query tabs with unique IDs", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openQueryTab();
|
||||
store.openQueryTab();
|
||||
store.openQueryTab();
|
||||
const tabs = useDbViewerStore.getState().tabs;
|
||||
expect(tabs).toHaveLength(3);
|
||||
const ids = tabs.map((t) => t.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
tabs.forEach((t) => expect(t.tabType).toBe("query"));
|
||||
});
|
||||
|
||||
it("openTab creates table-type tabs by default (backward compat)", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
const tab = useDbViewerStore.getState().tabs[0];
|
||||
expect(tab.tabType).toBe("table");
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,8 @@ export interface ViewerTab {
|
||||
sortRules: SortRule[];
|
||||
hiddenColumns: string[];
|
||||
smartSortApplied: boolean;
|
||||
tabType: "table" | "query";
|
||||
query?: string;
|
||||
}
|
||||
|
||||
// ─── Auto-increment counters ───────────────────────────────────
|
||||
@@ -69,6 +71,8 @@ const initialTab = (schema: string, table: string, defaultPageSize?: number): Vi
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "table",
|
||||
query: undefined,
|
||||
});
|
||||
|
||||
// ─── State interface ────────────────────────────────────────────
|
||||
@@ -78,6 +82,7 @@ interface DbViewerState {
|
||||
activeTabId: string | null;
|
||||
defaultPageSize: number;
|
||||
changesQueue: QueueItem[];
|
||||
changesPanelExpanded: boolean;
|
||||
databases: string[];
|
||||
schemas: string[];
|
||||
tables: TableInfo[];
|
||||
@@ -91,6 +96,7 @@ interface DbViewerState {
|
||||
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
openQueryTab: () => void;
|
||||
setDefaultPageSize: (size: number) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
@@ -119,6 +125,7 @@ interface DbViewerState {
|
||||
cancelChange: (changeId: string) => void;
|
||||
markChangeCommitted: (changeId: string) => void;
|
||||
markChangeFailed: (changeId: string, error: string) => void;
|
||||
toggleChangesPanel: () => void;
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
setFunctions: (functions: FunctionInfo[]) => void;
|
||||
@@ -141,6 +148,7 @@ const initialState = {
|
||||
activeTabId: null as string | null,
|
||||
defaultPageSize: 50,
|
||||
changesQueue: [] as QueueItem[],
|
||||
changesPanelExpanded: true,
|
||||
databases: [] as string[],
|
||||
schemas: [] as string[],
|
||||
tables: [] as TableInfo[],
|
||||
@@ -176,6 +184,28 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
openQueryTab: () => {
|
||||
const { tabs, currentSchema } = get();
|
||||
const queryCount = tabs.filter((t) => t.tabType === "query").length;
|
||||
const tab: ViewerTab = {
|
||||
id: `tab-${++tabCounter}`,
|
||||
schema: currentSchema ?? "public",
|
||||
table: queryCount === 0 ? "Query" : `Query ${queryCount + 1}`,
|
||||
page: 1,
|
||||
pageSize: get().defaultPageSize,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "query",
|
||||
query: "",
|
||||
};
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
setDefaultPageSize: (size) => set({ defaultPageSize: size }),
|
||||
|
||||
closeTab: (tabId) => {
|
||||
@@ -327,6 +357,9 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
),
|
||||
})),
|
||||
|
||||
toggleChangesPanel: () =>
|
||||
set((state) => ({ changesPanelExpanded: !state.changesPanelExpanded })),
|
||||
|
||||
setCurrentDatabase: (db) => set({ currentDatabase: db }),
|
||||
setCurrentSchema: (schema) => set({ currentSchema: schema }),
|
||||
setFunctions: (functions) => set({ functions }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useUiStore } from "./uiStore";
|
||||
|
||||
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", prefilledConnectionString: null, activeConnectionId: null }));
|
||||
beforeEach(() => useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", prefilledConnectionString: null, activeConnectionId: null }));
|
||||
|
||||
describe("uiStore", () => {
|
||||
it("starts on home view", () => expect(useUiStore.getState().activeView).toBe("home"));
|
||||
@@ -41,6 +41,17 @@ describe("uiStore", () => {
|
||||
expect(useUiStore.getState().prefilledConnectionString).toBeNull();
|
||||
});
|
||||
|
||||
it("setEnvironment sets activeEnvironment", () => {
|
||||
useUiStore.getState().setEnvironment("production");
|
||||
expect(useUiStore.getState().activeEnvironment).toBe("production");
|
||||
});
|
||||
|
||||
it("clearFilters resets activeEnvironment", () => {
|
||||
useUiStore.getState().setEnvironment("staging");
|
||||
useUiStore.getState().clearFilters();
|
||||
expect(useUiStore.getState().activeEnvironment).toBeNull();
|
||||
});
|
||||
|
||||
it("sets and clears activeConnectionId", () => {
|
||||
useUiStore.getState().setActiveConnectionId("c1");
|
||||
expect(useUiStore.getState().activeConnectionId).toBe("c1");
|
||||
|
||||
@@ -6,6 +6,7 @@ interface UiState {
|
||||
activeFolderId: string | null;
|
||||
activeTagIds: string[];
|
||||
activeDbTypes: DbType[];
|
||||
activeEnvironment: string | null;
|
||||
activeView: ActiveView;
|
||||
selectedItemIds: string[];
|
||||
prefilledConnectionString: string | null;
|
||||
@@ -15,6 +16,7 @@ interface UiState {
|
||||
setActiveFolderId: (id: string | null) => void;
|
||||
toggleTag: (id: string) => void;
|
||||
toggleDbType: (type: DbType) => void;
|
||||
setEnvironment: (env: string | null) => void;
|
||||
clearFilters: () => void;
|
||||
toggleItemSelection: (id: string) => void;
|
||||
selectAllItems: (ids: string[]) => void;
|
||||
@@ -25,13 +27,14 @@ interface UiState {
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null,
|
||||
searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeEnvironment: null, activeView: "home", selectedItemIds: [], prefilledConnectionString: null, activeConnectionId: null,
|
||||
setActiveView: (view) => set({ activeView: view }),
|
||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||
setActiveFolderId: (id) => set({ activeFolderId: id, selectedItemIds: [] }),
|
||||
toggleTag: (id) => set((s) => ({ activeTagIds: s.activeTagIds.includes(id) ? s.activeTagIds.filter((t) => t !== id) : [...s.activeTagIds, id] })),
|
||||
toggleDbType: (type) => set((s) => ({ activeDbTypes: s.activeDbTypes.includes(type) ? s.activeDbTypes.filter((t) => t !== type) : [...s.activeDbTypes, type] })),
|
||||
clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null }),
|
||||
setEnvironment: (env) => set({ activeEnvironment: env }),
|
||||
clearFilters: () => set({ searchQuery: "", activeTagIds: [], activeDbTypes: [], activeFolderId: null, activeEnvironment: null }),
|
||||
toggleItemSelection: (id) => set((s) => ({
|
||||
selectedItemIds: s.selectedItemIds.includes(id)
|
||||
? s.selectedItemIds.filter((i) => i !== id)
|
||||
|
||||
Reference in New Issue
Block a user