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:
83
package-lock.json
generated
83
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-resizable-panels": "^4.6.2",
|
"react-resizable-panels": "^4.6.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sql-formatter": "^15.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -5023,7 +5024,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
@@ -5746,6 +5746,12 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.4",
|
"version": "17.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
|
||||||
@@ -7832,6 +7838,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -7934,6 +7946,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -8790,6 +8849,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/rettime": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
|
||||||
@@ -9201,6 +9269,19 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-resizable-panels": "^4.6.2",
|
"react-resizable-panels": "^4.6.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sql-formatter": "^15.7.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -507,3 +507,83 @@ pub async fn manage_role_membership(
|
|||||||
|
|
||||||
Ok(())
|
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 history;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
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::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 crate::state::AppState;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -61,9 +61,14 @@ pub async fn list_tables(
|
|||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT table_name FROM information_schema.tables \
|
"SELECT t.table_name, \
|
||||||
WHERE table_schema = $1 AND table_type = 'BASE TABLE' \
|
c.reltuples::bigint as row_count, \
|
||||||
ORDER BY table_name",
|
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)
|
.bind(&schema)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -76,6 +81,8 @@ pub async fn list_tables(
|
|||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
object_type: "table".to_string(),
|
object_type: "table".to_string(),
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
|
row_count: r.get::<Option<i64>, _>(1),
|
||||||
|
size_bytes: r.get::<Option<i64>, _>(2),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -107,6 +114,8 @@ pub async fn list_views(
|
|||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
object_type: "view".to_string(),
|
object_type: "view".to_string(),
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
|
row_count: None,
|
||||||
|
size_bytes: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -138,6 +147,8 @@ pub async fn list_functions(
|
|||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
object_type: "function".to_string(),
|
object_type: "function".to_string(),
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
|
row_count: None,
|
||||||
|
size_bytes: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -169,6 +180,8 @@ pub async fn list_indexes(
|
|||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
object_type: "index".to_string(),
|
object_type: "index".to_string(),
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
|
row_count: None,
|
||||||
|
size_bytes: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -200,6 +213,8 @@ pub async fn list_sequences(
|
|||||||
name: r.get(0),
|
name: r.get(0),
|
||||||
object_type: "sequence".to_string(),
|
object_type: "sequence".to_string(),
|
||||||
schema: schema.clone(),
|
schema: schema.clone(),
|
||||||
|
row_count: None,
|
||||||
|
size_bytes: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -378,3 +393,42 @@ pub async fn get_completion_schema(
|
|||||||
|
|
||||||
Ok(result)
|
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_constraints,
|
||||||
commands::schema::get_table_indexes,
|
commands::schema::get_table_indexes,
|
||||||
commands::schema::get_completion_schema,
|
commands::schema::get_completion_schema,
|
||||||
|
commands::schema::get_column_details,
|
||||||
// data
|
// data
|
||||||
commands::data::get_table_data,
|
commands::data::get_table_data,
|
||||||
commands::data::update_row,
|
commands::data::update_row,
|
||||||
@@ -55,10 +56,17 @@ pub fn run() {
|
|||||||
commands::management::get_table_privileges,
|
commands::management::get_table_privileges,
|
||||||
commands::management::grant_revoke,
|
commands::management::grant_revoke,
|
||||||
commands::management::manage_role_membership,
|
commands::management::manage_role_membership,
|
||||||
|
commands::management::list_sessions,
|
||||||
|
commands::management::cancel_query,
|
||||||
|
commands::management::terminate_backend,
|
||||||
// history
|
// history
|
||||||
commands::history::add_history_entry,
|
commands::history::add_history_entry,
|
||||||
commands::history::get_history,
|
commands::history::get_history,
|
||||||
commands::history::clear_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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ pub struct TablePrivilege {
|
|||||||
pub is_grantable: bool,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct GrantRevokeParams {
|
pub struct GrantRevokeParams {
|
||||||
pub action: String,
|
pub action: String,
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod connection;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod management;
|
pub mod management;
|
||||||
pub mod query_result;
|
pub mod query_result;
|
||||||
|
pub mod saved_queries;
|
||||||
pub mod schema;
|
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 name: String,
|
||||||
pub object_type: String,
|
pub object_type: String,
|
||||||
pub schema: String,
|
pub schema: String,
|
||||||
|
pub row_count: Option<i64>,
|
||||||
|
pub size_bytes: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -18,6 +20,15 @@ pub struct ColumnInfo {
|
|||||||
pub is_primary_key: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ConstraintInfo {
|
pub struct ConstraintInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onExecute: () => void;
|
onExecute: () => void;
|
||||||
|
onFormat?: () => void;
|
||||||
schema?: Record<string, Record<string, string[]>>;
|
schema?: Record<string, Record<string, string[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ function buildSqlNamespace(
|
|||||||
return ns;
|
return ns;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
|
export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Props) {
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(val: string) => {
|
(val: string) => {
|
||||||
onChange(val);
|
onChange(val);
|
||||||
@@ -56,9 +57,16 @@ export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "Shift-Alt-f",
|
||||||
|
run: () => {
|
||||||
|
onFormat?.();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}, [onExecute, schema]);
|
}, [onExecute, onFormat, schema]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useState } from "react";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { SchemaTree } from "@/components/schema/SchemaTree";
|
import { SchemaTree } from "@/components/schema/SchemaTree";
|
||||||
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
||||||
|
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
||||||
import { AdminPanel } from "@/components/management/AdminPanel";
|
import { AdminPanel } from "@/components/management/AdminPanel";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
type SidebarView = "schema" | "history" | "admin";
|
type SidebarView = "schema" | "history" | "saved" | "admin";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [view, setView] = useState<SidebarView>("schema");
|
const [view, setView] = useState<SidebarView>("schema");
|
||||||
@@ -34,6 +35,16 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
History
|
History
|
||||||
</button>
|
</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
|
<button
|
||||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
className={`flex-1 px-3 py-1.5 font-medium ${
|
||||||
view === "admin"
|
view === "admin"
|
||||||
@@ -65,6 +76,8 @@ export function Sidebar() {
|
|||||||
</>
|
</>
|
||||||
) : view === "history" ? (
|
) : view === "history" ? (
|
||||||
<HistoryPanel />
|
<HistoryPanel />
|
||||||
|
) : view === "saved" ? (
|
||||||
|
<SavedQueriesPanel />
|
||||||
) : (
|
) : (
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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() {
|
export function TabBar() {
|
||||||
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
||||||
@@ -14,6 +14,7 @@ export function TabBar() {
|
|||||||
table: <Table2 className="h-3 w-3" />,
|
table: <Table2 className="h-3 w-3" />,
|
||||||
structure: <Columns className="h-3 w-3" />,
|
structure: <Columns className="h-3 w-3" />,
|
||||||
roles: <Users className="h-3 w-3" />,
|
roles: <Users className="h-3 w-3" />,
|
||||||
|
sessions: <Activity className="h-3 w-3" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Users,
|
Users,
|
||||||
|
Activity,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Tab, RoleInfo } from "@/types";
|
import type { Tab, RoleInfo } from "@/types";
|
||||||
@@ -57,6 +58,18 @@ export function AdminPanel() {
|
|||||||
addTab(tab);
|
addTab(tab);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<SessionsSection
|
||||||
|
connectionId={activeConnectionId}
|
||||||
|
onOpenSessions={() => {
|
||||||
|
const tab: Tab = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "sessions",
|
||||||
|
title: "Active Sessions",
|
||||||
|
connectionId: activeConnectionId,
|
||||||
|
};
|
||||||
|
addTab(tab);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -298,3 +311,34 @@ function RolesSection({
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
171
src/components/management/SessionsView.tsx
Normal file
171
src/components/management/SessionsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface ExternalSort {
|
||||||
|
column: string | undefined;
|
||||||
|
direction: string | undefined;
|
||||||
|
onSort: (column: string | undefined, direction: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
columns: string[];
|
columns: string[];
|
||||||
types: string[];
|
types: string[];
|
||||||
@@ -21,6 +27,7 @@ interface Props {
|
|||||||
value: unknown
|
value: unknown
|
||||||
) => void;
|
) => void;
|
||||||
highlightedCells?: Set<string>;
|
highlightedCells?: Set<string>;
|
||||||
|
externalSort?: ExternalSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultsTable({
|
export function ResultsTable({
|
||||||
@@ -28,6 +35,7 @@ export function ResultsTable({
|
|||||||
rows,
|
rows,
|
||||||
onCellDoubleClick,
|
onCellDoubleClick,
|
||||||
highlightedCells,
|
highlightedCells,
|
||||||
|
externalSort,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnResizeMode] = useState<ColumnResizeMode>("onChange");
|
const [columnResizeMode] = useState<ColumnResizeMode>("onChange");
|
||||||
@@ -96,6 +104,38 @@ export function ResultsTable({
|
|||||||
[columnSizing]
|
[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;
|
if (colNames.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,16 +151,16 @@ export function ResultsTable({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
|
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
|
||||||
>
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
{header.column.getIsSorted() === "asc" && (
|
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
|
||||||
<ArrowUp className="h-3 w-3" />
|
<ArrowUp className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
{header.column.getIsSorted() === "desc" && (
|
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
|
||||||
<ArrowDown className="h-3 w-3" />
|
<ArrowDown className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
94
src/components/saved-queries/SaveQueryDialog.tsx
Normal file
94
src/components/saved-queries/SaveQueryDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/saved-queries/SavedQueriesPanel.tsx
Normal file
81
src/components/saved-queries/SavedQueriesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,33 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
|
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() {
|
export function SchemaTree() {
|
||||||
const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } =
|
const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } =
|
||||||
@@ -407,6 +433,7 @@ function CategoryNode({
|
|||||||
<span className="w-3.5 shrink-0" />
|
<span className="w-3.5 shrink-0" />
|
||||||
{icon}
|
{icon}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
|
{category === "tables" && <TableSizeInfo item={item} />}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
162
src/components/table-viewer/InsertRowDialog.tsx
Normal file
162
src/components/table-viewer/InsertRowDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import { getTableColumns } from "@/lib/tauri";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { toast } from "sonner";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -31,14 +32,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(50);
|
||||||
const [sortColumn, _setSortColumn] = useState<string | undefined>();
|
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||||
const [sortDirection, _setSortDirection] = useState<string | undefined>();
|
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
||||||
const [pendingChanges, setPendingChanges] = useState<
|
const [pendingChanges, setPendingChanges] = useState<
|
||||||
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [insertDialogOpen, setInsertDialogOpen] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading, error } = useTableData({
|
const { data, isLoading, error } = useTableData({
|
||||||
@@ -155,6 +157,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
[data, table]
|
[data, table]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSort = useCallback(
|
||||||
|
(column: string | undefined, direction: string | undefined) => {
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection(direction);
|
||||||
|
setPage(1);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleApplyFilter = () => {
|
const handleApplyFilter = () => {
|
||||||
setAppliedFilter(filter || undefined);
|
setAppliedFilter(filter || undefined);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -207,6 +218,17 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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 && (
|
{pendingChanges.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -253,6 +275,11 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
rows={data.rows}
|
rows={data.rows}
|
||||||
onCellDoubleClick={handleCellDoubleClick}
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
highlightedCells={highlightedCells}
|
highlightedCells={highlightedCells}
|
||||||
|
externalSort={{
|
||||||
|
column: sortColumn,
|
||||||
|
direction: sortDirection,
|
||||||
|
onSort: handleSort,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WorkspacePanel } from "./WorkspacePanel";
|
|||||||
import { TableDataView } from "@/components/table-viewer/TableDataView";
|
import { TableDataView } from "@/components/table-viewer/TableDataView";
|
||||||
import { TableStructure } from "@/components/table-viewer/TableStructure";
|
import { TableStructure } from "@/components/table-viewer/TableStructure";
|
||||||
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
||||||
|
import { SessionsView } from "@/components/management/SessionsView";
|
||||||
|
|
||||||
export function TabContent() {
|
export function TabContent() {
|
||||||
const { tabs, activeTabId, updateTab } = useAppStore();
|
const { tabs, activeTabId, updateTab } = useAppStore();
|
||||||
@@ -51,6 +52,13 @@ export function TabContent() {
|
|||||||
connectionId={activeTab.connectionId}
|
connectionId={activeTab.connectionId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "sessions":
|
||||||
|
return (
|
||||||
|
<SessionsView
|
||||||
|
key={activeTab.id}
|
||||||
|
connectionId={activeTab.connectionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
|
|||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -47,6 +49,7 @@ export function WorkspacePanel({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
|
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
|
||||||
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
||||||
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
|
|
||||||
const queryMutation = useQueryExecution();
|
const queryMutation = useQueryExecution();
|
||||||
const addHistoryMutation = useAddHistory();
|
const addHistoryMutation = useAddHistory();
|
||||||
@@ -150,6 +153,17 @@ export function WorkspacePanel({
|
|||||||
);
|
);
|
||||||
}, [connectionId, sqlValue, queryMutation]);
|
}, [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(
|
const handleExport = useCallback(
|
||||||
async (format: "csv" | "json") => {
|
async (format: "csv" | "json") => {
|
||||||
if (!result || result.columns.length === 0) return;
|
if (!result || result.columns.length === 0) return;
|
||||||
@@ -175,6 +189,7 @@ export function WorkspacePanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ResizablePanelGroup orientation="vertical">
|
<ResizablePanelGroup orientation="vertical">
|
||||||
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -207,6 +222,28 @@ export function WorkspacePanel({
|
|||||||
)}
|
)}
|
||||||
Explain
|
Explain
|
||||||
</Button>
|
</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 && (
|
{result && result.columns.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -244,6 +281,7 @@ export function WorkspacePanel({
|
|||||||
value={sqlValue}
|
value={sqlValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
|
onFormat={handleFormat}
|
||||||
schema={completionSchema}
|
schema={completionSchema}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,5 +326,13 @@ export function WorkspacePanel({
|
|||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
<SaveQueryDialog
|
||||||
|
open={saveDialogOpen}
|
||||||
|
onOpenChange={setSaveDialogOpen}
|
||||||
|
sql={sqlValue}
|
||||||
|
connectionId={connectionId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
getTablePrivileges,
|
getTablePrivileges,
|
||||||
grantRevoke,
|
grantRevoke,
|
||||||
manageRoleMembership,
|
manageRoleMembership,
|
||||||
|
listSessions,
|
||||||
|
cancelQuery,
|
||||||
|
terminateBackend,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import type {
|
import type {
|
||||||
CreateDatabaseParams,
|
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() {
|
export function useManageRoleMembership() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
34
src/hooks/use-saved-queries.ts
Normal file
34
src/hooks/use-saved-queries.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listFunctions,
|
listFunctions,
|
||||||
listSequences,
|
listSequences,
|
||||||
switchDatabase,
|
switchDatabase,
|
||||||
|
getColumnDetails,
|
||||||
} from "@/lib/tauri";
|
} from "@/lib/tauri";
|
||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
|
|
||||||
@@ -72,3 +73,11 @@ export function useSequences(connectionId: string | null, schema: string) {
|
|||||||
enabled: !!connectionId && !!schema,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import type {
|
|||||||
QueryResult,
|
QueryResult,
|
||||||
PaginatedQueryResult,
|
PaginatedQueryResult,
|
||||||
SchemaObject,
|
SchemaObject,
|
||||||
|
ColumnDetail,
|
||||||
ColumnInfo,
|
ColumnInfo,
|
||||||
ConstraintInfo,
|
ConstraintInfo,
|
||||||
IndexInfo,
|
IndexInfo,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
|
SavedQuery,
|
||||||
|
SessionInfo,
|
||||||
DatabaseInfo,
|
DatabaseInfo,
|
||||||
CreateDatabaseParams,
|
CreateDatabaseParams,
|
||||||
RoleInfo,
|
RoleInfo,
|
||||||
@@ -79,6 +82,12 @@ export const getTableColumns = (
|
|||||||
table: string
|
table: string
|
||||||
) => invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
) => 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 = (
|
export const getTableConstraints = (
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
schema: string,
|
schema: string,
|
||||||
@@ -151,6 +160,16 @@ export const getHistory = (params?: {
|
|||||||
|
|
||||||
export const clearHistory = () => invoke<void>("clear_history");
|
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
|
// Completion schema
|
||||||
export const getCompletionSchema = (connectionId: string) =>
|
export const getCompletionSchema = (connectionId: string) =>
|
||||||
invoke<Record<string, Record<string, 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) =>
|
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
|
||||||
invoke<void>("manage_role_membership", { connectionId, params });
|
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 });
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ export interface SchemaObject {
|
|||||||
name: string;
|
name: string;
|
||||||
object_type: string;
|
object_type: string;
|
||||||
schema: 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 {
|
export interface ColumnInfo {
|
||||||
@@ -186,7 +196,27 @@ export interface RoleMembershipParams {
|
|||||||
member_name: string;
|
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 {
|
export interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user