feat: add column sort, SQL formatter, table stats, insert dialog, saved queries & sessions monitor

- Column sort by header click in table view (ASC/DESC/none cycle, server-side)
- SQL formatter with Format button and Shift+Alt+F keybinding (sql-formatter)
- Table size and row count display in schema tree via pg_class
- Insert row dialog with column type hints and auto-skip for identity columns
- Saved queries (bookmarks) with CRUD backend, sidebar panel, and save dialog
- Active sessions monitor (pg_stat_activity) with auto-refresh, cancel & terminate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 11:52:05 +03:00
parent ab72eeee80
commit 9d54167023
29 changed files with 1223 additions and 18 deletions

83
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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<Vec<SessionInfo>> {
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<bool> {
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::<bool, _>(0))
}
#[tauri::command]
pub async fn terminate_backend(
state: State<'_, AppState>,
connection_id: String,
pid: i32,
) -> TuskResult<bool> {
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::<bool, _>(0))
}

View File

@@ -4,4 +4,5 @@ pub mod export;
pub mod history;
pub mod management;
pub mod queries;
pub mod saved_queries;
pub mod schema;

View File

@@ -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<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("saved_queries.json"))
}
#[tauri::command]
pub async fn list_saved_queries(
app: AppHandle,
search: Option<String>,
) -> TuskResult<Vec<SavedQuery>> {
let path = get_saved_queries_path(&app)?;
if !path.exists() {
return Ok(vec![]);
}
let data = fs::read_to_string(&path)?;
let entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
let filtered: Vec<SavedQuery> = 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::<Vec<SavedQuery>>(&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<SavedQuery> = 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(())
}

View File

@@ -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::<Option<i64>, _>(1),
size_bytes: r.get::<Option<i64>, _>(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<Vec<ColumnDetail>> {
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::<String, _>(0),
data_type: r.get::<String, _>(1),
is_nullable: r.get::<bool, _>(2),
column_default: r.get::<Option<String>, _>(3),
is_identity: r.get::<bool, _>(4),
})
.collect())
}

View File

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

View File

