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

@@ -0,0 +1,76 @@
use crate::error::{TuskError, TuskResult};
use crate::models::history::HistoryEntry;
use std::fs;
use tauri::{AppHandle, Manager};
fn get_history_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| TuskError::Custom(e.to_string()))?;
fs::create_dir_all(&dir)?;
Ok(dir.join("query_history.json"))
}
#[tauri::command]
pub async fn add_history_entry(app: AppHandle, entry: HistoryEntry) -> TuskResult<()> {
let path = get_history_path(&app)?;
let mut entries = if path.exists() {
let data = fs::read_to_string(&path)?;
serde_json::from_str::<Vec<HistoryEntry>>(&data).unwrap_or_default()
} else {
vec![]
};
entries.insert(0, entry);
entries.truncate(500);
let data = serde_json::to_string_pretty(&entries)?;
fs::write(&path, data)?;
Ok(())
}
#[tauri::command]
pub async fn get_history(
app: AppHandle,
connection_id: Option<String>,
search: Option<String>,
limit: Option<usize>,
) -> TuskResult<Vec<HistoryEntry>> {
let path = get_history_path(&app)?;
if !path.exists() {
return Ok(vec![]);
}
let data = fs::read_to_string(&path)?;
let entries: Vec<HistoryEntry> = serde_json::from_str(&data).unwrap_or_default();
let filtered: Vec<HistoryEntry> = entries
.into_iter()
.filter(|e| {
if let Some(ref cid) = connection_id {
if &e.connection_id != cid {
return false;
}
}
if let Some(ref s) = search {
let lower = s.to_lowercase();
if !e.sql.to_lowercase().contains(&lower) {
return false;
}
}
true
})
.take(limit.unwrap_or(100))
.collect();
Ok(filtered)
}
#[tauri::command]
pub async fn clear_history(app: AppHandle) -> TuskResult<()> {
let path = get_history_path(&app)?;
if path.exists() {
fs::remove_file(&path)?;
}
Ok(())
}

View File

@@ -1,5 +1,6 @@
pub mod connections;
pub mod data;
pub mod export;
pub mod history;
pub mod queries;
pub mod schema;

View File

@@ -2,6 +2,7 @@ use crate::error::{TuskError, TuskResult};
use crate::models::schema::{ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
use crate::state::AppState;
use sqlx::Row;
use std::collections::HashMap;
use tauri::State;
#[tauri::command]
@@ -340,3 +341,40 @@ pub async fn get_table_indexes(
})
.collect())
}
#[tauri::command]
pub async fn get_completion_schema(
state: State<'_, AppState>,
connection_id: String,
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
let pools = state.pools.read().await;
let pool = pools
.get(&connection_id)
.ok_or(TuskError::NotConnected(connection_id))?;
let rows = sqlx::query(
"SELECT table_schema, table_name, column_name \
FROM information_schema.columns \
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
ORDER BY table_schema, table_name, ordinal_position",
)
.fetch_all(pool)
.await
.map_err(TuskError::Database)?;
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
for row in &rows {
let schema: String = row.get(0);
let table: String = row.get(1);
let column: String = row.get(2);
result
.entry(schema)
.or_default()
.entry(table)
.or_default()
.push(column);
}
Ok(result)
}

View File

