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