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

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

* feat: add isDestructiveQuery utility (Task 2)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: lockfile for html-to-image

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

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

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

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

* style: reduce tab bar height to 32px

* style: revert tab bar height to 36px

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

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

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

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

* feat: distinguish table and query tabs with icons

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

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

* style: blue rounded Run Query button in query toolbar

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

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

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

* feat: show placeholder text in empty query editor

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

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

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

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

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

* feat: resizable + collapsible query results panel

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

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

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

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

* docs: mark tab-bar Changes queue button in AGENTS.md and README
This commit is contained in:
2026-08-01 05:56:47 +08:00
committed by GitHub
parent 4f18993e70
commit 80962d7d11
66 changed files with 5681 additions and 509 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
MiniMap,
@@ -6,18 +6,24 @@ import {
Background,
useNodesState,
useEdgesState,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "dagre";
import { RotateCcw, ChevronUp, ChevronDown } from "lucide-react";
import { RotateCcw, ChevronUp, ChevronDown, Download, Loader2 } from "lucide-react";
import { toPng, toJpeg, toSvg } from "html-to-image";
import { save } from "@tauri-apps/plugin-dialog";
import { writeFile } from "@tauri-apps/plugin-fs";
import { CrowsFootEdge } from "./CrowsFootEdge";
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
import { LEGEND_ITEMS } from "./legendHelpers";
import { SelectDropdown } from "../ui/SelectDropdown";
import { getSchemaGraph } from "../../lib/commands";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import type { SchemaGraph, TableNode as TableNodeType } from "../../lib/types";
const nodeTypes = { tableNode: SchemaVisualizerNode };
@@ -27,6 +33,20 @@ const CARD_WIDTH = 240;
const ROW_HEIGHT = 28;
const HEADER_HEIGHT = 32;
// Export size for "Entire Schema" renders
const EXPORT_WIDTH = 1600;
const EXPORT_HEIGHT = 1000;
/**
* Decode an html-to-image data URL (base64 or URL-encoded) into bytes so it
* can be written to disk via the Tauri fs plugin.
*/
function dataUrlToBytes(dataUrl: string): Uint8Array {
const [meta, payload] = dataUrl.split(",");
const raw = /;base64/i.test(meta) ? atob(payload) : decodeURIComponent(payload);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
function getNodeHeight(colCount: number): number {
return HEADER_HEIGHT + colCount * ROW_HEIGHT + 4;
}
@@ -59,6 +79,8 @@ function layoutGraph(
position: { x: 0, y: 0 },
data: { table, isExternal: false },
style: { width: CARD_WIDTH },
width: CARD_WIDTH,
height,
});
}
@@ -148,6 +170,134 @@ export function SchemaVisualizerPage({
const [legendOpen, setLegendOpen] = useState(true);
const [highlightedEdge, setHighlightedEdge] = useState<string | null>(null);
// Export state
const containerRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const [exportScope, setExportScope] = useState<"schema" | "viewport">(
"schema",
);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportBackground, setExportBackground] = useState<
"opaque" | "transparent"
>("opaque");
const transparent = exportBackground === "transparent";
const notify = useNotificationStore((s) => s.notify);
// Close the export menu on outside click (ignoring the trigger button)
useEffect(() => {
if (!exportOpen) return;
const close = (e: MouseEvent) => {
const target = e.target as Element | null;
if (exportButtonRef.current?.contains(target)) return;
if (exportMenuRef.current?.contains(target)) return;
setExportOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [exportOpen]);
const handleExport = useCallback(
async (scope: "schema" | "viewport", format: "png" | "jpeg" | "svg") => {
const element = document.querySelector<HTMLElement>(
".react-flow__viewport",
);
if (!element) return;
setExporting(true);
setExportError(null);
try {
let width: number;
let height: number;
let style: Partial<CSSStyleDeclaration> | undefined;
if (scope === "viewport") {
const container = containerRef.current;
width = container?.clientWidth || 1024;
height = container?.clientHeight || 768;
} else {
width = EXPORT_WIDTH;
height = EXPORT_HEIGHT;
const bounds = getNodesBounds(nodes);
if (bounds.width === 0 && bounds.height === 0) {
throw new Error("Nothing to export");
}
const viewport = getViewportForBounds(
bounds,
width,
height,
0.5,
2,
0.05,
);
style = {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
};
}
const options = {
// JPEG has no alpha channel; transparency only applies to PNG/SVG
...(transparent && format !== "jpeg"
? {}
: { backgroundColor: "#0a0a0b" }),
width,
height,
style,
pixelRatio: 2,
};
const dataUrl =
format === "png"
? await toPng(element, options)
: format === "jpeg"
? await toJpeg(element, { ...options, quality: 0.95 })
: await toSvg(element, options);
// Filename: <db name>-<locale timestamp>.<ext>
const dbName = currentDatabase ?? currentSchema ?? "schema";
const timestamp = new Date()
.toLocaleString()
.replace(/[\\/:*?"<>|]/g, "-")
.replace(/\s+/g, "-");
const ext = format === "jpeg" ? "jpg" : format;
const filename = `${dbName}-${timestamp}.${ext}`;
const bytes = dataUrlToBytes(dataUrl);
let savedPath: string | null = null;
try {
const path = await save({
defaultPath: filename,
filters: [
{ name: format.toUpperCase(), extensions: [ext] },
],
});
if (path) {
await writeFile(path, bytes);
savedPath = path;
}
} catch {
// Not running in Tauri (e.g. plain browser dev): fall back to the
// webview's default download handler.
const a = document.createElement("a");
a.href = dataUrl;
a.download = filename;
a.click();
}
if (savedPath) {
notify(`Schema exported to ${savedPath}`, "success");
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setExportError(`Export failed: ${msg}`);
} finally {
setExporting(false);
setExportOpen(false);
}
},
[nodes, currentSchema, currentDatabase, exportBackground, notify],
);
const fetchGraph = useCallback(async () => {
if (!currentSchema) return;
setLoading(true);
@@ -240,7 +390,7 @@ export function SchemaVisualizerPage({
return (
<div className="flex-1 flex flex-col min-h-0 bg-canvas">
{/* Toolbar */}
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-10">
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-20">
<div className="flex items-center gap-2">
{databases.length > 1 && (
<SelectDropdown
@@ -276,10 +426,90 @@ export function SchemaVisualizerPage({
<RotateCcw size={12} />
Reset Layout
</button>
{/* Export */}
<div className="flex items-center gap-2">
{exportError && (
<span className="text-[11px] text-red-400 max-w-56 truncate">
{exportError}
</span>
)}
<div className="relative">
<button
type="button"
ref={exportButtonRef}
onClick={() => setExportOpen((v) => !v)}
disabled={exporting}
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised disabled:opacity-50"
>
{exporting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Download size={12} />
)}
{exporting ? "Exporting…" : "Export"}
<ChevronDown size={12} />
</button>
{exportOpen && !exporting && (
<div
ref={exportMenuRef}
className="absolute right-0 top-full mt-1 z-30 w-52 rounded-lg bg-surface border border-border shadow-lg py-2 px-2"
>
<div className="px-1 pb-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Scope
</div>
<SelectDropdown
value={exportScope}
onChange={(v) =>
setExportScope(v as "schema" | "viewport")
}
options={[
{ value: "schema", label: "Entire Schema" },
{ value: "viewport", label: "Viewport" },
]}
aria-label="Export scope"
variant="pill"
/>
<div className="px-1 pb-1.5 pt-1.5 text-[10px] text-text-muted uppercase tracking-wider">
Background
</div>
<SelectDropdown
value={exportBackground}
onChange={(v) =>
setExportBackground(v as "opaque" | "transparent")
}
options={[
{ value: "opaque", label: "Opaque" },
{ value: "transparent", label: "Transparent" },
]}
aria-label="Export background"
variant="pill"
/>
<div className="border-t border-border my-1.5" />
{[
{ format: "png" as const, label: "PNG" },
{ format: "jpeg" as const, label: "JPEG" },
{ format: "svg" as const, label: "SVG" },
]
.filter((f) => !(transparent && f.format === "jpeg"))
.map(({ format, label }) => (
<button
key={format}
type="button"
onClick={() => handleExport(exportScope, format)}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
{label}
</button>
))}
</div>
)}
</div>
</div>
</div>
{/* Canvas */}
<div className="flex-1 min-h-0 relative">
<div className="flex-1 min-h-0 relative" ref={containerRef}>
{loading && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-canvas/80">
<p className="text-text-muted text-sm">Loading schema...</p>