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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user