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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ pub mod export;
|
||||
pub mod history;
|
||||
pub mod management;
|
||||
pub mod queries;
|
||||
pub mod saved_queries;
|
||||
pub mod schema;
|
||||
|
||||
72
src-tauri/src/commands/saved_queries.rs
Normal file
72
src-tauri/src/commands/saved_queries.rs
Normal 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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod connection;
|
||||
pub mod history;
|
||||
pub mod management;
|
||||
pub mod query_result;
|
||||
pub mod saved_queries;
|
||||
pub mod schema;
|
||||
|
||||
10
src-tauri/src/models/saved_queries.rs
Normal file
10
src-tauri/src/models/saved_queries.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user