From 792efe40536ae260f1847b1041789a718f247ecd Mon Sep 17 00:00:00 2001 From: blankll Date: Tue, 23 Jun 2026 21:52:25 +0800 Subject: [PATCH 01/15] feat: redesign queries sidebar with modular components + materialized view support - Replace 981-line DatabaseBrowser.vue with 11 focused sidebar components - Add SchemaTree (schema-database browser with lazy-load + context menus) - Add SavedQueriesPanel (bottom-pinned with metadata sync, rename/delete/change-connection dialogs) - Add ConnectionSelector (connection picker + status indicator) - Add DatabaseSelectorRow (database picker + refresh + action menu stub) - Add SidebarSplitView (resizable top/bottom layout for tree + saved queries) - Add Rust backend: read/write saved query metadata JSON, serde camelCase - Add PostgreSQL materialized view support (list_views UNION pg_matviews + DDL/DROP/RENAME arms) - Fix ListingTab.vue: use obj.object_type instead of props.type for DDL/drop/rename/copy - Add i18n keys (enUS + zhCN) for all sidebar strings - Keep DataTableView.vue and its 5 panel tabs unchanged - Keep all table context menu actions preserved (selectTopN, viewStructure, etc.) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/file_operations.rs | 164 +++ src-tauri/src/database/postgres.rs | 26 +- src-tauri/src/lib.rs | 2 + .../database-browser/DatabaseBrowser.vue | 981 ------------------ .../database-browser/ListingTab.vue | 8 +- src/components/database-browser/index.ts | 1 - .../sidebar/ChangeConnectionDialog.vue | 92 ++ src/components/sidebar/ConnectionSelector.vue | 98 ++ .../sidebar/DatabaseSelectorRow.vue | 181 ++++ src/components/sidebar/DeleteQueryDialog.vue | 61 ++ src/components/sidebar/RenameQueryDialog.vue | 68 ++ src/components/sidebar/SavedQueriesPanel.vue | 275 +++++ src/components/sidebar/SavedQueryItem.vue | 128 +++ src/components/sidebar/SchemaTree.vue | 584 +++++++++++ src/components/sidebar/SidebarSplitView.vue | 109 ++ src/components/sidebar/TreeGroup.vue | 52 + src/components/sidebar/dbCapabilities.ts | 125 +++ src/components/sidebar/index.ts | 12 + src/datasources/fileApi.ts | 19 + src/lang/enUS.ts | 78 ++ src/lang/zhCN.ts | 78 ++ src/pages/QueriesPage.vue | 111 +- 24 files changed, 2216 insertions(+), 1039 deletions(-) delete mode 100644 src/components/database-browser/DatabaseBrowser.vue create mode 100644 src/components/sidebar/ChangeConnectionDialog.vue create mode 100644 src/components/sidebar/ConnectionSelector.vue create mode 100644 src/components/sidebar/DatabaseSelectorRow.vue create mode 100644 src/components/sidebar/DeleteQueryDialog.vue create mode 100644 src/components/sidebar/RenameQueryDialog.vue create mode 100644 src/components/sidebar/SavedQueriesPanel.vue create mode 100644 src/components/sidebar/SavedQueryItem.vue create mode 100644 src/components/sidebar/SchemaTree.vue create mode 100644 src/components/sidebar/SidebarSplitView.vue create mode 100644 src/components/sidebar/TreeGroup.vue create mode 100644 src/components/sidebar/dbCapabilities.ts create mode 100644 src/components/sidebar/index.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0676f009..715bc315 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6423,6 +6423,7 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tempfile", "thiserror 1.0.69", "tiberius", "tokio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0cb1fdd4..b7590547 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -104,6 +104,7 @@ pageant = "0.2" [dev-dependencies] mockall = "0.13" +tempfile = "3" [features] default = [] diff --git a/src-tauri/src/commands/file_operations.rs b/src-tauri/src/commands/file_operations.rs index 94eb09d5..e1e00406 100644 --- a/src-tauri/src/commands/file_operations.rs +++ b/src-tauri/src/commands/file_operations.rs @@ -38,6 +38,23 @@ pub struct SavedQueryInfo { pub size_bytes: u64, } +/// Metadata for a saved query entry in the metadata file +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SavedQueryMetadata { + pub connection_id: Option, + pub connection_name: Option, + pub created_at: u64, + pub modified_at: u64, +} + +/// Collection of saved query metadata entries keyed by file path +#[derive(Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SavedQueriesMetadata { + pub queries: std::collections::HashMap, +} + /// Get the queries directory path, creating it if necessary fn get_queries_dir(app_handle: &AppHandle) -> Result { let app_data_dir = app_handle @@ -56,6 +73,11 @@ fn get_queries_dir(app_handle: &AppHandle) -> Result { Ok(queries_dir) } +fn get_metadata_file_path(app_handle: &AppHandle) -> Result { + let queries_dir = get_queries_dir(app_handle)?; + Ok(queries_dir.join("metadata.json")) +} + /// Save a SQL query to a file. /// /// If file_path is provided, saves to that path. @@ -245,3 +267,145 @@ pub async fn write_text_file(path: String, content: String) -> Result<(), String } fs::write(target, content).map_err(|e| format!("Failed to write file: {}", e)) } + +#[tauri::command] +pub async fn read_saved_queries_metadata(app_handle: AppHandle) -> Result { + let path = get_metadata_file_path(&app_handle)?; + if !path.exists() { + return Ok(SavedQueriesMetadata::default()); + } + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read metadata file: {}", e))?; + match serde_json::from_str::(&content) { + Ok(metadata) => Ok(metadata), + Err(e) => { + eprintln!("Corrupt metadata file, returning empty: {}", e); + Ok(SavedQueriesMetadata::default()) + } + } +} + +#[tauri::command] +pub async fn write_saved_queries_metadata( + app_handle: AppHandle, + metadata: SavedQueriesMetadata, +) -> Result<(), String> { + let path = get_metadata_file_path(&app_handle)?; + let tmp_path = path.with_extension("json.tmp"); + let content = serde_json::to_string_pretty(&metadata) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + fs::write(&tmp_path, content) + .map_err(|e| format!("Failed to write temp metadata file: {}", e))?; + fs::rename(&tmp_path, &path) + .map_err(|e| format!("Failed to rename metadata file: {}", e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_test_metadata() -> SavedQueriesMetadata { + let mut queries = HashMap::new(); + queries.insert( + "/path/to/query1.sql".to_string(), + SavedQueryMetadata { + connection_id: Some("conn-uuid-1234".to_string()), + connection_name: Some("pg-prod".to_string()), + created_at: 1718926200, + modified_at: 1719185400, + }, + ); + queries.insert( + "/path/to/query2.sql".to_string(), + SavedQueryMetadata { + connection_id: None, + connection_name: None, + created_at: 1718000000, + modified_at: 1719000000, + }, + ); + SavedQueriesMetadata { queries } + } + + #[test] + fn test_metadata_write_then_read_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + let original = make_test_metadata(); + + let json = serde_json::to_string_pretty(&original).unwrap(); + std::fs::write(&metadata_path, &json).unwrap(); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let read_back: SavedQueriesMetadata = serde_json::from_str(&content).unwrap(); + + assert_eq!(read_back.queries.len(), 2); + assert!(read_back.queries.contains_key("/path/to/query1.sql")); + assert!(read_back.queries.contains_key("/path/to/query2.sql")); + + let q1 = read_back.queries.get("/path/to/query1.sql").unwrap(); + assert_eq!(q1.connection_id, Some("conn-uuid-1234".to_string())); + assert_eq!(q1.connection_name, Some("pg-prod".to_string())); + assert_eq!(q1.created_at, 1718926200); + assert_eq!(q1.modified_at, 1719185400); + + let q2 = read_back.queries.get("/path/to/query2.sql").unwrap(); + assert!(q2.connection_id.is_none()); + assert!(q2.connection_name.is_none()); + } + + #[test] + fn test_metadata_read_missing_file_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + assert!(!metadata_path.exists()); + + let result: SavedQueriesMetadata = if !metadata_path.exists() { + SavedQueriesMetadata::default() + } else { + let content = std::fs::read_to_string(&metadata_path).unwrap(); + serde_json::from_str(&content).unwrap_or_default() + }; + + assert_eq!(result.queries.len(), 0); + } + + #[test] + fn test_metadata_read_corrupt_json_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + + std::fs::write(&metadata_path, "not valid json {{{{").unwrap(); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let result: SavedQueriesMetadata = match serde_json::from_str(&content) { + Ok(m) => m, + Err(_) => SavedQueriesMetadata::default(), + }; + + assert_eq!(result.queries.len(), 0); + } + + #[test] + fn test_metadata_write_is_atomic() { + let dir = tempfile::tempdir().unwrap(); + let metadata_path = dir.path().join("metadata.json"); + let tmp_path = metadata_path.with_extension("json.tmp"); + + let metadata = make_test_metadata(); + + let json = serde_json::to_string_pretty(&metadata).unwrap(); + std::fs::write(&tmp_path, &json).unwrap(); + std::fs::rename(&tmp_path, &metadata_path).unwrap(); + + assert!(metadata_path.exists()); + assert!(!tmp_path.exists()); + + let content = std::fs::read_to_string(&metadata_path).unwrap(); + let _: SavedQueriesMetadata = serde_json::from_str(&content).unwrap(); + } +} diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs index 74ba15cf..7a10c53a 100644 --- a/src-tauri/src/database/postgres.rs +++ b/src-tauri/src/database/postgres.rs @@ -1553,7 +1553,15 @@ impl DatabaseAdapter for PostgresAdapter { view_definition as definition FROM information_schema.views WHERE table_schema = $1 - ORDER BY table_name + UNION ALL + SELECT + matviewname as name, + 'MATERIALIZED VIEW' as object_type, + schemaname as schema_name, + definition as definition + FROM pg_matviews + WHERE schemaname = $1 + ORDER BY name "#; let pool = self @@ -2022,6 +2030,20 @@ impl DatabaseAdapter for PostgresAdapter { .map(|r| r.get::<_, String>(0)) .ok_or_else(|| DbError::DatabaseNotFound(object_name.to_string())) } + "MATERIALIZED VIEW" => { + let query = r#" + SELECT definition + FROM pg_matviews + WHERE schemaname = $1 AND matviewname = $2 + "#; + let rows = client + .query(query, &[&schema_filter, &object_name]) + .await + .map_err(|e| DbError::QueryExecution(e.to_string()))?; + rows.first() + .map(|r| r.get::<_, String>(0)) + .ok_or_else(|| DbError::DatabaseNotFound(object_name.to_string())) + } "PROCEDURE" | "FUNCTION" => { let query = r#" SELECT pg_get_functiondef(p.oid) @@ -2082,6 +2104,7 @@ impl DatabaseAdapter for PostgresAdapter { let sql = match object_type.to_uppercase().as_str() { "VIEW" => format!("DROP VIEW IF EXISTS {} CASCADE", qualified), + "MATERIALIZED VIEW" => format!("DROP MATERIALIZED VIEW IF EXISTS {} CASCADE", qualified), "PROCEDURE" => format!("DROP PROCEDURE IF EXISTS {} CASCADE", qualified), "FUNCTION" => format!("DROP FUNCTION IF EXISTS {} CASCADE", qualified), "TRIGGER" => { @@ -2143,6 +2166,7 @@ impl DatabaseAdapter for PostgresAdapter { let sql = match object_type.to_uppercase().as_str() { "TABLE" => format!("ALTER TABLE {} RENAME TO {}", qualified, new_quoted), "VIEW" => format!("ALTER VIEW {} RENAME TO {}", qualified, new_quoted), + "MATERIALIZED VIEW" => format!("ALTER MATERIALIZED VIEW {} RENAME TO {}", qualified, new_quoted), "PROCEDURE" => format!("ALTER PROCEDURE {} RENAME TO {}", qualified, new_quoted), "FUNCTION" => format!("ALTER FUNCTION {} RENAME TO {}", qualified, new_quoted), _ => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e0d2c5d8..6eabaec6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -273,6 +273,8 @@ pub fn run() { commands::list_saved_queries, commands::delete_query_file, commands::write_text_file, + commands::read_saved_queries_metadata, + commands::write_saved_queries_metadata, commands::preview_export_data, commands::execute_export_data, commands::detect_file_format, diff --git a/src/components/database-browser/DatabaseBrowser.vue b/src/components/database-browser/DatabaseBrowser.vue deleted file mode 100644 index 5e8ccbaa..00000000 --- a/src/components/database-browser/DatabaseBrowser.vue +++ /dev/null @@ -1,981 +0,0 @@ - - - - - diff --git a/src/components/database-browser/ListingTab.vue b/src/components/database-browser/ListingTab.vue index eb508a73..e5b656b2 100644 --- a/src/components/database-browser/ListingTab.vue +++ b/src/components/database-browser/ListingTab.vue @@ -75,7 +75,7 @@ async function openDdlModal(obj: ObjectInfo) { props.database, props.schema, obj.name, - props.type, + obj.object_type, ) ddlContent.value = ddl } @@ -108,7 +108,7 @@ async function executeDrop() { props.database, props.schema, dropTarget.value.name, - props.type, + dropTarget.value.object_type, ) toast.success(`${dropTarget.value.name} dropped`) dropDialogOpen.value = false @@ -146,7 +146,7 @@ async function executeRename() { props.database, props.schema, renameTarget.value.name, - props.type, + renameTarget.value.object_type, newName.value.trim(), ) toast.success(`${renameTarget.value.name} → ${newName.value.trim()}`) @@ -203,7 +203,7 @@ async function copyDdlContext() { props.database, props.schema, contextMenuTarget.value.name, - props.type, + contextMenuTarget.value.object_type, ) await navigator.clipboard.writeText(ddl) toast.success(t('common.copied')) diff --git a/src/components/database-browser/index.ts b/src/components/database-browser/index.ts index af27b8a7..225f005f 100644 --- a/src/components/database-browser/index.ts +++ b/src/components/database-browser/index.ts @@ -1,4 +1,3 @@ -export { default as DatabaseBrowser } from './DatabaseBrowser.vue' export { default as DataTableView } from './DataTableView.vue' export { default as DbTypeIcon } from './DbTypeIcon.vue' export { default as QueryResultPanel } from './QueryResultPanel.vue' diff --git a/src/components/sidebar/ChangeConnectionDialog.vue b/src/components/sidebar/ChangeConnectionDialog.vue new file mode 100644 index 00000000..cfaab96b --- /dev/null +++ b/src/components/sidebar/ChangeConnectionDialog.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/components/sidebar/ConnectionSelector.vue b/src/components/sidebar/ConnectionSelector.vue new file mode 100644 index 00000000..7bb7f8da --- /dev/null +++ b/src/components/sidebar/ConnectionSelector.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/components/sidebar/DatabaseSelectorRow.vue b/src/components/sidebar/DatabaseSelectorRow.vue new file mode 100644 index 00000000..e012a8ab --- /dev/null +++ b/src/components/sidebar/DatabaseSelectorRow.vue @@ -0,0 +1,181 @@ + + + diff --git a/src/components/sidebar/DeleteQueryDialog.vue b/src/components/sidebar/DeleteQueryDialog.vue new file mode 100644 index 00000000..8a160e33 --- /dev/null +++ b/src/components/sidebar/DeleteQueryDialog.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/components/sidebar/RenameQueryDialog.vue b/src/components/sidebar/RenameQueryDialog.vue new file mode 100644 index 00000000..652bcb40 --- /dev/null +++ b/src/components/sidebar/RenameQueryDialog.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/components/sidebar/SavedQueriesPanel.vue b/src/components/sidebar/SavedQueriesPanel.vue new file mode 100644 index 00000000..2e242dbb --- /dev/null +++ b/src/components/sidebar/SavedQueriesPanel.vue @@ -0,0 +1,275 @@ + + + diff --git a/src/components/sidebar/SavedQueryItem.vue b/src/components/sidebar/SavedQueryItem.vue new file mode 100644 index 00000000..88339149 --- /dev/null +++ b/src/components/sidebar/SavedQueryItem.vue @@ -0,0 +1,128 @@ + + + diff --git a/src/components/sidebar/SchemaTree.vue b/src/components/sidebar/SchemaTree.vue new file mode 100644 index 00000000..d3cc4944 --- /dev/null +++ b/src/components/sidebar/SchemaTree.vue @@ -0,0 +1,584 @@ + + + diff --git a/src/components/sidebar/SidebarSplitView.vue b/src/components/sidebar/SidebarSplitView.vue new file mode 100644 index 00000000..c316fd35 --- /dev/null +++ b/src/components/sidebar/SidebarSplitView.vue @@ -0,0 +1,109 @@ + + + diff --git a/src/components/sidebar/TreeGroup.vue b/src/components/sidebar/TreeGroup.vue new file mode 100644 index 00000000..02bb7694 --- /dev/null +++ b/src/components/sidebar/TreeGroup.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/components/sidebar/dbCapabilities.ts b/src/components/sidebar/dbCapabilities.ts new file mode 100644 index 00000000..8fedd82c --- /dev/null +++ b/src/components/sidebar/dbCapabilities.ts @@ -0,0 +1,125 @@ +import { DatabaseType } from '@/store' + +export type DbCapabilities = { + schemas: boolean + procedures: boolean + functions: boolean + views: boolean + materializedViews: boolean + backup: boolean + export: boolean + newDatabase: boolean + newSchema: boolean + dropDatabase: boolean +} + +const ALL: DbCapabilities = { + schemas: true, + procedures: true, + functions: true, + views: true, + materializedViews: true, + backup: true, + export: true, + newDatabase: true, + newSchema: true, + dropDatabase: true, +} + +const NONE: DbCapabilities = { + schemas: false, + procedures: false, + functions: false, + views: false, + materializedViews: false, + backup: false, + export: false, + newDatabase: false, + newSchema: false, + dropDatabase: false, +} + +const PG_COMPAT: DbCapabilities = { ...ALL, backup: false } + +const MYSQL_COMPAT: DbCapabilities = { ...ALL, schemas: false, materializedViews: false, backup: false } + +const SQLITE_LIKE: DbCapabilities = { ...NONE, views: true, export: true } + +const CAPABILITY_MAP: Partial> = { + [DatabaseType.POSTGRESQL]: PG_COMPAT, + [DatabaseType.COCKROACHDB]: PG_COMPAT, + [DatabaseType.REDSHIFT]: PG_COMPAT, + [DatabaseType.YUGABYTEDB]: PG_COMPAT, + [DatabaseType.TIMESCALEDB]: PG_COMPAT, + [DatabaseType.KINGBASEES]: PG_COMPAT, + [DatabaseType.GAUSSDB]: PG_COMPAT, + [DatabaseType.HIGHGO]: PG_COMPAT, + [DatabaseType.UXDB]: PG_COMPAT, + [DatabaseType.OPENGAUSS]: PG_COMPAT, + [DatabaseType.GBASE8C]: PG_COMPAT, + [DatabaseType.QUESTDB]: PG_COMPAT, + [DatabaseType.VASTBASE]: PG_COMPAT, + [DatabaseType.YASHANDB]: PG_COMPAT, + [DatabaseType.GREENPLUM]: PG_COMPAT, + [DatabaseType.ENTERPRISEDB]: PG_COMPAT, + [DatabaseType.CRATEDB]: PG_COMPAT, + [DatabaseType.MATERIALIZE]: { ...PG_COMPAT, materializedViews: true }, + [DatabaseType.ALLOYDB]: PG_COMPAT, + [DatabaseType.CLOUDSQLPG]: PG_COMPAT, + [DatabaseType.FUJITSUPG]: PG_COMPAT, + + [DatabaseType.MYSQL]: MYSQL_COMPAT, + [DatabaseType.MARIADB]: MYSQL_COMPAT, + [DatabaseType.TIDB]: MYSQL_COMPAT, + [DatabaseType.OCEANBASE]: MYSQL_COMPAT, + [DatabaseType.TDSQL]: MYSQL_COMPAT, + [DatabaseType.POLARDB]: MYSQL_COMPAT, + [DatabaseType.DORIS]: { ...MYSQL_COMPAT, procedures: false }, + [DatabaseType.SELECTDB]: { ...MYSQL_COMPAT, procedures: false }, + [DatabaseType.STARROCKS]: { ...MYSQL_COMPAT, procedures: false }, + [DatabaseType.DATABEND]: { ...MYSQL_COMPAT, procedures: false }, + [DatabaseType.GOLDENDB]: MYSQL_COMPAT, + [DatabaseType.MANTICORESEARCH]: { ...MYSQL_COMPAT, procedures: false, functions: false }, + [DatabaseType.SINGLESTOREMEMSQL]: MYSQL_COMPAT, + [DatabaseType.CLOUDSQLMYSQL]: MYSQL_COMPAT, + + [DatabaseType.SQLITE]: SQLITE_LIKE, + [DatabaseType.DUCKDB]: { ...NONE, views: true, export: true, materializedViews: true }, + [DatabaseType.RQLITE]: SQLITE_LIKE, + [DatabaseType.TURSO]: SQLITE_LIKE, + + [DatabaseType.SQLSERVER]: { ...ALL, materializedViews: false }, + [DatabaseType.ORACLE]: { ...ALL, materializedViews: false }, + [DatabaseType.DAMENG]: { ...ALL, materializedViews: false }, + [DatabaseType.OCEANBASE_ORACLE]: { ...ALL, materializedViews: false }, + [DatabaseType.XUGUDB]: { ...ALL, materializedViews: false }, + [DatabaseType.GBASE8A]: { ...ALL, materializedViews: false }, + + [DatabaseType.CLICKHOUSE]: { ...ALL, procedures: false, functions: false, materializedViews: true, schemas: false }, + [DatabaseType.DB2]: { ...ALL, materializedViews: false }, + [DatabaseType.H2]: { ...ALL, materializedViews: false }, + [DatabaseType.SNOWFLAKE]: ALL, + [DatabaseType.TRINO]: { ...ALL, newDatabase: false, newSchema: false, dropDatabase: false, procedures: false }, + [DatabaseType.PRESTO]: { ...ALL, newDatabase: false, newSchema: false, dropDatabase: false, procedures: false }, + [DatabaseType.DERBY]: { ...ALL, materializedViews: false }, + [DatabaseType.HIVE]: { ...ALL, newDatabase: false, newSchema: false, dropDatabase: false, procedures: false, functions: false }, + [DatabaseType.DATABRICKS]: { ...ALL, newDatabase: false, newSchema: false, dropDatabase: false, procedures: false }, + [DatabaseType.HANA]: { ...ALL, materializedViews: false }, + [DatabaseType.TERADATA]: { ...ALL, materializedViews: false }, + [DatabaseType.VERTICA]: ALL, + [DatabaseType.EXASOL]: { ...ALL, materializedViews: false }, + [DatabaseType.BIGQUERY]: { ...ALL, newDatabase: false, dropDatabase: false, materializedViews: true }, + [DatabaseType.INFORMIX]: { ...ALL, materializedViews: false }, + [DatabaseType.KYLIN]: { ...ALL, newDatabase: false, newSchema: false, dropDatabase: false }, + [DatabaseType.CASSANDRA]: { ...NONE, export: true, views: false }, + [DatabaseType.IRIS]: { ...ALL, materializedViews: false }, + [DatabaseType.ACCESS]: { ...NONE, export: true, views: true }, + [DatabaseType.FIREBIRD]: { ...ALL, materializedViews: false }, + [DatabaseType.TDENGINE]: { ...NONE, export: true }, +} + +const DEFAULT_CAPS: DbCapabilities = { ...ALL } + +export function getDbCapabilities(dbType: DatabaseType): DbCapabilities { + return CAPABILITY_MAP[dbType] ?? DEFAULT_CAPS +} diff --git a/src/components/sidebar/index.ts b/src/components/sidebar/index.ts new file mode 100644 index 00000000..adde538a --- /dev/null +++ b/src/components/sidebar/index.ts @@ -0,0 +1,12 @@ +export { default as ChangeConnectionDialog } from './ChangeConnectionDialog.vue' +export { default as ConnectionSelector } from './ConnectionSelector.vue' +export { default as DatabaseSelectorRow } from './DatabaseSelectorRow.vue' +export { getDbCapabilities } from './dbCapabilities' +export type { DbCapabilities } from './dbCapabilities' +export { default as DeleteQueryDialog } from './DeleteQueryDialog.vue' +export { default as RenameQueryDialog } from './RenameQueryDialog.vue' +export { default as SavedQueriesPanel } from './SavedQueriesPanel.vue' +export { default as SavedQueryItem } from './SavedQueryItem.vue' +export { default as SchemaTree } from './SchemaTree.vue' +export { default as SidebarSplitView } from './SidebarSplitView.vue' +export { default as TreeGroup } from './TreeGroup.vue' diff --git a/src/datasources/fileApi.ts b/src/datasources/fileApi.ts index 771a361b..8398aa27 100644 --- a/src/datasources/fileApi.ts +++ b/src/datasources/fileApi.ts @@ -58,3 +58,22 @@ export function deleteQueryFile(filePath: string): Promise { filePath, }) } + +export type SavedQueryMetadata = { + connectionId: string | null + connectionName: string | null + createdAt: number + modifiedAt: number +} + +export type SavedQueriesMetadata = { + queries: Record +} + +export function readSavedQueriesMetadata(): Promise { + return invoke('read_saved_queries_metadata') +} + +export function writeSavedQueriesMetadata(metadata: SavedQueriesMetadata): Promise { + return invoke('write_saved_queries_metadata', { metadata }) +} diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index 588993ee..d21e2aa0 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -1471,4 +1471,82 @@ export const enUS = { PERMISSION_DENIED: 'You don\'t have sufficient privileges for this operation', }, }, + sidebar: { + connection: { + placeholder: 'Select connection', + connected: 'Connected', + lost: 'Connection lost', + disconnected: 'Disconnected', + connecting: 'Connecting...', + }, + database: { + placeholder: 'All Databases', + selectDatabase: 'Select database', + refresh: 'Refresh', + refreshTooltip: 'Reload database list', + }, + actionMenu: { + label: 'Database actions', + newDatabase: 'New Database', + newSchema: 'New Schema', + newTable: 'New Table', + newView: 'New View', + newFunction: 'New Function', + newProcedure: 'New Procedure', + backupDatabase: 'Backup Database', + exportDatabase: 'Export Database', + dropDatabase: 'Drop Database', + }, + groups: { + tables: 'TABLES', + views: 'VIEWS', + procedures: 'PROCEDURES', + functions: 'FUNCTIONS', + }, + loading: 'Loading...', + empty: 'No database selected. Choose one to browse.', + noObjects: 'No objects found', + noDatabases: 'No databases found. Connect to a server first.', + search: 'Search tables, views...', + savedQueries: { + header: 'Saved Queries', + expand: 'Expand saved queries', + collapse: 'Collapse saved queries', + newQuery: 'New query', + empty: 'No saved queries yet', + created: 'Created', + modified: 'Modified', + connectionNone: 'No target connection', + actions: { + open: 'Open', + rename: 'Rename', + changeConnection: 'Change Target Connection', + reveal: 'Reveal in File Explorer', + delete: 'Delete', + }, + delete: { + title: 'Delete saved query?', + description: 'Are you sure you want to delete "{name}"? This action cannot be undone.', + cancel: 'Cancel', + confirm: 'Delete', + target: 'Target', + created: 'Created', + }, + renameDialog: { + title: 'Rename saved query', + label: 'New name', + placeholder: 'Enter new file name', + cancel: 'Cancel', + confirm: 'Rename', + }, + connectionDialog: { + title: 'Change Target Connection', + label: 'Select connection', + none: 'No connection', + cancel: 'Cancel', + confirm: 'Save', + }, + }, + notImplemented: 'This feature is not yet implemented', + }, } diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index 76e57cc3..a6d72174 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -1471,4 +1471,82 @@ export const zhCN = { PERMISSION_DENIED: '您没有足够的权限执行此操作', }, }, + sidebar: { + connection: { + placeholder: '选择连接', + connected: '已连接', + lost: '连接丢失', + disconnected: '未连接', + connecting: '连接中...', + }, + database: { + placeholder: '所有数据库', + selectDatabase: '选择数据库', + refresh: '刷新', + refreshTooltip: '重新加载数据库列表', + }, + actionMenu: { + label: '数据库操作', + newDatabase: '新建数据库', + newSchema: '新建模式', + newTable: '新建表', + newView: '新建视图', + newFunction: '新建函数', + newProcedure: '新建存储过程', + backupDatabase: '备份数据库', + exportDatabase: '导出数据库', + dropDatabase: '删除数据库', + }, + groups: { + tables: '表', + views: '视图', + procedures: '存储过程', + functions: '函数', + }, + loading: '加载中...', + empty: '未选择数据库。请选择一个数据库进行浏览。', + noObjects: '未找到对象', + noDatabases: '未找到数据库。请先连接服务器。', + search: '搜索表、视图...', + savedQueries: { + header: '已保存的查询', + expand: '展开已保存的查询', + collapse: '折叠已保存的查询', + newQuery: '新建查询', + empty: '暂无已保存的查询', + created: '创建于', + modified: '修改于', + connectionNone: '无目标连接', + actions: { + open: '打开', + rename: '重命名', + changeConnection: '更改目标连接', + reveal: '在文件管理器中显示', + delete: '删除', + }, + delete: { + title: '删除已保存的查询?', + description: '确定要删除 "{name}" 吗?此操作无法撤销。', + cancel: '取消', + confirm: '删除', + target: '目标', + created: '创建于', + }, + renameDialog: { + title: '重命名已保存的查询', + label: '新名称', + placeholder: '输入新文件名', + cancel: '取消', + confirm: '重命名', + }, + connectionDialog: { + title: '更改目标连接', + label: '选择连接', + none: '无连接', + cancel: '取消', + confirm: '保存', + }, + }, + notImplemented: '此功能尚未实现', + }, } diff --git a/src/pages/QueriesPage.vue b/src/pages/QueriesPage.vue index d2c0cbb0..37d0a11a 100644 --- a/src/pages/QueriesPage.vue +++ b/src/pages/QueriesPage.vue @@ -6,21 +6,15 @@ import { invoke } from '@tauri-apps/api/core' import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' -import { DatabaseBrowser, DataTableView, DbTypeIcon, QueryResultPanel, QueryTabs } from '@/components/database-browser' +import { DataTableView, DbTypeIcon, QueryResultPanel, QueryTabs } from '@/components/database-browser' import ListingTab from '@/components/database-browser/ListingTab.vue' import ErDiagramView from '@/components/er-diagram/ErDiagramView.vue' import AppLayout from '@/components/layout/AppLayout.vue' +import { ConnectionSelector, DatabaseSelectorRow, SavedQueriesPanel, SchemaTree, SidebarSplitView } from '@/components/sidebar' import SQLEditor from '@/components/SQLEditor.vue' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { toast } from '@/composables/useNotifications' import { usePlatform } from '@/composables/usePlatform' @@ -43,7 +37,9 @@ const isResizingSidebar = ref(false) const selectedDatabase = ref('') const selectedSchema = ref('') const queryTabsRef = ref>() -const databaseBrowserRef = ref>() +const schemaTreeRef = ref>() +const savedQueriesRef = ref>() +const savedQueriesCollapsed = ref(true) const { formatSql, resolveDialect } = useSqlFormatter() @@ -54,9 +50,6 @@ const isDestructiveActionExecuting = ref(false) const showOrphanTabDialog = ref(false) const orphanTabToHandle = ref(null) -// Available connections -const availableConnections = computed(() => connectionStore.connections) - // Get active connection ID (can be changed by user) const selectedConnectionId = ref('') @@ -485,7 +478,7 @@ async function handleDestructiveConfirm() { toast.success(t('pages.queries.notifications.tableActionSuccess', { action: actionLabel })) destructiveDialogOpen.value = false destructiveAction.value = null - databaseBrowserRef.value?.refreshTree() + schemaTreeRef.value?.refresh() } catch (err) { toast.error(t('pages.queries.notifications.tableActionFailed', { action: action.type, error: err instanceof Error ? err.message : String(err) })) @@ -604,7 +597,7 @@ async function handleSaveQuery() { if (result.success && result.file_path) { tabStore.markTabSaved(activeTab.value.id, result.file_path) toast.success(t('pages.queries.notifications.querySaved'), { description: result.file_path }) - databaseBrowserRef.value?.fetchSavedQueryFiles() + savedQueriesRef.value?.refresh() } else { toast.error(t('pages.queries.notifications.saveFailed'), { description: result.message }) @@ -635,7 +628,7 @@ async function handleDownloadQuery() { if (result.success && result.file_path) { tabStore.markTabSaved(activeTab.value.id, result.file_path) toast.success(t('pages.queries.notifications.querySaved'), { description: result.file_path }) - databaseBrowserRef.value?.fetchSavedQueryFiles() + savedQueriesRef.value?.refresh() } else { toast.error(t('pages.queries.notifications.loadFailed'), { description: result.message }) @@ -646,6 +639,14 @@ async function handleDownloadQuery() { } } +function handleDatabaseRefresh() { + schemaTreeRef.value?.refresh() +} + +function handleDatabaseAction(_kind: string) { + toast.info(t('sidebar.notImplemented')) +} + function startSidebarResize(_e: MouseEvent) { isResizingSidebar.value = true document.addEventListener('mousemove', handleSidebarResize) @@ -674,49 +675,55 @@ function closeResultPanel() {
- +
- -
- -
+ + + + + - - + + :bottom-open="!savedQueriesCollapsed" + > + + +
From a79bb01da57bef0822bc1c2b1e5d0995781fd997 Mon Sep 17 00:00:00 2001 From: blankll Date: Tue, 23 Jun 2026 23:22:55 +0800 Subject: [PATCH 02/15] fix: database selector crash, connection search, tree groups, saved queries height - DatabaseSelectorRow: remove that crashed radix-vue (empty string is reserved for clearing selection) - ConnectionSelector: remove host:port display, add search filter input inside dropdown for filtering connections by name - SchemaTree: remove v-if from Views/Procedures/Functions groups so they always show even when empty; dedup database names in Mode B tree - SavedQueriesPanel: restore h-full on outer div so body gets proper height when panel is expanded (flex-1 needs parent height) - Remove unused capabilities computed + imports from SchemaTree --- src/components/sidebar/ConnectionSelector.vue | 39 +- .../sidebar/DatabaseSelectorRow.vue | 29 +- src/components/sidebar/SavedQueriesPanel.vue | 8 +- src/components/sidebar/SchemaTree.vue | 501 +++++++++--------- src/components/sidebar/SidebarSplitView.vue | 10 + src/components/sidebar/TreeGroup.vue | 10 +- 6 files changed, 341 insertions(+), 256 deletions(-) diff --git a/src/components/sidebar/ConnectionSelector.vue b/src/components/sidebar/ConnectionSelector.vue index 7bb7f8da..312fa9d8 100644 --- a/src/components/sidebar/ConnectionSelector.vue +++ b/src/components/sidebar/ConnectionSelector.vue @@ -1,7 +1,8 @@ @@ -64,24 +80,37 @@ function handleSelect(value: string) { - + + +
+ +
-
+
{{ conn.name }} {{ t('sidebar.connection.connected') }}
+
+ {{ t('sidebar.noObjects') }} +
diff --git a/src/components/sidebar/DatabaseSelectorRow.vue b/src/components/sidebar/DatabaseSelectorRow.vue index e012a8ab..72e6ce5e 100644 --- a/src/components/sidebar/DatabaseSelectorRow.vue +++ b/src/components/sidebar/DatabaseSelectorRow.vue @@ -1,6 +1,6 @@ - - - {{ t('sidebar.database.placeholder') }} - + diff --git a/src/components/sidebar/SavedQueriesPanel.vue b/src/components/sidebar/SavedQueriesPanel.vue index 2e242dbb..7bdcf6fd 100644 --- a/src/components/sidebar/SavedQueriesPanel.vue +++ b/src/components/sidebar/SavedQueriesPanel.vue @@ -1,7 +1,7 @@