feat: Query history + saved queries (#5)

* fix: restore frontend test baseline (vitest jsdom env + tsc + mock fixes)

* feat: v6 migration — favorite column + queries table (Task 1)

* feat: extend TS types for favorites + saved queries (Task 2)

* feat: dedup consecutive + prune to 500 in insert_query_history (Task 3)

* feat: favorite column threading + set_history_favorite command (Task 4)

* feat: saved queries store CRUD (Task 5)

* feat: saved query commands + registration (Task 6)

* feat: queryStore — Zustand cache for history + saved queries (Task 7)

* feat: QueryHistoryDropdown — toolbar history dropdown (Task 8)

* feat: SaveQueryDialog — save query modal (Task 9)

* feat: QueryToolbar — add History + Save icons (Task 10)

* feat: QueriesPanel — History + Saved tabs (Task 11)

* feat: wire Queries view + toolbar to panel (Task 12)

* fix: global scope wrappers, empty/spinner states, cache invalidation (Task 13)

* feat: Queries view — history sidebar + tabbed query workspace (PR feedback)

* fix: toolbar action order + view-specific empty state (PR feedback)

* fix: view-specific empty state icon (PR feedback)

* fix: portal Tooltip to body so it is never clipped by overflow containers (PR feedback)

* feat: Queries sidebar — Explorer-style header + per-connection scoping (PR feedback)

* fix: move History/Saved dropdown to right side of Queries header (PR feedback)

* fix: History/Saved dropdown on its own row in Queries header (PR feedback)

* fix: Queries header order (search above dropdown) + sidebar width matches Explorer (PR feedback)

* fix: Queries header spacing — tight rows, pb-3 on container (PR feedback)

* fix: Queries header spacing — space-y-2 on container, no mb on title row (PR feedback)

* feat: history/saved rows click-to-load, remove sub-buttons (PR feedback)

* feat: merge Functions/Triggers/Sequences/Enums/Extensions into single Objects view (PR feedback)

* fix: Schema Visualizer nav icon — node graph glyph (PR feedback)

* fix: Objects view — type dropdown replaces static header label (PR feedback)

* fix: Objects empty state — remove bg circle, larger icon (PR feedback)

* fix: center icon in Objects empty state (PR feedback)

* feat: merge Backup/Restore/DB Sync into single Tools view (PR feedback)

* docs: update AGENTS.md + README for query history/saved queries, merged Objects + Tools views
This commit is contained in:
2026-08-01 17:43:40 +08:00
committed by GitHub
parent 80962d7d11
commit 7db66f1160
33 changed files with 3069 additions and 192 deletions
+180 -87
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, Suspense, lazy } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown, ChevronUp, Table2, Terminal } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar } from "./DbViewerSidebar";
@@ -25,10 +25,10 @@ import { useConnectionStore } from "../../stores/connectionStore";
import { useSettingsStore } from "../../stores/settingsStore";
import { useShortcut } from "../../hooks/useShortcut";
import { ConnectionDropBanner } from "./ConnectionDropBanner";
import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage";
import { ToolsPage } from "./ToolsPage";
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
import { QueriesPanel } from "../queries/QueriesPanel";
import { useQueryStore } from "../../stores/queryStore";
import * as cmd from "../../lib/commands";
export interface DbViewerScreenProps {
@@ -46,6 +46,7 @@ export function DbViewerScreen({
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<string>("db-viewer");
const [tablePanelWidth, setTablePanelWidth] = useState(280);
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
const [searchQuery, setSearchQuery] = useState("");
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -70,6 +71,10 @@ export function DbViewerScreen({
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
);
const queriesPanelResizeRef = useRef<{
startX: number;
startW: number;
} | null>(null);
const activeTab = useDbViewerStore((s) => {
if (!s.activeTabId) return null;
@@ -124,8 +129,10 @@ export function DbViewerScreen({
try {
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result);
useQueryStore.getState().invalidateHistory(connectionId);
} catch (e) {
setTabError(tabId, e instanceof Error ? e.message : String(e));
useQueryStore.getState().invalidateHistory(connectionId);
}
}
@@ -168,6 +175,54 @@ export function DbViewerScreen({
}
}, [currentConnection?.db_type]);
// Restore SQL from history/saved panel: fill active query tab or open a new one
const handleRestoreSql = useCallback((sql: string) => {
const state = useDbViewerStore.getState();
const tab = state.tabs.find((t) => t.id === state.activeTabId);
if (tab && tab.tabType === "query") {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tab.id ? { ...t, query: sql } : t,
),
}));
} else {
state.openQueryTab();
requestAnimationFrame(() => {
const ns = useDbViewerStore.getState();
const nt = ns.tabs.find((t) => t.id === ns.activeTabId);
if (nt) {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === nt.id ? { ...t, query: sql } : t,
),
}));
}
});
}
}, []);
// Run SQL from history/saved panel: always open a new tab and execute
const handleRunFromHistory = useCallback((sql: string) => {
const state = useDbViewerStore.getState();
state.openQueryTab();
requestAnimationFrame(() => {
const ns = useDbViewerStore.getState();
const nt = ns.tabs.find((t) => t.id === ns.activeTabId);
if (nt) {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === nt.id ? { ...t, query: sql } : t,
),
}));
if (isDestructiveQuery(sql)) {
setDestructiveQuery(sql);
} else {
executeQueryForTab(nt.id, sql);
}
}
});
}, [connectionId]);
// Cmd+W / Ctrl+W: close current tab, or navigate home if no tabs (configurable in Settings → Shortcuts)
useShortcut("close_tab", () => {
const state = useDbViewerStore.getState();
@@ -488,6 +543,36 @@ export function DbViewerScreen({
[tablePanelWidth],
);
const onQueriesPanelResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
queriesPanelResizeRef.current = {
startX: e.clientX,
startW: queriesPanelWidth,
};
const onMove = (ev: MouseEvent) => {
if (!queriesPanelResizeRef.current) return;
const w = Math.max(
180,
Math.min(
600,
queriesPanelResizeRef.current.startW +
(ev.clientX - queriesPanelResizeRef.current.startX),
),
);
setQueriesPanelWidth(w);
};
const onUp = () => {
queriesPanelResizeRef.current = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[queriesPanelWidth],
);
// Query results panel: collapsible + resizable (min 120px, max 80% of column)
const queryColumnRef = useRef<HTMLDivElement>(null);
const resultsResizeRef = useRef<{ startY: number; startH: number } | null>(
@@ -541,58 +626,24 @@ export function DbViewerScreen({
const activeSchema = activeTab?.schema ?? "";
const activeTable = activeTab?.table ?? "";
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar
currentView={currentView}
onNavigate={handleNavigate}
/>
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
{currentView === "db-viewer" ? (
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: tablePanelWidth }}
>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
function renderQueryWorkspace() {
return (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar />
{activeTab?.tabType === "query" ? (
{!activeTab ? (
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? (
<Terminal size={32} />
) : (
<Table2 size={32} />
)}
<span>
{currentView === "queries"
? "Open a new query tab or run a query from the history"
: "Select a table from the tree to browse its data, or open a new query tab"}
</span>
</div>
) : activeTab?.tabType === "query" ? (
<Suspense
fallback={
<div className="p-4 text-text-muted">
@@ -604,6 +655,9 @@ export function DbViewerScreen({
<QueryToolbar
onRun={handleRunQuery}
onFormat={handleFormatQuery}
connectionId={connectionId}
onRestore={handleRestoreSql}
onRunFromHistory={handleRunFromHistory}
dbType={currentConnection?.db_type}
/>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -922,39 +976,78 @@ export function DbViewerScreen({
</>
)}
</div>
);
}
return (
<TooltipProvider>
<div className="h-screen bg-canvas flex border-t border-border">
<DbViewerSidebar
currentView={currentView}
onNavigate={handleNavigate}
/>
<div className="flex-1 flex flex-col min-h-0">
{connectionError && connectionError !== dismissedError && (
<ConnectionDropBanner
error={connectionError}
onRetry={() => {
setDismissedError(null);
connect();
}}
onDismiss={() => setDismissedError(connectionError)}
/>
)}
{currentView === "db-viewer" ? (
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: tablePanelWidth }}
>
<DbViewerToolbar
databases={databases}
currentDatabase={currentDatabase}
setCurrentDatabase={setCurrentDatabase}
schemas={schemas}
currentSchema={currentSchema}
setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)}
connectionId={connectionId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
<div
className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }}
>
<TableTree searchQuery={searchQuery} />
</div>
</div>
{/* panel resize handle */}
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
{renderQueryWorkspace()}
</div>
) : currentView === "objects" ? (
<ObjectExplorerPage connectionId={connectionId} />
) : currentView === "tools" ? (
<ToolsPage connectionId={connectionId} />
) : currentView === "queries" ? (
<div className="flex flex-1 min-h-0 overflow-hidden">
<QueriesPanel
connectionId={connectionId}
onRestore={handleRestoreSql}
style={{ width: queriesPanelWidth }}
/>
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onQueriesPanelResizeStart}
onDoubleClick={() => setQueriesPanelWidth(280)}
/>
{renderQueryWorkspace()}
</div>
) : currentView === "functions" ? (
<ObjectExplorerPage
key="functions"
type="functions"
connectionId={connectionId}
/>
) : currentView === "triggers" ? (
<ObjectExplorerPage
key="triggers"
type="triggers"
connectionId={connectionId}
/>
) : currentView === "sequences" ? (
<ObjectExplorerPage
key="sequences"
type="sequences"
connectionId={connectionId}
/>
) : currentView === "enums" ? (
<ObjectExplorerPage key="enums" type="enums" connectionId={connectionId} />
) : currentView === "extensions" ? (
<ObjectExplorerPage
key="extensions"
type="extensions"
connectionId={connectionId}
/>
) : currentView === "backup" ? (
<BackupPage connectionId={connectionId} />
) : currentView === "restore" ? (
<RestorePage connectionId={connectionId} />
) : currentView === "sync" ? (
<SyncPage />
) : currentView === "schema-visualizer" ? (
<SchemaVisualizerPage
connectionId={connectionId}
@@ -963,7 +1056,7 @@ export function DbViewerScreen({
}}
/>
) : null}
{currentView === "db-viewer" && <ChangesQueuePanel />}
{(currentView === "db-viewer" || currentView === "queries") && <ChangesQueuePanel />}
</div>
{currentConnection && (
<EditConnectionModal
@@ -50,4 +50,22 @@ describe("DbViewerSidebar", () => {
expect(btn).toBeInTheDocument();
expect(btn).not.toBeDisabled();
});
it("renders Queries nav item", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
});
it("renders Tools nav item", () => {
render(
<TooltipProvider>
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
</TooltipProvider>
);
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
});
});
+12 -30
View File
@@ -1,16 +1,11 @@
import {
ArrowLeftRight,
Boxes,
Clock,
Database,
Download,
FunctionSquare,
GitBranch,
Grid2x2,
DatabaseBackup,
Home,
ListOrdered,
Puzzle,
Settings,
Tag,
Upload,
Share2,
} from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
@@ -32,31 +27,18 @@ export function DbViewerSidebar({
}: DbViewerSidebarProps) {
const topItems: NavItem[] = [
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
{
id: "queries",
label: "Queries",
icon: <Clock size={16} />,
},
{
id: "schema-visualizer",
label: "Schema Visualizer",
icon: <Grid2x2 size={16} />,
},
{
id: "functions",
label: "Functions",
icon: <FunctionSquare size={16} />,
},
{ id: "triggers", label: "Triggers", icon: <GitBranch size={16} /> },
{
id: "sequences",
label: "Sequences",
icon: <ListOrdered size={16} />,
},
{ 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={16} />,
icon: <Share2 size={16} />,
},
{ id: "objects", label: "Objects", icon: <Boxes size={16} /> },
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
];
const bottomItems: NavItem[] = [
+28 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
import {
ChevronRight,
FunctionSquare,
@@ -29,7 +29,6 @@ export type ObjectType =
| "extensions";
interface ObjectExplorerPageProps {
type: ObjectType;
connectionId: string;
}
@@ -41,6 +40,10 @@ const TYPE_LABELS: Record<ObjectType, string> = {
extensions: "Extensions",
};
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
(t) => ({ value: t, label: TYPE_LABELS[t] }),
);
const SINGULAR_LABELS: Record<ObjectType, string> = {
functions: "function",
triggers: "trigger",
@@ -805,10 +808,8 @@ function renderDetail(type: ObjectType, item: AnyObject) {
}
}
export function ObjectExplorerPage({
type,
connectionId,
}: ObjectExplorerPageProps) {
export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
const [type, setType] = useState<ObjectType>("functions");
const [panelWidth, setPanelWidth] = useState(280);
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
null,
@@ -943,6 +944,18 @@ export function ObjectExplorerPage({
const label = TYPE_LABELS[type];
const singular = SINGULAR_LABELS[type];
// Switching object type: reset selection/search, clear the stale list so
// the loading state renders (no flash of the previous type's objects), and
// reset the last-fetched-schema marker so the fetch effect re-runs.
const handleTypeChange = (next: ObjectType) => {
setType(next);
setSearchQuery("");
setSelectedItem(null);
setItems(null);
setLoading(true);
lastSchemaRef.current = undefined;
};
return (
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left panel: toolbar + object list */}
@@ -952,9 +965,13 @@ export function ObjectExplorerPage({
>
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text">
{label}
</span>
<SelectDropdown
value={type}
onChange={(v) => handleTypeChange(v as ObjectType)}
options={OBJECT_TYPE_OPTIONS}
variant="ghost"
aria-label="Object type"
/>
<div className="flex items-center gap-1">
<button
aria-label="Refresh"
@@ -1144,8 +1161,8 @@ export function ObjectExplorerPage({
) : (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<div className="w-12 h-12 mx-auto rounded-full bg-surface flex items-center justify-center">
{icon}
<div className="flex justify-center">
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
</div>
<p className="text-sm">
Select a {singular} to view details
@@ -14,9 +14,9 @@ const sampleTable: TableNode = {
schema: "public",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, fk_ref: null },
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, fk_ref: null },
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
],
};
@@ -4,6 +4,7 @@ import {
MiniMap,
Controls,
Background,
BackgroundVariant,
useNodesState,
useEdgesState,
getNodesBounds,
@@ -107,7 +108,7 @@ function layoutGraph(
labelStyle: { fill: "#9ca3af", fontSize: 9 },
labelBgStyle: { fill: "#1f2937", fillOpacity: 0.85 },
labelBgPadding: [3, 1],
labelBorderRadius: 0,
labelBgBorderRadius: 0,
});
}
}
@@ -162,8 +163,8 @@ export function SchemaVisualizerPage({
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tableCount, setTableCount] = useState(0);
@@ -552,7 +553,11 @@ export function SchemaVisualizerPage({
className="bg-canvas"
proOptions={{ hideAttribution: true }}
>
<Background variant="dots" gap={20} color="var(--color-border)" />
<Background
variant={BackgroundVariant.Dots}
gap={20}
color="var(--color-border)"
/>
<MiniMap
position="bottom-right"
nodeStrokeWidth={2}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from "react";
import { SelectDropdown } from "../ui/SelectDropdown";
import { BackupPage } from "./BackupPage";
import { RestorePage } from "./RestorePage";
import { SyncPage } from "./SyncPage";
type ToolOperation = "backup" | "restore" | "sync";
const OPERATION_OPTIONS = [
{ value: "backup", label: "Backup" },
{ value: "restore", label: "Restore" },
{ value: "sync", label: "DB Sync" },
];
export function ToolsPage({ connectionId }: { connectionId: string }) {
const [operation, setOperation] = useState<ToolOperation>("backup");
return (
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
{/* Operation switcher toolbar */}
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0">
<SelectDropdown
value={operation}
onChange={(v) => setOperation(v as ToolOperation)}
options={OPERATION_OPTIONS}
variant="ghost"
aria-label="Operation"
/>
</div>
{/* Content — BackupPage/RestorePage/SyncPage each render their own
toolbar header and flex-1 overflow-y-auto scroll container, so this
wrapper only provides a definite height (h-full resolves against it). */}
<div className="flex-1 min-h-0">
{operation === "backup" && <BackupPage connectionId={connectionId} />}
{operation === "restore" && <RestorePage connectionId={connectionId} />}
{operation === "sync" && <SyncPage />}
</div>
</div>
);
}
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
import { useQueryStore } from "../../stores/queryStore";
import { TooltipProvider } from "../ui/Tooltip";
// Mock queryStore
vi.mock("../../stores/queryStore", () => ({
useQueryStore: vi.fn(),
}));
const mockLoadHistory = vi.fn();
const mockClearHistory = vi.fn();
const mockToggleFavorite = vi.fn();
function setStoreState(overrides: Record<string, unknown> = {}) {
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
loadHistory: mockLoadHistory,
clearHistory: mockClearHistory,
toggleFavorite: mockToggleFavorite,
...overrides,
}),
);
}
function renderDropdown(props: {
connectionId: string;
onRestore?: (sql: string) => void;
onRun?: (sql: string) => void;
}) {
return render(
<TooltipProvider>
<QueryHistoryDropdown
connectionId={props.connectionId}
onRestore={props.onRestore ?? (() => {})}
onRun={props.onRun ?? (() => {})}
/>
</TooltipProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
setStoreState();
});
describe("QueryHistoryDropdown", () => {
it("opens dropdown on button click and loads history if stale", async () => {
const user = userEvent.setup();
renderDropdown({ connectionId: "conn-1" });
const btn = screen.getByLabelText("Query history");
await user.click(btn);
// Should trigger loadHistory because stale
expect(mockLoadHistory).toHaveBeenCalledWith("conn-1");
// Dropdown menu should appear (check for empty state)
await waitFor(() => {
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
});
});
it("displays history items with metadata", async () => {
const user = userEvent.setup();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT * FROM users",
execution_time_ms: 42,
row_count: 10,
status: "success",
error_message: null,
executed_at: "2026-01-01T12:00:00Z",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByText(/SELECT \* FROM users/)).toBeInTheDocument();
expect(screen.getByText(/42ms/)).toBeInTheDocument();
expect(screen.getByText(/10 rows/)).toBeInTheDocument();
});
});
it("calls onRestore when Load button clicked", async () => {
const user = userEvent.setup();
const onRestore = vi.fn();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT 1",
execution_time_ms: 1,
row_count: 1,
status: "success",
error_message: null,
executed_at: "",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1", onRestore });
await user.click(screen.getByLabelText("Query history"));
await user.click(screen.getByLabelText("Load query into editor"));
expect(onRestore).toHaveBeenCalledWith("SELECT 1");
});
it("calls onRun when Run button clicked", async () => {
const user = userEvent.setup();
const onRun = vi.fn();
setStoreState({
history: [
{
id: "h1",
connection_id: "conn-1",
query_text: "SELECT 1",
execution_time_ms: 1,
row_count: 1,
status: "success",
error_message: null,
executed_at: "",
favorite: false,
},
],
historyStale: false,
});
renderDropdown({ connectionId: "conn-1", onRun });
await user.click(screen.getByLabelText("Query history"));
await user.click(screen.getByLabelText("Run query from history"));
expect(onRun).toHaveBeenCalledWith("SELECT 1");
});
it("shows loading spinner while fetching", async () => {
const user = userEvent.setup();
setStoreState({ history: null, historyLoading: true, historyStale: true });
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByTestId("query-history-spinner")).toBeInTheDocument();
});
});
it("shows error state on fetch failure", async () => {
const user = userEvent.setup();
setStoreState({
history: null,
historyLoading: false,
historyError: "Fetch failed",
historyStale: false,
});
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
await waitFor(() => {
expect(screen.getByText(/fetch failed/i)).toBeInTheDocument();
});
});
it("closes on Escape key", async () => {
const user = userEvent.setup();
renderDropdown({ connectionId: "conn-1" });
await user.click(screen.getByLabelText("Query history"));
// Menu is open
expect(screen.getByText(/no queries/i)).toBeInTheDocument();
// Press Escape
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByText(/no queries/i)).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,180 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Clock, Trash2, Download, Play, Star } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { useQueryStore } from "../../stores/queryStore";
interface QueryHistoryDropdownProps {
connectionId: string;
onRestore: (sql: string) => void;
onRun: (sql: string) => void;
}
export function QueryHistoryDropdown({
connectionId,
onRestore,
onRun,
}: QueryHistoryDropdownProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const history = useQueryStore((s) => s.history);
const loading = useQueryStore((s) => s.historyLoading);
const historyError = useQueryStore((s) => s.historyError);
const stale = useQueryStore((s) => s.historyStale);
const loadHistory = useQueryStore((s) => s.loadHistory);
const clearHistory = useQueryStore((s) => s.clearHistory);
const toggleFavorite = useQueryStore((s) => s.toggleFavorite);
// Lazy fetch on open
const handleToggle = useCallback(() => {
const willOpen = !open;
setOpen(willOpen);
if (willOpen && stale) {
loadHistory(connectionId);
}
}, [open, stale, connectionId, loadHistory]);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
const onMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div className="relative" ref={menuRef}>
<Tooltip content="Query history" side="bottom">
<button
type="button"
onClick={handleToggle}
aria-label="Query history"
className="flex items-center rounded px-2 py-1.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer text-text-muted"
>
<Clock className="h-3.5 w-3.5" />
</button>
</Tooltip>
{open && (
<div className="absolute left-0 top-full mt-1 rounded-xl bg-surface border border-border py-1 z-20 w-80 shadow-lg max-h-80 overflow-y-auto">
{loading && history === null && (
<div className="flex items-center justify-center gap-2 px-3 py-4 text-center text-sm text-text-muted">
<span
data-testid="query-history-spinner"
aria-hidden="true"
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-text-muted/30 border-t-text-muted"
/>
Loading...
</div>
)}
{historyError && !loading && (
<div className="px-3 py-4 text-center text-sm text-red-400">
{historyError}
</div>
)}
{!loading && !historyError && (!history || history.length === 0) && (
<div className="px-3 py-4 text-center text-sm text-text-muted">
No queries yet
</div>
)}
{!loading && !historyError && history && history.length > 0 && (
<>
{history.slice(0, 50).map((entry) => (
<div
key={entry.id}
className="group flex items-start gap-2 px-3 py-2 hover:bg-surface-raised border-b border-border/50 last:border-b-0"
>
<div className="flex-1 min-w-0">
<div className="text-xs text-text font-mono truncate max-w-[220px]">
{entry.query_text}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
{entry.status === "error" ? (
<span className="text-red-400">Error</span>
) : (
<>
{entry.execution_time_ms != null && (
<span>{entry.execution_time_ms}ms</span>
)}
{entry.row_count != null && (
<span>{entry.row_count} rows</span>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Tooltip content="Load into editor" side="top">
<button
type="button"
aria-label="Load query into editor"
onClick={() => {
setOpen(false);
onRestore(entry.query_text);
}}
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Download className="h-3 w-3" />
</button>
</Tooltip>
<Tooltip content="Run query" side="top">
<button
type="button"
aria-label="Run query from history"
onClick={() => {
setOpen(false);
onRun(entry.query_text);
}}
className="rounded p-1 text-text-muted hover:text-text hover:bg-surface-raised opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
<Play className="h-3 w-3" />
</button>
</Tooltip>
<button
type="button"
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
onClick={() => toggleFavorite(entry.id, connectionId)}
className={`rounded p-1 cursor-pointer ${
entry.favorite
? "text-amber-400 hover:text-amber-300"
: "text-text-muted hover:text-amber-400 opacity-0 group-hover:opacity-100 transition-opacity"
}`}
>
<Star
className="h-3 w-3"
fill={entry.favorite ? "currentColor" : "none"}
/>
</button>
</div>
</div>
))}
{/* Clear History footer */}
<button
type="button"
onClick={() => {
setOpen(false);
clearHistory(connectionId);
}}
className="flex items-center gap-1.5 w-full px-3 py-2 text-xs text-text-muted hover:text-red-400 hover:bg-surface-raised transition-colors cursor-pointer border-t border-border"
>
<Trash2 className="h-3 w-3" />
Clear History
</button>
</>
)}
</div>
)}
</div>
);
}
@@ -8,6 +8,9 @@ function renderToolbar(props: {
onRun?: () => void;
onFormat?: () => void;
dbType?: "postgresql" | "mysql" | "sqlite" | "redis";
connectionId?: string;
onRestore?: (sql: string) => void;
onRunFromHistory?: (sql: string) => void;
}) {
return render(
<TooltipProvider>
@@ -15,6 +18,9 @@ function renderToolbar(props: {
onRun={props.onRun ?? (() => {})}
onFormat={props.onFormat ?? (() => {})}
dbType={props.dbType}
connectionId={props.connectionId ?? "conn-1"}
onRestore={props.onRestore ?? (() => {})}
onRunFromHistory={props.onRunFromHistory ?? (() => {})}
/>
</TooltipProvider>,
);
@@ -136,4 +142,26 @@ describe("QueryToolbar", () => {
const button = screen.getByRole("button", { name: /auto format/i });
expect(button.textContent?.trim()).toBe("");
});
it("renders History icon button", () => {
renderToolbar({});
expect(screen.getByLabelText("Query history")).toBeInTheDocument();
});
it("renders Save Query icon button", () => {
renderToolbar({});
expect(screen.getByLabelText("Save query")).toBeInTheDocument();
});
it("orders toolbar actions: Run, History, Format, Save", () => {
renderToolbar({});
const container = screen.getByLabelText("Query toolbar actions");
const buttons = Array.from(container.querySelectorAll("button"));
const btnLabels = buttons.map((b) => b.getAttribute("aria-label"));
// Run Query first, then History, then Auto Format, then Save
expect(btnLabels[0]).toBe("Run query");
expect(btnLabels[1]).toBe("Query history");
expect(btnLabels[2]).toBe("Auto format query");
expect(btnLabels[3]).toBe("Save query");
});
});
+49 -2
View File
@@ -1,5 +1,8 @@
import { Play, Wand2 } from "lucide-react";
import { useState } from "react";
import { Play, Wand2, Save } from "lucide-react";
import { Tooltip } from "../ui/Tooltip";
import { QueryHistoryDropdown } from "./QueryHistoryDropdown";
import { SaveQueryDialog } from "./SaveQueryDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import type { DbType } from "../../lib/types";
@@ -28,6 +31,9 @@ export function queryShortcut(platform: string): {
interface QueryToolbarProps {
onRun: () => void;
onFormat: () => void;
connectionId: string;
onRestore: (sql: string) => void;
onRunFromHistory: (sql: string) => void;
dbType?: DbType;
readOnly?: boolean;
}
@@ -35,12 +41,19 @@ interface QueryToolbarProps {
export function QueryToolbar({
onRun,
onFormat,
connectionId,
onRestore,
onRunFromHistory,
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 [saveDialogOpen, setSaveDialogOpen] = useState(false);
const activeTab = tabs.find((t) => t.id === activeTabId);
const currentQueryText = activeTab?.query ?? "";
const shortcut = queryShortcut(
typeof navigator !== "undefined" ? navigator.platform : "",
@@ -56,7 +69,10 @@ export function QueryToolbar({
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
/>
)}
<div className="flex items-center gap-1">
<div
className="flex items-center gap-1"
aria-label="Query toolbar actions"
>
{/* Run Query: outline play that fills on hover; tooltip (delayed) reveals the shortcut */}
<Tooltip
content={
@@ -84,6 +100,13 @@ export function QueryToolbar({
</button>
</Tooltip>
{/* History dropdown */}
<QueryHistoryDropdown
connectionId={connectionId}
onRestore={onRestore}
onRun={onRunFromHistory}
/>
{/* Auto format: icon only with tooltip */}
<Tooltip content="Auto format query" side="bottom">
<button
@@ -96,6 +119,23 @@ export function QueryToolbar({
<Wand2 className="h-3.5 w-3.5" />
</button>
</Tooltip>
{/* Save Query icon */}
<Tooltip content="Save query" side="bottom">
<button
type="button"
aria-label="Save query"
onClick={() => {
if (currentQueryText.trim()) {
setSaveDialogOpen(true);
}
}}
disabled={readOnly || !currentQueryText.trim()}
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"
>
<Save className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
{dbType && (
@@ -105,6 +145,13 @@ export function QueryToolbar({
</span>
</div>
)}
<SaveQueryDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
connectionId={connectionId}
queryText={currentQueryText}
/>
</div>
);
}
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SaveQueryDialog } from "./SaveQueryDialog";
import { useQueryStore } from "../../stores/queryStore";
vi.mock("../../stores/queryStore", () => ({
useQueryStore: vi.fn(),
}));
const mockSaveCurrentQuery = vi.fn();
function setStoreMock() {
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector: (s: Record<string, unknown>) => unknown) =>
selector({
saveCurrentQuery: mockSaveCurrentQuery,
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
setStoreMock();
mockSaveCurrentQuery.mockResolvedValue({ id: "q-new" });
});
describe("SaveQueryDialog", () => {
it("renders nothing when closed", () => {
render(
<SaveQueryDialog
open={false}
onClose={() => {}}
connectionId="c1"
queryText="SELECT 1"
/>,
);
expect(screen.queryByText(/save query/i)).not.toBeInTheDocument();
});
it("shows form when open", () => {
render(
<SaveQueryDialog
open={true}
onClose={() => {}}
connectionId="c1"
queryText="SELECT 1"
/>,
);
expect(screen.getByText(/save query/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/query name/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/folder/i)).toBeInTheDocument();
});
it("blocks save when name is empty", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
expect(mockSaveCurrentQuery).not.toHaveBeenCalled();
});
it("saves with name + folder and closes", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.type(screen.getByPlaceholderText(/query name/i), "My Query");
await user.type(screen.getByPlaceholderText(/folder/i), "reports");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockSaveCurrentQuery).toHaveBeenCalledWith({
connectionId: "c1",
name: "My Query",
queryText: "SELECT 1",
folder: "reports",
});
});
expect(onClose).toHaveBeenCalled();
});
it("closes on cancel", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(
<SaveQueryDialog open={true} onClose={onClose} connectionId="c1" queryText="SELECT 1" />,
);
await user.click(screen.getByText(/cancel/i));
expect(onClose).toHaveBeenCalled();
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { useQueryStore } from "../../stores/queryStore";
interface SaveQueryDialogProps {
open: boolean;
onClose: () => void;
connectionId: string;
queryText: string;
}
export function SaveQueryDialog({
open,
onClose,
connectionId,
queryText,
}: SaveQueryDialogProps) {
const [name, setName] = useState("");
const [folder, setFolder] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const saveCurrentQuery = useQueryStore((s) => s.saveCurrentQuery);
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Name is required");
return;
}
setSaving(true);
setError("");
try {
await saveCurrentQuery({
connectionId,
name: trimmed,
queryText,
folder: folder.trim(),
});
// Reset form on success
setName("");
setFolder("");
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleClose = () => {
setError("");
setName("");
setFolder("");
onClose();
};
if (!open) return null;
return (
<AnimatedModal open={open} onClose={handleClose}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-1">Save Query</h3>
<p className="text-sm text-text-muted mb-4">Save the current query for later use.</p>
<div className="flex flex-col gap-3 mb-4">
<div>
<label className="block text-xs text-text-muted mb-1">Name</label>
<Input
value={name}
placeholder="Query name"
onChange={(v) => { setName(v); if (error) setError(""); }}
aria-label="Query name"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Folder (optional)</label>
<Input
value={folder}
placeholder="Folder"
onChange={setFolder}
aria-label="Folder"
/>
</div>
</div>
{error && (
<p className="text-red-400 text-xs mb-3">{error}</p>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</AnimatedModal>
);
}
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { QueriesPanel } from "./QueriesPanel";
import { useQueryStore } from "../../stores/queryStore";
import { TooltipProvider } from "../ui/Tooltip";
vi.mock("../../stores/queryStore", () => ({
useQueryStore: vi.fn(),
}));
type MockStore = Record<string, unknown>;
function setMocks(historyOverrides = {}, savedOverrides = {}) {
const store: MockStore = {
history: [],
historyLoading: false,
historyError: null,
historyStale: false,
historySearch: "",
favoritesOnly: false,
savedQueries: [],
savedLoading: false,
savedError: null,
loadHistory: vi.fn(),
clearHistory: vi.fn(),
toggleFavorite: vi.fn(),
setHistorySearch: vi.fn(),
setFavoritesOnly: vi.fn(),
loadSavedQueries: vi.fn(),
renameSavedQuery: vi.fn(),
deleteSavedQuery: vi.fn(),
...historyOverrides,
...savedOverrides,
};
(useQueryStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(sel: (s: MockStore) => unknown) => sel(store),
);
return store;
}
function renderPanel(ui: ReactElement) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
beforeEach(() => {
vi.clearAllMocks();
setMocks();
});
describe("QueriesPanel", () => {
it("renders Queries header with History/Saved dropdown", () => {
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
expect(screen.getByText("Queries")).toBeInTheDocument();
expect(screen.getByLabelText("History/Saved")).toBeInTheDocument();
// Dropdown shows the current value (History by default)
expect(screen.getByText("History")).toBeInTheDocument();
});
it("switches between History and Saved Queries via the dropdown", async () => {
const user = userEvent.setup();
setMocks({}, {
savedQueries: [
{ id: "q1", connection_id: "c1", name: "My Saved", query_text: "SELECT 2", folder: "", created_at: "", updated_at: "" },
],
savedLoading: false,
});
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await user.click(screen.getByLabelText("History/Saved"));
await user.click(screen.getByText("Saved Queries"));
await waitFor(() => {
expect(screen.getByText("My Saved")).toBeInTheDocument();
});
});
it("shows empty state when no history", async () => {
setMocks({ history: [], historyStale: false });
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await waitFor(() => {
expect(screen.getByText(/no queries yet/i)).toBeInTheDocument();
});
});
it("loads history scoped to the connection on mount", async () => {
const store = setMocks({ historyStale: true });
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await waitFor(() => {
expect(store.loadHistory).toHaveBeenCalledWith("c1");
});
});
it("loads saved queries scoped to the connection on mount", async () => {
const store = setMocks();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await waitFor(() => {
expect(store.loadSavedQueries).toHaveBeenCalledWith("c1");
});
});
it("shows history entries; clicking a row restores the query into the editor", async () => {
const onRestore = vi.fn();
const store = setMocks({
history: [
{ id: "h1", connection_id: "c1", query_text: "SELECT 1", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false },
],
historyStale: false,
});
const user = userEvent.setup();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={onRestore} />,
);
await waitFor(() => {
expect(screen.getByText(/SELECT 1/)).toBeInTheDocument();
});
// Load/Run sub-buttons are gone
expect(screen.queryByLabelText("Load query into editor")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Run query from history")).not.toBeInTheDocument();
// Clicking the row loads the query into the editor
await user.click(screen.getByText(/SELECT 1/));
expect(onRestore).toHaveBeenCalledWith("SELECT 1");
// Favorite toggle still exists, toggles the favorite, and does NOT restore
onRestore.mockClear();
await user.click(screen.getByLabelText("Favorite"));
expect(store.toggleFavorite).toHaveBeenCalledWith("h1", "c1");
expect(onRestore).not.toHaveBeenCalled();
});
it("shows saved queries; clicking a row restores it, delete works without restoring", async () => {
const onRestore = vi.fn();
const store = setMocks({}, {
savedQueries: [
{ id: "q1", connection_id: "c1", name: "My Saved", query_text: "SELECT 2", folder: "", created_at: "", updated_at: "" },
],
savedLoading: false,
});
const user = userEvent.setup();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={onRestore} />,
);
// Switch to Saved Queries via the dropdown
await user.click(screen.getByLabelText("History/Saved"));
await user.click(screen.getByText("Saved Queries"));
await waitFor(() => {
expect(screen.getByText("My Saved")).toBeInTheDocument();
});
// Load/Run sub-buttons are gone
expect(screen.queryByLabelText("Load saved query")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Run saved query")).not.toBeInTheDocument();
// Clicking the row loads the query into the editor
await user.click(screen.getByText("My Saved"));
expect(onRestore).toHaveBeenCalledWith("SELECT 2");
// Delete button exists, deletes, and does NOT restore
onRestore.mockClear();
await user.click(screen.getByLabelText("Delete saved query"));
expect(store.deleteSavedQuery).toHaveBeenCalledWith("q1");
expect(onRestore).not.toHaveBeenCalled();
});
it("shows Favorites and Clear icons in history mode only", async () => {
const user = userEvent.setup();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
expect(screen.getByLabelText("Show favorites only")).toBeInTheDocument();
expect(screen.getByLabelText("Clear history")).toBeInTheDocument();
// Switch to saved mode — the history-only icons disappear
await user.click(screen.getByLabelText("History/Saved"));
await user.click(screen.getByText("Saved Queries"));
expect(screen.queryByLabelText("Show favorites only")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Clear history")).not.toBeInTheDocument();
});
it("toggles favorites only and clears history for the connection", async () => {
const store = setMocks({ history: [], historyStale: false });
const user = userEvent.setup();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await user.click(screen.getByLabelText("Show favorites only"));
expect(store.setFavoritesOnly).toHaveBeenCalledWith(true);
await user.click(screen.getByLabelText("Clear history"));
expect(store.clearHistory).toHaveBeenCalledWith("c1");
});
it("filters history by search query and syncs to the store", async () => {
const store = setMocks({
history: [
{ id: "h1", connection_id: "c1", query_text: "SELECT 1", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false },
{ id: "h2", connection_id: "c1", query_text: "SELECT 2", execution_time_ms: 5, row_count: 1, status: "success", error_message: null, executed_at: "2026-01-01", favorite: false },
],
historyStale: false,
});
const user = userEvent.setup();
renderPanel(
<QueriesPanel connectionId="c1" onRestore={() => {}} />,
);
await user.click(screen.getByLabelText("Search queries"));
const input = screen.getByPlaceholderText("Filter queries…");
await user.type(input, "SELECT 2");
await waitFor(() => {
expect(store.setHistorySearch).toHaveBeenCalledWith("SELECT 2");
});
expect(screen.queryByText("SELECT 1")).not.toBeInTheDocument();
expect(screen.getByText("SELECT 2")).toBeInTheDocument();
});
});
+311
View File
@@ -0,0 +1,311 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Trash2, Star, Search, X } from "lucide-react";
import { useQueryStore } from "../../stores/queryStore";
import { SelectDropdown } from "../ui/SelectDropdown";
import { Tooltip } from "../ui/Tooltip";
import { ErrorBanner } from "../ui/ErrorBanner";
interface QueriesPanelProps {
connectionId: string;
onRestore: (sql: string) => void;
style?: React.CSSProperties;
}
const MODE_OPTIONS = [
{ value: "history", label: "History" },
{ value: "saved", label: "Saved Queries" },
];
export function QueriesPanel({ connectionId, onRestore, style }: QueriesPanelProps) {
const [mode, setMode] = useState<"history" | "saved">("history");
const [searchOpen, setSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// History state
const history = useQueryStore((s) => s.history);
const historyLoading = useQueryStore((s) => s.historyLoading);
const historyError = useQueryStore((s) => s.historyError);
const historyStale = useQueryStore((s) => s.historyStale);
const historySearch = useQueryStore((s) => s.historySearch);
const favoritesOnly = useQueryStore((s) => s.favoritesOnly);
const loadHistory = useQueryStore((s) => s.loadHistory);
const clearHistory = useQueryStore((s) => s.clearHistory);
const toggleFavorite = useQueryStore((s) => s.toggleFavorite);
const setHistorySearch = useQueryStore((s) => s.setHistorySearch);
const setFavoritesOnly = useQueryStore((s) => s.setFavoritesOnly);
// Saved queries state
const savedQueries = useQueryStore((s) => s.savedQueries);
const savedLoading = useQueryStore((s) => s.savedLoading);
const savedError = useQueryStore((s) => s.savedError);
const loadSavedQueries = useQueryStore((s) => s.loadSavedQueries);
const deleteSavedQuery = useQueryStore((s) => s.deleteSavedQuery);
// Shared local search state; history mode also syncs to the store
const [searchQuery, setSearchQuery] = useState(historySearch);
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value);
setHistorySearch(value);
},
[setHistorySearch],
);
// Fetch scoped to this connection only
useEffect(() => {
if (historyStale) loadHistory(connectionId);
}, [historyStale, connectionId, loadHistory]);
useEffect(() => {
loadSavedQueries(connectionId);
}, [connectionId, loadSavedQueries]);
// Focus input when search opens
useEffect(() => {
if (searchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchOpen]);
// Auto-hide on blur when empty
const handleSearchBlur = useCallback(() => {
// Small delay to allow clicks on clear button / search icon
setTimeout(() => {
if (!searchQuery.trim()) {
setSearchOpen(false);
}
}, 150);
}, [searchQuery]);
const toggleSearch = useCallback(() => {
setSearchOpen((prev) => {
const next = !prev;
if (!next) handleSearchChange(""); // clear when closing
return next;
});
}, [handleSearchChange]);
// Client-side filtering for the active mode
const filteredHistory = (history ?? []).filter((e) => {
if (favoritesOnly && !e.favorite) return false;
if (searchQuery) {
return e.query_text.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
});
const filteredSaved = (savedQueries ?? []).filter((q) => {
if (!searchQuery) return true;
const haystack = [q.name, q.folder ?? "", q.query_text]
.join(" ")
.toLowerCase();
return haystack.includes(searchQuery.toLowerCase());
});
return (
<div className="flex flex-col h-full border-r border-border shrink-0" data-testid="queries-panel" style={style}>
{/* Header row */}
<div className="px-3 pt-3 pb-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-normal text-text-muted">Queries</span>
<div className="flex items-center gap-1">
{mode === "history" && (
<>
<Tooltip
content={favoritesOnly ? "Show all queries" : "Show favorites only"}
side="bottom"
>
<button
type="button"
aria-label="Show favorites only"
onClick={() => setFavoritesOnly(!favoritesOnly)}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${
favoritesOnly
? "text-accent bg-accent/10"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<Star size={14} fill={favoritesOnly ? "currentColor" : "none"} />
</button>
</Tooltip>
<Tooltip content="Clear history" side="bottom">
<button
type="button"
aria-label="Clear history"
onClick={() => clearHistory(connectionId)}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
>
<Trash2 size={14} />
</button>
</Tooltip>
</>
)}
<Tooltip content="Search queries" side="bottom">
<button
type="button"
aria-label="Search queries"
onClick={toggleSearch}
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${
searchOpen
? "text-accent bg-accent/10"
: "text-text-muted hover:text-text hover:bg-surface-raised"
}`}
>
<Search size={14} />
</button>
</Tooltip>
</div>
</div>
{/* Animated search input — row 2 */}
<div
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="relative flex items-center">
<Search
size={12}
className="absolute left-2.5 text-text-muted pointer-events-none"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onBlur={handleSearchBlur}
placeholder="Filter queries…"
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
/>
{searchQuery && (
<button
onClick={() => handleSearchChange("")}
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
>
<X size={12} />
</button>
)}
</div>
</div>
{/* Mode dropdown — its own row, below the search */}
<div className="flex items-center">
<SelectDropdown
value={mode}
onChange={(v) => setMode(v as "history" | "saved")}
options={MODE_OPTIONS}
variant="ghost"
aria-label="History/Saved"
/>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-y-auto">
{mode === "history" && (
<>
{historyLoading && (
<div className="px-4 py-8 text-center text-sm text-text-muted">Loading...</div>
)}
{historyError && (
<ErrorBanner error={historyError} onRetry={() => loadHistory(connectionId)} />
)}
{!historyLoading && !historyError && filteredHistory.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-text-muted">
{searchQuery || favoritesOnly ? "No matching queries" : "No queries yet"}
</div>
)}
{filteredHistory.map((entry) => (
<div
key={entry.id}
role="button"
tabIndex={0}
onClick={() => onRestore(entry.query_text)}
onKeyDown={(e) => {
if (e.key === "Enter" && e.target === e.currentTarget) {
onRestore(entry.query_text);
}
}}
className="group flex items-start gap-3 px-4 py-3 hover:bg-surface-raised border-b border-border/50 transition-colors cursor-pointer"
>
<button
type="button"
aria-label={entry.favorite ? "Unfavorite" : "Favorite"}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(entry.id, entry.connection_id);
}}
className={`shrink-0 mt-0.5 cursor-pointer ${
entry.favorite ? "text-amber-400" : "text-text-muted opacity-40 group-hover:opacity-80"
}`}
>
<Star className="h-3.5 w-3.5" fill={entry.favorite ? "currentColor" : "none"} />
</button>
<div className="flex-1 min-w-0">
<div className="text-xs text-text font-mono truncate">{entry.query_text}</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-text-muted">
{entry.status === "error" ? (
<span className="text-red-400">Error</span>
) : (
<>
{entry.execution_time_ms != null && <span>{entry.execution_time_ms}ms</span>}
{entry.row_count != null && <span>{entry.row_count} rows</span>}
</>
)}
</div>
</div>
</div>
))}
</>
)}
{mode === "saved" && (
<>
{savedLoading && (
<div className="px-4 py-8 text-center text-sm text-text-muted">Loading...</div>
)}
{savedError && (
<ErrorBanner error={savedError} onRetry={() => loadSavedQueries(connectionId)} />
)}
{!savedLoading && !savedError && savedQueries && savedQueries.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-text-muted">
No saved queries yet
</div>
)}
{filteredSaved.map((q) => (
<div
key={q.id}
role="button"
tabIndex={0}
onClick={() => onRestore(q.query_text)}
onKeyDown={(e) => {
if (e.key === "Enter" && e.target === e.currentTarget) {
onRestore(q.query_text);
}
}}
className="group flex items-start gap-3 px-4 py-3 hover:bg-surface-raised border-b border-border/50 transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="text-sm text-text font-medium">{q.name}</div>
{q.folder && (
<div className="text-[10px] text-text-muted mt-0.5">{q.folder}</div>
)}
<div className="text-xs text-text-muted font-mono truncate max-w-md mt-0.5">{q.query_text}</div>
</div>
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
aria-label="Delete saved query"
onClick={(e) => {
e.stopPropagation();
deleteSavedQuery(q.id);
}}
className="rounded p-1 text-text-muted hover:text-red-400 cursor-pointer"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</>
)}
</div>
</div>
);
}
+116 -38
View File
@@ -1,4 +1,17 @@
import { createContext, useContext, useId, useRef, useState, useCallback, type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react";
import {
createContext,
useContext,
useId,
useRef,
useState,
useCallback,
useLayoutEffect,
type Dispatch,
type ReactElement,
type ReactNode,
type SetStateAction,
} from "react";
import { createPortal } from "react-dom";
interface TooltipContextValue {
activeId: string | null;
@@ -34,38 +47,23 @@ interface TooltipProps {
side?: "top" | "right" | "bottom" | "left";
}
function tooltipClasses(side: "top" | "right" | "bottom" | "left") {
switch (side) {
case "right":
return {
wrapper: "left-full ml-2 top-1/2 -translate-y-1/2",
arrow: "right-full top-1/2 -translate-y-1/2 border-r-surface-raised",
};
case "bottom":
return {
wrapper: "top-full left-1/2 -translate-x-1/2 mt-2",
arrow: "bottom-full left-1/2 -translate-x-1/2 border-b-surface-raised",
};
case "left":
return {
wrapper: "right-full mr-2 top-1/2 -translate-y-1/2",
arrow: "left-full top-1/2 -translate-y-1/2 border-l-surface-raised",
};
case "top":
default:
return {
wrapper: "bottom-full left-1/2 -translate-x-1/2 mb-2",
arrow: "top-full left-1/2 -translate-x-1/2 border-t-surface-raised",
};
}
}
const GAP = 8;
const VIEWPORT_MARGIN = 4;
/**
* Tooltip that renders into `document.body` via a portal and positions itself
* with fixed coordinates relative to its trigger. Rendering through a portal
* means tooltips are never clipped by `overflow`/`transform` ancestors (e.g.
* scrollable dropdowns or panels).
*/
export function Tooltip({ content, children, side = "top" }: TooltipProps) {
const id = useId();
const { activeId, setActiveId } = useTooltipContext();
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const isActive = activeId === id;
const tc = tooltipClasses(side);
const clearTimer = useCallback(() => {
if (showTimer.current) {
@@ -86,8 +84,64 @@ export function Tooltip({ content, children, side = "top" }: TooltipProps) {
setActiveId((prev) => (prev === id ? null : prev));
}, [clearTimer, id, setActiveId]);
const measure = useCallback(() => {
const trigger = triggerRef.current;
const tooltipEl = tooltipRef.current;
if (!trigger || !tooltipEl) return;
const tr = trigger.getBoundingClientRect();
const tt = tooltipEl.getBoundingClientRect();
let top = 0;
let left = 0;
switch (side) {
case "right":
top = tr.top + tr.height / 2 - tt.height / 2;
left = tr.right + GAP;
break;
case "bottom":
top = tr.bottom + GAP;
left = tr.left + tr.width / 2 - tt.width / 2;
break;
case "left":
top = tr.top + tr.height / 2 - tt.height / 2;
left = tr.left - tt.width - GAP;
break;
case "top":
default:
top = tr.top - tt.height - GAP;
left = tr.left + tr.width / 2 - tt.width / 2;
break;
}
// Keep the tooltip fully inside the viewport.
const vw = window.innerWidth;
const vh = window.innerHeight;
top = Math.max(VIEWPORT_MARGIN, Math.min(top, vh - tt.height - VIEWPORT_MARGIN));
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - tt.width - VIEWPORT_MARGIN));
setPos({ top, left });
}, [side]);
// Position the tooltip once it is visible, and keep it glued to the trigger
// while scrolling (capture phase catches scrolls in any container).
useLayoutEffect(() => {
if (!isActive) {
setPos(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [isActive, measure]);
return (
<span
ref={triggerRef}
className="relative inline-flex cursor-pointer"
onMouseEnter={show}
onMouseLeave={hide}
@@ -95,18 +149,42 @@ export function Tooltip({ content, children, side = "top" }: TooltipProps) {
onBlur={hide}
>
{children}
{isActive && (
<span
role="tooltip"
className={`absolute z-50 px-2 py-1 text-xs rounded-md bg-surface-raised border border-border text-text shadow-lg whitespace-nowrap ${tc.wrapper}`}
>
{content}
{isActive &&
createPortal(
<span
className={`absolute border-4 border-transparent ${tc.arrow}`}
aria-hidden="true"
/>
</span>
)}
ref={tooltipRef}
role="tooltip"
className="fixed z-50 px-2 py-1 text-xs rounded-md bg-surface-raised border border-border text-text shadow-lg whitespace-nowrap"
style={pos ?? undefined}
>
{content}
{side === "top" && (
<span
className="absolute left-1/2 -translate-x-1/2 top-full border-4 border-transparent border-t-surface-raised"
aria-hidden="true"
/>
)}
{side === "bottom" && (
<span
className="absolute left-1/2 -translate-x-1/2 bottom-full border-4 border-transparent border-b-surface-raised"
aria-hidden="true"
/>
)}
{side === "left" && (
<span
className="absolute left-full top-1/2 -translate-y-1/2 border-4 border-transparent border-l-surface-raised"
aria-hidden="true"
/>
)}
{side === "right" && (
<span
className="absolute right-full top-1/2 -translate-y-1/2 border-4 border-transparent border-r-surface-raised"
aria-hidden="true"
/>
)}
</span>,
document.body,
)}
</span>
);
}
+134 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
@@ -19,8 +19,14 @@ import {
executeQuery,
getQueryHistory,
clearQueryHistory,
setHistoryFavorite,
saveQuery,
getSavedQueries,
updateSavedQuery,
deleteSavedQuery,
} from "./commands";
import type { SchemaGraph } from "./types";
import type { QueryHistoryEntry } from "./commands";
describe("commands", () => {
it("testConnection has correct signature", () => {
@@ -102,4 +108,131 @@ describe("query commands", () => {
await clearQueryHistory("conn-1");
expect(invoke).toHaveBeenCalledWith("clear_query_history", { connectionId: "conn-1" });
});
it("getQueryHistory with null calls invoke with null connectionId", async () => {
vi.mocked(invoke).mockResolvedValueOnce([]);
await getQueryHistory(null, 50, 0);
expect(invoke).toHaveBeenCalledWith("get_query_history", {
connectionId: null,
limit: 50,
offset: 0,
});
});
it("clearQueryHistory with null clears all", async () => {
vi.mocked(invoke).mockResolvedValueOnce(undefined);
await clearQueryHistory(null);
expect(invoke).toHaveBeenCalledWith("clear_query_history", {
connectionId: null,
});
});
});
describe("Query History — v6", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("QueryHistoryEntry includes favorite field", () => {
const entry: QueryHistoryEntry = {
id: "h1",
connection_id: "c1",
query_text: "SELECT 1",
execution_time_ms: 42,
row_count: 5,
status: "success",
error_message: null,
executed_at: "2026-01-01T00:00:00Z",
favorite: false, // NEW — must be accepted
};
expect(entry.favorite).toBe(false);
});
it("setHistoryFavorite calls invoke with correct params", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await setHistoryFavorite("entry-id-1", "conn-abc");
expect(mockInvoke).toHaveBeenCalledWith("set_history_favorite", {
id: "entry-id-1",
connectionId: "conn-abc",
});
});
it("saveQuery calls invoke with correct params", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await saveQuery({
connectionId: "conn-xyz",
name: "My Saved Query",
queryText: "SELECT * FROM users",
folder: "reports",
});
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
connectionId: "conn-xyz",
name: "My Saved Query",
queryText: "SELECT * FROM users",
folder: "reports",
});
});
it("saveQuery accepts null connectionId for global queries", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await saveQuery({
connectionId: null,
name: "Global query",
queryText: "SELECT 1",
folder: "",
});
expect(mockInvoke).toHaveBeenCalledWith("save_query", {
connectionId: null,
name: "Global query",
queryText: "SELECT 1",
folder: "",
});
});
it("getSavedQueries calls invoke with correct params", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getSavedQueries("conn-123");
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
connectionId: "conn-123",
});
});
it("getSavedQueries accepts null for all-connections", async () => {
const mockInvoke = vi.fn().mockResolvedValue([]);
vi.mocked(invoke).mockImplementation(mockInvoke);
await getSavedQueries(null);
expect(mockInvoke).toHaveBeenCalledWith("get_saved_queries", {
connectionId: null,
});
});
it("updateSavedQuery calls invoke with correct params", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await updateSavedQuery("q-id", { name: "Renamed" });
expect(mockInvoke).toHaveBeenCalledWith("update_saved_query", {
id: "q-id",
patch: { name: "Renamed" },
});
});
it("deleteSavedQuery calls invoke with correct params", async () => {
const mockInvoke = vi.fn().mockResolvedValue(undefined);
vi.mocked(invoke).mockImplementation(mockInvoke);
await deleteSavedQuery("q-id");
expect(mockInvoke).toHaveBeenCalledWith("delete_saved_query", {
id: "q-id",
});
});
});
+59 -2
View File
@@ -159,6 +159,30 @@ export interface QueryHistoryEntry {
status: string;
error_message: string | null;
executed_at: string;
favorite: boolean; // NEW — v6
}
export interface SavedQuery {
id: string;
connection_id: string | null;
name: string;
query_text: string;
folder: string;
created_at: string;
updated_at: string;
}
export interface SaveQueryInput {
connectionId: string | null;
name: string;
queryText: string;
folder: string;
}
export interface UpdateSavedQueryPatch {
name?: string;
queryText?: string;
folder?: string;
}
export async function executeQuery(
@@ -171,13 +195,46 @@ export async function executeQuery(
}
export async function getQueryHistory(
connectionId: string,
connectionId: string | null,
limit: number,
offset: number,
): Promise<QueryHistoryEntry[]> {
return invoke<QueryHistoryEntry[]>("get_query_history", { connectionId, limit, offset });
}
export async function clearQueryHistory(connectionId: string): Promise<void> {
export async function clearQueryHistory(connectionId: string | null): Promise<void> {
return invoke<void>("clear_query_history", { connectionId });
}
export async function setHistoryFavorite(
id: string,
connectionId: string,
): Promise<void> {
return invoke<void>("set_history_favorite", { id, connectionId });
}
export async function saveQuery(input: SaveQueryInput): Promise<SavedQuery> {
return invoke<SavedQuery>("save_query", {
connectionId: input.connectionId,
name: input.name,
queryText: input.queryText,
folder: input.folder,
});
}
export async function getSavedQueries(
connectionId: string | null,
): Promise<SavedQuery[]> {
return invoke<SavedQuery[]>("get_saved_queries", { connectionId });
}
export async function updateSavedQuery(
id: string,
patch: UpdateSavedQueryPatch,
): Promise<void> {
return invoke<void>("update_saved_query", { id, patch });
}
export async function deleteSavedQuery(id: string): Promise<void> {
return invoke<void>("delete_saved_query", { id });
}
+4 -2
View File
@@ -372,6 +372,7 @@ describe("Schema graph types", () => {
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: false,
fk_ref: ["public", "users", "id"],
};
expect(col.name).toBe("user_id");
@@ -385,8 +386,8 @@ describe("Schema graph types", () => {
schema: "public",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, fk_ref: ["public", "users", "id"] },
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, is_nullable: false, fk_ref: ["public", "users", "id"] },
],
};
expect(node.name).toBe("orders");
@@ -424,6 +425,7 @@ describe("Schema graph types", () => {
is_pk: false,
is_fk: false,
is_unique: false,
is_nullable: true,
fk_ref: null,
};
expect(col.fk_ref).toBeNull();
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useQueryStore } from "./queryStore";
import * as commands from "../lib/commands";
// Mock the commands module
vi.mock("../lib/commands", () => ({
getQueryHistory: vi.fn().mockResolvedValue([]),
clearQueryHistory: vi.fn().mockResolvedValue(undefined),
setHistoryFavorite: vi.fn().mockResolvedValue(undefined),
getSavedQueries: vi.fn().mockResolvedValue([]),
saveQuery: vi.fn().mockResolvedValue({
id: "q-new",
connection_id: "c1",
name: "New",
query_text: "SELECT 1",
folder: "",
created_at: "2026-01-01",
updated_at: "2026-01-01",
}),
updateSavedQuery: vi.fn().mockResolvedValue(undefined),
deleteSavedQuery: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(() => {
// Reset the store to initial state before each test
useQueryStore.setState({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
savedQueries: null,
savedLoading: false,
savedError: null,
historyScope: null,
historySearch: "",
favoritesOnly: false,
});
vi.clearAllMocks();
});
describe("queryStore — history", () => {
it("loadHistory fetches and stores entries, clears stale flag", async () => {
const mockEntries = [
{
id: "h1", connection_id: "c1", query_text: "SELECT 1",
execution_time_ms: 5, row_count: 1, status: "success",
error_message: null, executed_at: "2026-01-01", favorite: false,
},
];
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
await useQueryStore.getState().loadHistory("c1");
const state = useQueryStore.getState();
expect(state.history).toEqual(mockEntries);
expect(state.historyStale).toBe(false);
expect(state.historyLoading).toBe(false);
expect(state.historyError).toBeNull();
});
it("loadHistory sets error on failure", async () => {
vi.mocked(commands.getQueryHistory).mockRejectedValueOnce(new Error("fail"));
await useQueryStore.getState().loadHistory("c1");
const state = useQueryStore.getState();
expect(state.historyError).toBe("fail");
expect(state.historyLoading).toBe(false);
});
it("clearHistory calls clearQueryHistory then resets", async () => {
await useQueryStore.getState().clearHistory("c1");
expect(commands.clearQueryHistory).toHaveBeenCalledWith("c1");
const state = useQueryStore.getState();
expect(state.history).toEqual([]);
expect(state.historyStale).toBe(false);
});
it('loadHistory("") maps the All-connections sentinel to null (global scope)', async () => {
await useQueryStore.getState().loadHistory("");
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
});
it("loadHistory(undefined) maps to null (global scope)", async () => {
await useQueryStore.getState().loadHistory(undefined);
expect(commands.getQueryHistory).toHaveBeenCalledWith(null, 200, 0);
});
it('clearHistory("") maps the All-connections sentinel to null (global scope)', async () => {
await useQueryStore.getState().clearHistory("");
expect(commands.clearQueryHistory).toHaveBeenCalledWith(null);
});
it("toggleFavorite optimistic-updates then refetches on failure", async () => {
const mockEntries = [
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
];
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(mockEntries);
await useQueryStore.getState().loadHistory("c1");
// Toggle h1
vi.mocked(commands.setHistoryFavorite).mockResolvedValueOnce(undefined);
await useQueryStore.getState().toggleFavorite("h1", "c1");
let state = useQueryStore.getState();
expect(state.history![0].favorite).toBe(true); // optimistic set
// On failure, refetch
const reverted = [
{ id: "h1", connection_id: "c1", query_text: "X", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
{ id: "h2", connection_id: "c1", query_text: "Y", execution_time_ms: null, row_count: null, status: "success", error_message: null, executed_at: "", favorite: false },
];
vi.mocked(commands.setHistoryFavorite).mockRejectedValueOnce(new Error("boom"));
vi.mocked(commands.getQueryHistory).mockResolvedValueOnce(reverted);
// First toggle h2 — should fail and refetch
await useQueryStore.getState().toggleFavorite("h2", "c1");
state = useQueryStore.getState();
expect(state.history![1].favorite).toBe(false); // rolled back via refetch
});
});
describe("queryStore — saved queries", () => {
it("loadSavedQueries fetches and stores entries", async () => {
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
await useQueryStore.getState().loadSavedQueries("c1");
const state = useQueryStore.getState();
expect(state.savedQueries).toEqual(mockSaved);
expect(state.savedLoading).toBe(false);
expect(state.savedError).toBeNull();
});
it("saveCurrentQuery calls saveQuery with correct input", async () => {
vi.mocked(commands.saveQuery).mockResolvedValueOnce({
id: "new-q", connection_id: "c1", name: "MyQ", query_text: "SELECT 2", folder: "r", created_at: "", updated_at: "",
});
await useQueryStore.getState().saveCurrentQuery({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
expect(commands.saveQuery).toHaveBeenCalledWith({ connectionId: "c1", name: "MyQ", queryText: "SELECT 2", folder: "r" });
});
it("renameSavedQuery calls updateSavedQuery", async () => {
await useQueryStore.getState().renameSavedQuery("q1", { name: "Renamed" });
expect(commands.updateSavedQuery).toHaveBeenCalledWith("q1", { name: "Renamed" });
});
it("deleteSavedQuery calls deleteSavedQuery and removes from list", async () => {
const mockSaved = [{ id: "q1", connection_id: "c1", name: "Q1", query_text: "SELECT 1", folder: "", created_at: "", updated_at: "" }];
vi.mocked(commands.getSavedQueries).mockResolvedValueOnce(mockSaved);
await useQueryStore.getState().loadSavedQueries("c1");
vi.mocked(commands.deleteSavedQuery).mockResolvedValueOnce(undefined);
await useQueryStore.getState().deleteSavedQuery("q1");
expect(commands.deleteSavedQuery).toHaveBeenCalledWith("q1");
});
});
+132
View File
@@ -0,0 +1,132 @@
import { create } from "zustand";
import type { QueryHistoryEntry, SavedQuery, SaveQueryInput, UpdateSavedQueryPatch } from "../lib/commands";
import {
getQueryHistory,
clearQueryHistory,
setHistoryFavorite,
getSavedQueries,
saveQuery,
updateSavedQuery,
deleteSavedQuery,
} from "../lib/commands";
interface QueryState {
// History
history: QueryHistoryEntry[] | null;
historyLoading: boolean;
historyError: string | null;
historyStale: boolean;
historyScope: string | null; // null = all connections, string = connectionId
historySearch: string;
favoritesOnly: boolean;
// Saved queries
savedQueries: SavedQuery[] | null;
savedLoading: boolean;
savedError: string | null;
// Actions — History
loadHistory: (connectionId?: string) => Promise<void>;
clearHistory: (connectionId?: string) => Promise<void>;
toggleFavorite: (id: string, connectionId: string) => Promise<void>;
invalidateHistory: (connectionId: string) => void;
setHistoryScope: (scope: string | null) => void;
setHistorySearch: (search: string) => void;
setFavoritesOnly: (on: boolean) => void;
// Actions — Saved queries
loadSavedQueries: (connectionId?: string | null) => Promise<void>;
saveCurrentQuery: (input: SaveQueryInput) => Promise<SavedQuery>;
renameSavedQuery: (id: string, patch: UpdateSavedQueryPatch) => Promise<void>;
deleteSavedQuery: (id: string) => Promise<void>;
}
export const useQueryStore = create<QueryState>((set, get) => ({
history: null,
historyLoading: false,
historyError: null,
historyStale: true,
historyScope: null,
historySearch: "",
favoritesOnly: false,
savedQueries: null,
savedLoading: false,
savedError: null,
loadHistory: async (connectionId) => {
set({ historyLoading: true, historyError: null });
try {
const rows = await getQueryHistory(connectionId || null, 200, 0);
set({ history: rows, historyStale: false, historyLoading: false });
} catch (e) {
set({ historyError: e instanceof Error ? e.message : String(e), historyLoading: false });
}
},
clearHistory: async (connectionId) => {
await clearQueryHistory(connectionId || null);
set({ history: [], historyStale: false });
},
toggleFavorite: async (id, connectionId) => {
const prev = get().history;
if (prev) {
// Optimistic toggle
set({
history: prev.map((e) =>
e.id === id ? { ...e, favorite: !e.favorite } : e,
),
});
}
try {
await setHistoryFavorite(id, connectionId);
} catch {
// Roll back by refetching
await get().loadHistory(connectionId);
}
},
invalidateHistory: (connectionId) => {
// Only invalidate if the current scope includes this connection
const scope = get().historyScope;
if (scope === null || scope === connectionId) {
set({ historyStale: true });
}
},
setHistoryScope: (scope) => set({ historyScope: scope, historyStale: true }),
setHistorySearch: (search) => set({ historySearch: search }),
setFavoritesOnly: (on) => set({ favoritesOnly: on }),
loadSavedQueries: async (connectionId) => {
set({ savedLoading: true, savedError: null });
try {
const rows = await getSavedQueries(connectionId ?? null);
set({ savedQueries: rows, savedLoading: false });
} catch (e) {
set({ savedError: e instanceof Error ? e.message : String(e), savedLoading: false });
}
},
saveCurrentQuery: async (input) => {
const result = await saveQuery(input);
// Refresh the list
await get().loadSavedQueries(input.connectionId);
return result;
},
renameSavedQuery: async (id, patch) => {
await updateSavedQuery(id, patch);
// Refresh — we don't know which scope the panel was viewing, so reload all
await get().loadSavedQueries(null);
},
deleteSavedQuery: async (id) => {
await deleteSavedQuery(id);
// Optimistic: remove from local state
const prev = get().savedQueries;
if (prev) {
set({ savedQueries: prev.filter((q) => q.id !== id) });
}
},
}));
+67
View File
@@ -0,0 +1,67 @@
// Bun test runner DOM setup: `bun test` uses Bun's native runner, which does
// not read vite.config.ts and provides no DOM. Register jsdom globals (plus the
// ResizeObserver polyfill used by @xyflow/react) so the vitest-authored suite
// runs under `bun test` too. This MUST load before any module that imports
// @testing-library/react, because bun caches modules process-wide and
// testing-library's `screen` binds `document.body` at module-evaluation time.
import { JSDOM } from "jsdom";
if (typeof globalThis.document === "undefined") {
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
url: "http://localhost/",
pretendToBeVisual: true,
});
const { window } = dom;
for (const key of Object.getOwnPropertyNames(window)) {
if (
key !== "window" &&
key !== "self" &&
key !== "top" &&
!(key in globalThis)
) {
(globalThis as Record<string, unknown>)[key] = (
window as unknown as Record<string, unknown>
)[key];
}
}
globalThis.window = window as unknown as Window & typeof globalThis;
globalThis.document = window.document;
globalThis.navigator = window.navigator;
globalThis.HTMLElement = window.HTMLElement;
globalThis.Element = window.Element;
globalThis.Node = window.Node;
globalThis.getComputedStyle = window.getComputedStyle.bind(window);
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) =>
setTimeout(() => cb(Date.now()), 0) as unknown as number;
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
globalThis.matchMedia =
globalThis.matchMedia ||
((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}));
}
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
}
// jsdom does not implement the execCommand family; monaco-editor probes
// document.queryCommandSupported at import time and crashes without it.
if (typeof globalThis.document.queryCommandSupported !== "function") {
globalThis.document.queryCommandSupported = () => false;
globalThis.document.queryCommandEnabled = () => false;
globalThis.document.execCommand = () => false;
}
+23
View File
@@ -0,0 +1,23 @@
// Bun test runner setup: register testing-library cleanup (bun does not inject
// a global `afterEach`, so RTL's auto-cleanup never hooks in) and minimal
// vitest-compat shims for `vi.mocked`/`vi.hoisted` (bun's compat `vi` omits
// them). Loaded via bunfig.toml [test].preload — bun-dom.ts must run first so
// `document` exists before @testing-library/react evaluates.
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";
// NOTE: under `bun test`, importing from "vitest" resolves to bun's built-in
// vitest-compat module (which provides afterEach and a limited vi), while under
// `tsc` it resolves to the real vitest types.
// @testing-library/react auto-cleanup relies on a global `afterEach`, which
// bun does not inject; register it explicitly so DOM doesn't leak between tests.
afterEach(() => {
cleanup();
});
// Minimal vi shims matching vitest semantics used by the suite.
(vi as unknown as { mocked: unknown }).mocked = (m: unknown) => m;
(vi as unknown as { hoisted: unknown }).hoisted = <T>(factory: () => T): T =>
factory();
+4
View File
@@ -0,0 +1,4 @@
// jsdom ships no type declarations and @types/jsdom is not installed; this
// ambient declaration keeps `src/test/bun-dom.ts` (used only by `bun test`)
// type-clean. The JSDOM API is consumed through `any`.
declare module "jsdom";
+1 -1
View File
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom";
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
global.ResizeObserver = class ResizeObserver {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}