diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..c8e7df3 --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -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 { + 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::>(&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, + search: Option, + limit: Option, +) -> TuskResult> { + let path = get_history_path(&app)?; + if !path.exists() { + return Ok(vec![]); + } + let data = fs::read_to_string(&path)?; + let entries: Vec = serde_json::from_str(&data).unwrap_or_default(); + + let filtered: Vec = 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(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a75d8e6..6e5a69a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod connections; pub mod data; pub mod export; +pub mod history; pub mod queries; pub mod schema; diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 9811452..b26776c 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -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>>> { + 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>> = 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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd2122e..7e96bb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/models/history.rs b/src-tauri/src/models/history.rs new file mode 100644 index 0000000..54a33f4 --- /dev/null +++ b/src-tauri/src/models/history.rs @@ -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, + pub row_count: Option, + pub execution_time_ms: f64, + pub executed_at: String, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 6483f13..adae318 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod connection; +pub mod history; pub mod query_result; pub mod schema; diff --git a/src/components/connections/ConnectionDialog.tsx b/src/components/connections/ConnectionDialog.tsx index 70f1f9c..0a9d5b1 100644 --- a/src/components/connections/ConnectionDialog.tsx +++ b/src/components/connections/ConnectionDialog.tsx @@ -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) { +
+ +
+ + {CONNECTION_COLORS.map((c) => ( +
+
diff --git a/src/components/connections/ConnectionList.tsx b/src/components/connections/ConnectionList.tsx index 544d229..95b9b3b 100644 --- a/src/components/connections/ConnectionList.tsx +++ b/src/components/connections/ConnectionList.tsx @@ -91,7 +91,14 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) { isActive ? "border-primary bg-accent" : "" }`} > - + {conn.color ? ( + + ) : ( + + )}
{conn.name} diff --git a/src/components/connections/ConnectionSelector.tsx b/src/components/connections/ConnectionSelector.tsx index 2eb6268..cf4ead7 100644 --- a/src/components/connections/ConnectionSelector.tsx +++ b/src/components/connections/ConnectionSelector.tsx @@ -21,18 +21,36 @@ export function ConnectionSelector() { ); } + const activeConn = connectedList.find((c) => c.id === activeConnectionId); + return ( setSearch(e.target.value)} + /> +
+ +
+
+ {entries?.map((entry) => ( + + ))} + {(!entries || entries.length === 0) && ( +
+ No history entries +
+ )} +
+ + ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4d18d9c..4e7b5f7 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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("schema"); const [search, setSearch] = useState(""); return (
-
-
- - setSearch(e.target.value)} - /> -
-
-
- +
+ +
+ + {view === "schema" ? ( + <> +
+
+ + setSearch(e.target.value)} + /> +
+
+
+ +
+ + ) : ( + + )}
); } diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 108d6b8..fac808e 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -20,9 +20,16 @@ export function StatusBar({ rowCount, executionTime }: Props) {
- + {activeConn?.color ? ( + + ) : ( + + )} {activeConn ? activeConn.name : "No connection"} {isConnected && activeConnectionId && ( diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index 535d318..eb4b337 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -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 ? ( + + ) : null; + })()} {iconMap[tab.type]} {tab.title} + {expanded && + hasChildren && + node.Plans!.map((child, i) => ( + + ))} +
+ ); +} + +interface Props { + data: ExplainResult; +} + +export function ExplainView({ data }: Props) { + const maxCost = getMaxCost(data.Plan); + + return ( +
+
+ Summary + + Execution:{" "} + {data["Execution Time"].toFixed(3)} ms + + + Planning:{" "} + {data["Planning Time"].toFixed(3)} ms + + + Total Cost:{" "} + {data.Plan["Total Cost"].toFixed(2)} + +
+
+ +
+
+ ); +} diff --git a/src/components/workspace/WorkspacePanel.tsx b/src/components/workspace/WorkspacePanel.tsx index 5bf725c..f0bde08 100644 --- a/src/components/workspace/WorkspacePanel.tsx +++ b/src/components/workspace/WorkspacePanel.tsx @@ -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(null); const [error, setError] = useState(null); + const [explainData, setExplainData] = useState(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 ( @@ -73,13 +153,27 @@ export function WorkspacePanel({ onClick={handleExecute} disabled={queryMutation.isPending || !sqlValue.trim()} > - {queryMutation.isPending ? ( + {queryMutation.isPending && resultView === "results" ? ( ) : ( )} Run + Ctrl+Enter to execute @@ -95,17 +189,48 @@ export function WorkspacePanel({ value={sqlValue} onChange={handleChange} onExecute={handleExecute} + schema={completionSchema} />
- + {(explainData || result || error) && ( +
+ + {explainData && ( + + )} +
+ )} + {resultView === "explain" && explainData ? ( + + ) : ( + + )}
); diff --git a/src/hooks/use-completion-schema.ts b/src/hooks/use-completion-schema.ts new file mode 100644 index 0000000..38ba5fd --- /dev/null +++ b/src/hooks/use-completion-schema.ts @@ -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, + }); +} diff --git a/src/hooks/use-history.ts b/src/hooks/use-history.ts new file mode 100644 index 0000000..6f09a54 --- /dev/null +++ b/src/hooks/use-history.ts @@ -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"] }); + }, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 0bd10ed..e2ce911 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -7,6 +7,7 @@ import type { ColumnInfo, ConstraintInfo, IndexInfo, + HistoryEntry, } from "@/types"; // Connections @@ -125,6 +126,30 @@ export const deleteRows = (params: { pkValuesList: unknown[][]; }) => invoke("delete_rows", params); +// History +export const addHistoryEntry = (entry: HistoryEntry) => + invoke("add_history_entry", { entry }); + +export const getHistory = (params?: { + connectionId?: string; + search?: string; + limit?: number; +}) => + invoke("get_history", { + connectionId: params?.connectionId, + search: params?.search, + limit: params?.limit, + }); + +export const clearHistory = () => invoke("clear_history"); + +// Completion schema +export const getCompletionSchema = (connectionId: string) => + invoke>>( + "get_completion_schema", + { connectionId } + ); + // Export export const exportCsv = ( path: string, diff --git a/src/types/index.ts b/src/types/index.ts index 504240c..08449f5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 {