diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f940cdd5d..8c4dd4e87 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -365,8 +365,7 @@ async function openSqlFile() { async function openSqlFilePath(path: string) { if (!isTauriRuntime()) return; try { - const { readTextFile } = await import("@tauri-apps/plugin-fs"); - const content = await readTextFile(path); + const content = await api.readExternalSqlFile(path); const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 2f1b2d76b..c00f662d3 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -43,6 +43,7 @@ const tableError = ref(null); const includeStructure = ref(true); const includeData = ref(true); const includeObjects = ref(true); +const dropTableIfExists = ref(false); // Export state const isExporting = ref(false); @@ -197,6 +198,7 @@ async function startExport() { includeStructure: includeStructure.value, includeData: includeData.value, includeObjects: includeObjects.value, + dropTableIfExists: dropTableIfExists.value, batchSize: 1000, }; @@ -240,6 +242,7 @@ function resetState() { includeStructure.value = true; includeData.value = true; includeObjects.value = true; + dropTableIfExists.value = false; isExporting.value = false; exportProgress.value = null; exportDone.value = false; @@ -415,6 +418,15 @@ watch(open, async (val) => { {{ t("databaseExport.includeStructure") }} +
+ + + {{ t("databaseExport.dropTableIfExists") }} +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0b4912e57..fb8598cbc 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1173,6 +1173,7 @@ export default { databaseExport: { title: "Export Database", includeStructure: "Table structure (DDL)", + dropTableIfExists: "Add DROP TABLE IF EXISTS before DDL", includeData: "Table data (INSERT)", includeObjects: "Views / Procedures / Functions", tableSelection: "Tables", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 2fa4575a5..3160c555d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1078,6 +1078,7 @@ export default { databaseExport: { title: "Exportar base de datos", includeStructure: "Estructura de tablas (DDL)", + dropTableIfExists: "Agregar DROP TABLE IF EXISTS antes del DDL", includeData: "Datos de tablas (INSERT)", includeObjects: "Vistas / Procedimientos / Funciones", tableSelection: "Tablas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 454c94c04..3d41b3180 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1150,6 +1150,7 @@ export default { databaseExport: { title: "导出数据库", includeStructure: "表结构 (DDL)", + dropTableIfExists: "导出前添加 DROP TABLE IF EXISTS", includeData: "表数据 (INSERT)", includeObjects: "视图 / 存储过程 / 函数", tableSelection: "选择表", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 3bc267d75..9a532c66b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -102,6 +102,7 @@ export const executeSqlFile = forward("executeSqlFile"); export const cancelSqlFileExecution = forward("cancelSqlFileExecution"); export const listenSqlFileProgress = forward("listenSqlFileProgress"); export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles"); +export const readExternalSqlFile = forward("readExternalSqlFile"); // Data Transfer export const startTransfer = forward("startTransfer"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index a072c85d6..c99a1eccc 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -482,6 +482,10 @@ export async function pendingOpenSqlFiles(): Promise { return []; } +export async function readExternalSqlFile(_path: string): Promise { + throw new Error("Opening external SQL file paths is only available in the desktop app"); +} + // --------------------------------------------------------------------------- // Data Transfer // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index a6e3f1b0c..d027b5cbe 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -112,6 +112,10 @@ export async function pendingOpenSqlFiles(): Promise { return invoke("pending_open_sql_files"); } +export async function readExternalSqlFile(path: string): Promise { + return invoke("read_external_sql_file", { path }); +} + // --- AI Conversations --- export interface AiChatMessage { @@ -868,6 +872,7 @@ export interface DatabaseExportRequest { includeStructure: boolean; includeData: boolean; includeObjects: boolean; + dropTableIfExists?: boolean; batchSize: number; } diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index dd28c76bf..57930218c 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -21,6 +21,8 @@ pub struct DatabaseExportRequest { pub include_structure: bool, pub include_data: bool, pub include_objects: bool, + #[serde(default)] + pub drop_table_if_exists: bool, pub batch_size: usize, } @@ -159,6 +161,10 @@ pub async fn export_database_sql_core( // Export structure if request.include_structure { + if request.drop_table_if_exists { + writeln!(file, "{}\n", drop_table_if_exists_sql(table_name, &request.schema, &db_type)) + .map_err(|e| format!("Failed to write file: {e}"))?; + } match crate::schema::get_table_ddl_core( state, &request.connection_id, @@ -448,9 +454,14 @@ fn filter_selected_table_infos( tables.into_iter().filter(|table| selected.contains(table.name.as_str())).collect() } +fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String { + format!("DROP TABLE IF EXISTS {};", crate::transfer::qualified_table(table_name, schema, db_type)) +} + #[cfg(test)] mod tests { - use super::filter_selected_table_infos; + use super::{drop_table_if_exists_sql, filter_selected_table_infos}; + use crate::models::connection::DatabaseType; use crate::types::TableInfo; fn table(name: &str, table_type: &str) -> TableInfo { @@ -474,4 +485,18 @@ mod tests { assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users", "orders"]); } + + #[test] + fn builds_drop_table_if_exists_with_qualified_mysql_name() { + let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql); + + assert_eq!(sql, "DROP TABLE IF EXISTS `app`.`users`;"); + } + + #[test] + fn builds_drop_table_if_exists_without_empty_schema() { + let sql = drop_table_if_exists_sql("users", "", &DatabaseType::Postgres); + + assert_eq!(sql, "DROP TABLE IF EXISTS \"users\";"); + } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0bfb349b8..3e2b562ab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ tauri-build = { version = "2.5.6", features = [] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" log = "0.4" -tauri = { version = "2.10.3", features = [] } +tauri = { version = "2.10.3", features = ["tray-icon"] } tauri-plugin-log = "2" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid", "rust_decimal"] } rust_decimal = { version = "1", features = ["serde"] } diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index bdccc5f0c..e33d9d445 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -9,6 +9,11 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> dedupe_paths(paths) } +#[tauri::command] +pub fn read_external_sql_file(path: String) -> Result { + read_external_sql_file_content(Path::new(&path)) +} + #[derive(Default)] pub struct ExternalSqlOpenState { pending: Mutex>, @@ -55,6 +60,13 @@ pub fn is_sql_file_path(path: &Path) -> bool { path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false) } +pub fn read_external_sql_file_content(path: &Path) -> Result { + if !is_sql_file_path(path) { + return Err("Only .sql files can be opened this way".to_string()); + } + std::fs::read_to_string(path).map_err(|e| format!("Failed to read SQL file: {e}")) +} + fn dedupe_paths(paths: Vec) -> Vec { let mut unique = Vec::new(); for path in paths { @@ -91,4 +103,26 @@ mod tests { assert_eq!(state.drain(), vec!["/tmp/a.sql"]); assert!(state.drain().is_empty()); } + + #[test] + fn reads_external_sql_file_content() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + std::fs::write(&path, "select 1;").unwrap(); + + let result = read_external_sql_file_content(&path); + + let _ = std::fs::remove_file(&path); + assert_eq!(result.unwrap(), "select 1;"); + } + + #[test] + fn rejects_external_non_sql_file_content() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4())); + std::fs::write(&path, "select 1;").unwrap(); + + let result = read_external_sql_file_content(&path); + + let _ = std::fs::remove_file(&path); + assert!(result.unwrap_err().contains(".sql")); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e3a7e1d9..a6ef47200 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,8 +8,66 @@ use commands::connection::AppState; use dbx_core::storage::Storage; use std::sync::Arc; use std::time::Instant; +use tauri::{ + menu::MenuBuilder, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; use tauri::{Emitter, Manager, RunEvent}; +fn should_hide_window_on_close(target_os: &str) -> bool { + matches!(target_os, "macos" | "windows") +} + +fn show_main_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn setup_windows_tray(app: &mut tauri::App) -> tauri::Result<()> { + let menu = MenuBuilder::new(app).text("show", "Show DBX").separator().text("quit", "Quit DBX").build()?; + let mut tray = TrayIconBuilder::with_id("main-tray").tooltip("DBX").menu(&menu).show_menu_on_left_click(false); + + if let Some(icon) = app.default_window_icon().cloned() { + tray = tray.icon(icon); + } + + tray.on_menu_event(|app, event| { + if event.id() == "show" { + show_main_window(app); + } else if event.id() == "quit" { + app.exit(0); + } + }) + .on_tray_icon_event(|tray, event| match event { + TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } + | TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } => show_main_window(tray.app_handle()), + _ => {} + }) + .build(app)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::should_hide_window_on_close; + + #[test] + fn hides_window_on_close_for_windows_and_macos() { + assert!(should_hide_window_on_close("windows")); + assert!(should_hide_window_on_close("macos")); + } + + #[test] + fn does_not_hide_window_on_close_for_other_platforms() { + assert!(!should_hide_window_on_close("linux")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider"); @@ -75,15 +133,18 @@ pub fn run() { let _ = window.set_decorations(false); } } + #[cfg(target_os = "windows")] + setup_windows_tray(app)?; window_state_guard::enforce_main_window_bounds(app.handle()); Ok(()) }) .on_window_event(|window, event| { - #[cfg(target_os = "macos")] if let tauri::WindowEvent::CloseRequested { api, .. } = event { - window.hide().unwrap(); - api.prevent_close(); + if should_hide_window_on_close(std::env::consts::OS) { + let _ = window.hide(); + api.prevent_close(); + } } }) .invoke_handler(tauri::generate_handler![ @@ -135,6 +196,7 @@ pub fn run() { commands::sql_file::execute_sql_file, commands::sql_file::cancel_sql_file_execution, commands::external_sql::pending_open_sql_files, + commands::external_sql::read_external_sql_file, commands::table_import::preview_table_import_file, commands::table_import::import_table_file, commands::table_import::cancel_table_import, @@ -215,10 +277,7 @@ pub fn run() { #[cfg(target_os = "macos")] if let RunEvent::Reopen { has_visible_windows, .. } = &event { if !has_visible_windows { - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + show_main_window(app_handle); } } });