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:
76
src-tauri/src/commands/history.rs
Normal file
76
src-tauri/src/commands/history.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod connections;
|
||||
pub mod data;
|
||||
pub mod export;
|
||||
pub mod history;
|
||||
pub mod queries;
|
||||
pub mod schema;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
15
src-tauri/src/models/history.rs
Normal file
15
src-tauri/src/models/history.rs
Normal 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,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod connection;
|
||||
pub mod history;
|
||||
pub mod query_result;
|
||||
pub mod schema;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
88
src/components/history/HistoryPanel.tsx
Normal file
88
src/components/history/HistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
151
src/components/results/ExplainView.tsx
Normal file
151
src/components/results/ExplainView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
11
src/hooks/use-completion-schema.ts
Normal file
11
src/hooks/use-completion-schema.ts
Normal 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
30
src/hooks/use-history.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user