feat: initial release
Multi-database management tool built with Tauri + Vue 3. Supports MySQL, PostgreSQL, SQLite, Redis, MongoDB, DuckDB, ClickHouse, SQL Server.
|
|
@ -0,0 +1,32 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Tauri build output is handled by src-tauri/.gitignore
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
|
||||
# GitHub (keep workflows)
|
||||
.github/*
|
||||
!.github/workflows/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "reka-nova",
|
||||
"font": "geist-sans",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"composables": "@/composables"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DBX</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "dbx",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^3.0.0",
|
||||
"reka-ui": "^2.9.6",
|
||||
"shadcn-vue": "^2.6.2",
|
||||
"simple-icons": "^16.18.0",
|
||||
"splitpanes": "^4.0.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-i18n": "^11.4.0",
|
||||
"vue-virtual-scroller": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/splitpanes": "^2.2.6",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
approveBuilds: esbuild
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
# Generated by Cargo
|
||||
/target/
|
||||
/gen/schemas
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
[package]
|
||||
name = "dbx"
|
||||
version = "0.1.0"
|
||||
description = "Open-source database management tool"
|
||||
authors = ["skyler"]
|
||||
license = "MIT"
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[lib]
|
||||
name = "dbx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.6", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
tauri = { version = "2.10.3", features = [] }
|
||||
tauri-plugin-log = "2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri-plugin-dialog = "2.7.0"
|
||||
tauri-plugin-fs = "2.5.0"
|
||||
redis = { version = "0.32.2", features = ["tokio-comp"] }
|
||||
portpicker = "0.1.1"
|
||||
duckdb = { version = "1.3.2", features = ["bundled"] }
|
||||
clickhouse = { version = "0.13.3", features = ["lz4"] }
|
||||
tiberius = { version = "0.12.3", features = ["tds73", "chrono"] }
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
mongodb = "3.2.5"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-text-file"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
|
@ -0,0 +1,285 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db;
|
||||
use crate::db::ssh_tunnel::TunnelManager;
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
|
||||
pub enum PoolKind {
|
||||
Mysql(sqlx::mysql::MySqlPool),
|
||||
Postgres(sqlx::postgres::PgPool),
|
||||
Sqlite(sqlx::sqlite::SqlitePool),
|
||||
Redis(tokio::sync::Mutex<redis::aio::MultiplexedConnection>),
|
||||
DuckDb(std::sync::Arc<std::sync::Mutex<duckdb::Connection>>),
|
||||
MongoDb(mongodb::Client),
|
||||
ClickHouse(db::clickhouse_driver::ChClient),
|
||||
SqlServer(std::sync::Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub connections: Mutex<HashMap<String, PoolKind>>,
|
||||
pub configs: Mutex<HashMap<String, ConnectionConfig>>,
|
||||
pub tunnels: TunnelManager,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Mutex::new(HashMap::new()),
|
||||
configs: Mutex::new(HashMap::new()),
|
||||
tunnels: TunnelManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_pool(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
database: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let is_embedded = {
|
||||
let configs = self.configs.lock().await;
|
||||
configs.get(connection_id)
|
||||
.map(|c| c.db_type == DatabaseType::Sqlite || c.db_type == DatabaseType::DuckDb)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if is_embedded {
|
||||
return Ok(connection_id.to_string());
|
||||
}
|
||||
|
||||
let pool_key = match database {
|
||||
Some(db) => format!("{connection_id}:{db}"),
|
||||
None => connection_id.to_string(),
|
||||
};
|
||||
|
||||
let conns = self.connections.lock().await;
|
||||
if conns.contains_key(&pool_key) {
|
||||
return Ok(pool_key);
|
||||
}
|
||||
drop(conns);
|
||||
|
||||
let configs = self.configs.lock().await;
|
||||
let config = configs
|
||||
.get(connection_id)
|
||||
.ok_or("Connection config not found")?
|
||||
.clone();
|
||||
drop(configs);
|
||||
|
||||
let mut db_config = config.clone();
|
||||
if let Some(db) = database {
|
||||
db_config.database = Some(db.to_string());
|
||||
}
|
||||
|
||||
let url = db_config.connection_url();
|
||||
let pool = match db_config.db_type {
|
||||
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
|
||||
DatabaseType::Postgres => PoolKind::Postgres(db::postgres::connect(&url).await?),
|
||||
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect(&url).await?),
|
||||
DatabaseType::Redis => {
|
||||
let con = db::redis_driver::connect(&url).await?;
|
||||
PoolKind::Redis(tokio::sync::Mutex::new(con))
|
||||
}
|
||||
DatabaseType::DuckDb => {
|
||||
let con = duckdb::Connection::open(&db_config.host).map_err(|e| e.to_string())?;
|
||||
PoolKind::DuckDb(std::sync::Arc::new(std::sync::Mutex::new(con)))
|
||||
}
|
||||
DatabaseType::MongoDb => {
|
||||
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
|
||||
PoolKind::MongoDb(client)
|
||||
}
|
||||
DatabaseType::ClickHouse => {
|
||||
let client = db::clickhouse_driver::ChClient::new(&url);
|
||||
db::clickhouse_driver::test_connection(&client).await?;
|
||||
PoolKind::ClickHouse(client)
|
||||
}
|
||||
DatabaseType::SqlServer => {
|
||||
let client = db::sqlserver::connect(
|
||||
&db_config.host, db_config.port,
|
||||
&db_config.username, &db_config.password,
|
||||
db_config.database.as_deref(),
|
||||
).await?;
|
||||
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
|
||||
}
|
||||
};
|
||||
|
||||
self.connections.lock().await.insert(pool_key.clone(), pool);
|
||||
Ok(pool_key)
|
||||
}
|
||||
|
||||
pub async fn reconnect_pool(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
database: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let pool_key = match database {
|
||||
Some(db) => format!("{connection_id}:{db}"),
|
||||
None => connection_id.to_string(),
|
||||
};
|
||||
self.connections.lock().await.remove(&pool_key);
|
||||
self.get_or_create_pool(connection_id, database).await
|
||||
}
|
||||
}
|
||||
|
||||
fn connections_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("connections.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_connections(
|
||||
app: AppHandle,
|
||||
configs: Vec<ConnectionConfig>,
|
||||
) -> Result<(), String> {
|
||||
let path = connections_file(&app)?;
|
||||
let json = serde_json::to_string_pretty(&configs).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, String> {
|
||||
let path = connections_file(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let configs: Vec<ConnectionConfig> =
|
||||
serde_json::from_str(&json).map_err(|e| e.to_string())?;
|
||||
Ok(configs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(config: ConnectionConfig) -> Result<String, String> {
|
||||
let url = config.connection_url();
|
||||
match config.db_type {
|
||||
DatabaseType::Mysql => {
|
||||
let pool = db::mysql::connect(&url).await?;
|
||||
pool.close().await;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Postgres => {
|
||||
let pool = db::postgres::connect(&url).await?;
|
||||
pool.close().await;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Sqlite => {
|
||||
let pool = db::sqlite::connect(&url).await?;
|
||||
pool.close().await;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::Redis => {
|
||||
let _con = db::redis_driver::connect(&url).await?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::DuckDb => {
|
||||
let _con = duckdb::Connection::open(&config.host).map_err(|e| e.to_string())?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::MongoDb => {
|
||||
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
|
||||
client.list_database_names().await.map_err(|e| e.to_string())?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::ClickHouse => {
|
||||
let client = db::clickhouse_driver::ChClient::new(&url);
|
||||
db::clickhouse_driver::test_connection(&client).await?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
DatabaseType::SqlServer => {
|
||||
let _client = db::sqlserver::connect(
|
||||
&config.host, config.port,
|
||||
&config.username, &config.password,
|
||||
config.database.as_deref(),
|
||||
).await?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_db(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
config: ConnectionConfig,
|
||||
) -> Result<String, String> {
|
||||
let id = config.id.clone();
|
||||
|
||||
let url = if config.ssh_enabled && !config.ssh_host.is_empty() {
|
||||
let local_port = state.tunnels.start_tunnel(
|
||||
&id, &config.ssh_host, config.ssh_port,
|
||||
&config.ssh_user, &config.ssh_key_path,
|
||||
&config.host, config.port,
|
||||
).await?;
|
||||
config.connection_url_with_host("127.0.0.1", local_port)
|
||||
} else {
|
||||
config.connection_url()
|
||||
};
|
||||
|
||||
let pool = match config.db_type {
|
||||
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
|
||||
DatabaseType::Postgres => PoolKind::Postgres(db::postgres::connect(&url).await?),
|
||||
DatabaseType::Sqlite => PoolKind::Sqlite(db::sqlite::connect(&url).await?),
|
||||
DatabaseType::Redis => {
|
||||
let con = db::redis_driver::connect(&url).await?;
|
||||
PoolKind::Redis(tokio::sync::Mutex::new(con))
|
||||
}
|
||||
DatabaseType::DuckDb => {
|
||||
let con = duckdb::Connection::open(&config.host).map_err(|e| e.to_string())?;
|
||||
PoolKind::DuckDb(std::sync::Arc::new(std::sync::Mutex::new(con)))
|
||||
}
|
||||
DatabaseType::MongoDb => {
|
||||
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
|
||||
PoolKind::MongoDb(client)
|
||||
}
|
||||
DatabaseType::ClickHouse => {
|
||||
let client = db::clickhouse_driver::ChClient::new(&url);
|
||||
db::clickhouse_driver::test_connection(&client).await?;
|
||||
PoolKind::ClickHouse(client)
|
||||
}
|
||||
DatabaseType::SqlServer => {
|
||||
let client = db::sqlserver::connect(
|
||||
&config.host, config.port,
|
||||
&config.username, &config.password,
|
||||
config.database.as_deref(),
|
||||
).await?;
|
||||
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) }
|
||||
};
|
||||
|
||||
state.connections.lock().await.insert(id.clone(), pool);
|
||||
state.configs.lock().await.insert(id.clone(), config);
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect_db(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut conns = state.connections.lock().await;
|
||||
let keys_to_remove: Vec<String> = conns
|
||||
.keys()
|
||||
.filter(|k| *k == &connection_id || k.starts_with(&format!("{connection_id}:")))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
if let Some(pool) = conns.remove(&key) {
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => p.close().await,
|
||||
PoolKind::Postgres(p) => p.close().await,
|
||||
PoolKind::Sqlite(p) => p.close().await,
|
||||
PoolKind::Redis(_) => {},
|
||||
PoolKind::DuckDb(_) => {},
|
||||
PoolKind::MongoDb(_) => {},
|
||||
PoolKind::ClickHouse(_) => {},
|
||||
PoolKind::SqlServer(_) => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(conns);
|
||||
state.configs.lock().await.remove(&connection_id);
|
||||
state.tunnels.stop_tunnel(&connection_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
pub id: String,
|
||||
pub connection_name: String,
|
||||
pub database: String,
|
||||
pub sql: String,
|
||||
pub executed_at: String,
|
||||
pub execution_time_ms: u128,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn history_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("query_history.json"))
|
||||
}
|
||||
|
||||
fn read_all(app: &AppHandle) -> Result<Vec<HistoryEntry>, String> {
|
||||
let path = history_file(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn write_all(app: &AppHandle, entries: &[HistoryEntry]) -> Result<(), String> {
|
||||
let path = history_file(app)?;
|
||||
let json = serde_json::to_string(entries).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
const MAX_HISTORY: usize = 1000;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_history(app: AppHandle, entry: HistoryEntry) -> Result<(), String> {
|
||||
let mut entries = read_all(&app)?;
|
||||
entries.insert(0, entry);
|
||||
entries.truncate(MAX_HISTORY);
|
||||
write_all(&app, &entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_history(
|
||||
app: AppHandle,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<HistoryEntry>, String> {
|
||||
let entries = read_all(&app)?;
|
||||
Ok(entries.into_iter().skip(offset).take(limit).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_history(app: AppHandle) -> Result<(), String> {
|
||||
write_all(&app, &[])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_history_entry(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let entries: Vec<HistoryEntry> = read_all(&app)?
|
||||
.into_iter()
|
||||
.filter(|e| e.id != id)
|
||||
.collect();
|
||||
write_all(&app, &entries)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
pub mod connection;
|
||||
pub mod history;
|
||||
pub mod mongo_cmd;
|
||||
pub mod query;
|
||||
pub mod redis_cmd;
|
||||
pub mod schema;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::{AppState, PoolKind};
|
||||
use crate::db::mongo_driver::{self, MongoDocumentResult};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_list_databases(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_databases(client).await,
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_list_collections(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => mongo_driver::list_collections(client, &database).await,
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_find_documents(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
skip: u64,
|
||||
limit: i64,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::find_documents(client, &database, &collection, skip, limit).await
|
||||
}
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_insert_document(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
doc_json: String,
|
||||
) -> Result<String, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::insert_document(client, &database, &collection, &doc_json).await
|
||||
}
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_update_document(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
id: String,
|
||||
doc_json: String,
|
||||
) -> Result<u64, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::update_document(client, &database, &collection, &id, &doc_json).await
|
||||
}
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mongo_delete_document(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
collection: String,
|
||||
id: String,
|
||||
) -> Result<u64, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::delete_document(client, &database, &collection, &id).await
|
||||
}
|
||||
_ => Err("Not a MongoDB connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::commands::connection::{AppState, PoolKind};
|
||||
use crate::db;
|
||||
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_ROWS: usize = 10000;
|
||||
|
||||
fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result<db::QueryResult, String> {
|
||||
let start = std::time::Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT") || trimmed.starts_with("SHOW") || trimmed.starts_with("DESCRIBE")
|
||||
|| trimmed.starts_with("EXPLAIN") || trimmed.starts_with("WITH") || trimmed.starts_with("PRAGMA")
|
||||
{
|
||||
let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?;
|
||||
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||
let stmt_ref = rows.as_ref().ok_or("DuckDB statement unavailable")?;
|
||||
let col_count = stmt_ref.column_count();
|
||||
let columns: Vec<String> = (0..col_count)
|
||||
.map(|i| stmt_ref.column_name(i).map(|s| s.to_string()).unwrap_or_else(|_| "?".to_string()))
|
||||
.collect();
|
||||
|
||||
let mut result_rows = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||
if result_rows.len() >= MAX_ROWS { break; }
|
||||
let vals: Vec<serde_json::Value> = (0..col_count).map(|i| {
|
||||
row.get::<_, String>(i)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| row.get::<_, i64>(i).map(|v| serde_json::Value::Number(v.into())))
|
||||
.or_else(|_| row.get::<_, f64>(i).map(|v| {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}))
|
||||
.or_else(|_| row.get::<_, bool>(i).map(serde_json::Value::Bool))
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}).collect();
|
||||
result_rows.push(vals);
|
||||
}
|
||||
|
||||
let truncated = result_rows.len() >= MAX_ROWS;
|
||||
Ok(db::QueryResult { columns, rows: result_rows, affected_rows: 0, execution_time_ms: start.elapsed().as_millis(), truncated })
|
||||
} else {
|
||||
let affected = con.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
Ok(db::QueryResult { columns: vec![], rows: vec![], affected_rows: affected as u64, execution_time_ms: start.elapsed().as_millis(), truncated: false })
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_result(mut result: db::QueryResult) -> db::QueryResult {
|
||||
if result.rows.len() > MAX_ROWS {
|
||||
result.rows.truncate(MAX_ROWS);
|
||||
result.truncated = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_connection_error(err: &str) -> bool {
|
||||
let lower = err.to_lowercase();
|
||||
lower.contains("connection")
|
||||
|| lower.contains("broken pipe")
|
||||
|| lower.contains("reset by peer")
|
||||
|| lower.contains("timed out")
|
||||
|| lower.contains("closed")
|
||||
|| lower.contains("eof")
|
||||
}
|
||||
|
||||
async fn do_execute(
|
||||
state: &AppState,
|
||||
pool_key: &str,
|
||||
sql: &str,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(pool_key).ok_or("Connection not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::DuckDb(con) => {
|
||||
let con = con.clone();
|
||||
let sql = sql.to_string();
|
||||
drop(connections);
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
let con = con.lock().map_err(|e| e.to_string())?;
|
||||
duckdb_execute(&con, &sql)
|
||||
});
|
||||
timeout(QUERY_TIMEOUT, task)
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
PoolKind::Mysql(p) => {
|
||||
let p = p.clone();
|
||||
drop(connections);
|
||||
timeout(QUERY_TIMEOUT, db::mysql::execute_query(&p, sql))
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map(truncate_result)
|
||||
}
|
||||
PoolKind::Postgres(p) => {
|
||||
let p = p.clone();
|
||||
drop(connections);
|
||||
timeout(QUERY_TIMEOUT, db::postgres::execute_query(&p, sql))
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map(truncate_result)
|
||||
}
|
||||
PoolKind::Sqlite(p) => {
|
||||
let p = p.clone();
|
||||
drop(connections);
|
||||
timeout(QUERY_TIMEOUT, db::sqlite::execute_query(&p, sql))
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map(truncate_result)
|
||||
}
|
||||
PoolKind::ClickHouse(client) => {
|
||||
let client = client.clone();
|
||||
let database = pool_key.split(':').nth(1).unwrap_or("default").to_string();
|
||||
drop(connections);
|
||||
timeout(QUERY_TIMEOUT, db::clickhouse_driver::execute_query(&client, &database, sql))
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map(truncate_result)
|
||||
}
|
||||
PoolKind::SqlServer(client) => {
|
||||
let client = client.clone();
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
timeout(QUERY_TIMEOUT, db::sqlserver::execute_query(&mut client, sql))
|
||||
.await
|
||||
.map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))?
|
||||
.map(truncate_result)
|
||||
}
|
||||
PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()),
|
||||
PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_query(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
sql: String,
|
||||
) -> Result<db::QueryResult, String> {
|
||||
let pool_key = if database.is_empty() {
|
||||
connection_id.clone()
|
||||
} else {
|
||||
state.get_or_create_pool(&connection_id, Some(&database)).await?
|
||||
};
|
||||
|
||||
let result = do_execute(&state, &pool_key, &sql).await;
|
||||
|
||||
match &result {
|
||||
Err(e) if is_connection_error(e) => {
|
||||
let db_opt = if database.is_empty() { None } else { Some(database.as_str()) };
|
||||
let new_key = state.reconnect_pool(&connection_id, db_opt).await?;
|
||||
do_execute(&state, &new_key, &sql).await
|
||||
}
|
||||
_ => result,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::{AppState, PoolKind};
|
||||
use crate::db::redis_driver::{self, RedisKeyInfo, RedisValue};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_list_databases(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Redis(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::list_databases(&mut con).await
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_scan_keys(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
db: u32,
|
||||
pattern: String,
|
||||
count: usize,
|
||||
) -> Result<Vec<RedisKeyInfo>, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Redis(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::select_db(&mut con, db).await?;
|
||||
redis_driver::scan_keys(&mut con, &pattern, count).await
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_get_value(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
) -> Result<RedisValue, String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Redis(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::get_value(&mut con, &key).await
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_set_string(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
value: String,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Redis(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::set_string(&mut con, &key, &value, ttl).await
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_delete_key(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Redis(con) => {
|
||||
let mut con = con.lock().await;
|
||||
redis_driver::delete_key(&mut con, &key).await
|
||||
}
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_hash_set(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, field: String, value: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::hash_set(&mut *con.lock().await, &key, &field, &value).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_hash_del(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, field: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::hash_del(&mut *con.lock().await, &key, &field).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_list_push(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, value: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::list_push(&mut *con.lock().await, &key, &value).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_list_remove(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, index: i64,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::list_remove(&mut *con.lock().await, &key, index).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_set_add(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, member: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::set_add(&mut *con.lock().await, &key, &member).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn redis_set_remove(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String, key: String, member: String,
|
||||
) -> Result<(), String> {
|
||||
let connections = state.connections.lock().await;
|
||||
match connections.get(&connection_id).ok_or("Not found")? {
|
||||
PoolKind::Redis(con) => redis_driver::set_remove(&mut *con.lock().await, &key, &member).await,
|
||||
_ => Err("Not a Redis connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::{AppState, PoolKind};
|
||||
use crate::db;
|
||||
|
||||
fn duckdb_query_tables(con: &duckdb::Connection) -> Result<Vec<db::TableInfo>, String> {
|
||||
let mut stmt = con.prepare(
|
||||
"SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'main' ORDER BY table_name"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(db::TableInfo {
|
||||
name: row.get::<_, String>(0)?,
|
||||
table_type: row.get::<_, String>(1)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
fn duckdb_query_columns(con: &duckdb::Connection, table: &str) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
let mut pk_stmt = con.prepare(
|
||||
"SELECT kcu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name
|
||||
AND tc.table_schema = kcu.table_schema
|
||||
AND tc.table_name = kcu.table_name
|
||||
WHERE tc.constraint_type = 'PRIMARY KEY'
|
||||
AND tc.table_schema = 'main'
|
||||
AND tc.table_name = ?
|
||||
ORDER BY kcu.ordinal_position"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let pk_rows = pk_stmt.query_map([table], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let primary_keys: std::collections::HashSet<String> = pk_rows.filter_map(|r| r.ok()).collect();
|
||||
|
||||
let mut stmt = con.prepare(
|
||||
"SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'main' AND table_name = ?
|
||||
ORDER BY ordinal_position"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([table], |row| {
|
||||
let name = row.get::<_, String>(0)?;
|
||||
Ok(db::ColumnInfo {
|
||||
is_primary_key: primary_keys.contains(&name),
|
||||
name,
|
||||
data_type: row.get::<_, String>(1)?,
|
||||
is_nullable: row.get::<_, String>(2).unwrap_or_default() == "YES",
|
||||
column_default: row.get::<_, Option<String>>(3)?,
|
||||
extra: None,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
fn extract_duckdb(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<std::sync::Arc<std::sync::Mutex<duckdb::Connection>>> {
|
||||
match connections.get(key)? {
|
||||
PoolKind::DuckDb(con) => Some(con.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_sqlserver(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<std::sync::Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>> {
|
||||
match connections.get(key)? {
|
||||
PoolKind::SqlServer(client) => Some(client.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_clickhouse(connections: &std::collections::HashMap<String, PoolKind>, key: &str) -> Option<db::clickhouse_driver::ChClient> {
|
||||
match connections.get(key)? {
|
||||
PoolKind::ClickHouse(client) => Some(client.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_databases(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
) -> Result<Vec<db::DatabaseInfo>, String> {
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(client) = extract_clickhouse(&connections, &connection_id) {
|
||||
drop(connections);
|
||||
return db::clickhouse_driver::list_databases(&client).await;
|
||||
}
|
||||
if let Some(client) = extract_sqlserver(&connections, &connection_id) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_databases(&mut client).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&connection_id).ok_or("Connection not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::list_databases(p).await,
|
||||
PoolKind::Postgres(p) => db::postgres::list_databases(p).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_databases(p).await,
|
||||
PoolKind::DuckDb(_) => Ok(vec![db::DatabaseInfo { name: "main".to_string() }]),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_schemas(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_schemas(&mut client).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Postgres(p) => db::postgres::list_schemas(p).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_tables(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
) -> Result<Vec<db::TableInfo>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(con) = extract_duckdb(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let con = con.lock().map_err(|e| e.to_string())?;
|
||||
return duckdb_query_tables(&con);
|
||||
}
|
||||
if let Some(client) = extract_clickhouse(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
return db::clickhouse_driver::list_tables(&client, &database).await;
|
||||
}
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_tables(&mut client, &schema).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::list_tables(p, &schema).await,
|
||||
PoolKind::Postgres(p) => db::postgres::list_tables(p, &schema).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_tables(p, &schema).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_columns(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> Result<Vec<db::ColumnInfo>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(con) = extract_duckdb(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let con = con.lock().map_err(|e| e.to_string())?;
|
||||
return duckdb_query_columns(&con, &table);
|
||||
}
|
||||
if let Some(client) = extract_clickhouse(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
return db::clickhouse_driver::get_columns(&client, &database, &table).await;
|
||||
}
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::get_columns(&mut client, &schema, &table).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::get_columns(p, &schema, &table).await,
|
||||
PoolKind::Postgres(p) => db::postgres::get_columns(p, &schema, &table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::get_columns(p, &schema, &table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_indexes(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> Result<Vec<db::IndexInfo>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_indexes(&mut client, &schema, &table).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::list_indexes(p, &schema, &table).await,
|
||||
PoolKind::Postgres(p) => db::postgres::list_indexes(p, &schema, &table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_indexes(p, &schema, &table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_foreign_keys(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> Result<Vec<db::ForeignKeyInfo>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_foreign_keys(&mut client, &schema, &table).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::list_foreign_keys(p, &schema, &table).await,
|
||||
PoolKind::Postgres(p) => db::postgres::list_foreign_keys(p, &schema, &table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_foreign_keys(p, &schema, &table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_triggers(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
database: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> Result<Vec<db::TriggerInfo>, String> {
|
||||
let pool_key = state.get_or_create_pool(&connection_id, Some(&database)).await?;
|
||||
|
||||
{
|
||||
let connections = state.connections.lock().await;
|
||||
if let Some(client) = extract_sqlserver(&connections, &pool_key) {
|
||||
drop(connections);
|
||||
let mut client = client.lock().await;
|
||||
return db::sqlserver::list_triggers(&mut client, &schema, &table).await;
|
||||
}
|
||||
}
|
||||
|
||||
let connections = state.connections.lock().await;
|
||||
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
|
||||
|
||||
match pool {
|
||||
PoolKind::Mysql(p) => db::mysql::list_triggers(p, &schema, &table).await,
|
||||
PoolKind::Postgres(p) => db::postgres::list_triggers(p, &schema, &table).await,
|
||||
PoolKind::Sqlite(p) => db::sqlite::list_triggers(p, &schema, &table).await,
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
use reqwest::Client as HttpClient;
|
||||
use serde::Deserialize;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{ColumnInfo, DatabaseInfo, QueryResult, TableInfo};
|
||||
|
||||
pub struct ChClient {
|
||||
http: HttpClient,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl ChClient {
|
||||
pub fn new(url: &str) -> Self {
|
||||
Self {
|
||||
http: HttpClient::new(),
|
||||
base_url: url.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ChClient {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
http: self.http.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChJsonResult {
|
||||
meta: Vec<ChColumn>,
|
||||
data: Vec<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChColumn {
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
_type: String,
|
||||
}
|
||||
|
||||
async fn ch_query(client: &ChClient, sql: &str, database: Option<&str>) -> Result<ChJsonResult, String> {
|
||||
let mut url = format!("{}/?default_format=JSONCompact", client.base_url);
|
||||
if let Some(db) = database {
|
||||
url.push_str(&format!("&database={}", db));
|
||||
}
|
||||
let resp = client.http.post(&url)
|
||||
.body(sql.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ClickHouse request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ClickHouse error: {body}"));
|
||||
}
|
||||
resp.json::<ChJsonResult>().await.map_err(|e| format!("ClickHouse parse error: {e}"))
|
||||
}
|
||||
|
||||
pub async fn test_connection(client: &ChClient) -> Result<(), String> {
|
||||
let url = format!("{}/ping", client.base_url);
|
||||
client.http.get(&url).send().await
|
||||
.map_err(|e| format!("ClickHouse connection failed: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_databases(client: &ChClient) -> Result<Vec<DatabaseInfo>, String> {
|
||||
let result = ch_query(client, "SELECT name FROM system.databases ORDER BY name", None).await?;
|
||||
Ok(result.data.iter().map(|row| {
|
||||
DatabaseInfo { name: row[0].as_str().unwrap_or("").to_string() }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_tables(client: &ChClient, database: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT name, engine FROM system.tables WHERE database = '{}' ORDER BY name",
|
||||
database.replace('\'', "\\'")
|
||||
);
|
||||
let result = ch_query(client, &sql, Some(database)).await?;
|
||||
Ok(result.data.iter().map(|row| {
|
||||
let engine = row.get(1).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let table_type = if engine.contains("View") { "VIEW" } else { "BASE TABLE" };
|
||||
TableInfo {
|
||||
name: row[0].as_str().unwrap_or("").to_string(),
|
||||
table_type: table_type.to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT name, type, default_kind, default_expression, is_in_primary_key \
|
||||
FROM system.columns WHERE database = '{}' AND table = '{}' ORDER BY position",
|
||||
database.replace('\'', "\\'"),
|
||||
table.replace('\'', "\\'")
|
||||
);
|
||||
let result = ch_query(client, &sql, Some(database)).await?;
|
||||
Ok(result.data.iter().map(|row| {
|
||||
let data_type = row.get(1).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let is_nullable = data_type.starts_with("Nullable");
|
||||
let is_pk = row.get(4).and_then(|v| v.as_u64()).unwrap_or(0) == 1;
|
||||
let default_kind = row.get(2).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let default_expr = row.get(3).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let column_default = if default_kind.is_empty() { None } else { Some(default_expr.to_string()) };
|
||||
ColumnInfo {
|
||||
name: row[0].as_str().unwrap_or("").to_string(),
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
is_primary_key: is_pk,
|
||||
extra: None,
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn execute_query(client: &ChClient, database: &str, sql: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT")
|
||||
|| trimmed.starts_with("SHOW")
|
||||
|| trimmed.starts_with("DESCRIBE")
|
||||
|| trimmed.starts_with("EXPLAIN")
|
||||
|| trimmed.starts_with("WITH")
|
||||
{
|
||||
let result = ch_query(client, sql, Some(database)).await?;
|
||||
let columns: Vec<String> = result.meta.iter().map(|c| c.name.clone()).collect();
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows: result.data,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let url = format!("{}/?default_format=JSONCompact&database={}", client.base_url, database);
|
||||
let resp = client.http.post(&url)
|
||||
.body(sql.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ClickHouse request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("ClickHouse error: {body}"));
|
||||
}
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
pub mod clickhouse_driver;
|
||||
pub mod mongo_driver;
|
||||
pub mod mysql;
|
||||
pub mod postgres;
|
||||
pub mod redis_driver;
|
||||
pub mod sqlite;
|
||||
pub mod sqlserver;
|
||||
pub mod ssh_tunnel;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TableInfo {
|
||||
pub name: String,
|
||||
pub table_type: String, // "TABLE" or "VIEW"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnInfo {
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
pub is_primary_key: bool,
|
||||
pub extra: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryResult {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
pub affected_rows: u64,
|
||||
pub execution_time_ms: u128,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
pub is_unique: bool,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForeignKeyInfo {
|
||||
pub name: String,
|
||||
pub column: String,
|
||||
pub ref_table: String,
|
||||
pub ref_column: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriggerInfo {
|
||||
pub name: String,
|
||||
pub event: String,
|
||||
pub timing: String,
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
use mongodb::{bson::{doc, Document, Bson}, Client};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MongoDocumentResult {
|
||||
pub documents: Vec<serde_json::Value>,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
pub async fn list_databases(client: &Client) -> Result<Vec<String>, String> {
|
||||
client
|
||||
.list_database_names()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn list_collections(client: &Client, database: &str) -> Result<Vec<String>, String> {
|
||||
client
|
||||
.database(database)
|
||||
.list_collection_names()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn find_documents(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
skip: u64,
|
||||
limit: i64,
|
||||
) -> Result<MongoDocumentResult, String> {
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
|
||||
let total = col.count_documents(doc! {}).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut cursor = col
|
||||
.find(doc! {})
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut documents = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| e.to_string())? {
|
||||
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
|
||||
let json = bson_to_json(&Bson::Document(doc));
|
||||
documents.push(json);
|
||||
}
|
||||
|
||||
Ok(MongoDocumentResult { documents, total })
|
||||
}
|
||||
|
||||
pub async fn insert_document(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
doc_json: &str,
|
||||
) -> Result<String, String> {
|
||||
let doc: Document = serde_json::from_str(doc_json)
|
||||
.map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let result = col.insert_one(doc).await.map_err(|e| e.to_string())?;
|
||||
Ok(format!("{}", result.inserted_id))
|
||||
}
|
||||
|
||||
pub async fn update_document(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
doc_json: &str,
|
||||
) -> Result<u64, String> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(id)
|
||||
.map_err(|e| format!("Invalid ObjectId: {e}"))?;
|
||||
let new_doc: Document = serde_json::from_str(doc_json)
|
||||
.map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let result = col
|
||||
.replace_one(doc! { "_id": oid }, new_doc)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(result.modified_count)
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
client: &Client,
|
||||
database: &str,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<u64, String> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(id)
|
||||
.map_err(|e| format!("Invalid ObjectId: {e}"))?;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let result = col
|
||||
.delete_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(result.deleted_count)
|
||||
}
|
||||
|
||||
fn bson_to_json(bson: &Bson) -> serde_json::Value {
|
||||
match bson {
|
||||
Bson::Double(v) => serde_json::json!(v),
|
||||
Bson::String(v) => serde_json::Value::String(v.clone()),
|
||||
Bson::Boolean(v) => serde_json::Value::Bool(*v),
|
||||
Bson::Null => serde_json::Value::Null,
|
||||
Bson::Int32(v) => serde_json::json!(v),
|
||||
Bson::Int64(v) => serde_json::json!(v),
|
||||
Bson::ObjectId(oid) => serde_json::Value::String(oid.to_hex()),
|
||||
Bson::DateTime(dt) => serde_json::Value::String(dt.to_string()),
|
||||
Bson::Array(arr) => serde_json::Value::Array(arr.iter().map(bson_to_json).collect()),
|
||||
Bson::Document(doc) => {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (k, v) in doc {
|
||||
map.insert(k.clone(), bson_to_json(v));
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
_ => serde_json::Value::String(format!("{bson}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
use sqlx::mysql::{MySqlPool, MySqlPoolOptions, MySqlRow};
|
||||
use sqlx::{Column, Executor, Row};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
|
||||
|
||||
pub async fn connect(url: &str) -> Result<MySqlPool, String> {
|
||||
MySqlPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(300))
|
||||
.connect(url)
|
||||
.await
|
||||
.map_err(|e| format!("MySQL connection failed: {e}"))
|
||||
}
|
||||
|
||||
pub async fn list_databases(pool: &MySqlPool) -> Result<Vec<DatabaseInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query("SHOW DATABASES")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| DatabaseInfo {
|
||||
name: row.get::<String, _>(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_tables(pool: &MySqlPool, database: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query(
|
||||
"SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
|
||||
)
|
||||
.bind(database)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| TableInfo {
|
||||
name: row.get::<String, _>("TABLE_NAME"),
|
||||
table_type: row.get::<String, _>("TABLE_TYPE"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(
|
||||
pool: &MySqlPool,
|
||||
database: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<ColumnInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query(
|
||||
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.EXTRA, \
|
||||
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
|
||||
FROM information_schema.COLUMNS c \
|
||||
LEFT JOIN information_schema.KEY_COLUMN_USAGE kcu \
|
||||
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA \
|
||||
AND c.TABLE_NAME = kcu.TABLE_NAME \
|
||||
AND c.COLUMN_NAME = kcu.COLUMN_NAME \
|
||||
AND kcu.CONSTRAINT_NAME = 'PRIMARY' \
|
||||
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? \
|
||||
ORDER BY c.ORDINAL_POSITION",
|
||||
)
|
||||
.bind(database)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ColumnInfo {
|
||||
name: row.get::<String, _>("COLUMN_NAME"),
|
||||
data_type: row.get::<String, _>("DATA_TYPE"),
|
||||
is_nullable: row.get::<String, _>("IS_NULLABLE") == "YES",
|
||||
column_default: row.get::<Option<String>, _>("COLUMN_DEFAULT"),
|
||||
is_primary_key: row.get::<i32, _>("IS_PK") == 1,
|
||||
extra: row.get::<Option<String>, _>("EXTRA"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn execute_query(pool: &MySqlPool, sql: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT") || trimmed.starts_with("SHOW") || trimmed.starts_with("DESCRIBE") || trimmed.starts_with("EXPLAIN") {
|
||||
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
|
||||
let rows: Vec<MySqlRow> = sqlx::query(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result_rows: Vec<Vec<serde_json::Value>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| {
|
||||
row.try_get::<String, _>(i)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| row.try_get::<i64, _>(i).map(|v| serde_json::Value::Number(v.into())))
|
||||
.or_else(|_| row.try_get::<f64, _>(i).map(|v| {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}))
|
||||
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows: result_rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let result = sqlx::query(sql)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: result.rows_affected(),
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_indexes(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query(
|
||||
"SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS columns, \
|
||||
NOT NON_UNIQUE AS is_unique, INDEX_NAME = 'PRIMARY' AS is_primary \
|
||||
FROM information_schema.STATISTICS \
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? \
|
||||
GROUP BY INDEX_NAME, NON_UNIQUE \
|
||||
ORDER BY INDEX_NAME",
|
||||
)
|
||||
.bind(database)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let cols_str: String = row.get("columns");
|
||||
IndexInfo {
|
||||
name: row.get::<String, _>("INDEX_NAME"),
|
||||
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
|
||||
is_unique: row.get::<bool, _>("is_unique"),
|
||||
is_primary: row.get::<bool, _>("is_primary"),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query(
|
||||
"SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, \
|
||||
kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME \
|
||||
FROM information_schema.KEY_COLUMN_USAGE kcu \
|
||||
WHERE kcu.TABLE_SCHEMA = ? AND kcu.TABLE_NAME = ? \
|
||||
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL \
|
||||
ORDER BY kcu.CONSTRAINT_NAME",
|
||||
)
|
||||
.bind(database)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ForeignKeyInfo {
|
||||
name: row.get::<String, _>("CONSTRAINT_NAME"),
|
||||
column: row.get::<String, _>("COLUMN_NAME"),
|
||||
ref_table: row.get::<String, _>("REFERENCED_TABLE_NAME"),
|
||||
ref_column: row.get::<String, _>("REFERENCED_COLUMN_NAME"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||||
let rows: Vec<MySqlRow> = sqlx::query(
|
||||
"SELECT TRIGGER_NAME, EVENT_MANIPULATION, ACTION_TIMING \
|
||||
FROM information_schema.TRIGGERS \
|
||||
WHERE TRIGGER_SCHEMA = ? AND EVENT_OBJECT_TABLE = ? \
|
||||
ORDER BY TRIGGER_NAME",
|
||||
)
|
||||
.bind(database)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| TriggerInfo {
|
||||
name: row.get::<String, _>("TRIGGER_NAME"),
|
||||
event: row.get::<String, _>("EVENT_MANIPULATION"),
|
||||
timing: row.get::<String, _>("ACTION_TIMING"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
|
||||
use sqlx::{Column, Executor, Row};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
|
||||
|
||||
pub async fn connect(url: &str) -> Result<PgPool, String> {
|
||||
PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(300))
|
||||
.connect(url)
|
||||
.await
|
||||
.map_err(|e| format!("PostgreSQL connection failed: {e}"))
|
||||
}
|
||||
|
||||
pub async fn list_databases(pool: &PgPool) -> Result<Vec<DatabaseInfo>, String> {
|
||||
let rows: Vec<PgRow> =
|
||||
sqlx::query("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| DatabaseInfo {
|
||||
name: row.get::<String, _>("datname"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_tables(pool: &PgPool, schema: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT table_name, table_type FROM information_schema.tables \
|
||||
WHERE table_schema = $1 ORDER BY table_name",
|
||||
)
|
||||
.bind(schema)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| TableInfo {
|
||||
name: row.get::<String, _>("table_name"),
|
||||
table_type: row.get::<String, _>("table_type"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_schemas(pool: &PgPool) -> Result<Vec<String>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \
|
||||
ORDER BY schema_name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| row.get::<String, _>("schema_name"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(
|
||||
pool: &PgPool,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
) -> Result<Vec<ColumnInfo>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT c.column_name, c.data_type, c.is_nullable, c.column_default, \
|
||||
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN true ELSE false END AS is_pk \
|
||||
FROM information_schema.columns c \
|
||||
LEFT JOIN information_schema.key_column_usage kcu \
|
||||
ON c.table_schema = kcu.table_schema \
|
||||
AND c.table_name = kcu.table_name \
|
||||
AND c.column_name = kcu.column_name \
|
||||
LEFT JOIN information_schema.table_constraints tc \
|
||||
ON kcu.constraint_name = tc.constraint_name \
|
||||
AND kcu.table_schema = tc.table_schema \
|
||||
AND tc.constraint_type = 'PRIMARY KEY' \
|
||||
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(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ColumnInfo {
|
||||
name: row.get::<String, _>("column_name"),
|
||||
data_type: row.get::<String, _>("data_type"),
|
||||
is_nullable: row.get::<String, _>("is_nullable") == "YES",
|
||||
column_default: row.get::<Option<String>, _>("column_default"),
|
||||
is_primary_key: row.get::<bool, _>("is_pk"),
|
||||
extra: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn execute_query(pool: &PgPool, sql: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT")
|
||||
|| trimmed.starts_with("SHOW")
|
||||
|| trimmed.starts_with("EXPLAIN")
|
||||
|| trimmed.starts_with("WITH")
|
||||
|| trimmed.starts_with("TABLE")
|
||||
{
|
||||
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
|
||||
let rows: Vec<PgRow> = sqlx::query(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result_rows: Vec<Vec<serde_json::Value>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| {
|
||||
row.try_get::<String, _>(i)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| {
|
||||
row.try_get::<i64, _>(i)
|
||||
.map(|v| serde_json::Value::Number(v.into()))
|
||||
})
|
||||
.or_else(|_| {
|
||||
row.try_get::<i32, _>(i)
|
||||
.map(|v| serde_json::Value::Number(v.into()))
|
||||
})
|
||||
.or_else(|_| {
|
||||
row.try_get::<f64, _>(i).map(|v| {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
})
|
||||
})
|
||||
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows: result_rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let result = sqlx::query(sql)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: result.rows_affected(),
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_indexes(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT i.relname AS index_name, \
|
||||
array_agg(a.attname ORDER BY k.n) AS columns, \
|
||||
ix.indisunique AS is_unique, \
|
||||
ix.indisprimary AS is_primary \
|
||||
FROM pg_index ix \
|
||||
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum \
|
||||
WHERE n.nspname = $1 AND t.relname = $2 \
|
||||
GROUP BY i.relname, ix.indisunique, ix.indisprimary \
|
||||
ORDER BY i.relname",
|
||||
)
|
||||
.bind(schema)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| IndexInfo {
|
||||
name: row.get::<String, _>("index_name"),
|
||||
columns: row.get::<Vec<String>, _>("columns"),
|
||||
is_unique: row.get::<bool, _>("is_unique"),
|
||||
is_primary: row.get::<bool, _>("is_primary"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT kcu.constraint_name, kcu.column_name, \
|
||||
ccu.table_name AS ref_table, ccu.column_name AS ref_column \
|
||||
FROM information_schema.key_column_usage kcu \
|
||||
JOIN information_schema.referential_constraints rc \
|
||||
ON kcu.constraint_name = rc.constraint_name \
|
||||
AND kcu.constraint_schema = rc.constraint_schema \
|
||||
JOIN information_schema.constraint_column_usage ccu \
|
||||
ON rc.unique_constraint_name = ccu.constraint_name \
|
||||
AND rc.unique_constraint_schema = ccu.constraint_schema \
|
||||
WHERE kcu.table_schema = $1 AND kcu.table_name = $2 \
|
||||
ORDER BY kcu.constraint_name",
|
||||
)
|
||||
.bind(schema)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ForeignKeyInfo {
|
||||
name: row.get::<String, _>("constraint_name"),
|
||||
column: row.get::<String, _>("column_name"),
|
||||
ref_table: row.get::<String, _>("ref_table"),
|
||||
ref_column: row.get::<String, _>("ref_column"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_triggers(pool: &PgPool, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||||
let rows: Vec<PgRow> = sqlx::query(
|
||||
"SELECT trigger_name, event_manipulation, action_timing \
|
||||
FROM information_schema.triggers \
|
||||
WHERE trigger_schema = $1 AND event_object_table = $2 \
|
||||
ORDER BY trigger_name",
|
||||
)
|
||||
.bind(schema)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| TriggerInfo {
|
||||
name: row.get::<String, _>("trigger_name"),
|
||||
event: row.get::<String, _>("event_manipulation"),
|
||||
timing: row.get::<String, _>("action_timing"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
use redis::AsyncCommands;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RedisKeyInfo {
|
||||
pub key: String,
|
||||
pub key_type: String,
|
||||
pub ttl: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RedisValue {
|
||||
pub key: String,
|
||||
pub key_type: String,
|
||||
pub ttl: i64,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn connect(url: &str) -> Result<redis::aio::MultiplexedConnection, String> {
|
||||
let client = redis::Client::open(url).map_err(|e| format!("Redis connection failed: {e}"))?;
|
||||
client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_err(|e| format!("Redis connection failed: {e}"))
|
||||
}
|
||||
|
||||
pub async fn list_databases(con: &mut redis::aio::MultiplexedConnection) -> Result<Vec<u32>, String> {
|
||||
let info: String = redis::cmd("INFO")
|
||||
.arg("keyspace")
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut dbs: Vec<u32> = Vec::new();
|
||||
for line in info.lines() {
|
||||
if line.starts_with("db") {
|
||||
if let Some(num) = line.strip_prefix("db").and_then(|s| s.split(':').next()) {
|
||||
if let Ok(n) = num.parse::<u32>() {
|
||||
dbs.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dbs.is_empty() {
|
||||
dbs.push(0);
|
||||
}
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
pub async fn select_db(con: &mut redis::aio::MultiplexedConnection, db: u32) -> Result<(), String> {
|
||||
redis::cmd("SELECT")
|
||||
.arg(db)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn scan_keys(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
pattern: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<RedisKeyInfo>, String> {
|
||||
let mut cursor: u64 = 0;
|
||||
let mut all_keys: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg(pattern)
|
||||
.arg("COUNT")
|
||||
.arg(100)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
all_keys.extend(keys);
|
||||
cursor = new_cursor;
|
||||
if cursor == 0 || all_keys.len() >= count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
all_keys.truncate(count);
|
||||
|
||||
let mut result = Vec::new();
|
||||
for key in &all_keys {
|
||||
let key_type: String = redis::cmd("TYPE")
|
||||
.arg(key.as_str())
|
||||
.query_async(con)
|
||||
.await
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
|
||||
let ttl: i64 = con.ttl(key.as_str()).await.unwrap_or(-1);
|
||||
|
||||
result.push(RedisKeyInfo {
|
||||
key: key.clone(),
|
||||
key_type,
|
||||
ttl,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_value(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
) -> Result<RedisValue, String> {
|
||||
let key_type: String = redis::cmd("TYPE")
|
||||
.arg(key)
|
||||
.query_async(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let ttl: i64 = con.ttl(key).await.unwrap_or(-1);
|
||||
|
||||
let value = match key_type.as_str() {
|
||||
"string" => {
|
||||
let v: String = con.get(key).await.map_err(|e| e.to_string())?;
|
||||
serde_json::Value::String(v)
|
||||
}
|
||||
"list" => {
|
||||
let v: Vec<String> = con.lrange(key, 0, -1).await.map_err(|e| e.to_string())?;
|
||||
serde_json::json!(v)
|
||||
}
|
||||
"set" => {
|
||||
let v: Vec<String> = con.smembers(key).await.map_err(|e| e.to_string())?;
|
||||
serde_json::json!(v)
|
||||
}
|
||||
"zset" => {
|
||||
let v: Vec<(String, f64)> = con
|
||||
.zrange_withscores(key, 0, -1)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::json!(v.iter().map(|(m, s)| serde_json::json!({"member": m, "score": s})).collect::<Vec<_>>())
|
||||
}
|
||||
"hash" => {
|
||||
let v: Vec<(String, String)> = con.hgetall(key).await.map_err(|e| e.to_string())?;
|
||||
let map: serde_json::Map<String, serde_json::Value> = v
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, serde_json::Value::String(v)))
|
||||
.collect();
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
_ => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
Ok(RedisValue {
|
||||
key: key.to_string(),
|
||||
key_type,
|
||||
ttl,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_string(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
con.set::<_, _, ()>(key, value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(t) = ttl {
|
||||
if t > 0 {
|
||||
con.expire::<_, ()>(key, t)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_key(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
) -> Result<(), String> {
|
||||
con.del::<_, ()>(key).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn hash_set(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
field: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
con.hset::<_, _, _, ()>(key, field, value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn hash_del(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
con.hdel::<_, _, ()>(key, field)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn list_push(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
con.rpush::<_, _, ()>(key, value)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn list_remove(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
index: i64,
|
||||
) -> Result<(), String> {
|
||||
let placeholder = "__DELETED_PLACEHOLDER__";
|
||||
redis::cmd("LSET")
|
||||
.arg(key)
|
||||
.arg(index)
|
||||
.arg(placeholder)
|
||||
.query_async::<()>(con)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
con.lrem::<_, _, ()>(key, 1, placeholder)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn set_add(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
member: &str,
|
||||
) -> Result<(), String> {
|
||||
con.sadd::<_, _, ()>(key, member)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn set_remove(
|
||||
con: &mut redis::aio::MultiplexedConnection,
|
||||
key: &str,
|
||||
member: &str,
|
||||
) -> Result<(), String> {
|
||||
con.srem::<_, _, ()>(key, member)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions, SqliteRow};
|
||||
use sqlx::{Column, Executor, Row};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
|
||||
|
||||
pub async fn connect(url: &str) -> Result<SqlitePool, String> {
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.acquire_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(300))
|
||||
.connect(url)
|
||||
.await
|
||||
.map_err(|e| format!("SQLite connection failed: {e}"))
|
||||
}
|
||||
|
||||
pub async fn list_databases(_pool: &SqlitePool) -> Result<Vec<DatabaseInfo>, String> {
|
||||
Ok(vec![DatabaseInfo { name: "main".to_string() }])
|
||||
}
|
||||
|
||||
pub async fn list_tables(pool: &SqlitePool, _schema: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let rows: Vec<SqliteRow> = sqlx::query(
|
||||
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let t: String = row.get("type");
|
||||
TableInfo {
|
||||
name: row.get::<String, _>("name"),
|
||||
table_type: if t == "view" { "VIEW".to_string() } else { "BASE TABLE".to_string() },
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA table_info(\"{}\")", table))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ColumnInfo {
|
||||
name: row.get::<String, _>("name"),
|
||||
data_type: row.get::<String, _>("type"),
|
||||
is_nullable: row.get::<i32, _>("notnull") == 0,
|
||||
column_default: row.get::<Option<String>, _>("dflt_value"),
|
||||
is_primary_key: row.get::<i32, _>("pk") > 0,
|
||||
extra: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_indexes(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let idx_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_list(\"{}\")", table))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut indexes = Vec::new();
|
||||
for idx_row in &idx_rows {
|
||||
let name: String = idx_row.get("name");
|
||||
let is_unique: bool = idx_row.get::<i32, _>("unique") != 0;
|
||||
|
||||
let col_rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA index_info(\"{}\")", name))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let columns: Vec<String> = col_rows.iter().map(|r| r.get::<String, _>("name")).collect();
|
||||
|
||||
indexes.push(IndexInfo {
|
||||
name,
|
||||
columns,
|
||||
is_unique,
|
||||
is_primary: false,
|
||||
});
|
||||
}
|
||||
Ok(indexes)
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
||||
let rows: Vec<SqliteRow> = sqlx::query(&format!("PRAGMA foreign_key_list(\"{}\")", table))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| ForeignKeyInfo {
|
||||
name: format!("fk_{}", row.get::<i32, _>("id")),
|
||||
column: row.get::<String, _>("from"),
|
||||
ref_table: row.get::<String, _>("table"),
|
||||
ref_column: row.get::<String, _>("to"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn list_triggers(pool: &SqlitePool, _schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||||
let rows: Vec<SqliteRow> = sqlx::query(
|
||||
"SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ? ORDER BY name",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let sql_text: String = row.get::<Option<String>, _>("sql").unwrap_or_default();
|
||||
let upper = sql_text.to_uppercase();
|
||||
let timing = if upper.contains("BEFORE") { "BEFORE" } else if upper.contains("AFTER") { "AFTER" } else { "INSTEAD OF" };
|
||||
let event = if upper.contains("INSERT") { "INSERT" } else if upper.contains("UPDATE") { "UPDATE" } else { "DELETE" };
|
||||
TriggerInfo {
|
||||
name: row.get::<String, _>("name"),
|
||||
event: event.to_string(),
|
||||
timing: timing.to_string(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn execute_query(pool: &SqlitePool, sql: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT")
|
||||
|| trimmed.starts_with("PRAGMA")
|
||||
|| trimmed.starts_with("EXPLAIN")
|
||||
|| trimmed.starts_with("WITH")
|
||||
{
|
||||
let desc = pool.describe(sql).await.map_err(|e| e.to_string())?;
|
||||
let columns: Vec<String> = desc.columns().iter().map(|c| c.name().to_string()).collect();
|
||||
|
||||
let rows: Vec<SqliteRow> = sqlx::query(sql)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result_rows: Vec<Vec<serde_json::Value>> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(0..row.len())
|
||||
.map(|i| {
|
||||
row.try_get::<String, _>(i)
|
||||
.map(serde_json::Value::String)
|
||||
.or_else(|_| row.try_get::<i64, _>(i).map(|v| serde_json::Value::Number(v.into())))
|
||||
.or_else(|_| row.try_get::<f64, _>(i).map(|v| {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}))
|
||||
.or_else(|_| row.try_get::<bool, _>(i).map(serde_json::Value::Bool))
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns,
|
||||
rows: result_rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let result = sqlx::query(sql)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: result.rows_affected(),
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
use tiberius::{AuthMethod, Client, Config};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo};
|
||||
|
||||
pub type SqlServerClient = Client<Compat<TcpStream>>;
|
||||
|
||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str, database: Option<&str>) -> Result<SqlServerClient, String> {
|
||||
let mut config = Config::new();
|
||||
config.host(host);
|
||||
config.port(port);
|
||||
config.authentication(AuthMethod::sql_server(user, pass));
|
||||
if let Some(db) = database {
|
||||
config.database(db);
|
||||
}
|
||||
config.trust_cert();
|
||||
|
||||
let tcp = TcpStream::connect(config.get_addr())
|
||||
.await
|
||||
.map_err(|e| format!("SQL Server connection failed: {e}"))?;
|
||||
Client::connect(config, tcp.compat_write())
|
||||
.await
|
||||
.map_err(|e| format!("SQL Server connection failed: {e}"))
|
||||
}
|
||||
|
||||
fn row_to_json(row: &tiberius::Row) -> Vec<serde_json::Value> {
|
||||
(0..row.len()).map(|i| {
|
||||
if let Some(v) = row.try_get::<&str, _>(i).ok().flatten() {
|
||||
serde_json::Value::String(v.to_string())
|
||||
} else if let Some(v) = row.try_get::<i32, _>(i).ok().flatten() {
|
||||
serde_json::Value::Number(v.into())
|
||||
} else if let Some(v) = row.try_get::<i64, _>(i).ok().flatten() {
|
||||
serde_json::Value::Number(v.into())
|
||||
} else if let Some(v) = row.try_get::<f64, _>(i).ok().flatten() {
|
||||
serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
|
||||
} else if let Some(v) = row.try_get::<bool, _>(i).ok().flatten() {
|
||||
serde_json::Value::Bool(v)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub async fn list_databases(client: &mut SqlServerClient) -> Result<Vec<DatabaseInfo>, String> {
|
||||
let stream = client.query("SELECT name FROM sys.databases ORDER BY name", &[])
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
DatabaseInfo { name: row.get::<&str, _>(0).unwrap_or("").to_string() }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_schemas(client: &mut SqlServerClient) -> Result<Vec<String>, String> {
|
||||
let stream = client.query(
|
||||
"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA \
|
||||
WHERE SCHEMA_NAME NOT IN ('guest','INFORMATION_SCHEMA','sys') \
|
||||
ORDER BY SCHEMA_NAME",
|
||||
&[],
|
||||
).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
row.get::<&str, _>(0).unwrap_or("").to_string()
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_tables(client: &mut SqlServerClient, schema: &str) -> Result<Vec<TableInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '{}' ORDER BY TABLE_NAME",
|
||||
schema.replace('\'', "''")
|
||||
);
|
||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
TableInfo {
|
||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||
table_type: row.get::<&str, _>(1).unwrap_or("BASE TABLE").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, \
|
||||
CASE WHEN kcu.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK \
|
||||
FROM INFORMATION_SCHEMA.COLUMNS c \
|
||||
LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu \
|
||||
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND c.TABLE_NAME = kcu.TABLE_NAME AND c.COLUMN_NAME = kcu.COLUMN_NAME \
|
||||
AND kcu.CONSTRAINT_NAME IN (SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE = 'PRIMARY KEY' AND TABLE_SCHEMA = '{s}' AND TABLE_NAME = '{t}') \
|
||||
WHERE c.TABLE_SCHEMA = '{s}' AND c.TABLE_NAME = '{t}' \
|
||||
ORDER BY c.ORDINAL_POSITION",
|
||||
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
|
||||
);
|
||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
ColumnInfo {
|
||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||
data_type: row.get::<&str, _>(1).unwrap_or("").to_string(),
|
||||
is_nullable: row.get::<&str, _>(2).unwrap_or("NO") == "YES",
|
||||
column_default: row.get::<&str, _>(3).map(|s| s.to_string()),
|
||||
is_primary_key: row.get::<i32, _>(4).unwrap_or(0) == 1,
|
||||
extra: None,
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_indexes(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT i.name, STRING_AGG(c.name, ',') WITHIN GROUP (ORDER BY ic.key_ordinal) AS columns, \
|
||||
i.is_unique, i.is_primary_key \
|
||||
FROM sys.indexes i \
|
||||
JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
|
||||
JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
|
||||
WHERE i.object_id = OBJECT_ID('{s}.{t}') AND i.name IS NOT NULL \
|
||||
GROUP BY i.name, i.is_unique, i.is_primary_key \
|
||||
ORDER BY i.name",
|
||||
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
|
||||
);
|
||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
let cols_str = row.get::<&str, _>(1).unwrap_or("");
|
||||
IndexInfo {
|
||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||
columns: cols_str.split(',').map(|s| s.to_string()).collect(),
|
||||
is_unique: row.get::<bool, _>(2).unwrap_or(false),
|
||||
is_primary: row.get::<bool, _>(3).unwrap_or(false),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT fk.name, c.name, rt.name, rc.name \
|
||||
FROM sys.foreign_keys fk \
|
||||
JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
|
||||
JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
|
||||
JOIN sys.tables rt ON fkc.referenced_object_id = rt.object_id \
|
||||
JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
|
||||
WHERE fk.parent_object_id = OBJECT_ID('{s}.{t}') \
|
||||
ORDER BY fk.name",
|
||||
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
|
||||
);
|
||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
ForeignKeyInfo {
|
||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||
column: row.get::<&str, _>(1).unwrap_or("").to_string(),
|
||||
ref_table: row.get::<&str, _>(2).unwrap_or("").to_string(),
|
||||
ref_column: row.get::<&str, _>(3).unwrap_or("").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn list_triggers(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
||||
let sql = format!(
|
||||
"SELECT t.name, te.type_desc, CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END \
|
||||
FROM sys.triggers t \
|
||||
JOIN sys.trigger_events te ON t.object_id = te.object_id \
|
||||
WHERE t.parent_id = OBJECT_ID('{s}.{t}') \
|
||||
ORDER BY t.name",
|
||||
s = schema.replace('\'', "''"), t = table.replace('\'', "''")
|
||||
);
|
||||
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
Ok(rows.iter().map(|row| {
|
||||
TriggerInfo {
|
||||
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
|
||||
event: row.get::<&str, _>(1).unwrap_or("").to_string(),
|
||||
timing: row.get::<&str, _>(2).unwrap_or("AFTER").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result<QueryResult, String> {
|
||||
let start = Instant::now();
|
||||
let trimmed = sql.trim().to_uppercase();
|
||||
|
||||
if trimmed.starts_with("SELECT")
|
||||
|| trimmed.starts_with("EXEC")
|
||||
|| trimmed.starts_with("WITH")
|
||||
|| trimmed.starts_with("TABLE")
|
||||
{
|
||||
let mut stream = client.query(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
let columns_meta = stream.columns().await.map_err(|e| e.to_string())?
|
||||
.map(|cols| cols.iter().map(|c| c.name().to_string()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
|
||||
let result_rows: Vec<Vec<serde_json::Value>> = rows.iter().map(|row| row_to_json(row)).collect();
|
||||
|
||||
Ok(QueryResult {
|
||||
columns: columns_meta,
|
||||
rows: result_rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
} else {
|
||||
let result = client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
Ok(QueryResult {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
affected_rows: result.rows_affected().iter().sum::<u64>(),
|
||||
execution_time_ms: start.elapsed().as_millis(),
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
use std::collections::HashMap;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct TunnelManager {
|
||||
tunnels: Mutex<HashMap<String, (Child, u16)>>,
|
||||
}
|
||||
|
||||
impl TunnelManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tunnels: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_tunnel(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
ssh_host: &str,
|
||||
ssh_port: u16,
|
||||
ssh_user: &str,
|
||||
ssh_key_path: &str,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
) -> Result<u16, String> {
|
||||
let local_port = portpicker::pick_unused_port().ok_or("No available port")?;
|
||||
|
||||
let mut args = vec![
|
||||
"-N".to_string(),
|
||||
"-o".to_string(), "StrictHostKeyChecking=no".to_string(),
|
||||
"-o".to_string(), "ServerAliveInterval=60".to_string(),
|
||||
"-L".to_string(), format!("{local_port}:{remote_host}:{remote_port}"),
|
||||
"-p".to_string(), ssh_port.to_string(),
|
||||
];
|
||||
|
||||
if !ssh_key_path.is_empty() {
|
||||
args.push("-i".to_string());
|
||||
args.push(ssh_key_path.to_string());
|
||||
}
|
||||
|
||||
args.push(format!("{ssh_user}@{ssh_host}"));
|
||||
|
||||
let child = Command::new("ssh")
|
||||
.args(&args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start SSH tunnel: {e}"))?;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
|
||||
|
||||
self.tunnels
|
||||
.lock()
|
||||
.await
|
||||
.insert(connection_id.to_string(), (child, local_port));
|
||||
|
||||
Ok(local_port)
|
||||
}
|
||||
|
||||
pub async fn stop_tunnel(&self, connection_id: &str) {
|
||||
if let Some((mut child, _)) = self.tunnels.lock().await.remove(connection_id) {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
mod commands;
|
||||
mod db;
|
||||
mod models;
|
||||
|
||||
use commands::connection::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let state = Arc::new(AppState::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(state)
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
window.hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::connection::test_connection,
|
||||
commands::connection::connect_db,
|
||||
commands::connection::disconnect_db,
|
||||
commands::connection::save_connections,
|
||||
commands::connection::load_connections,
|
||||
commands::schema::list_databases,
|
||||
commands::schema::list_tables,
|
||||
commands::schema::list_schemas,
|
||||
commands::schema::get_columns,
|
||||
commands::schema::list_indexes,
|
||||
commands::schema::list_foreign_keys,
|
||||
commands::schema::list_triggers,
|
||||
commands::query::execute_query,
|
||||
commands::redis_cmd::redis_list_databases,
|
||||
commands::redis_cmd::redis_scan_keys,
|
||||
commands::redis_cmd::redis_get_value,
|
||||
commands::redis_cmd::redis_set_string,
|
||||
commands::redis_cmd::redis_delete_key,
|
||||
commands::redis_cmd::redis_hash_set,
|
||||
commands::redis_cmd::redis_hash_del,
|
||||
commands::redis_cmd::redis_list_push,
|
||||
commands::redis_cmd::redis_list_remove,
|
||||
commands::redis_cmd::redis_set_add,
|
||||
commands::redis_cmd::redis_set_remove,
|
||||
commands::mongo_cmd::mongo_list_databases,
|
||||
commands::mongo_cmd::mongo_list_collections,
|
||||
commands::mongo_cmd::mongo_find_documents,
|
||||
commands::mongo_cmd::mongo_insert_document,
|
||||
commands::mongo_cmd::mongo_update_document,
|
||||
commands::mongo_cmd::mongo_delete_document,
|
||||
commands::history::save_history,
|
||||
commands::history::load_history,
|
||||
commands::history::clear_history,
|
||||
commands::history::delete_history_entry,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dbx_lib::run();
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub db_type: DatabaseType,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub database: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub ssh_host: String,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
pub ssh_port: u16,
|
||||
#[serde(default)]
|
||||
pub ssh_user: String,
|
||||
#[serde(default)]
|
||||
pub ssh_key_path: String,
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 { 22 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DatabaseType {
|
||||
Mysql,
|
||||
Postgres,
|
||||
Sqlite,
|
||||
Redis,
|
||||
#[serde(rename = "duckdb")]
|
||||
DuckDb,
|
||||
#[serde(rename = "clickhouse")]
|
||||
ClickHouse,
|
||||
#[serde(rename = "sqlserver")]
|
||||
SqlServer,
|
||||
#[serde(rename = "mongodb")]
|
||||
MongoDb,
|
||||
}
|
||||
|
||||
impl ConnectionConfig {
|
||||
pub fn connection_url(&self) -> String {
|
||||
self.connection_url_with_host(&self.host, self.port)
|
||||
}
|
||||
|
||||
pub fn connection_url_with_host(&self, host: &str, port: u16) -> String {
|
||||
let db_part = self.database.as_deref().map(|d| format!("/{d}")).unwrap_or_default();
|
||||
|
||||
match self.db_type {
|
||||
DatabaseType::Sqlite | DatabaseType::DuckDb => {
|
||||
format!("{}?mode=rwc", self.host)
|
||||
}
|
||||
DatabaseType::Redis => {
|
||||
if self.password.is_empty() {
|
||||
format!("redis://{host}:{port}/")
|
||||
} else {
|
||||
format!("redis://:{}@{host}:{port}/", self.password)
|
||||
}
|
||||
}
|
||||
DatabaseType::Mysql => format!(
|
||||
"mysql://{}:{}@{host}:{port}{db_part}",
|
||||
self.username, self.password
|
||||
),
|
||||
DatabaseType::Postgres => format!(
|
||||
"postgres://{}:{}@{host}:{port}{db_part}",
|
||||
self.username, self.password
|
||||
),
|
||||
DatabaseType::ClickHouse => format!(
|
||||
"http://{host}:{port}{db_part}"
|
||||
),
|
||||
DatabaseType::SqlServer => format!(
|
||||
"server=tcp:{host},{port};user={};password={};database={}",
|
||||
self.username, self.password, self.database.as_deref().unwrap_or("master")
|
||||
),
|
||||
DatabaseType::MongoDb => {
|
||||
if self.username.is_empty() {
|
||||
format!("mongodb://{host}:{port}{db_part}")
|
||||
} else {
|
||||
format!("mongodb://{}:{}@{host}:{port}{db_part}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
pub mod connection;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "dbx",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.dbx.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "DBX",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
|
||||
import AiAssistant from "@/components/editor/AiAssistant.vue";
|
||||
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
|
||||
import QueryHistory from "@/components/editor/QueryHistory.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import { setLocale, currentLocale, type Locale } from "@/i18n";
|
||||
import * as api from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const historyStore = useHistoryStore();
|
||||
|
||||
const showConnectionDialog = ref(false);
|
||||
const showHistory = ref(false);
|
||||
const dangerSql = ref("");
|
||||
const showDangerDialog = ref(false);
|
||||
|
||||
const editConfig = computed(() => {
|
||||
const id = connectionStore.editingConnectionId;
|
||||
if (!id) return undefined;
|
||||
return connectionStore.getConfig(id);
|
||||
});
|
||||
|
||||
watch(editConfig, (v) => {
|
||||
if (v) showConnectionDialog.value = true;
|
||||
});
|
||||
|
||||
watch(showConnectionDialog, (v) => {
|
||||
if (!v) connectionStore.stopEditing();
|
||||
});
|
||||
|
||||
const activeTab = computed(() =>
|
||||
queryStore.tabs.find((t) => t.id === queryStore.activeTabId)
|
||||
);
|
||||
|
||||
function onEditorUpdate(val: string) {
|
||||
if (queryStore.activeTabId) {
|
||||
queryStore.updateSql(queryStore.activeTabId, val);
|
||||
}
|
||||
}
|
||||
|
||||
function newQuery() {
|
||||
if (!connectionStore.activeConnectionId) return;
|
||||
const conn = connectionStore.connections.find(
|
||||
(c) => c.id === connectionStore.activeConnectionId
|
||||
);
|
||||
if (!conn) return;
|
||||
queryStore.createTab(conn.id, conn.database || "");
|
||||
}
|
||||
|
||||
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER)\b/i;
|
||||
|
||||
function isDangerousSql(sql: string): boolean {
|
||||
return DANGER_RE.test(sql);
|
||||
}
|
||||
|
||||
function tryExecute() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || !tab.sql.trim()) return;
|
||||
if (isDangerousSql(tab.sql)) {
|
||||
dangerSql.value = tab.sql;
|
||||
showDangerDialog.value = true;
|
||||
} else {
|
||||
doExecute();
|
||||
}
|
||||
}
|
||||
|
||||
async function doExecute() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
const connName = connectionStore.getConfig(tab.connectionId)?.name || "";
|
||||
const sql = tab.sql;
|
||||
const start = Date.now();
|
||||
await queryStore.executeCurrentTab();
|
||||
const elapsed = Date.now() - start;
|
||||
const success = !tab.result?.columns.includes("Error");
|
||||
historyStore.add({
|
||||
connection_name: connName,
|
||||
database: tab.database,
|
||||
sql,
|
||||
execution_time_ms: elapsed,
|
||||
success,
|
||||
error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
function onDangerConfirm() {
|
||||
doExecute();
|
||||
}
|
||||
|
||||
function onHistoryRestore(sql: string) {
|
||||
if (queryStore.activeTabId) {
|
||||
queryStore.updateSql(queryStore.activeTabId, sql);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExecuteSql(sql: string) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
await api.executeQuery(tab.connectionId, tab.database, sql);
|
||||
}
|
||||
|
||||
async function onReloadData() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
if (tab.mode === "data" && tab.tableMeta) {
|
||||
queryStore.updateSql(tab.id, buildTableSql(tab));
|
||||
}
|
||||
queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
type ActiveTab = NonNullable<typeof activeTab.value>;
|
||||
|
||||
function quoteIdent(tab: ActiveTab, name: string): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
return config?.db_type === "mysql"
|
||||
? `\`${name.replace(/`/g, "``")}\``
|
||||
: `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function qualifiedTableName(tab: NonNullable<typeof activeTab.value>): string {
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
if (!tab.tableMeta) return "";
|
||||
if (config?.db_type === "postgres" && tab.tableMeta.schema) {
|
||||
return `${quoteIdent(tab, tab.tableMeta.schema)}.${quoteIdent(tab, tab.tableMeta.tableName)}`;
|
||||
}
|
||||
return quoteIdent(tab, tab.tableMeta.tableName);
|
||||
}
|
||||
|
||||
function defaultOrderBy(tab: NonNullable<typeof activeTab.value>): string | undefined {
|
||||
const primaryKeys = tab.tableMeta?.primaryKeys ?? [];
|
||||
if (primaryKeys.length === 0) return undefined;
|
||||
return primaryKeys.map((pk) => `${quoteIdent(tab, pk)} ASC`).join(", ");
|
||||
}
|
||||
|
||||
function buildTableSql(
|
||||
tab: NonNullable<typeof activeTab.value>,
|
||||
options: { orderBy?: string; limit?: number; offset?: number } = {},
|
||||
): string {
|
||||
const limit = options.limit ?? 100;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy(tab);
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
return `SELECT * FROM ${qualifiedTableName(tab)}${order} LIMIT ${limit}${offset};`;
|
||||
}
|
||||
|
||||
async function onPaginate(offset: number, limit: number) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab?.tableMeta) return;
|
||||
const sql = buildTableSql(tab, { limit, offset });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
async function onSort(column: string, direction: "asc" | "desc" | null) {
|
||||
const tab = activeTab.value;
|
||||
if (!tab?.tableMeta) return;
|
||||
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : defaultOrderBy(tab);
|
||||
const sql = buildTableSql(tab, { orderBy });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
function toggleLocale() {
|
||||
const next: Locale = currentLocale() === "zh-CN" ? "en" : "zh-CN";
|
||||
setLocale(next);
|
||||
}
|
||||
|
||||
const isDark = ref(localStorage.getItem("dbx-theme") === "dark");
|
||||
|
||||
function applyTheme() {
|
||||
document.documentElement.classList.toggle("dark", isDark.value);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem("dbx-theme", isDark.value ? "dark" : "light");
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.metaKey && e.key === "w") {
|
||||
e.preventDefault();
|
||||
if (queryStore.activeTabId) {
|
||||
queryStore.closeTab(queryStore.activeTabId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme();
|
||||
connectionStore.initFromDisk();
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<div class="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showConnectionDialog = true">
|
||||
<DatabaseZap class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.newConnection') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="newQuery" :disabled="!connectionStore.activeConnectionId">
|
||||
<FilePlus2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.newQuery') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="!activeTab || activeTab.isExecuting"
|
||||
@click="tryExecute()"
|
||||
>
|
||||
<Loader2 v-if="activeTab?.isExecuting" class="h-4 w-4 animate-spin" />
|
||||
<Play v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.executeShortcut') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="showHistory = !showHistory">
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('history.title') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="toggleTheme">
|
||||
<Moon v-if="!isDark" class="h-4 w-4" />
|
||||
<Sun v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isDark ? 'Light' : 'Dark' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="toggleLocale">
|
||||
<Globe class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('common.language') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span class="text-xs text-muted-foreground mr-2">DBX</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<Splitpanes class="flex-1 min-h-0">
|
||||
<!-- Sidebar -->
|
||||
<Pane :size="20" :min-size="10" :max-size="40">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="h-8 flex items-center px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20">
|
||||
{{ t('sidebar.connections') }}
|
||||
<span class="flex-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.importConnectionsFromFile()">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.import') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.exportConnectionsToFile()">
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('sidebar.export') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ConnectionTree />
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Editor + Results -->
|
||||
<Pane :size="80">
|
||||
<div class="h-full flex flex-col min-w-0">
|
||||
<!-- Tabs Bar -->
|
||||
<div v-if="queryStore.tabs.length > 0" class="h-8 flex items-center border-b bg-muted/20 overflow-x-auto shrink-0">
|
||||
<div
|
||||
v-for="tab in queryStore.tabs"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-1 px-3 h-full text-xs cursor-pointer border-r hover:bg-accent transition-colors whitespace-nowrap"
|
||||
:class="{ 'bg-background font-medium': tab.id === queryStore.activeTabId }"
|
||||
@click="queryStore.activeTabId = tab.id"
|
||||
>
|
||||
<span>{{ tab.title }}</span>
|
||||
<button
|
||||
class="ml-1 rounded hover:bg-muted-foreground/20 p-0.5"
|
||||
@click.stop="queryStore.closeTab(tab.id)"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div v-if="activeTab" class="flex flex-col flex-1 min-h-0">
|
||||
<!-- Query mode: editor + results -->
|
||||
<template v-if="activeTab.mode === 'query'">
|
||||
<Splitpanes horizontal class="flex-1">
|
||||
<Pane :size="40" :min-size="15">
|
||||
<div class="h-full flex flex-col">
|
||||
<QueryEditor
|
||||
class="flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
@update:model-value="onEditorUpdate"
|
||||
@execute="tryExecute()"
|
||||
/>
|
||||
<AiAssistant
|
||||
table-context=""
|
||||
@insert-sql="(sql: string) => { queryStore.updateSql(activeTab!.id, sql); }"
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane :size="60" :min-size="20">
|
||||
<div class="h-full">
|
||||
<DataGrid v-if="activeTab.result" :key="activeTab.id" :result="activeTab.result" :sql="activeTab.sql" />
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('editor.pressToExecute') }}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
<!-- Data mode: full-height grid -->
|
||||
<template v-else-if="activeTab.mode === 'data'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DataGrid
|
||||
v-if="activeTab.result"
|
||||
:key="activeTab.id"
|
||||
:result="activeTab.result"
|
||||
:sql="activeTab.sql"
|
||||
:editable="!!activeTab.tableMeta?.primaryKeys?.length"
|
||||
:table-meta="activeTab.tableMeta"
|
||||
:on-execute-sql="onExecuteSql"
|
||||
@reload="onReloadData"
|
||||
@paginate="onPaginate"
|
||||
@sort="onSort"
|
||||
/>
|
||||
<div v-else-if="activeTab.isExecuting" class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" /> {{ t('common.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redis mode: key browser -->
|
||||
<template v-else-if="activeTab.mode === 'redis'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<RedisKeyBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:db="Number(activeTab.database)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MongoDB mode: document browser -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoDocBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:collection="activeTab.sql"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h2 class="text-lg font-medium mb-2">{{ t('welcome.title') }}</h2>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
{{ t('welcome.subtitle') }}
|
||||
</p>
|
||||
<Button @click="showConnectionDialog = true">
|
||||
<Plus class="h-4 w-4 mr-2" /> {{ t('toolbar.newConnection') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<!-- History Panel (fixed width, outside Splitpanes) -->
|
||||
<div v-if="showHistory" class="w-72 h-full shrink-0 border-l bg-background">
|
||||
<QueryHistory @restore="onHistoryRestore" @close="showHistory = false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionDialog v-model:open="showConnectionDialog" :edit-config="editConfig" />
|
||||
<DangerConfirmDialog v-model:open="showDangerDialog" :sql="dangerSql" @confirm="onDangerConfirm" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
editConfig?: ConnectionConfig;
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const defaultForm = (): Omit<ConnectionConfig, "id"> => ({
|
||||
name: "",
|
||||
db_type: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "",
|
||||
database: undefined,
|
||||
ssh_enabled: false,
|
||||
ssh_host: "",
|
||||
ssh_port: 22,
|
||||
ssh_user: "",
|
||||
ssh_key_path: "",
|
||||
});
|
||||
|
||||
const form = ref(defaultForm());
|
||||
|
||||
watch(() => props.editConfig, (config) => {
|
||||
if (config) {
|
||||
editingId.value = config.id;
|
||||
form.value = {
|
||||
name: config.name,
|
||||
db_type: config.db_type,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssh_enabled: config.ssh_enabled || false,
|
||||
ssh_host: config.ssh_host || "",
|
||||
ssh_port: config.ssh_port || 22,
|
||||
ssh_user: config.ssh_user || "",
|
||||
ssh_key_path: config.ssh_key_path || "",
|
||||
};
|
||||
} else {
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
}
|
||||
testResult.value = null;
|
||||
});
|
||||
|
||||
const isEditing = ref(false);
|
||||
watch(() => editingId.value, (v) => { isEditing.value = !!v; });
|
||||
|
||||
function onDbTypeChange(val: string) {
|
||||
form.value.db_type = val as DatabaseType;
|
||||
if (!editingId.value) {
|
||||
if (val === "mysql") { form.value.port = 3306; form.value.username = "root"; }
|
||||
else if (val === "postgres") { form.value.port = 5432; form.value.username = "postgres"; }
|
||||
else if (val === "redis") { form.value.port = 6379; form.value.username = ""; }
|
||||
else if (val === "sqlite" || val === "duckdb") { form.value.port = 0; form.value.username = ""; }
|
||||
else if (val === "mongodb") { form.value.port = 27017; form.value.username = ""; }
|
||||
else if (val === "clickhouse") { form.value.port = 8123; form.value.username = "default"; }
|
||||
else if (val === "sqlserver") { form.value.port = 1433; form.value.username = "sa"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const config: ConnectionConfig = { ...form.value, id: editingId.value || crypto.randomUUID() };
|
||||
const msg = await api.testConnection(config);
|
||||
testResult.value = { ok: true, message: msg };
|
||||
} catch (e: any) {
|
||||
testResult.value = { ok: false, message: String(e) };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (editingId.value) {
|
||||
const updated: ConnectionConfig = { ...form.value, id: editingId.value };
|
||||
store.updateConnection(updated);
|
||||
open.value = false;
|
||||
} else {
|
||||
const config: ConnectionConfig = { ...form.value, id: crypto.randomUUID() };
|
||||
store.addConnection(config);
|
||||
await store.connect(config);
|
||||
open.value = false;
|
||||
}
|
||||
editingId.value = null;
|
||||
form.value = defaultForm();
|
||||
testResult.value = null;
|
||||
}
|
||||
|
||||
const dialogTitle = ref("");
|
||||
watch([() => editingId.value, () => open.value], () => {
|
||||
dialogTitle.value = editingId.value ? t('connection.editTitle') : t('connection.title');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingId ? t('connection.editTitle') : t('connection.title') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.name') }}</Label>
|
||||
<Input v-model="form.name" class="col-span-3" :placeholder="t('connection.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.type') }}</Label>
|
||||
<Select :model-value="form.db_type" @update:model-value="(val: any) => onDbTypeChange(String(val))">
|
||||
<SelectTrigger class="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
<SelectItem value="sqlite">SQLite</SelectItem>
|
||||
<SelectItem value="redis">Redis</SelectItem>
|
||||
<SelectItem value="mongodb">MongoDB</SelectItem>
|
||||
<SelectItem value="duckdb">DuckDB</SelectItem>
|
||||
<SelectItem value="clickhouse">ClickHouse</SelectItem>
|
||||
<SelectItem value="sqlserver">SQL Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- SQLite / DuckDB: file path only -->
|
||||
<template v-if="form.db_type === 'sqlite' || form.db_type === 'duckdb'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.filePath') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-3" placeholder="/path/to/database.db" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Redis: host, port, password -->
|
||||
<template v-else-if="form.db_type === 'redis'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.host') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.password') }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.host') }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.user') }}</Label>
|
||||
<Input v-model="form.username" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.password') }}</Label>
|
||||
<Input v-model="form.password" type="password" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('connection.database') }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" :placeholder="t('connection.databasePlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SSH Tunnel (not for SQLite) -->
|
||||
<template v-if="form.db_type !== 'sqlite'">
|
||||
<div class="grid grid-cols-4 items-center gap-4 pt-2 border-t">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshTunnel') }}</Label>
|
||||
<div class="col-span-3">
|
||||
<input type="checkbox" v-model="form.ssh_enabled" class="mr-2" />
|
||||
<span class="text-xs text-muted-foreground">{{ t('connection.sshEnable') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="form.ssh_enabled">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshHost') }}</Label>
|
||||
<Input v-model="form.ssh_host" class="col-span-2" placeholder="ssh.example.com" />
|
||||
<Input v-model.number="form.ssh_port" type="number" class="col-span-1" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshUser') }}</Label>
|
||||
<Input v-model="form.ssh_user" class="col-span-3" placeholder="root" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right text-xs">{{ t('connection.sshKeyPath') }}</Label>
|
||||
<Input v-model="form.ssh_key_path" class="col-span-3" placeholder="~/.ssh/id_rsa" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<span v-if="testResult" :class="testResult.ok ? 'text-green-500' : 'text-red-500'" class="text-sm mr-auto">
|
||||
{{ testResult.ok ? t('connection.testSuccess') : testResult.message }}
|
||||
</span>
|
||||
<Button variant="outline" :disabled="isTesting" @click="testConnection">
|
||||
{{ isTesting ? t('connection.testing') : t('connection.test') }}
|
||||
</Button>
|
||||
<Button @click="save" :disabled="!form.name || !form.host">
|
||||
{{ editingId ? t('connection.save') : t('connection.saveAndConnect') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Sparkles, Loader2, Settings } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useSettingsStore, type AiProvider } from "@/stores/settingsStore";
|
||||
import { generateSql } from "@/lib/ai";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const props = defineProps<{
|
||||
tableContext: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
insertSql: [sql: string];
|
||||
}>();
|
||||
|
||||
const prompt = ref("");
|
||||
const isGenerating = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const tempProvider = ref<AiProvider>(settings.aiConfig.provider);
|
||||
const tempApiKey = ref(settings.aiConfig.apiKey);
|
||||
const tempEndpoint = ref(settings.aiConfig.endpoint);
|
||||
const tempModel = ref(settings.aiConfig.model);
|
||||
|
||||
function openSettings() {
|
||||
tempProvider.value = settings.aiConfig.provider;
|
||||
tempApiKey.value = settings.aiConfig.apiKey;
|
||||
tempEndpoint.value = settings.aiConfig.endpoint;
|
||||
tempModel.value = settings.aiConfig.model;
|
||||
showSettings.value = true;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
settings.updateAiConfig({
|
||||
provider: tempProvider.value,
|
||||
apiKey: tempApiKey.value,
|
||||
endpoint: tempEndpoint.value,
|
||||
model: tempModel.value,
|
||||
});
|
||||
showSettings.value = false;
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!prompt.value.trim()) return;
|
||||
if (!settings.isConfigured()) {
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const sql = await generateSql(settings.aiConfig, prompt.value, props.tableContext);
|
||||
emit("insertSql", sql.trim());
|
||||
prompt.value = "";
|
||||
} catch (e: any) {
|
||||
error.value = String(e.message || e);
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-t bg-muted/20">
|
||||
<Sparkles class="w-3.5 h-3.5 text-purple-500 shrink-0" />
|
||||
<Input
|
||||
v-model="prompt"
|
||||
class="h-6 text-xs flex-1 border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('ai.placeholder')"
|
||||
@keydown.enter="generate"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="isGenerating" @click="generate">
|
||||
<Loader2 v-if="isGenerating" class="h-3 w-3 animate-spin" />
|
||||
<Sparkles v-else class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="openSettings">
|
||||
<Settings class="h-3 w-3" />
|
||||
</Button>
|
||||
<span v-if="error" class="text-destructive text-xs truncate max-w-40">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Settings Dialog -->
|
||||
<Dialog v-model:open="showSettings">
|
||||
<DialogContent class="sm:max-w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('ai.settings') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3 py-3">
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">{{ t('ai.provider') }}</Label>
|
||||
<Select :model-value="tempProvider" @update:model-value="(v: any) => tempProvider = v">
|
||||
<SelectTrigger class="col-span-2 h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API Key</Label>
|
||||
<Input v-model="tempApiKey" type="password" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Endpoint</Label>
|
||||
<Input v-model="tempEndpoint" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Model</Label>
|
||||
<Input v-model="tempModel" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button size="sm" @click="saveSettings">{{ t('grid.save') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
defineProps<{
|
||||
sql: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [];
|
||||
}>();
|
||||
|
||||
function onConfirm() {
|
||||
open.value = false;
|
||||
emit("confirm");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle class="h-5 w-5" />
|
||||
{{ t('dangerDialog.title') }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4">
|
||||
<p class="text-sm text-muted-foreground mb-3">{{ t('dangerDialog.message') }}</p>
|
||||
<pre class="text-xs bg-muted p-3 rounded overflow-auto max-h-40 font-mono whitespace-pre-wrap">{{ sql }}</pre>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="open = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="onConfirm">{{ t('dangerDialog.confirm') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
dialect?: "mysql" | "postgres";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
execute: [];
|
||||
}>();
|
||||
|
||||
const editorRef = ref<HTMLDivElement>();
|
||||
const view = shallowRef<EditorViewType | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRef.value) return;
|
||||
|
||||
const [
|
||||
{ EditorView, keymap },
|
||||
{ EditorState },
|
||||
{ sql, MySQL, PostgreSQL },
|
||||
{ basicSetup },
|
||||
{ oneDark },
|
||||
] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-sql"),
|
||||
import("codemirror"),
|
||||
import("@codemirror/theme-one-dark"),
|
||||
]);
|
||||
|
||||
const dialect = props.dialect === "postgres" ? PostgreSQL : MySQL;
|
||||
|
||||
const runKeymap = keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
emit("execute");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: props.modelValue,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
sql({ dialect }),
|
||||
oneDark,
|
||||
runKeymap,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
emit("update:modelValue", update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "13px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view.value = new EditorView({ state, parent: editorRef.value });
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (view.value && val !== view.value.state.doc.toString()) {
|
||||
view.value.dispatch({
|
||||
changes: { from: 0, to: view.value.state.doc.length, insert: val },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRef" class="h-full w-full overflow-hidden" />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, Trash2, Clock, X } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useHistoryStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restore: [sql: string];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const searchText = ref("");
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!searchText.value) return store.entries;
|
||||
const q = searchText.value.toLowerCase();
|
||||
return store.entries.filter((e) =>
|
||||
e.sql.toLowerCase().includes(q) || e.connection_name.toLowerCase().includes(q) || e.database.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
function restore(sql: string) {
|
||||
emit("restore", sql);
|
||||
}
|
||||
|
||||
function copySql(sql: string) {
|
||||
navigator.clipboard.writeText(sql);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function truncateSql(sql: string): string {
|
||||
const line = sql.replace(/\s+/g, " ").trim();
|
||||
return line.length > 120 ? line.slice(0, 120) + "..." : line;
|
||||
}
|
||||
|
||||
onMounted(() => store.load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden border-l">
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 bg-muted/20">
|
||||
<Clock class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span class="text-xs font-medium">{{ t('history.title') }}</span>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="store.entries.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="store.clear()">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('history.search')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ContextMenu v-for="entry in filtered" :key="entry.id">
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
class="px-3 py-2 border-b border-border/50 cursor-pointer hover:bg-accent/50 text-xs"
|
||||
@click="restore(entry.sql)"
|
||||
>
|
||||
<div class="flex items-center gap-1 mb-0.5">
|
||||
<span class="font-medium truncate">{{ entry.connection_name }}</span>
|
||||
<span v-if="entry.database" class="text-muted-foreground">/ {{ entry.database }}</span>
|
||||
<span class="ml-auto text-muted-foreground shrink-0">{{ formatTime(entry.executed_at) }}</span>
|
||||
</div>
|
||||
<div class="font-mono text-muted-foreground truncate">{{ truncateSql(entry.sql) }}</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span :class="entry.success ? 'text-green-500' : 'text-red-500'">
|
||||
{{ entry.success ? `${entry.execution_time_ms}ms` : t('history.failed') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="w-40">
|
||||
<ContextMenuItem @click="restore(entry.sql)">{{ t('history.restore') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copySql(entry.sql)">{{ t('history.copy') }}</ContextMenuItem>
|
||||
<ContextMenuItem class="text-destructive" @click="store.remove(entry.id)">{{ t('history.delete') }}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div v-if="filtered.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('history.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from "vue";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowUp, ArrowDown, Download, Plus, Trash2, Save, ChevronLeft, ChevronRight, Search, Inbox, SearchX } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
ContextMenuSeparator, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { QueryResult, ColumnInfo } from "@/types/database";
|
||||
import { save as savePath } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
result: QueryResult;
|
||||
sql?: string;
|
||||
editable?: boolean;
|
||||
tableMeta?: {
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
primaryKeys: string[];
|
||||
};
|
||||
onExecuteSql?: (sql: string) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reload: [];
|
||||
paginate: [offset: number, limit: number];
|
||||
sort: [column: string, direction: "asc" | "desc" | null];
|
||||
}>();
|
||||
|
||||
const hasData = computed(() => props.result.columns.length > 0);
|
||||
const contextCell = ref<{ row: number; col: number } | null>(null);
|
||||
const sortCol = ref<string | null>(null);
|
||||
const sortDir = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
const columnWidths = ref<number[]>([]);
|
||||
const gridRef = ref<HTMLDivElement>();
|
||||
const headerRef = ref<HTMLDivElement>();
|
||||
const { width: gridWidth } = useElementSize(gridRef);
|
||||
|
||||
function initColumnWidths() {
|
||||
if (columnWidths.value.length !== props.result.columns.length) {
|
||||
columnWidths.value = props.result.columns.map(() => 150);
|
||||
}
|
||||
}
|
||||
|
||||
function syncHeaderScroll(e: Event) {
|
||||
if (headerRef.value) {
|
||||
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
let isResizing = false;
|
||||
|
||||
function onResizeStart(colIdx: number, event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
isResizing = true;
|
||||
const startX = event.clientX;
|
||||
const startWidth = columnWidths.value[colIdx];
|
||||
const onMove = (e: MouseEvent) => {
|
||||
columnWidths.value[colIdx] = Math.max(60, startWidth + e.clientX - startX);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
requestAnimationFrame(() => { isResizing = false; });
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
const baseTotalWidth = computed(() => columnWidths.value.reduce((a, b) => a + b, 0));
|
||||
const renderedColumnWidths = computed(() => {
|
||||
const widths = columnWidths.value;
|
||||
if (widths.length === 0) return widths;
|
||||
|
||||
const extraWidth = Math.max(0, gridWidth.value - baseTotalWidth.value);
|
||||
if (extraWidth === 0) return widths;
|
||||
|
||||
const extraPerColumn = extraWidth / widths.length;
|
||||
return widths.map((width) => width + extraPerColumn);
|
||||
});
|
||||
const totalWidth = computed(() => renderedColumnWidths.value.reduce((a, b) => a + b, 0));
|
||||
|
||||
const columnVars = computed(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
renderedColumnWidths.value.forEach((w, i) => {
|
||||
vars[`--col-w-${i}`] = `${w}px`;
|
||||
});
|
||||
vars['--total-w'] = `${totalWidth.value}px`;
|
||||
return vars;
|
||||
});
|
||||
|
||||
initColumnWidths();
|
||||
watch(() => props.result.columns.length, initColumnWidths);
|
||||
|
||||
// --- Pagination ---
|
||||
const pageSize = ref(100);
|
||||
const currentPage = ref(1);
|
||||
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value <= 1) return;
|
||||
currentPage.value--;
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
|
||||
}
|
||||
function nextPage() {
|
||||
if (!isFullPage.value) return;
|
||||
currentPage.value++;
|
||||
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
|
||||
}
|
||||
function changePageSize(size: number) {
|
||||
pageSize.value = size;
|
||||
currentPage.value = 1;
|
||||
emit("paginate", 0, size);
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
type CellValue = string | number | boolean | null;
|
||||
const editingCell = ref<{ row: number; col: number } | null>(null);
|
||||
const editValue = ref("");
|
||||
const scrollerRef = ref<HTMLElement | { $el?: HTMLElement; el?: HTMLElement | { value?: HTMLElement } } | null>(null);
|
||||
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
|
||||
const newRows = ref<CellValue[][]>([]);
|
||||
const deletedRows = ref<Set<number>>(new Set());
|
||||
|
||||
const hasPendingChanges = computed(() =>
|
||||
dirtyRows.value.size > 0 || newRows.value.length > 0 || deletedRows.value.size > 0
|
||||
);
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
let rows = props.result.rows;
|
||||
if (searchText.value) {
|
||||
const q = searchText.value.toLowerCase();
|
||||
rows = rows.filter((row) => row.some((cell) => cell !== null && String(cell).toLowerCase().includes(q)));
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
interface RowItem {
|
||||
id: number;
|
||||
data: CellValue[];
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
isDirtyCol: boolean[];
|
||||
}
|
||||
|
||||
const displayItems = computed<RowItem[]>(() => {
|
||||
const cols = props.result.columns;
|
||||
const items: RowItem[] = sortedRows.value.map((row, i) => {
|
||||
const dirty = dirtyRows.value.get(i);
|
||||
const data = row.map((v, colIdx) => dirty?.get(colIdx) ?? v);
|
||||
const isDirtyCol = row.map((_, colIdx) => dirty?.has(colIdx) ?? false);
|
||||
return { id: i, data, isNew: false, isDeleted: deletedRows.value.has(i), isDirtyCol };
|
||||
});
|
||||
newRows.value.forEach((row, i) => {
|
||||
items.push({ id: 100000 + i, data: row, isNew: true, isDeleted: false, isDirtyCol: cols.map(() => false) });
|
||||
});
|
||||
return items;
|
||||
});
|
||||
const hasVisibleRows = computed(() => displayItems.value.length > 0);
|
||||
const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
|
||||
const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
|
||||
|
||||
function toggleSort(colName: string) {
|
||||
if (isResizing) return;
|
||||
if (sortCol.value === colName) {
|
||||
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc"); }
|
||||
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null); }
|
||||
} else {
|
||||
sortCol.value = colName;
|
||||
sortDir.value = "asc";
|
||||
emit("sort", colName, "asc");
|
||||
}
|
||||
}
|
||||
|
||||
function formatCell(value: CellValue): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isNull(value: unknown): boolean { return value === null; }
|
||||
|
||||
// --- Inline editor ---
|
||||
let isCancelling = false;
|
||||
let cancelScrollRestoreFrame = 0;
|
||||
|
||||
function getScrollerElement(): HTMLElement | null {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller) return null;
|
||||
if (scroller instanceof HTMLElement) return scroller;
|
||||
if (scroller.$el instanceof HTMLElement) return scroller.$el;
|
||||
if (scroller.el instanceof HTMLElement) return scroller.el;
|
||||
if (scroller.el?.value instanceof HTMLElement) return scroller.el.value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function preserveScrollPosition() {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return () => {};
|
||||
const top = el.scrollTop;
|
||||
const left = el.scrollLeft;
|
||||
return () => {
|
||||
el.scrollTop = top;
|
||||
el.scrollLeft = left;
|
||||
};
|
||||
}
|
||||
|
||||
function focusScrollerWithoutScrolling() {
|
||||
const el = getScrollerElement();
|
||||
if (!el) return;
|
||||
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function restoreScrollAcrossFrames(restoreScroll: () => void) {
|
||||
if (cancelScrollRestoreFrame) cancelAnimationFrame(cancelScrollRestoreFrame);
|
||||
restoreScroll();
|
||||
nextTick(() => {
|
||||
restoreScroll();
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(() => {
|
||||
restoreScroll();
|
||||
cancelScrollRestoreFrame = requestAnimationFrame(() => {
|
||||
restoreScroll();
|
||||
cancelScrollRestoreFrame = 0;
|
||||
isCancelling = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit(rowIdx: number, colIdx: number) {
|
||||
if (!props.editable) return;
|
||||
isCancelling = false;
|
||||
editingCell.value = { row: rowIdx, col: colIdx };
|
||||
const item = displayItems.value.find((it) => it.id === rowIdx);
|
||||
const val = item?.data[colIdx] ?? null;
|
||||
editValue.value = val === null ? "" : String(val);
|
||||
nextTick(() => {
|
||||
const input = document.querySelector(".cell-edit-input") as HTMLInputElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (isCancelling) return;
|
||||
if (!editingCell.value) return;
|
||||
const { row, col } = editingCell.value;
|
||||
const oldVal = sortedRows.value[row]?.[col];
|
||||
let newVal: CellValue = editValue.value;
|
||||
if (newVal === "" && isNull(oldVal)) newVal = null;
|
||||
else if (newVal === "NULL") newVal = null;
|
||||
else if (typeof oldVal === "number") {
|
||||
const num = Number(newVal);
|
||||
if (!isNaN(num)) newVal = num;
|
||||
} else if (typeof oldVal === "boolean") {
|
||||
newVal = newVal === "true" || newVal === "1";
|
||||
}
|
||||
if (newVal !== oldVal) {
|
||||
if (!dirtyRows.value.has(row)) dirtyRows.value.set(row, new Map());
|
||||
dirtyRows.value.get(row)!.set(col, newVal);
|
||||
}
|
||||
editingCell.value = null;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
const restoreScroll = preserveScrollPosition();
|
||||
isCancelling = true;
|
||||
focusScrollerWithoutScrolling();
|
||||
editingCell.value = null;
|
||||
restoreScrollAcrossFrames(restoreScroll);
|
||||
}
|
||||
|
||||
function onEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") { e.preventDefault(); commitEdit(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelEdit(); }
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
newRows.value.push(props.result.columns.map(() => null));
|
||||
}
|
||||
|
||||
function deleteSelectedRow() {
|
||||
if (!contextCell.value) return;
|
||||
deletedRows.value.add(contextCell.value.row);
|
||||
}
|
||||
|
||||
function escapeVal(v: CellValue): string {
|
||||
if (v === null) return "NULL";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
if (typeof v === "number") return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function qualifiedTableName(): string {
|
||||
if (!props.tableMeta) return "";
|
||||
const { schema, tableName } = props.tableMeta;
|
||||
return schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
|
||||
}
|
||||
|
||||
function generateSaveStatements(): string[] {
|
||||
if (!props.tableMeta) return [];
|
||||
const { primaryKeys } = props.tableMeta;
|
||||
const cols = props.result.columns;
|
||||
const stmts: string[] = [];
|
||||
const tbl = qualifiedTableName();
|
||||
|
||||
for (const [rowIdx, changes] of dirtyRows.value) {
|
||||
const row = sortedRows.value[rowIdx];
|
||||
if (!row) continue;
|
||||
const sets = Array.from(changes.entries())
|
||||
.map(([colIdx, val]) => `"${cols[colIdx]}" = ${escapeVal(val)}`)
|
||||
.join(", ");
|
||||
const where = primaryKeys
|
||||
.map((pk) => `"${pk}" = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`UPDATE ${tbl} SET ${sets} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const rowIdx of deletedRows.value) {
|
||||
const row = sortedRows.value[rowIdx];
|
||||
if (!row) continue;
|
||||
const where = primaryKeys
|
||||
.map((pk) => `"${pk}" = ${escapeVal(row[cols.indexOf(pk)])}`)
|
||||
.join(" AND ");
|
||||
stmts.push(`DELETE FROM ${tbl} WHERE ${where};`);
|
||||
}
|
||||
|
||||
for (const newRow of newRows.value) {
|
||||
const colNames = cols.map((c) => `"${c}"`).join(", ");
|
||||
const vals = newRow.map((v) => escapeVal(v)).join(", ");
|
||||
stmts.push(`INSERT INTO ${tbl} (${colNames}) VALUES (${vals});`);
|
||||
}
|
||||
return stmts;
|
||||
}
|
||||
|
||||
const saveError = ref("");
|
||||
|
||||
async function saveChanges() {
|
||||
const stmts = generateSaveStatements();
|
||||
if (stmts.length === 0) return;
|
||||
saveError.value = "";
|
||||
if (props.onExecuteSql) {
|
||||
try {
|
||||
for (const sql of stmts) {
|
||||
await props.onExecuteSql(sql);
|
||||
}
|
||||
} catch (e: any) {
|
||||
saveError.value = String(e.message || e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dirtyRows.value.clear();
|
||||
newRows.value = [];
|
||||
deletedRows.value.clear();
|
||||
emit("reload");
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
dirtyRows.value.clear();
|
||||
newRows.value = [];
|
||||
deletedRows.value.clear();
|
||||
editingCell.value = null;
|
||||
}
|
||||
|
||||
// --- Copy/Export ---
|
||||
function onCellContext(rowIdx: number, colIdx: number) {
|
||||
contextCell.value = { row: rowIdx, col: colIdx };
|
||||
}
|
||||
|
||||
function copyCell() {
|
||||
if (!contextCell.value) return;
|
||||
const item = displayItems.value.find((it) => it.id === contextCell.value!.row);
|
||||
const val = item?.data[contextCell.value.col] ?? null;
|
||||
navigator.clipboard.writeText(formatCell(val));
|
||||
}
|
||||
|
||||
function copyRow() {
|
||||
if (!contextCell.value) return;
|
||||
const row = sortedRows.value[contextCell.value.row];
|
||||
if (!row) return;
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
navigator.clipboard.writeText(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
const header = props.result.columns.join("\t");
|
||||
const body = sortedRows.value.map((row) => row.map((c) => formatCell(c)).join("\t")).join("\n");
|
||||
navigator.clipboard.writeText(`${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = sortedRows.value.map((row) => row.map((c) => escape(formatCell(c))).join(",")).join("\n");
|
||||
const path = await savePath({ filters: [{ name: "CSV", extensions: ["csv"] }] });
|
||||
if (path) await writeTextFile(path, `${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
const data = sortedRows.value.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
props.result.columns.forEach((col, i) => { obj[col] = row[i]; });
|
||||
return obj;
|
||||
});
|
||||
const path = await savePath({ filters: [{ name: "JSON", extensions: ["json"] }] });
|
||||
if (path) await writeTextFile(path, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
const pad = (s: string, len: number) => s.padEnd(len);
|
||||
const cols = props.result.columns;
|
||||
const widths = cols.map((c, i) => Math.max(c.length, ...sortedRows.value.map((r) => formatCell(r[i]).length), 3));
|
||||
const header = `| ${cols.map((c, i) => pad(c, widths[i])).join(" | ")} |`;
|
||||
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
|
||||
const body = sortedRows.value.map((row) =>
|
||||
`| ${row.map((c, i) => pad(formatCell(c), widths[i])).join(" | ")} |`
|
||||
).join("\n");
|
||||
const md = `${header}\n${sep}\n${body}\n`;
|
||||
const path = await savePath({ filters: [{ name: "Markdown", extensions: ["md"] }] });
|
||||
if (path) await writeTextFile(path, md);
|
||||
}
|
||||
|
||||
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="gridRef" class="h-full flex flex-col overflow-hidden" :style="columnVars">
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div v-if="hasData" class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="flex items-center gap-1 px-2 py-1 border-b shrink-0 bg-muted/20">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.search')"
|
||||
/>
|
||||
<span v-if="searchText" class="text-xs text-muted-foreground">
|
||||
{{ sortedRows.length }}/{{ result.rows.length }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sticky header -->
|
||||
<div ref="headerRef" class="shrink-0 bg-muted z-10 border-b border-border overflow-hidden">
|
||||
<div class="flex text-xs font-medium" :style="{ width: 'var(--total-w)' }">
|
||||
<div
|
||||
v-for="(col, colIdx) in result.columns"
|
||||
:key="col"
|
||||
class="shrink-0 px-3 py-1.5 border-r border-border whitespace-nowrap cursor-pointer hover:bg-accent/50 select-none relative"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
@click="toggleSort(col)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ col }}
|
||||
<ArrowUp v-if="sortCol === col && sortDir === 'asc'" class="w-3 h-3" />
|
||||
<ArrowDown v-else-if="sortCol === col && sortDir === 'desc'" class="w-3 h-3" />
|
||||
</span>
|
||||
<div
|
||||
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||
@mousedown.stop="onResizeStart(colIdx, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasVisibleRows"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-2 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
<component
|
||||
:is="searchText ? SearchX : Inbox"
|
||||
class="h-8 w-8 text-muted-foreground/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-foreground">{{ emptyTitle }}</div>
|
||||
<div class="text-xs">{{ emptyDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scrolled rows -->
|
||||
<RecycleScroller
|
||||
v-else
|
||||
ref="scrollerRef"
|
||||
class="data-grid-scroller flex-1 overflow-x-auto"
|
||||
:items="displayItems"
|
||||
:item-size="26"
|
||||
key-field="id"
|
||||
@scroll="syncHeaderScroll"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
class="flex text-xs border-b border-border hover:bg-accent/50"
|
||||
:class="{
|
||||
'line-through opacity-30': item.isDeleted,
|
||||
'bg-green-500/10': item.isNew,
|
||||
}"
|
||||
:style="{ height: '26px', width: 'var(--total-w)' }"
|
||||
>
|
||||
<div
|
||||
v-for="(cell, colIdx) in item.data"
|
||||
:key="colIdx"
|
||||
class="shrink-0 px-3 py-1 border-r border-border whitespace-nowrap overflow-hidden text-ellipsis relative"
|
||||
:style="{ width: `var(--col-w-${colIdx})` }"
|
||||
:class="{
|
||||
'text-muted-foreground italic': isNull(cell),
|
||||
'bg-yellow-500/10': item.isDirtyCol[colIdx],
|
||||
}"
|
||||
@dblclick="!item.isNew && !item.isDeleted && startEdit(item.id, colIdx)"
|
||||
@contextmenu="onCellContext(item.id, colIdx)"
|
||||
>
|
||||
<template v-if="editingCell?.row === item.id && editingCell?.col === colIdx">
|
||||
<input
|
||||
v-model="editValue"
|
||||
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2 py-0.5 text-xs outline-none z-10"
|
||||
@blur="commitEdit"
|
||||
@keydown.stop="onEditKeydown"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCell(cell) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-48">
|
||||
<ContextMenuItem @click="copyCell">{{ t('grid.copyCell') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyRow">{{ t('grid.copyRow') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyAll">{{ t('grid.copyAll') }}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<template v-if="editable">
|
||||
<ContextMenuItem class="text-destructive" @click="deleteSelectedRow">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('grid.deleteRow') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</template>
|
||||
<ContextMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportJson">{{ t('grid.exportJson') }}</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div v-if="!hasData" class="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('grid.querySuccess') }}
|
||||
</div>
|
||||
|
||||
<!-- Error bar -->
|
||||
<div v-if="saveError" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0 flex items-center gap-2">
|
||||
<span class="flex-1">{{ saveError }}</span>
|
||||
<button class="hover:underline" @click="saveError = ''">dismiss</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom status bar -->
|
||||
<div class="flex items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
|
||||
<span v-if="hasData">{{ t('grid.rows', { count: result.rows.length }) }}</span>
|
||||
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
|
||||
<span>{{ result.execution_time_ms }}ms</span>
|
||||
|
||||
<template v-if="editable && tableMeta">
|
||||
<Button v-if="hasPendingChanges" variant="default" size="sm" class="h-5 text-xs ml-2" @click="saveChanges">
|
||||
<Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}
|
||||
</Button>
|
||||
<Button v-if="hasPendingChanges" variant="ghost" size="sm" class="h-5 text-xs" @click="discardChanges">
|
||||
{{ t('grid.discard') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs" @click="addRow">
|
||||
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5">
|
||||
{{ pageSize }}{{ t('grid.rowsPerPageShort') }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem v-for="s in [50, 100, 500, 1000]" :key="s" @click="changePageSize(s)">
|
||||
{{ s }} {{ t('grid.rowsPerPageShort') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="currentPage <= 1" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ currentPage }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!isFullPage" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5">
|
||||
<Download class="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="exportCsv">{{ t('grid.exportCsv') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportJson">{{ t('grid.exportJson') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="exportMarkdown">{{ t('grid.exportMarkdown') }}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<span v-if="sqlOneLiner" class="truncate max-w-[30%] opacity-60" :title="sqlOneLiner">
|
||||
{{ sqlOneLiner }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.data-grid-scroller {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.data-grid-scroller :deep(.vue-recycle-scroller__item-wrapper) {
|
||||
min-width: var(--total-w);
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { siMysql, siPostgresql, siSqlite, siRedis, siMongodb, siClickhouse, siDuckdb } from "simple-icons";
|
||||
import { Database } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
dbType: string;
|
||||
}>();
|
||||
|
||||
const icons: Record<string, { path: string; color: string }> = {
|
||||
mysql: { path: siMysql.path, color: `#${siMysql.hex}` },
|
||||
postgres: { path: siPostgresql.path, color: `#${siPostgresql.hex}` },
|
||||
sqlite: { path: siSqlite.path, color: `#${siSqlite.hex}` },
|
||||
redis: { path: siRedis.path, color: `#${siRedis.hex}` },
|
||||
mongodb: { path: siMongodb.path, color: `#${siMongodb.hex}` },
|
||||
clickhouse: { path: siClickhouse.path, color: `#${siClickhouse.hex}` },
|
||||
duckdb: { path: siDuckdb.path, color: `#${siDuckdb.hex}` },
|
||||
};
|
||||
|
||||
const iconData = computed(() => icons[props.dbType]);
|
||||
const hasBrandIcon = computed(() => !!iconData.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="hasBrandIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" :fill="iconData.color">
|
||||
<path :d="iconData.path" />
|
||||
</svg>
|
||||
<Database v-else class="text-blue-400" />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronLeft, ChevronRight } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
}>();
|
||||
|
||||
const documents = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const page = ref(0);
|
||||
const pageSize = 50;
|
||||
const selectedIdx = ref<number | null>(null);
|
||||
const editJson = ref("");
|
||||
const isEditing = ref(false);
|
||||
const isNew = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.mongoFindDocuments(
|
||||
props.connectionId, props.database, props.collection,
|
||||
page.value * pageSize, pageSize
|
||||
);
|
||||
documents.value = result.documents;
|
||||
total.value = result.total;
|
||||
} catch (e: any) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDoc(idx: number) {
|
||||
selectedIdx.value = idx;
|
||||
editJson.value = JSON.stringify(documents.value[idx], null, 2);
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
}
|
||||
|
||||
function startNew() {
|
||||
selectedIdx.value = null;
|
||||
editJson.value = '{\n \n}';
|
||||
isEditing.value = true;
|
||||
isNew.value = true;
|
||||
}
|
||||
|
||||
async function saveDoc() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (isNew.value) {
|
||||
await api.mongoInsertDocument(props.connectionId, props.database, props.collection, editJson.value);
|
||||
} else if (selectedIdx.value !== null) {
|
||||
const doc = documents.value[selectedIdx.value];
|
||||
const id = doc._id;
|
||||
if (!id) { error.value = "No _id field"; return; }
|
||||
const parsed = JSON.parse(editJson.value);
|
||||
delete parsed._id;
|
||||
await api.mongoUpdateDocument(props.connectionId, props.database, props.collection, id, JSON.stringify(parsed));
|
||||
}
|
||||
isEditing.value = false;
|
||||
isNew.value = false;
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(idx: number) {
|
||||
const doc = documents.value[idx];
|
||||
const id = doc._id;
|
||||
if (!id) return;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.mongoDeleteDocument(props.connectionId, props.database, props.collection, id);
|
||||
if (selectedIdx.value === idx) { selectedIdx.value = null; editJson.value = ""; }
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page.value <= 0) return;
|
||||
page.value--;
|
||||
load();
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if ((page.value + 1) * pageSize >= total.value) return;
|
||||
page.value++;
|
||||
load();
|
||||
}
|
||||
|
||||
function docPreview(doc: any): string {
|
||||
const id = doc._id || "";
|
||||
const keys = Object.keys(doc).filter(k => k !== "_id").slice(0, 3);
|
||||
const preview = keys.map(k => `${k}: ${JSON.stringify(doc[k]).substring(0, 30)}`).join(", ");
|
||||
return `${id} — ${preview}`;
|
||||
}
|
||||
|
||||
function highlightedJson(json: string): string {
|
||||
const escaped = json
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
return escaped.replace(
|
||||
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match) => {
|
||||
let cls = "json-number";
|
||||
if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string";
|
||||
else if (match === "true" || match === "false") cls = "json-boolean";
|
||||
else if (match === "null") cls = "json-null";
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Splitpanes class="h-full">
|
||||
<!-- Document list (left) -->
|
||||
<Pane :size="30" :min-size="15" :max-size="50">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-1 px-3 py-1.5 border-b shrink-0 text-xs text-muted-foreground">
|
||||
<span>{{ total }} documents</span>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="load"><RefreshCw class="h-3 w-3" /></Button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="(doc, idx) in documents"
|
||||
:key="idx"
|
||||
class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group"
|
||||
:class="{ 'bg-accent': selectedIdx === idx }"
|
||||
@click="selectDoc(idx)"
|
||||
>
|
||||
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="deleteDoc(idx)">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="documents.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
Empty collection
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground shrink-0">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="page <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span>{{ page + 1 }} / {{ Math.max(1, Math.ceil(total / pageSize)) }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="(page + 1) * pageSize >= total" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Document viewer/editor (right) -->
|
||||
<Pane :size="70">
|
||||
<div class="h-full flex flex-col min-w-0 overflow-hidden">
|
||||
<template v-if="selectedIdx !== null || isNew">
|
||||
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
|
||||
<Badge variant="secondary" class="text-xs">{{ isNew ? 'New' : documents[selectedIdx!]?._id }}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = true">Edit</Button>
|
||||
<template v-if="isEditing">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="isEditing = false; isNew = false">{{ t('grid.discard') }}</Button>
|
||||
<Button size="sm" class="h-6 text-xs" @click="saveDoc"><Save class="w-3 h-3 mr-1" />{{ t('grid.save') }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="isEditing"
|
||||
v-model="editJson"
|
||||
class="flex-1 p-4 font-mono text-xs bg-background resize-none outline-none"
|
||||
/>
|
||||
<div v-else class="flex-1 overflow-auto bg-muted/10">
|
||||
<pre
|
||||
class="json-viewer min-w-fit p-5 font-mono text-[13px] leading-6"
|
||||
v-html="highlightedJson(editJson)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
Select a document
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-viewer {
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
:deep(.json-key) {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.json-string) {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
:deep(.json-number) {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
:deep(.json-boolean) {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.json-null) {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-key) {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-string) {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-number) {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-boolean) {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.json-null) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Key } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { RedisKeyInfo } from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
db: number;
|
||||
}>();
|
||||
|
||||
const keys = ref<RedisKeyInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const searchPattern = ref("*");
|
||||
const selectedKey = ref<string | null>(null);
|
||||
|
||||
async function loadKeys() {
|
||||
loading.value = true;
|
||||
try {
|
||||
keys.value = await api.redisScanKeys(props.connectionId, props.db, searchPattern.value, 1000);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectKey(key: string) {
|
||||
selectedKey.value = key;
|
||||
}
|
||||
|
||||
function onKeyDeleted() {
|
||||
if (selectedKey.value) {
|
||||
keys.value = keys.value.filter((k) => k.key !== selectedKey.value);
|
||||
selectedKey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
switch (type) {
|
||||
case "string": return "text-green-500";
|
||||
case "list": return "text-blue-500";
|
||||
case "set": return "text-purple-500";
|
||||
case "zset": return "text-amber-500";
|
||||
case "hash": return "text-orange-500";
|
||||
default: return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadKeys);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Splitpanes class="h-full">
|
||||
<!-- Key list (left) -->
|
||||
<Pane :size="30" :min-size="15" :max-size="50">
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Search bar -->
|
||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
|
||||
<Search class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
class="h-6 text-xs border-0 shadow-none focus-visible:ring-0"
|
||||
:placeholder="t('redis.pattern')"
|
||||
@keydown.enter="loadKeys"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" @click="loadKeys">
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Key count -->
|
||||
<div class="px-3 py-1 text-xs text-muted-foreground border-b shrink-0">
|
||||
{{ t('redis.keys', { count: keys.length }) }}
|
||||
</div>
|
||||
|
||||
<!-- Key list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-for="k in keys"
|
||||
:key="k.key"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer hover:bg-accent/50 border-b border-border/50"
|
||||
:class="{ 'bg-accent': selectedKey === k.key }"
|
||||
@click="selectKey(k.key)"
|
||||
>
|
||||
<Key class="w-3 h-3 shrink-0" :class="typeColor(k.key_type)" />
|
||||
<span class="truncate flex-1 font-mono">{{ k.key }}</span>
|
||||
<Badge variant="outline" class="text-[10px] px-1 py-0 shrink-0">{{ k.key_type }}</Badge>
|
||||
</div>
|
||||
<div v-if="keys.length === 0 && !loading" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('redis.noKeys') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
|
||||
<!-- Value viewer (right) -->
|
||||
<Pane :size="70">
|
||||
<div class="h-full min-w-0">
|
||||
<RedisValueViewer
|
||||
v-if="selectedKey"
|
||||
:key="selectedKey"
|
||||
:connection-id="connectionId"
|
||||
:key-name="selectedKey"
|
||||
@deleted="onKeyDeleted"
|
||||
/>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
{{ t('redis.selectKey') }}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Copy, Trash2, Save, RefreshCw, Plus } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { RedisValue } from "@/lib/tauri";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
keyName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ deleted: [] }>();
|
||||
|
||||
const data = ref<RedisValue | null>(null);
|
||||
const loading = ref(false);
|
||||
const editValue = ref("");
|
||||
const isEditing = ref(false);
|
||||
const newField = ref("");
|
||||
const newValue = ref("");
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
data.value = await api.redisGetValue(props.connectionId, props.keyName);
|
||||
if (data.value.key_type === "string") {
|
||||
editValue.value = String(data.value.value);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveString() {
|
||||
await api.redisSetString(props.connectionId, props.keyName, editValue.value);
|
||||
isEditing.value = false;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function deleteKey() {
|
||||
await api.redisDeleteKey(props.connectionId, props.keyName);
|
||||
emit("deleted");
|
||||
}
|
||||
|
||||
function copyValue() {
|
||||
if (!data.value) return;
|
||||
const text = typeof data.value.value === "string" ? data.value.value : JSON.stringify(data.value.value, null, 2);
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
// Hash
|
||||
async function hashSet() {
|
||||
if (!newField.value) return;
|
||||
await api.redisHashSet(props.connectionId, props.keyName, newField.value, newValue.value);
|
||||
newField.value = "";
|
||||
newValue.value = "";
|
||||
await load();
|
||||
}
|
||||
async function hashDel(field: string) {
|
||||
await api.redisHashDel(props.connectionId, props.keyName, field);
|
||||
await load();
|
||||
}
|
||||
|
||||
// List
|
||||
async function listPush() {
|
||||
if (!newValue.value) return;
|
||||
await api.redisListPush(props.connectionId, props.keyName, newValue.value);
|
||||
newValue.value = "";
|
||||
await load();
|
||||
}
|
||||
async function listRemove(index: number) {
|
||||
await api.redisListRemove(props.connectionId, props.keyName, index);
|
||||
await load();
|
||||
}
|
||||
|
||||
// Set
|
||||
async function setAdd() {
|
||||
if (!newValue.value) return;
|
||||
await api.redisSetAdd(props.connectionId, props.keyName, newValue.value);
|
||||
newValue.value = "";
|
||||
await load();
|
||||
}
|
||||
async function setRemove(member: string) {
|
||||
await api.redisSetRemove(props.connectionId, props.keyName, member);
|
||||
await load();
|
||||
}
|
||||
|
||||
function formatValue(val: any): string {
|
||||
if (typeof val === "string") return val;
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30 shrink-0">
|
||||
<span class="font-mono text-sm font-medium truncate">{{ data.key }}</span>
|
||||
<Badge variant="secondary" class="text-xs">{{ data.key_type }}</Badge>
|
||||
<Badge v-if="data.ttl > 0" variant="outline" class="text-xs">TTL: {{ data.ttl }}s</Badge>
|
||||
<Badge v-else-if="data.ttl === -1" variant="outline" class="text-xs opacity-50">{{ t('redis.noExpiry') }}</Badge>
|
||||
<span class="flex-1" />
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="load"><RefreshCw class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive" @click="deleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
|
||||
<!-- String -->
|
||||
<div v-if="data.key_type === 'string'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<textarea v-model="editValue" class="flex-1 p-4 font-mono text-sm bg-background resize-none outline-none" @input="isEditing = true" />
|
||||
<div v-if="isEditing" class="px-4 py-2 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" @click="isEditing = false; editValue = String(data.value)">{{ t('grid.discard') }}</Button>
|
||||
<Button size="sm" @click="saveString"><Save class="w-3 h-3 mr-1" /> {{ t('grid.save') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div v-else-if="data.key_type === 'list'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="value" @keydown.enter="listPush" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="listPush"><Plus class="w-3 h-3 mr-1" />Push</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
|
||||
<span class="text-muted-foreground text-xs w-8 shrink-0">{{ idx }}</span>
|
||||
<span class="truncate flex-1">{{ item }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="listRemove(Number(idx))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set -->
|
||||
<div v-else-if="data.key_type === 'set'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.items', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newValue" class="h-6 w-40 text-xs" placeholder="member" @keydown.enter="setAdd" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="setAdd"><Plus class="w-3 h-3 mr-1" />Add</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-2 group">
|
||||
<span class="truncate flex-1">{{ item }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="setRemove(String(item))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hash -->
|
||||
<div v-else-if="data.key_type === 'hash'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 border-b shrink-0">
|
||||
<span class="text-xs text-muted-foreground">{{ t('redis.fields', { count: Object.keys(data.value || {}).length }) }}</span>
|
||||
<span class="flex-1" />
|
||||
<Input v-model="newField" class="h-6 w-24 text-xs" placeholder="field" />
|
||||
<Input v-model="newValue" class="h-6 w-32 text-xs" placeholder="value" @keydown.enter="hashSet" />
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="hashSet"><Plus class="w-3 h-3 mr-1" />Set</Button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div v-for="(val, field) in data.value" :key="String(field)" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3 group">
|
||||
<span class="text-blue-500 shrink-0 min-w-24">{{ field }}</span>
|
||||
<span class="truncate text-muted-foreground flex-1">{{ val }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" @click="hashDel(String(field))"><Trash2 class="w-3 h-3" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sorted Set (readonly) -->
|
||||
<div v-else-if="data.key_type === 'zset'" class="flex-1 overflow-auto">
|
||||
<div class="px-4 py-1 text-xs text-muted-foreground border-b">
|
||||
{{ t('redis.members', { count: Array.isArray(data.value) ? data.value.length : 0 }) }}
|
||||
</div>
|
||||
<div v-for="(item, idx) in data.value" :key="idx" class="px-4 py-1.5 border-b text-sm font-mono hover:bg-accent/50 flex items-center gap-3">
|
||||
<span class="text-muted-foreground text-xs w-16 shrink-0">{{ item.score }}</span>
|
||||
<span class="truncate">{{ item.member }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unknown -->
|
||||
<div v-else class="flex-1 overflow-auto p-4">
|
||||
<pre class="font-mono text-sm whitespace-pre-wrap">{{ formatValue(data.value) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import TreeItem from "./TreeItem.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-sm select-none">
|
||||
<TreeItem v-for="node in store.treeNodes" :key="node.id" :node="node" :depth="0" />
|
||||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('sidebar.noConnections') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
ContextMenuSeparator, ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
}>();
|
||||
|
||||
function quoteIdent(name: string): string {
|
||||
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
|
||||
return config?.db_type === "mysql"
|
||||
? `\`${name.replace(/`/g, "``")}\``
|
||||
: `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
switch (node.type) {
|
||||
case "connection":
|
||||
return null;
|
||||
case "database":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "schema":
|
||||
return { icon: FolderOpen, colorClass: "text-sky-400" };
|
||||
case "table":
|
||||
return { icon: Table, colorClass: "text-green-500" };
|
||||
case "view":
|
||||
return { icon: Eye, colorClass: "text-purple-500" };
|
||||
case "column":
|
||||
return { icon: Columns3, colorClass: "text-muted-foreground" };
|
||||
case "group-columns":
|
||||
return { icon: ListTree, colorClass: "text-green-400" };
|
||||
case "group-indexes":
|
||||
return { icon: Key, colorClass: "text-amber-500" };
|
||||
case "group-fkeys":
|
||||
return { icon: Link, colorClass: "text-blue-400" };
|
||||
case "group-triggers":
|
||||
return { icon: Zap, colorClass: "text-orange-400" };
|
||||
case "index":
|
||||
return { icon: Key, colorClass: "text-amber-400" };
|
||||
case "fkey":
|
||||
return { icon: Link, colorClass: "text-blue-300" };
|
||||
case "trigger":
|
||||
return { icon: Zap, colorClass: "text-orange-300" };
|
||||
case "redis-db":
|
||||
return { icon: Database, colorClass: "text-red-400" };
|
||||
case "redis-key":
|
||||
return { icon: Key, colorClass: "text-red-300" };
|
||||
case "mongo-db":
|
||||
return { icon: Database, colorClass: "text-green-500" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
default:
|
||||
return { icon: Database, colorClass: "text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "redis-key"]);
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]);
|
||||
|
||||
function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
const node = props.node;
|
||||
if (node.isLoading) return;
|
||||
if (node.isExpanded) { node.isExpanded = false; return; }
|
||||
|
||||
if (node.type === "connection" && node.connectionId) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "redis") {
|
||||
await connectionStore.loadRedisDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "mongodb") {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId);
|
||||
}
|
||||
} else if (node.type === "redis-db" && node.connectionId && node.database) {
|
||||
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
|
||||
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
|
||||
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
|
||||
await connectionStore.loadMongoCollections(node.connectionId, node.database);
|
||||
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
|
||||
const tabTitle = `${node.database}.${node.label}`;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else if (node.type === "database" && node.connectionId && node.database) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "postgres") {
|
||||
await connectionStore.loadSchemas(node.connectionId, node.database);
|
||||
} else {
|
||||
await connectionStore.loadTables(node.connectionId, node.database);
|
||||
}
|
||||
} else if (node.type === "schema" && node.connectionId && node.database && node.schema) {
|
||||
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
|
||||
} else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) {
|
||||
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema);
|
||||
} else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadColumns(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadIndexes(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
|
||||
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
|
||||
await connectionStore.loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
|
||||
}
|
||||
}
|
||||
|
||||
async function openData() {
|
||||
const node = props.node;
|
||||
if (!(node.type === "table" || node.type === "view") || !node.connectionId || !node.database) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
const qualifiedName = config?.db_type === "postgres" && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "data");
|
||||
|
||||
const querySchema = node.schema || node.database;
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
const pks = columns.filter((c) => c.is_primary_key).map((c) => c.name);
|
||||
const order = pks.length ? ` ORDER BY ${pks.map((pk) => `${quoteIdent(pk)} ASC`).join(", ")}` : "";
|
||||
const sql = `SELECT * FROM ${qualifiedName}${order} LIMIT 100;`;
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
|
||||
queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
async function newQuery() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const node = props.node;
|
||||
node.isExpanded = false;
|
||||
node.children = [];
|
||||
await toggle();
|
||||
}
|
||||
|
||||
function deleteConnection() {
|
||||
const node = props.node;
|
||||
if (node.connectionId) {
|
||||
connectionStore.disconnect(node.connectionId);
|
||||
connectionStore.removeConnection(node.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
function copyName() {
|
||||
navigator.clipboard.writeText(props.node.label);
|
||||
}
|
||||
|
||||
function editConnection() {
|
||||
if (props.node.connectionId) {
|
||||
connectionStore.startEditing(props.node.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
const canExpand = !leafTypes.has(props.node.type);
|
||||
const paddingLeft = `${props.depth * 16 + 8}px`;
|
||||
|
||||
const CHILDREN_PAGE_SIZE = 100;
|
||||
const displayLimit = ref(CHILDREN_PAGE_SIZE);
|
||||
|
||||
const visibleChildren = computed(() => {
|
||||
if (!props.node.children) return [];
|
||||
return props.node.children.slice(0, displayLimit.value);
|
||||
});
|
||||
|
||||
const hasMoreChildren = computed(() =>
|
||||
(props.node.children?.length ?? 0) > displayLimit.value
|
||||
);
|
||||
|
||||
const remainingCount = computed(() =>
|
||||
(props.node.children?.length ?? 0) - displayLimit.value
|
||||
);
|
||||
|
||||
function showMore() {
|
||||
displayLimit.value += CHILDREN_PAGE_SIZE;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 py-1 px-2 rounded-sm cursor-pointer hover:bg-accent transition-colors"
|
||||
:style="{ paddingLeft }"
|
||||
@click="canExpand && toggle()"
|
||||
@dblclick="openData"
|
||||
>
|
||||
<template v-if="canExpand">
|
||||
<Loader2 v-if="node.isLoading" class="w-3.5 h-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
<ChevronDown v-else-if="node.isExpanded" class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
</template>
|
||||
<span v-else class="w-3.5 h-3.5 shrink-0" />
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionStore.getConfig(node.connectionId || '')?.db_type || 'postgres'" class="w-3.5 h-3.5 shrink-0" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="getIconInfo(node)?.colorClass" />
|
||||
<span class="truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
|
||||
</div>
|
||||
<template v-if="node.isExpanded && node.children">
|
||||
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" />
|
||||
<div
|
||||
v-if="hasMoreChildren"
|
||||
class="flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent text-xs text-muted-foreground"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 16 + 8}px` }"
|
||||
@click="showMore"
|
||||
>
|
||||
<span>{{ t('sidebar.showMore', { count: Math.min(CHILDREN_PAGE_SIZE, remainingCount) }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent class="w-48">
|
||||
<template v-if="node.type === 'connection'">
|
||||
<ContextMenuItem @click="toggle">
|
||||
<Plug class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.openConnection') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="editConnection">
|
||||
<Pencil class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.editConnection') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="deleteConnection">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.deleteConnection') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'database' || node.type === 'schema'">
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'table' || node.type === 'view'">
|
||||
<ContextMenuItem @click="openData">
|
||||
<TableProperties class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.viewData') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.newQuery') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="isGroupLabel(node)">
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="copyName">
|
||||
<Copy class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.copyName') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { BadgeVariants } from '.'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants } from '.'
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants['variant']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Badge } from './Badge.vue'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'icon': 'size-8',
|
||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
|
||||
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuRootProps>()
|
||||
const emits = defineEmits<ContextMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot
|
||||
data-slot="context-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuCheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</ContextMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuCheckboxItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuGroupProps } from 'reka-ui'
|
||||
import { ContextMenuGroup } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuGroup
|
||||
data-slot="context-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuGroup>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<ContextMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
const emits = defineEmits<ContextMenuItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuItem
|
||||
data-slot="context-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuLabel
|
||||
data-slot="context-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuLabel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuPortalProps } from 'reka-ui'
|
||||
import { ContextMenuPortal } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuPortalProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal
|
||||
data-slot="context-menu-portal"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuRadioGroupProps>()
|
||||
const emits = defineEmits<ContextMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuRadioGroup>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuItemIndicator,
|
||||
ContextMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</ContextMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuRadioItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuSeparator,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSeparator
|
||||
data-slot="context-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
:class="cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuSubProps>()
|
||||
const emits = defineEmits<ContextMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSub
|
||||
data-slot="context-menu-sub"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuSub>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuSubContent>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuSubTriggerProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
</ContextMenuSubTrigger>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContextMenuTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuTrigger
|
||||
data-slot="context-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('select-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
export { default as ContextMenu } from './ContextMenu.vue'
|
||||
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
|
||||
export { default as ContextMenuContent } from './ContextMenuContent.vue'
|
||||
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
|
||||
export { default as ContextMenuItem } from './ContextMenuItem.vue'
|
||||
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
|
||||
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
|
||||
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
|
||||
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
|
||||
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
|
||||
export { default as ContextMenuSub } from './ContextMenuSub.vue'
|
||||
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
|
||||
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
|
||||
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DialogOverlay from './DialogOverlay.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
as-child
|
||||
>
|
||||
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('gap-2 flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'start',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from 'reka-ui'
|
||||
import { DropdownMenuGroup } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'inset')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||