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

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,