@@ -79,6 +79,19 @@ pub struct TablePrivilege {
pub is_grantable: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionInfo {
pub pid: i32,
pub usename: Option<String>,
pub datname: Option<String>,
pub state: Option<String>,
pub query: Option<String>,
pub query_start: Option<String>,
pub wait_event_type: Option<String>,
pub wait_event: Option<String>,
pub client_addr: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GrantRevokeParams {
pub action: String,

View File

@@ -2,4 +2,5 @@ pub mod connection;
pub mod history;
pub mod management;
pub mod query_result;
pub mod saved_queries;
pub mod schema;

View File

@@ -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<String>,
pub created_at: String,
}

View File

@@ -5,6 +5,8 @@ pub struct SchemaObject {
pub name: String,
pub object_type: String,
pub schema: String,
pub row_count: Option<i64>,
pub size_bytes: Option<i64>,
}
#[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<String>,
pub is_identity: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintInfo {
pub name: String,

View File

@@ -7,6 +7,7 @@ interface Props {
value: string;
onChange: (value: string) => void;
onExecute: () => void;
onFormat?: () => void;
schema?: Record<string, Record<string, string[]>>;
}
@@ -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 (
<CodeMirror

View File

@@ -2,10 +2,11 @@ import { useState } from "react";
import { Input } from "@/components/ui/input";
import { SchemaTree } from "@/components/schema/SchemaTree";
import { HistoryPanel } from "@/components/history/HistoryPanel";
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
import { AdminPanel } from "@/components/management/AdminPanel";
import { Search } from "lucide-react";
type SidebarView = "schema" | "history" | "admin";
type SidebarView = "schema" | "history" | "saved" | "admin";
export function Sidebar() {
const [view, setView] = useState<SidebarView>("schema");
@@ -34,6 +35,16 @@ export function Sidebar() {
>
History
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "saved"
? "bg-background text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setView("saved")}
>
Saved
</button>
<button
className={`flex-1 px-3 py-1.5 font-medium ${
view === "admin"
@@ -65,6 +76,8 @@ export function Sidebar() {
</>
) : view === "history" ? (
<HistoryPanel />
) : view === "saved" ? (
<SavedQueriesPanel />
) : (
<AdminPanel />
)}

View File

@@ -1,7 +1,7 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { ScrollArea } from "@/components/ui/scroll-area";
import { X, Table2, Code, Columns, Users } from "lucide-react";
import { X, Table2, Code, Columns, Users, Activity } from "lucide-react";
export function TabBar() {
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
@@ -14,6 +14,7 @@ export function TabBar() {
table: <Table2 className="h-3 w-3" />,
structure: <Columns className="h-3 w-3" />,
roles: <Users className="h-3 w-3" />,
sessions: <Activity className="h-3 w-3" />,
};
return (

View File

@@ -20,6 +20,7 @@ import {
ChevronRight,
HardDrive,
Users,
Activity,
Loader2,
} from "lucide-react";
import type { Tab, RoleInfo } from "@/types";
@@ -57,6 +58,18 @@ export function AdminPanel() {
addTab(tab);
}}
/>
<SessionsSection
connectionId={activeConnectionId}
onOpenSessions={() => {
const tab: Tab = {
id: crypto.randomUUID(),
type: "sessions",
title: "Active Sessions",
connectionId: activeConnectionId,
};
addTab(tab);
}}
/>
</div>
);
}
@@ -298,3 +311,34 @@ function RolesSection({
</div>
);
}
function SessionsSection({
connectionId,
onOpenSessions,
}: {
connectionId: string;
onOpenSessions: () => void;
}) {
return (
<div className="border-b">
<div className="flex items-center gap-1 px-3 py-2">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold flex-1">Sessions</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-[10px]"
onClick={onOpenSessions}
title="View active sessions"
>
View Sessions
</Button>
</div>
<div className="px-6 pb-2 text-xs text-muted-foreground">
Monitor active database connections and running queries.
{/* The connectionId is used by parent to open the sessions tab */}
<span className="hidden">{connectionId}</span>
</div>
</div>
);
}

View File

@@ -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<string, string> = {
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 (
<Badge variant="outline" className={`text-[9px] px-1 py-0 ${colors[state] ?? ""}`}>
{state}
</Badge>
);
}
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 (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Loading sessions...
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className="text-xs font-semibold">Active Sessions</span>
<Badge variant="secondary" className="text-[10px]">
{sessions?.length ?? 0}
</Badge>
<span className="text-[10px] text-muted-foreground">Auto-refresh: 5s</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 gap-1 text-xs"
onClick={() => queryClient.invalidateQueries({ queryKey: ["sessions"] })}
>
<RefreshCw className="h-3 w-3" />
Refresh
</Button>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card border-b">
<tr>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">PID</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">User</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Database</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">State</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Duration</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Wait</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Query</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Client</th>
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{sessions?.map((s) => (
<tr key={s.pid} className="border-b hover:bg-accent/50">
<td className="px-2 py-1 font-mono">{s.pid}</td>
<td className="px-2 py-1">{s.usename ?? "-"}</td>
<td className="px-2 py-1">{s.datname ?? "-"}</td>
<td className="px-2 py-1">{getStateBadge(s.state)}</td>
<td className={`px-2 py-1 ${getDurationColor(s.query_start, s.state)}`}>
{formatDuration(s.query_start)}
</td>
<td className="px-2 py-1 text-muted-foreground">
{s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"}
</td>
<td className="px-2 py-1 max-w-xs truncate font-mono" title={s.query ?? ""}>
{s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"}
</td>
<td className="px-2 py-1 text-muted-foreground">{s.client_addr ?? "-"}</td>
<td className="px-2 py-1">
<div className="flex gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Cancel Query"
onClick={() => handleCancel(s.pid)}
>
<XCircle className="h-3 w-3 text-yellow-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
title="Terminate Backend"
onClick={() => handleTerminate(s.pid)}
>
<Skull className="h-3 w-3 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{(!sessions || sessions.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No active sessions
</div>
)}
</div>
</div>
);
}

View File

@@ -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<string>;
externalSort?: ExternalSort;
}
export function ResultsTable({
@@ -28,6 +35,7 @@ export function ResultsTable({
rows,
onCellDoubleClick,
highlightedCells,
externalSort,
}: Props) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnResizeMode] = useState<ColumnResizeMode>("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({
>
<div
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
onClick={header.column.getToggleSortingHandler()}
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" && (
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
<ArrowUp className="h-3 w-3" />
)}
{header.column.getIsSorted() === "desc" && (
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
<ArrowDown className="h-3 w-3" />
)}
</div>

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Save Query</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-4">
<div className="grid grid-cols-4 items-center gap-3">
<label className="text-right text-sm text-muted-foreground">Name</label>
<Input
className="col-span-3"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My query"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
autoFocus
/>
</div>
<div className="grid grid-cols-4 items-start gap-3">
<label className="text-right text-sm text-muted-foreground pt-1">SQL</label>
<pre className="col-span-3 rounded bg-muted p-2 text-xs max-h-32 overflow-auto font-mono">
{sql.length > 200 ? sql.slice(0, 200) + "..." : sql}
</pre>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<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 saved queries..."
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">
{queries?.map((query) => (
<div
key={query.id}
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
onDoubleClick={() => handleOpen(query.sql, query.connection_id)}
>
<div className="flex items-center gap-1.5">
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
<span className="truncate font-medium text-foreground">
{query.name}
</span>
<Button
variant="ghost"
size="icon"
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(query.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<span className="truncate font-mono text-muted-foreground">
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
</span>
<span className="text-muted-foreground">
{new Date(query.created_at).toLocaleDateString()}
</span>
</div>
))}
{(!queries || queries.length === 0) && (
<div className="py-8 text-center text-xs text-muted-foreground">
No saved queries
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
{parts.join(", ")}
</span>
);
}
export function SchemaTree() {
const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } =
@@ -407,6 +433,7 @@ function CategoryNode({
<span className="w-3.5 shrink-0" />
{icon}
<span className="truncate">{item.name}</span>
{category === "tables" && <TableSizeInfo item={item} />}
</div>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -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<Record<string, string>>({});
const [skipColumns, setSkipColumns] = useState<Set<string>>(new Set());
const [isInserting, setIsInserting] = useState(false);
useEffect(() => {
if (open && columns) {
const initial: Record<string, string> = {};
const skip = new Set<string>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[560px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Insert Row into {table}</DialogTitle>
</DialogHeader>
<div className="grid gap-2 py-2">
{columns?.map((col) => {
const isSkipped = skipColumns.has(col.column_name);
return (
<div key={col.column_name} className="grid grid-cols-4 items-center gap-2">
<div className="flex items-center gap-1 col-span-1 justify-end">
<span className="text-sm truncate">{col.column_name}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
{col.data_type}
</Badge>
</div>
<div className="col-span-3 flex items-center gap-1">
{isSkipped ? (
<span className="text-xs text-muted-foreground italic flex-1 px-2">
auto-generated
</span>
) : (
<Input
className="flex-1 text-sm"
value={values[col.column_name] ?? ""}
onChange={(e) =>
setValues((prev) => ({ ...prev, [col.column_name]: e.target.value }))
}
placeholder={
col.is_nullable ? "NULL" : col.column_default ?? ""
}
/>
)}
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] shrink-0"
onClick={() => toggleSkip(col.column_name)}
>
{isSkipped ? "Include" : "Skip"}
</Button>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleInsert} disabled={isInserting}>
{isInserting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Insert
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<string | undefined>();
const [sortDirection, _setSortDirection] = useState<string | undefined>();
const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortDirection, setSortDirection] = useState<string | undefined>();
const [filter, setFilter] = useState("");
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
const [pendingChanges, setPendingChanges] = useState<
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
>(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) {
</DropdownMenuContent>
</DropdownMenu>
)}
{!isReadOnly && (
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={() => setInsertDialogOpen(true)}
>
<Plus className="h-3 w-3" />
Insert Row
</Button>
)}
{pendingChanges.size > 0 && (
<>
<Button
@@ -253,6 +275,11 @@ export function TableDataView({ connectionId, schema, table }: Props) {
rows={data.rows}
onCellDoubleClick={handleCellDoubleClick}
highlightedCells={highlightedCells}
externalSort={{
column: sortColumn,
direction: sortDirection,
onSort: handleSort,
}}
/>
) : null}
</div>
@@ -269,6 +296,19 @@ export function TableDataView({ connectionId, schema, table }: Props) {
}}
/>
)}
<InsertRowDialog
open={insertDialogOpen}
onOpenChange={setInsertDialogOpen}
connectionId={connectionId}
schema={schema}
table={table}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: ["table-data", connectionId],
});
}}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { WorkspacePanel } from "./WorkspacePanel";
import { TableDataView } from "@/components/table-viewer/TableDataView";
import { TableStructure } from "@/components/table-viewer/TableStructure";
import { RoleManagerView } from "@/components/management/RoleManagerView";
import { SessionsView } from "@/components/management/SessionsView";
export function TabContent() {
const { tabs, activeTabId, updateTab } = useAppStore();
@@ -51,6 +52,13 @@ export function TabContent() {
connectionId={activeTab.connectionId}
/>
);
case "sessions":
return (
<SessionsView
key={activeTab.id}
connectionId={activeTab.connectionId}
/>
);
default:
return null;
}

View File

@@ -13,7 +13,9 @@ 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, BarChart3, Download } from "lucide-react";
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark } from "lucide-react";
import { format as formatSql } from "sql-formatter";
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -47,6 +49,7 @@ export function WorkspacePanel({
const [error, setError] = useState<string | null>(null);
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
const [resultView, setResultView] = useState<"results" | "explain">("results");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const queryMutation = useQueryExecution();
const addHistoryMutation = useAddHistory();
@@ -150,6 +153,17 @@ export function WorkspacePanel({
);
}, [connectionId, sqlValue, queryMutation]);
const handleFormat = useCallback(() => {
if (!sqlValue.trim()) return;
try {
const formatted = formatSql(sqlValue, { language: "postgresql" });
setSqlValue(formatted);
onSqlChange?.(formatted);
} catch {
// Silently ignore format errors on invalid SQL
}
}, [sqlValue, onSqlChange]);
const handleExport = useCallback(
async (format: "csv" | "json") => {
if (!result || result.columns.length === 0) return;
@@ -175,6 +189,7 @@ export function WorkspacePanel({
);
return (
<>
<ResizablePanelGroup orientation="vertical">
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
<div className="flex h-full flex-col">
@@ -207,6 +222,28 @@ export function WorkspacePanel({
)}
Explain
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={handleFormat}
disabled={!sqlValue.trim()}
title="Format SQL (Shift+Alt+F)"
>
<AlignLeft className="h-3 w-3" />
Format
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 gap-1 text-xs"
onClick={() => setSaveDialogOpen(true)}
disabled={!sqlValue.trim()}
title="Save query"
>
<Bookmark className="h-3 w-3" />
Save
</Button>
{result && result.columns.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -244,6 +281,7 @@ export function WorkspacePanel({
value={sqlValue}
onChange={handleChange}
onExecute={handleExecute}
onFormat={handleFormat}
schema={completionSchema}
/>
</div>
@@ -288,5 +326,13 @@ export function WorkspacePanel({
)}
</ResizablePanel>
</ResizablePanelGroup>
<SaveQueryDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
sql={sqlValue}
connectionId={connectionId}
/>
</>
);
}

View File

@@ -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({

View File

@@ -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"] });
},
});
}

View File

@@ -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,
});
}

View File

@@ -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<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
export const getColumnDetails = (
connectionId: string,
schema: string,
table: string
) => invoke<ColumnDetail[]>("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<void>("clear_history");
// Saved Queries
export const listSavedQueries = (params?: { search?: string }) =>
invoke<SavedQuery[]>("list_saved_queries", { search: params?.search });
export const saveQuery = (query: SavedQuery) =>
invoke<void>("save_query", { query });
export const deleteSavedQuery = (id: string) =>
invoke<void>("delete_saved_query", { id });
// Completion schema
export const getCompletionSchema = (connectionId: string) =>
invoke<Record<string, Record<string, string[]>>>(
@@ -201,3 +220,13 @@ export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
invoke<void>("manage_role_membership", { connectionId, params });
// Sessions
export const listSessions = (connectionId: string) =>
invoke<SessionInfo[]>("list_sessions", { connectionId });
export const cancelQuery = (connectionId: string, pid: number) =>
invoke<boolean>("cancel_query", { connectionId, pid });
export const terminateBackend = (connectionId: string, pid: number) =>
invoke<boolean>("terminate_backend", { connectionId, pid });

View File

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