@@ -34,6 +34,7 @@ pub fn run() {
commands::schema::get_table_columns,
commands::schema::get_table_constraints,
commands::schema::get_table_indexes,
commands::schema::get_completion_schema,
// data
commands::data::get_table_data,
commands::data::update_row,
@@ -42,6 +43,10 @@ pub fn run() {
// export
commands::export::export_csv,
commands::export::export_json,
// history
commands::history::add_history_entry,
commands::history::get_history,
commands::history::clear_history,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: String,
pub connection_id: String,
pub connection_name: String,
pub database: String,
pub sql: String,
pub status: String,
pub error_message: Option<String>,
pub row_count: Option<i64>,
pub execution_time_ms: f64,
pub executed_at: String,
}

View File

@@ -1,3 +1,4 @@
pub mod connection;
pub mod history;
pub mod query_result;
pub mod schema;

View File

@@ -18,7 +18,18 @@ import {
import { useSaveConnection, useTestConnection } from "@/hooks/use-connections";
import { toast } from "sonner";
import type { ConnectionConfig } from "@/types";
import { Loader2 } from "lucide-react";
import { Loader2, X } from "lucide-react";
const CONNECTION_COLORS = [
{ name: "Red", value: "#ef4444" },
{ name: "Orange", value: "#f97316" },
{ name: "Yellow", value: "#eab308" },
{ name: "Green", value: "#22c55e" },
{ name: "Cyan", value: "#06b6d4" },
{ name: "Blue", value: "#3b82f6" },
{ name: "Purple", value: "#a855f7" },
{ name: "Pink", value: "#ec4899" },
];
interface Props {
open: boolean;
@@ -173,6 +184,37 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) {
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">
Color
</label>
<div className="col-span-3 flex items-center gap-1.5">
<button
type="button"
className={`flex h-6 w-6 items-center justify-center rounded-full border-2 ${
!form.color ? "border-primary" : "border-transparent"
} bg-muted`}
onClick={() => setForm((f) => ({ ...f, color: undefined }))}
title="No color"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
{CONNECTION_COLORS.map((c) => (
<button
key={c.value}
type="button"
className={`h-6 w-6 rounded-full border-2 ${
form.color === c.value
? "border-primary"
: "border-transparent"
}`}
style={{ backgroundColor: c.value }}
onClick={() => setForm((f) => ({ ...f, color: c.value }))}
title={c.name}
/>
))}
</div>
</div>
</div>
<DialogFooter>

View File

@@ -91,7 +91,14 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
isActive ? "border-primary bg-accent" : ""
}`}
>
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
{conn.color ? (
<span
className="h-4 w-4 shrink-0 rounded-full"
style={{ backgroundColor: conn.color }}
/>
) : (
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{conn.name}

View File

@@ -21,18 +21,36 @@ export function ConnectionSelector() {
);
}
const activeConn = connectedList.find((c) => c.id === activeConnectionId);
return (
<Select
value={activeConnectionId ?? undefined}
onValueChange={setActiveConnectionId}
>
<SelectTrigger className="h-7 w-[200px] text-xs">
<SelectValue placeholder="Select connection" />
<div className="flex items-center gap-1.5">
{activeConn?.color && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: activeConn.color }}
/>
)}
<SelectValue placeholder="Select connection" />
</div>
</SelectTrigger>
<SelectContent>
{connectedList.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name}
<div className="flex items-center gap-1.5">
{conn.color && (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: conn.color }}
/>
)}
{conn.name}
</div>
</SelectItem>
))}
</SelectContent>

View File

@@ -7,9 +7,25 @@ interface Props {
value: string;
onChange: (value: string) => void;
onExecute: () => void;
schema?: Record<string, Record<string, string[]>>;
}
export function SqlEditor({ value, onChange, onExecute }: Props) {
function buildSqlNamespace(
schema: Record<string, Record<string, string[]>>
): Record<string, string[]> {
const ns: Record<string, string[]> = {};
for (const [schemaName, tables] of Object.entries(schema)) {
for (const [tableName, columns] of Object.entries(tables)) {
ns[`${schemaName}.${tableName}`] = columns;
if (schemaName === "public") {
ns[tableName] = columns;
}
}
}
return ns;
}
export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
const handleChange = useCallback(
(val: string) => {
onChange(val);
@@ -17,9 +33,14 @@ export function SqlEditor({ value, onChange, onExecute }: Props) {
[onChange]
);
const extensions = useMemo(
() => [
sql({ dialect: PostgreSQL }),
const extensions = useMemo(() => {
const sqlNamespace = schema ? buildSqlNamespace(schema) : undefined;
return [
sql({
dialect: PostgreSQL,
schema: sqlNamespace,
defaultSchema: "public",
}),
keymap.of([
{
key: "Ctrl-Enter",
@@ -36,9 +57,8 @@ export function SqlEditor({ value, onChange, onExecute }: Props) {
},
},
]),
],
[onExecute]
);
];
}, [onExecute, schema]);
return (
<CodeMirror

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { useHistory, useClearHistory } from "@/hooks/use-history";
import { useAppStore } from "@/stores/app-store";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Trash2, CheckCircle, XCircle } from "lucide-react";
import type { Tab } from "@/types";
export function HistoryPanel() {
const [search, setSearch] = useState("");
const { activeConnectionId, addTab } = useAppStore();
const { data: entries } = useHistory(undefined, search || undefined);
const clearMutation = useClearHistory();
const handleClick = (sql: string, connectionId: string) => {
const cid = activeConnectionId ?? connectionId;
const tab: Tab = {
id: crypto.randomUUID(),
type: "query",
title: "History Query",
connectionId: cid,
sql,
};
addTab(tab);
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-1 p-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search SQL..."
className="h-7 pl-7 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-destructive"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
title="Clear history"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{entries?.map((entry) => (
<button
key={entry.id}
className="flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent"
onClick={() => handleClick(entry.sql, entry.connection_id)}
>
<div className="flex items-center gap-1.5">
{entry.status === "success" ? (
<CheckCircle className="h-3 w-3 shrink-0 text-green-500" />
) : (
<XCircle className="h-3 w-3 shrink-0 text-red-500" />
)}
<span className="truncate font-mono text-foreground">
{entry.sql.length > 80
? entry.sql.slice(0, 80) + "..."
: entry.sql}
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>{entry.execution_time_ms} ms</span>
<span className="rounded bg-muted px-1">
{entry.connection_name}
</span>
<span className="ml-auto">
{new Date(entry.executed_at).toLocaleTimeString()}
</span>
</div>
</button>
))}
{(!entries || entries.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No history entries
</div>
)}
</div>
</div>
);
}

View File

@@ -1,27 +1,60 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { SchemaTree } from "@/components/schema/SchemaTree";
import { HistoryPanel } from "@/components/history/HistoryPanel";
import { Search } from "lucide-react";
type SidebarView = "schema" | "history";
export function Sidebar() {
const [view, setView] = useState<SidebarView>("schema");
const [search, setSearch] = useState("");
return (
<div className="flex h-full flex-col bg-card">
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search objects..."
className="h-7 pl-7 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<SchemaTree />
<div className="flex border-b text-xs">
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "schema"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("schema")}
>
Schema
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "history"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("history")}
>
History
</button>
</div>
{view === "schema" ? (
<>
<div className="p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search objects..."
className="h-7 pl-7 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<SchemaTree />
</div>
</>
) : (
<HistoryPanel />
)}
</div>
);
}

View File

@@ -20,9 +20,16 @@ export function StatusBar({ rowCount, executionTime }: Props) {
<div className="flex h-6 items-center justify-between border-t bg-card px-3 text-[11px] text-muted-foreground">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Circle
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
/>
{activeConn?.color ? (
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: activeConn.color }}
/>
) : (
<Circle
className={`h-2 w-2 ${isConnected ? "fill-green-500 text-green-500" : "fill-muted text-muted"}`}
/>
)}
{activeConn ? activeConn.name : "No connection"}
</span>
{isConnected && activeConnectionId && (

View File

@@ -1,9 +1,11 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
const { data: connections } = useConnections();
if (tabs.length === 0) return null;
@@ -27,6 +29,15 @@ export function TabBar() {
}`}
onClick={() => setActiveTabId(tab.id)}
>
{(() => {
const tabColor = connections?.find((c) => c.id === tab.connectionId)?.color;
return tabColor ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: tabColor }}
/>
) : null;
})()}
{iconMap[tab.type]}
<span className="max-w-[120px] truncate">{tab.title}</span>
<button

