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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user