feat: add connection colors, query history, SQL autocomplete, and EXPLAIN visualizer

Add four developer/QA features:
- Connection color coding: color picker in dialog, colored indicators across toolbar, tabs, status bar, and connection selectors
- Query history: Rust backend with JSON file storage (500 entry cap), sidebar panel with search/clear, auto-recording from workspace
- Schema-aware SQL autocomplete: backend fetches column metadata, CodeMirror receives schema namespace with public tables unprefixed
- EXPLAIN ANALYZE visualizer: recursive tree view with cost-colored bars, expand/collapse nodes, buffers info, Results/Explain tab toggle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:22:10 +03:00
parent 72c362dfae
commit 3b3e225e8f
21 changed files with 791 additions and 37 deletions

View File

@@ -6,11 +6,15 @@ import {
} from "@/components/ui/resizable";
import { SqlEditor } from "@/components/editor/SqlEditor";
import { ResultsPanel } from "@/components/results/ResultsPanel";
import { ExplainView } from "@/components/results/ExplainView";
import { useQueryExecution } from "@/hooks/use-query-execution";
import { useAddHistory } from "@/hooks/use-history";
import { useCompletionSchema } from "@/hooks/use-completion-schema";
import { useConnections } from "@/hooks/use-connections";
import { useAppStore } from "@/stores/app-store";
import { Button } from "@/components/ui/button";
import { Play, Loader2, Lock } from "lucide-react";
import type { QueryResult } from "@/types";
import { Play, Loader2, Lock, BarChart3 } from "lucide-react";
import type { QueryResult, ExplainResult } from "@/types";
interface Props {
connectionId: string;
@@ -26,12 +30,22 @@ export function WorkspacePanel({
onResult,
}: Props) {
const readOnlyMap = useAppStore((s) => s.readOnlyMap);
const currentDatabase = useAppStore((s) => s.currentDatabase);
const isReadOnly = readOnlyMap[connectionId] ?? true;
const [sqlValue, setSqlValue] = useState(initialSql);
const [result, setResult] = useState<QueryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
const [resultView, setResultView] = useState<"results" | "explain">("results");
const queryMutation = useQueryExecution();
const addHistoryMutation = useAddHistory();
const { data: connections } = useConnections();
const { data: completionSchema } = useCompletionSchema(connectionId);
const connName =
connections?.find((c) => c.id === connectionId)?.name ?? "unknown";
const handleChange = useCallback(
(val: string) => {
@@ -41,9 +55,35 @@ export function WorkspacePanel({
[onSqlChange]
);
const recordHistory = useCallback(
(
sql: string,
status: "success" | "error",
executionTimeMs: number,
rowCount?: number,
errorMessage?: string
) => {
addHistoryMutation.mutate({
id: crypto.randomUUID(),
connection_id: connectionId,
connection_name: connName,
database: currentDatabase ?? "",
sql,
status,
error_message: errorMessage,
row_count: rowCount,
execution_time_ms: executionTimeMs,
executed_at: new Date().toISOString(),
});
},
[addHistoryMutation, connectionId, connName, currentDatabase]
);
const handleExecute = useCallback(() => {
if (!sqlValue.trim() || !connectionId) return;
setError(null);
setExplainData(null);
setResultView("results");
queryMutation.mutate(
{ connectionId, sql: sqlValue },
{
@@ -51,15 +91,55 @@ export function WorkspacePanel({
setResult(data);
setError(null);
onResult?.(data, null);
recordHistory(
sqlValue,
"success",
data.execution_time_ms,
data.row_count
);
},
onError: (err) => {
setResult(null);
setError(String(err));
onResult?.(null, String(err));
recordHistory(sqlValue, "error", 0, undefined, String(err));
},
}
);
}, [connectionId, sqlValue, queryMutation, onResult]);
}, [connectionId, sqlValue, queryMutation, onResult, recordHistory]);
const handleExplain = useCallback(() => {
if (!sqlValue.trim() || !connectionId) return;
setError(null);
const explainSql = `EXPLAIN (ANALYZE, COSTS, BUFFERS, FORMAT JSON) ${sqlValue}`;
queryMutation.mutate(
{ connectionId, sql: explainSql },
{
onSuccess: (data) => {
try {
const raw = data.rows[0]?.[0];
const parsed =
typeof raw === "string" ? JSON.parse(raw) : raw;
const plan: ExplainResult = Array.isArray(parsed)
? parsed[0]
: parsed;
setExplainData(plan);
setResultView("explain");
setResult(null);
setError(null);
} catch {
setError("Failed to parse EXPLAIN output");
setExplainData(null);
}
},
onError: (err) => {
setResult(null);
setError(String(err));
setExplainData(null);
},
}
);
}, [connectionId, sqlValue, queryMutation]);
return (
<ResizablePanelGroup orientation="vertical">
@@ -73,13 +153,27 @@ export function WorkspacePanel({
onClick={handleExecute}
disabled={queryMutation.isPending || !sqlValue.trim()}
>
{queryMutation.isPending ? (
{queryMutation.isPending && resultView === "results" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Play className="h-3 w-3" />
)}
Run
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={handleExplain}
disabled={queryMutation.isPending || !sqlValue.trim()}
>
{queryMutation.isPending && resultView === "explain" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<BarChart3 className="h-3 w-3" />
)}
Explain
</Button>
<span className="text-[11px] text-muted-foreground">
Ctrl+Enter to execute
</span>
@@ -95,17 +189,48 @@ export function WorkspacePanel({
value={sqlValue}
onChange={handleChange}
onExecute={handleExecute}
schema={completionSchema}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
<ResultsPanel
result={result}
error={error}
isLoading={queryMutation.isPending}
/>
{(explainData || result || error) && (
<div className="flex border-b text-xs">
<button
className={`px-3 py-1 font-medium ${
resultView === "results"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultView("results")}
>
Results
</button>
{explainData && (
<button
className={`px-3 py-1 font-medium ${
resultView === "explain"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setResultView("explain")}
>
Explain
</button>
)}
</div>
)}
{resultView === "explain" && explainData ? (
<ExplainView data={explainData} />
) : (
<ResultsPanel
result={result}
error={error}
isLoading={queryMutation.isPending && resultView === "results"}
/>
)}
</ResizablePanel>
</ResizablePanelGroup>
);