View File

@@ -6,6 +6,7 @@ import { ConnectionList } from "@/components/connections/ConnectionList";
import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { Database, Plus } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
@@ -14,6 +15,8 @@ export function Toolbar() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, addTab } = useAppStore();
const { data: connections } = useConnections();
const activeColor = connections?.find((c) => c.id === activeConnectionId)?.color;
const handleNewQuery = () => {
if (!activeConnectionId) return;
@@ -29,7 +32,10 @@ export function Toolbar() {
return (
<>
<div className="flex h-10 items-center gap-2 border-b px-3 bg-card">
<div
className="flex h-10 items-center gap-2 border-b px-3 bg-card"
style={{ borderLeftWidth: activeColor ? 3 : 0, borderLeftColor: activeColor }}
>
<Button
variant="ghost"
size="sm"

View File

@@ -0,0 +1,151 @@
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { ExplainNode, ExplainResult } from "@/types";
function getCostColor(cost: number, maxCost: number): string {
if (maxCost === 0) return "#22c55e";
const ratio = cost / maxCost;
if (ratio < 0.33) return "#22c55e";
if (ratio < 0.66) return "#eab308";
return "#ef4444";
}
function getMaxCost(node: ExplainNode): number {
let max = node["Total Cost"];
if (node.Plans) {
for (const child of node.Plans) {
max = Math.max(max, getMaxCost(child));
}
}
return max;
}
function ExplainNodeRow({
node,
maxCost,
depth,
}: {
node: ExplainNode;
maxCost: number;
depth: number;
}) {
const [expanded, setExpanded] = useState(true);
const hasChildren = node.Plans && node.Plans.length > 0;
const costColor = getCostColor(node["Total Cost"], maxCost);
const costPercent = maxCost > 0 ? (node["Total Cost"] / maxCost) * 100 : 0;
return (
<div>
<button
className="flex w-full items-start gap-1.5 px-2 py-1.5 text-left text-xs hover:bg-accent"
style={{ paddingLeft: depth * 20 + 8 }}
onClick={() => setExpanded(!expanded)}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
)
) : (
<span className="w-3 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold" style={{ color: costColor }}>
{node["Node Type"]}
</span>
{node["Relation Name"] && (
<span className="text-muted-foreground">
on {node["Schema"] ? `${node["Schema"]}.` : ""}
{node["Relation Name"]}
{node["Alias"] && node["Alias"] !== node["Relation Name"]
? ` (${node["Alias"]})`
: ""}
</span>
)}
{node["Join Type"] && (
<span className="text-muted-foreground">
({node["Join Type"]})
</span>
)}
{node["Index Name"] && (
<span className="text-muted-foreground">
using {node["Index Name"]}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-3 text-muted-foreground">
<span>cost: {node["Total Cost"].toFixed(2)}</span>
{node["Actual Total Time"] != null && (
<span>time: {node["Actual Total Time"].toFixed(3)} ms</span>
)}
{node["Actual Rows"] != null && (
<span>rows: {node["Actual Rows"]}</span>
)}
{node["Actual Loops"] != null && node["Actual Loops"] > 1 && (
<span>loops: {node["Actual Loops"]}</span>
)}
{(node["Shared Hit Blocks"] != null ||
node["Shared Read Blocks"] != null) && (
<span>
buffers: hit={node["Shared Hit Blocks"] ?? 0} read=
{node["Shared Read Blocks"] ?? 0}
</span>
)}
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full"
style={{
width: `${costPercent}%`,
backgroundColor: costColor,
}}
/>
</div>
</div>
</button>
{expanded &&
hasChildren &&
node.Plans!.map((child, i) => (
<ExplainNodeRow
key={i}
node={child}
maxCost={maxCost}
depth={depth + 1}
/>
))}
</div>
);
}
interface Props {
data: ExplainResult;
}
export function ExplainView({ data }: Props) {
const maxCost = getMaxCost(data.Plan);
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-4 border-b px-3 py-2 text-xs">
<span className="font-semibold">Summary</span>
<span>
Execution:{" "}
<span className="font-mono">{data["Execution Time"].toFixed(3)} ms</span>
</span>
<span>
Planning:{" "}
<span className="font-mono">{data["Planning Time"].toFixed(3)} ms</span>
</span>
<span>
Total Cost:{" "}
<span className="font-mono">{data.Plan["Total Cost"].toFixed(2)}</span>
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<ExplainNodeRow node={data.Plan} maxCost={maxCost} depth={0} />
</div>
</div>
);
}

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>
);

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { getCompletionSchema } from "@/lib/tauri";
export function useCompletionSchema(connectionId?: string | null) {
return useQuery({
queryKey: ["completionSchema", connectionId],
queryFn: () => getCompletionSchema(connectionId!),
enabled: !!connectionId,
staleTime: 5 * 60 * 1000,
});
}

30
src/hooks/use-history.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { addHistoryEntry, getHistory, clearHistory } from "@/lib/tauri";
import type { HistoryEntry } from "@/types";
export function useHistory(connectionId?: string, search?: string) {
return useQuery({
queryKey: ["history", connectionId, search],
queryFn: () => getHistory({ connectionId, search }),
});
}
export function useAddHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (entry: HistoryEntry) => addHistoryEntry(entry),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["history"] });
},
});
}
export function useClearHistory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => clearHistory(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["history"] });
},
});
}

