diff --git a/package-lock.json b/package-lock.json index c015189..4f0c4c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react-dom": "^19.2.0", "react-resizable-panels": "^4.6.2", "sonner": "^2.0.7", + "sql-formatter": "^15.7.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, @@ -5023,7 +5024,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -5746,6 +5746,12 @@ "node": ">=0.3.1" } }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.4", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", @@ -7832,6 +7838,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7934,6 +7946,34 @@ "dev": true, "license": "MIT" }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -8588,6 +8628,25 @@ } } }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8790,6 +8849,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/rettime": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", @@ -9201,6 +9269,19 @@ "node": ">=0.10.0" } }, + "node_modules/sql-formatter": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.7.0.tgz", + "integrity": "sha512-o2yiy7fYXK1HvzA8P6wwj8QSuwG3e/XcpWht/jIxkQX99c0SVPw0OXdLSV9fHASPiYB09HLA0uq8hokGydi/QA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 5cb35f6..a63957b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react-dom": "^19.2.0", "react-resizable-panels": "^4.6.2", "sonner": "^2.0.7", + "sql-formatter": "^15.7.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, diff --git a/src-tauri/src/commands/management.rs b/src-tauri/src/commands/management.rs index f458f89..0f93eaa 100644 --- a/src-tauri/src/commands/management.rs +++ b/src-tauri/src/commands/management.rs @@ -507,3 +507,83 @@ pub async fn manage_role_membership( Ok(()) } + +#[tauri::command] +pub async fn list_sessions( + 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 pid, usename, datname, state, query, \ + query_start::text, wait_event_type, wait_event, \ + client_addr::text \ + FROM pg_stat_activity \ + WHERE datname IS NOT NULL \ + ORDER BY query_start DESC NULLS LAST", + ) + .fetch_all(pool) + .await + .map_err(TuskError::Database)?; + + let sessions = rows + .iter() + .map(|row| SessionInfo { + pid: row.get("pid"), + usename: row.get("usename"), + datname: row.get("datname"), + state: row.get("state"), + query: row.get("query"), + query_start: row.get("query_start"), + wait_event_type: row.get("wait_event_type"), + wait_event: row.get("wait_event"), + client_addr: row.get("client_addr"), + }) + .collect(); + + Ok(sessions) +} + +#[tauri::command] +pub async fn cancel_query( + state: State<'_, AppState>, + connection_id: String, + pid: i32, +) -> TuskResult { + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let row = sqlx::query("SELECT pg_cancel_backend($1)") + .bind(pid) + .fetch_one(pool) + .await + .map_err(TuskError::Database)?; + + Ok(row.get::(0)) +} + +#[tauri::command] +pub async fn terminate_backend( + state: State<'_, AppState>, + connection_id: String, + pid: i32, +) -> TuskResult { + let pools = state.pools.read().await; + let pool = pools + .get(&connection_id) + .ok_or(TuskError::NotConnected(connection_id))?; + + let row = sqlx::query("SELECT pg_terminate_backend($1)") + .bind(pid) + .fetch_one(pool) + .await + .map_err(TuskError::Database)?; + + Ok(row.get::(0)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3314687..8792a2c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,4 +4,5 @@ pub mod export; pub mod history; pub mod management; pub mod queries; +pub mod saved_queries; pub mod schema; diff --git a/src-tauri/src/commands/saved_queries.rs b/src-tauri/src/commands/saved_queries.rs new file mode 100644 index 0000000..7588ebf --- /dev/null +++ b/src-tauri/src/commands/saved_queries.rs @@ -0,0 +1,72 @@ +use crate::error::{TuskError, TuskResult}; +use crate::models::saved_queries::SavedQuery; +use std::fs; +use tauri::{AppHandle, Manager}; + +fn get_saved_queries_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("saved_queries.json")) +} + +#[tauri::command] +pub async fn list_saved_queries( + app: AppHandle, + search: Option, +) -> TuskResult> { + let path = get_saved_queries_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 s) = search { + let lower = s.to_lowercase(); + e.name.to_lowercase().contains(&lower) || e.sql.to_lowercase().contains(&lower) + } else { + true + } + }) + .collect(); + + Ok(filtered) +} + +#[tauri::command] +pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> { + let path = get_saved_queries_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, query); + + let data = serde_json::to_string_pretty(&entries)?; + fs::write(&path, data)?; + Ok(()) +} + +#[tauri::command] +pub async fn delete_saved_query(app: AppHandle, id: String) -> TuskResult<()> { + let path = get_saved_queries_path(&app)?; + if !path.exists() { + return Ok(()); + } + let data = fs::read_to_string(&path)?; + let mut entries: Vec = serde_json::from_str(&data).unwrap_or_default(); + entries.retain(|e| e.id != id); + + let data = serde_json::to_string_pretty(&entries)?; + fs::write(&path, data)?; + Ok(()) +} diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index b26776c..16318a8 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -1,5 +1,5 @@ use crate::error::{TuskError, TuskResult}; -use crate::models::schema::{ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject}; +use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject}; use crate::state::AppState; use sqlx::Row; use std::collections::HashMap; @@ -61,9 +61,14 @@ pub async fn list_tables( .ok_or(TuskError::NotConnected(connection_id))?; let rows = sqlx::query( - "SELECT table_name FROM information_schema.tables \ - WHERE table_schema = $1 AND table_type = 'BASE TABLE' \ - ORDER BY table_name", + "SELECT t.table_name, \ + c.reltuples::bigint as row_count, \ + pg_total_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::bigint as size_bytes \ + FROM information_schema.tables t \ + LEFT JOIN pg_class c ON c.relname = t.table_name \ + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) \ + WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' \ + ORDER BY t.table_name", ) .bind(&schema) .fetch_all(pool) @@ -76,6 +81,8 @@ pub async fn list_tables( name: r.get(0), object_type: "table".to_string(), schema: schema.clone(), + row_count: r.get::, _>(1), + size_bytes: r.get::, _>(2), }) .collect()) } @@ -107,6 +114,8 @@ pub async fn list_views( name: r.get(0), object_type: "view".to_string(), schema: schema.clone(), + row_count: None, + size_bytes: None, }) .collect()) } @@ -138,6 +147,8 @@ pub async fn list_functions( name: r.get(0), object_type: "function".to_string(), schema: schema.clone(), + row_count: None, + size_bytes: None, }) .collect()) } @@ -169,6 +180,8 @@ pub async fn list_indexes( name: r.get(0), object_type: "index".to_string(), schema: schema.clone(), + row_count: None, + size_bytes: None, }) .collect()) } @@ -200,6 +213,8 @@ pub async fn list_sequences( name: r.get(0), object_type: "sequence".to_string(), schema: schema.clone(), + row_count: None, + size_bytes: None, }) .collect()) } @@ -378,3 +393,42 @@ pub async fn get_completion_schema( Ok(result) } + +#[tauri::command] +pub async fn get_column_details( + state: State<'_, AppState>, + connection_id: String, + schema: String, + table: 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 c.column_name, c.data_type, \ + c.is_nullable = 'YES' as is_nullable, \ + c.column_default, \ + c.is_identity = 'YES' as is_identity \ + FROM information_schema.columns c \ + WHERE c.table_schema = $1 AND c.table_name = $2 \ + ORDER BY c.ordinal_position", + ) + .bind(&schema) + .bind(&table) + .fetch_all(pool) + .await + .map_err(TuskError::Database)?; + + Ok(rows + .iter() + .map(|r| ColumnDetail { + column_name: r.get::(0), + data_type: r.get::(1), + is_nullable: r.get::(2), + column_default: r.get::, _>(3), + is_identity: r.get::(4), + }) + .collect()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6081a74..60f1bc7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,7 @@ pub fn run() { commands::schema::get_table_constraints, commands::schema::get_table_indexes, commands::schema::get_completion_schema, + commands::schema::get_column_details, // data commands::data::get_table_data, commands::data::update_row, @@ -55,10 +56,17 @@ pub fn run() { commands::management::get_table_privileges, commands::management::grant_revoke, commands::management::manage_role_membership, + commands::management::list_sessions, + commands::management::cancel_query, + commands::management::terminate_backend, // history commands::history::add_history_entry, commands::history::get_history, commands::history::clear_history, + // saved queries + commands::saved_queries::list_saved_queries, + commands::saved_queries::save_query, + commands::saved_queries::delete_saved_query, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/management.rs b/src-tauri/src/models/management.rs index 192c3c1..24ede69 100644 --- a/src-tauri/src/models/management.rs +++ b/src-tauri/src/models/management.rs @@ -79,6 +79,19 @@ pub struct TablePrivilege { pub is_grantable: bool, } +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionInfo { + pub pid: i32, + pub usename: Option, + pub datname: Option, + pub state: Option, + pub query: Option, + pub query_start: Option, + pub wait_event_type: Option, + pub wait_event: Option, + pub client_addr: Option, +} + #[derive(Debug, Deserialize)] pub struct GrantRevokeParams { pub action: String, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c32fd4f..950eb4c 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,4 +2,5 @@ pub mod connection; pub mod history; pub mod management; pub mod query_result; +pub mod saved_queries; pub mod schema; diff --git a/src-tauri/src/models/saved_queries.rs b/src-tauri/src/models/saved_queries.rs new file mode 100644 index 0000000..e28b8da --- /dev/null +++ b/src-tauri/src/models/saved_queries.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedQuery { + pub id: String, + pub name: String, + pub sql: String, + pub connection_id: Option, + pub created_at: String, +} diff --git a/src-tauri/src/models/schema.rs b/src-tauri/src/models/schema.rs index c1115b3..5375cb1 100644 --- a/src-tauri/src/models/schema.rs +++ b/src-tauri/src/models/schema.rs @@ -5,6 +5,8 @@ pub struct SchemaObject { pub name: String, pub object_type: String, pub schema: String, + pub row_count: Option, + pub size_bytes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -18,6 +20,15 @@ pub struct ColumnInfo { pub is_primary_key: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ColumnDetail { + pub column_name: String, + pub data_type: String, + pub is_nullable: bool, + pub column_default: Option, + pub is_identity: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConstraintInfo { pub name: String, diff --git a/src/components/editor/SqlEditor.tsx b/src/components/editor/SqlEditor.tsx index 47017ce..7091896 100644 --- a/src/components/editor/SqlEditor.tsx +++ b/src/components/editor/SqlEditor.tsx @@ -7,6 +7,7 @@ interface Props { value: string; onChange: (value: string) => void; onExecute: () => void; + onFormat?: () => void; schema?: Record>; } @@ -25,7 +26,7 @@ function buildSqlNamespace( return ns; } -export function SqlEditor({ value, onChange, onExecute, schema }: Props) { +export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Props) { const handleChange = useCallback( (val: string) => { onChange(val); @@ -56,9 +57,16 @@ export function SqlEditor({ value, onChange, onExecute, schema }: Props) { return true; }, }, + { + key: "Shift-Alt-f", + run: () => { + onFormat?.(); + return true; + }, + }, ]), ]; - }, [onExecute, schema]); + }, [onExecute, onFormat, schema]); return ( ("schema"); @@ -34,6 +35,16 @@ export function Sidebar() { > History + + +
+ Monitor active database connections and running queries. + {/* The connectionId is used by parent to open the sessions tab */} + {connectionId} +
+ + ); +} diff --git a/src/components/management/SessionsView.tsx b/src/components/management/SessionsView.tsx new file mode 100644 index 0000000..59d5b46 --- /dev/null +++ b/src/components/management/SessionsView.tsx @@ -0,0 +1,171 @@ +import { + useSessions, + useCancelQuery, + useTerminateBackend, +} from "@/hooks/use-management"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { Loader2, XCircle, Skull, RefreshCw } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; + +interface Props { + connectionId: string; +} + +function getStateBadge(state: string | null) { + if (!state) return null; + const colors: Record = { + idle: "bg-green-500/15 text-green-600", + active: "bg-yellow-500/15 text-yellow-600", + "idle in transaction": "bg-orange-500/15 text-orange-600", + disabled: "bg-red-500/15 text-red-600", + }; + return ( + + {state} + + ); +} + +function formatDuration(queryStart: string | null): string { + if (!queryStart) return "-"; + const start = new Date(queryStart).getTime(); + const now = Date.now(); + const diffSec = Math.floor((now - start) / 1000); + if (diffSec < 0) return "-"; + if (diffSec < 60) return `${diffSec}s`; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`; + return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`; +} + +function getDurationColor(queryStart: string | null, state: string | null): string { + if (state !== "active" || !queryStart) return ""; + const diffSec = (Date.now() - new Date(queryStart).getTime()) / 1000; + if (diffSec > 30) return "text-red-500 font-semibold"; + if (diffSec > 5) return "text-yellow-500 font-semibold"; + return ""; +} + +export function SessionsView({ connectionId }: Props) { + const { data: sessions, isLoading } = useSessions(connectionId); + const cancelMutation = useCancelQuery(); + const terminateMutation = useTerminateBackend(); + const queryClient = useQueryClient(); + + const handleCancel = (pid: number) => { + cancelMutation.mutate( + { connectionId, pid }, + { + onSuccess: () => toast.success(`Cancel signal sent to PID ${pid}`), + onError: (err) => toast.error("Cancel failed", { description: String(err) }), + } + ); + }; + + const handleTerminate = (pid: number) => { + if (!confirm(`Terminate backend PID ${pid}? This will kill the session.`)) return; + terminateMutation.mutate( + { connectionId, pid }, + { + onSuccess: () => toast.success(`Terminate signal sent to PID ${pid}`), + onError: (err) => toast.error("Terminate failed", { description: String(err) }), + } + ); + }; + + if (isLoading) { + return ( +
+ + Loading sessions... +
+ ); + } + + return ( +
+
+ Active Sessions + + {sessions?.length ?? 0} + + Auto-refresh: 5s + +
+ +
+ + + + + + + + + + + + + + + + {sessions?.map((s) => ( + + + + + + + + + + + + ))} + +
PIDUserDatabaseStateDurationWaitQueryClientActions
{s.pid}{s.usename ?? "-"}{s.datname ?? "-"}{getStateBadge(s.state)} + {formatDuration(s.query_start)} + + {s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"} + + {s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"} + {s.client_addr ?? "-"} +
+ + +
+
+ {(!sessions || sessions.length === 0) && ( +
+ No active sessions +
+ )} +
+
+ ); +} diff --git a/src/components/results/ResultsTable.tsx b/src/components/results/ResultsTable.tsx index 66d6edc..4271941 100644 --- a/src/components/results/ResultsTable.tsx +++ b/src/components/results/ResultsTable.tsx @@ -11,6 +11,12 @@ import { import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowUp, ArrowDown } from "lucide-react"; +interface ExternalSort { + column: string | undefined; + direction: string | undefined; + onSort: (column: string | undefined, direction: string | undefined) => void; +} + interface Props { columns: string[]; types: string[]; @@ -21,6 +27,7 @@ interface Props { value: unknown ) => void; highlightedCells?: Set; + externalSort?: ExternalSort; } export function ResultsTable({ @@ -28,6 +35,7 @@ export function ResultsTable({ rows, onCellDoubleClick, highlightedCells, + externalSort, }: Props) { const [sorting, setSorting] = useState([]); const [columnResizeMode] = useState("onChange"); @@ -96,6 +104,38 @@ export function ResultsTable({ [columnSizing] ); + const handleHeaderClick = useCallback( + (colName: string, defaultHandler: ((e: unknown) => void) | undefined) => + (e: unknown) => { + if (externalSort) { + // Cycle: none → ASC → DESC → none + if (externalSort.column !== colName) { + externalSort.onSort(colName, "ASC"); + } else if (externalSort.direction === "ASC") { + externalSort.onSort(colName, "DESC"); + } else { + externalSort.onSort(undefined, undefined); + } + } else { + defaultHandler?.(e); + } + }, + [externalSort] + ); + + const getIsSorted = useCallback( + (colName: string, localSorted: false | "asc" | "desc") => { + if (externalSort) { + if (externalSort.column === colName) { + return externalSort.direction === "ASC" ? "asc" : externalSort.direction === "DESC" ? "desc" : false; + } + return false; + } + return localSorted; + }, + [externalSort] + ); + if (colNames.length === 0) return null; return ( @@ -111,16 +151,16 @@ export function ResultsTable({ >
{flexRender( header.column.columnDef.header, header.getContext() )} - {header.column.getIsSorted() === "asc" && ( + {getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && ( )} - {header.column.getIsSorted() === "desc" && ( + {getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && ( )}
diff --git a/src/components/saved-queries/SaveQueryDialog.tsx b/src/components/saved-queries/SaveQueryDialog.tsx new file mode 100644 index 0000000..e243dab --- /dev/null +++ b/src/components/saved-queries/SaveQueryDialog.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useSaveQuery } from "@/hooks/use-saved-queries"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + sql: string; + connectionId: string; +} + +export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props) { + const [name, setName] = useState(""); + const saveMutation = useSaveQuery(); + + useEffect(() => { + if (open) setName(""); + }, [open]); + + const handleSave = () => { + if (!name.trim()) { + toast.error("Query name is required"); + return; + } + saveMutation.mutate( + { + id: crypto.randomUUID(), + name: name.trim(), + sql, + connection_id: connectionId, + created_at: new Date().toISOString(), + }, + { + onSuccess: () => { + toast.success(`Query "${name}" saved`); + onOpenChange(false); + }, + onError: (err) => { + toast.error("Failed to save query", { description: String(err) }); + }, + } + ); + }; + + return ( + + + + Save Query + + +
+
+ + setName(e.target.value)} + placeholder="My query" + onKeyDown={(e) => e.key === "Enter" && handleSave()} + autoFocus + /> +
+
+ +
+              {sql.length > 200 ? sql.slice(0, 200) + "..." : sql}
+            
+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/saved-queries/SavedQueriesPanel.tsx b/src/components/saved-queries/SavedQueriesPanel.tsx new file mode 100644 index 0000000..27e7cbe --- /dev/null +++ b/src/components/saved-queries/SavedQueriesPanel.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries"; +import { useAppStore } from "@/stores/app-store"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Search, Trash2, Bookmark } from "lucide-react"; +import type { Tab } from "@/types"; + +export function SavedQueriesPanel() { + const [search, setSearch] = useState(""); + const { activeConnectionId, addTab } = useAppStore(); + const { data: queries } = useSavedQueries(search || undefined); + const deleteMutation = useDeleteSavedQuery(); + + const handleOpen = (sql: string, connectionId?: string) => { + const cid = activeConnectionId ?? connectionId ?? ""; + if (!cid) return; + const tab: Tab = { + id: crypto.randomUUID(), + type: "query", + title: "Saved Query", + connectionId: cid, + sql, + }; + addTab(tab); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
+
+ {queries?.map((query) => ( +
handleOpen(query.sql, query.connection_id)} + > +
+ + + {query.name} + + +
+ + {query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql} + + + {new Date(query.created_at).toLocaleDateString()} + +
+ ))} + {(!queries || queries.length === 0) && ( +
+ No saved queries +
+ )} +
+
+ ); +} diff --git a/src/components/schema/SchemaTree.tsx b/src/components/schema/SchemaTree.tsx index fdabced..d5ab1c5 100644 --- a/src/components/schema/SchemaTree.tsx +++ b/src/components/schema/SchemaTree.tsx @@ -31,7 +31,33 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog"; -import type { Tab } from "@/types"; +import type { Tab, SchemaObject } from "@/types"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatCount(n: number): string { + if (n < 0) return "~0"; + if (n < 1000) return `~${n}`; + if (n < 1_000_000) return `~${(n / 1000).toFixed(1)}k`; + return `~${(n / 1_000_000).toFixed(1)}M`; +} + +function TableSizeInfo({ item }: { item: SchemaObject }) { + if (item.row_count == null && item.size_bytes == null) return null; + const parts: string[] = []; + if (item.row_count != null) parts.push(formatCount(item.row_count)); + if (item.size_bytes != null) parts.push(formatSize(item.size_bytes)); + return ( + + {parts.join(", ")} + + ); +} export function SchemaTree() { const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } = @@ -407,6 +433,7 @@ function CategoryNode({ {icon} {item.name} + {category === "tables" && } diff --git a/src/components/table-viewer/InsertRowDialog.tsx b/src/components/table-viewer/InsertRowDialog.tsx new file mode 100644 index 0000000..6850e2c --- /dev/null +++ b/src/components/table-viewer/InsertRowDialog.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { useColumnDetails } from "@/hooks/use-schema"; +import { insertRow } from "@/lib/tauri"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + connectionId: string; + schema: string; + table: string; + onSuccess: () => void; +} + +export function InsertRowDialog({ + open, + onOpenChange, + connectionId, + schema, + table, + onSuccess, +}: Props) { + const { data: columns } = useColumnDetails( + open ? connectionId : null, + open ? schema : null, + open ? table : null + ); + const [values, setValues] = useState>({}); + const [skipColumns, setSkipColumns] = useState>(new Set()); + const [isInserting, setIsInserting] = useState(false); + + useEffect(() => { + if (open && columns) { + const initial: Record = {}; + const skip = new Set(); + for (const col of columns) { + if (col.is_identity || col.column_default?.startsWith("nextval(")) { + skip.add(col.column_name); + } else if (col.column_default != null) { + initial[col.column_name] = col.column_default; + } else { + initial[col.column_name] = ""; + } + } + setValues(initial); + setSkipColumns(skip); + } + }, [open, columns]); + + const handleInsert = async () => { + if (!columns) return; + setIsInserting(true); + try { + const cols: string[] = []; + const vals: unknown[] = []; + for (const col of columns) { + if (skipColumns.has(col.column_name)) continue; + const val = values[col.column_name]; + if (val === "" && col.is_nullable) { + cols.push(col.column_name); + vals.push(null); + } else if (val !== undefined) { + cols.push(col.column_name); + vals.push(val); + } + } + await insertRow({ connectionId, schema, table, columns: cols, values: vals }); + toast.success("Row inserted"); + onOpenChange(false); + onSuccess(); + } catch (err) { + toast.error("Insert failed", { description: String(err) }); + } finally { + setIsInserting(false); + } + }; + + const toggleSkip = (colName: string) => { + setSkipColumns((prev) => { + const next = new Set(prev); + if (next.has(colName)) { + next.delete(colName); + } else { + next.add(colName); + } + return next; + }); + }; + + return ( + + + + Insert Row into {table} + + +
+ {columns?.map((col) => { + const isSkipped = skipColumns.has(col.column_name); + return ( +
+
+ {col.column_name} + + {col.data_type} + +
+
+ {isSkipped ? ( + + auto-generated + + ) : ( + + setValues((prev) => ({ ...prev, [col.column_name]: e.target.value })) + } + placeholder={ + col.is_nullable ? "NULL" : col.column_default ?? "" + } + /> + )} + +
+
+ ); + })} +
+ + + + + +
+
+ ); +} diff --git a/src/components/table-viewer/TableDataView.tsx b/src/components/table-viewer/TableDataView.tsx index 11298ba..18ec2c9 100644 --- a/src/components/table-viewer/TableDataView.tsx +++ b/src/components/table-viewer/TableDataView.tsx @@ -9,7 +9,8 @@ import { getTableColumns } from "@/lib/tauri"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/stores/app-store"; import { toast } from "sonner"; -import { Save, RotateCcw, Filter, Loader2, Lock, Download } from "lucide-react"; +import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus } from "lucide-react"; +import { InsertRowDialog } from "./InsertRowDialog"; import { DropdownMenu, DropdownMenuContent, @@ -31,14 +32,15 @@ export function TableDataView({ connectionId, schema, table }: Props) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); - const [sortColumn, _setSortColumn] = useState(); - const [sortDirection, _setSortDirection] = useState(); + const [sortColumn, setSortColumn] = useState(); + const [sortDirection, setSortDirection] = useState(); const [filter, setFilter] = useState(""); const [appliedFilter, setAppliedFilter] = useState(); const [pendingChanges, setPendingChanges] = useState< Map >(new Map()); const [isSaving, setIsSaving] = useState(false); + const [insertDialogOpen, setInsertDialogOpen] = useState(false); const queryClient = useQueryClient(); const { data, isLoading, error } = useTableData({ @@ -155,6 +157,15 @@ export function TableDataView({ connectionId, schema, table }: Props) { [data, table] ); + const handleSort = useCallback( + (column: string | undefined, direction: string | undefined) => { + setSortColumn(column); + setSortDirection(direction); + setPage(1); + }, + [] + ); + const handleApplyFilter = () => { setAppliedFilter(filter || undefined); setPage(1); @@ -207,6 +218,17 @@ export function TableDataView({ connectionId, schema, table }: Props) { )} + {!isReadOnly && ( + + )} {pendingChanges.size > 0 && ( <> + + {result && result.columns.length > 0 && ( @@ -244,6 +281,7 @@ export function WorkspacePanel({ value={sqlValue} onChange={handleChange} onExecute={handleExecute} + onFormat={handleFormat} schema={completionSchema} /> @@ -288,5 +326,13 @@ export function WorkspacePanel({ )} + + + ); } diff --git a/src/hooks/use-management.ts b/src/hooks/use-management.ts index 337ad00..66fb296 100644 --- a/src/hooks/use-management.ts +++ b/src/hooks/use-management.ts @@ -10,6 +10,9 @@ import { getTablePrivileges, grantRevoke, manageRoleMembership, + listSessions, + cancelQuery, + terminateBackend, } from "@/lib/tauri"; import type { CreateDatabaseParams, @@ -149,6 +152,39 @@ export function useGrantRevoke() { }); } +// Sessions + +export function useSessions(connectionId: string | null) { + return useQuery({ + queryKey: ["sessions", connectionId], + queryFn: () => listSessions(connectionId!), + enabled: !!connectionId, + refetchInterval: 5000, + }); +} + +export function useCancelQuery() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) => + cancelQuery(connectionId, pid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessions"] }); + }, + }); +} + +export function useTerminateBackend() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) => + terminateBackend(connectionId, pid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessions"] }); + }, + }); +} + export function useManageRoleMembership() { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/hooks/use-saved-queries.ts b/src/hooks/use-saved-queries.ts new file mode 100644 index 0000000..88e2b84 --- /dev/null +++ b/src/hooks/use-saved-queries.ts @@ -0,0 +1,34 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + listSavedQueries, + saveQuery, + deleteSavedQuery, +} from "@/lib/tauri"; +import type { SavedQuery } from "@/types"; + +export function useSavedQueries(search?: string) { + return useQuery({ + queryKey: ["saved-queries", search], + queryFn: () => listSavedQueries({ search }), + }); +} + +export function useSaveQuery() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (query: SavedQuery) => saveQuery(query), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["saved-queries"] }); + }, + }); +} + +export function useDeleteSavedQuery() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteSavedQuery(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["saved-queries"] }); + }, + }); +} diff --git a/src/hooks/use-schema.ts b/src/hooks/use-schema.ts index f3bb3a2..02b4548 100644 --- a/src/hooks/use-schema.ts +++ b/src/hooks/use-schema.ts @@ -7,6 +7,7 @@ import { listFunctions, listSequences, switchDatabase, + getColumnDetails, } from "@/lib/tauri"; import type { ConnectionConfig } from "@/types"; @@ -72,3 +73,11 @@ export function useSequences(connectionId: string | null, schema: string) { enabled: !!connectionId && !!schema, }); } + +export function useColumnDetails(connectionId: string | null, schema: string | null, table: string | null) { + return useQuery({ + queryKey: ["column-details", connectionId, schema, table], + queryFn: () => getColumnDetails(connectionId!, schema!, table!), + enabled: !!connectionId && !!schema && !!table, + }); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 0e651fd..edb2d56 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -4,10 +4,13 @@ import type { QueryResult, PaginatedQueryResult, SchemaObject, + ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, HistoryEntry, + SavedQuery, + SessionInfo, DatabaseInfo, CreateDatabaseParams, RoleInfo, @@ -79,6 +82,12 @@ export const getTableColumns = ( table: string ) => invoke("get_table_columns", { connectionId, schema, table }); +export const getColumnDetails = ( + connectionId: string, + schema: string, + table: string +) => invoke("get_column_details", { connectionId, schema, table }); + export const getTableConstraints = ( connectionId: string, schema: string, @@ -151,6 +160,16 @@ export const getHistory = (params?: { export const clearHistory = () => invoke("clear_history"); +// Saved Queries +export const listSavedQueries = (params?: { search?: string }) => + invoke("list_saved_queries", { search: params?.search }); + +export const saveQuery = (query: SavedQuery) => + invoke("save_query", { query }); + +export const deleteSavedQuery = (id: string) => + invoke("delete_saved_query", { id }); + // Completion schema export const getCompletionSchema = (connectionId: string) => invoke>>( @@ -201,3 +220,13 @@ export const grantRevoke = (connectionId: string, params: GrantRevokeParams) => export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) => invoke("manage_role_membership", { connectionId, params }); + +// Sessions +export const listSessions = (connectionId: string) => + invoke("list_sessions", { connectionId }); + +export const cancelQuery = (connectionId: string, pid: number) => + invoke("cancel_query", { connectionId, pid }); + +export const terminateBackend = (connectionId: string, pid: number) => + invoke("terminate_backend", { connectionId, pid }); diff --git a/src/types/index.ts b/src/types/index.ts index 340e3d4..479c417 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,16 @@ export interface SchemaObject { name: string; object_type: string; schema: string; + row_count?: number; + size_bytes?: number; +} + +export interface ColumnDetail { + column_name: string; + data_type: string; + is_nullable: boolean; + column_default: string | null; + is_identity: boolean; } export interface ColumnInfo { @@ -186,7 +196,27 @@ export interface RoleMembershipParams { member_name: string; } -export type TabType = "query" | "table" | "structure" | "roles"; +export interface SessionInfo { + pid: number; + usename: string | null; + datname: string | null; + state: string | null; + query: string | null; + query_start: string | null; + wait_event_type: string | null; + wait_event: string | null; + client_addr: string | null; +} + +export interface SavedQuery { + id: string; + name: string; + sql: string; + connection_id?: string; + created_at: string; +} + +export type TabType = "query" | "table" | "structure" | "roles" | "sessions"; export interface Tab { id: string;