View File

@@ -7,6 +7,7 @@ import type {
ColumnInfo,
ConstraintInfo,
IndexInfo,
HistoryEntry,
} from "@/types";
// Connections
@@ -125,6 +126,30 @@ export const deleteRows = (params: {
pkValuesList: unknown[][];
}) => invoke<number>("delete_rows", params);
// History
export const addHistoryEntry = (entry: HistoryEntry) =>
invoke<void>("add_history_entry", { entry });
export const getHistory = (params?: {
connectionId?: string;
search?: string;
limit?: number;
}) =>
invoke<HistoryEntry[]>("get_history", {
connectionId: params?.connectionId,
search: params?.search,
limit: params?.limit,
});
export const clearHistory = () => invoke<void>("clear_history");
// Completion schema
export const getCompletionSchema = (connectionId: string) =>
invoke<Record<string, Record<string, string[]>>>(
"get_completion_schema",
{ connectionId }
);
// Export
export const exportCsv = (
path: string,

View File

@@ -53,6 +53,50 @@ export interface IndexInfo {
is_primary: boolean;
}
export interface HistoryEntry {
id: string;
connection_id: string;
connection_name: string;
database: string;
sql: string;
status: string;
error_message?: string;
row_count?: number;
execution_time_ms: number;
executed_at: string;
}
export interface ExplainNode {
"Node Type": string;
"Relation Name"?: string;
"Schema"?: string;
"Alias"?: string;
"Startup Cost": number;
"Total Cost": number;
"Plan Rows": number;
"Plan Width": number;
"Actual Startup Time"?: number;
"Actual Total Time"?: number;
"Actual Rows"?: number;
"Actual Loops"?: number;
"Shared Hit Blocks"?: number;
"Shared Read Blocks"?: number;
"Filter"?: string;
"Join Type"?: string;
"Index Name"?: string;
"Index Cond"?: string;
"Hash Cond"?: string;
"Sort Key"?: string[];
Plans?: ExplainNode[];
[key: string]: unknown;
}
export interface ExplainResult {
Plan: ExplainNode;
"Planning Time": number;
"Execution Time": number;
}
export type TabType = "query" | "table" | "structure";
export interface Tab {