diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 02764afa6..ec326aa00 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -926,13 +926,8 @@ async function saveActiveSqlAsLocalFile() { const tab = activeTab.value; if (!tab || !canSaveSqlTab(tab) || !isTauriRuntime()) return; try { - const { save } = await import("@tauri-apps/plugin-dialog"); - const path = await save({ - defaultPath: defaultSavedSqlName(tab.title), - filters: [{ name: "SQL", extensions: ["sql"] }], - }); + const path = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql); if (!path) return; - await api.writeExternalSqlFile(path, tab.sql); queryStore.linkExternalSqlPath(tab.id, path, sqlFileTitleFromPath(path)); showSaveSqlDialog.value = false; closePendingSavedTab(); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 739945df3..5529f073d 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -296,6 +296,7 @@ export const pendingOpenDbFiles = forward("pendingOpenDbFiles"); export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks"); export const readExternalSqlFile = forward("readExternalSqlFile"); export const writeExternalSqlFile = forward("writeExternalSqlFile"); +export const saveExternalSqlFile = forward("saveExternalSqlFile"); export const listSqlFilesInFolder = forward("listSqlFilesInFolder"); // Nacos diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index afc59bb59..d80625c52 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1430,6 +1430,10 @@ export async function writeExternalSqlFile(_path: string, _content: string): Pro throw new Error("Saving external SQL file paths is only available in the desktop app"); } +export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise { + throw new Error("Saving SQL files locally is only available in the desktop app"); +} + export interface SqlFileEntry { name: string; path: string; diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 63b69a479..0bff4baca 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -613,6 +613,10 @@ export async function writeExternalSqlFile(path: string, content: string): Promi return invoke("write_external_sql_file", { path, content }); } +export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise { + return invoke("save_external_sql_file", { defaultFileName, content }); +} + export interface SqlFileEntry { name: string; path: string; diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index c7e91979a..523ba8392 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use dbx_core::sql::decode_sql_file_bytes; +use tauri_plugin_dialog::DialogExt; #[tauri::command] pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> Vec { @@ -21,6 +22,27 @@ pub async fn write_external_sql_file(path: String, content: String) -> Result<() write_external_sql_file_content_async(PathBuf::from(path), content).await } +#[tauri::command] +pub async fn save_external_sql_file( + window: tauri::Window, + default_file_name: String, + content: String, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + window.dialog().file().set_file_name(default_file_name).add_filter("SQL", &["sql"]).save_file(move |file_path| { + let _ = sender.send(file_path); + }); + let path = receiver + .await + .map_err(|_| "SQL save dialog closed unexpectedly".to_string())? + .map(|file_path| file_path.into_path().map_err(|error| format!("Failed to resolve SQL file path: {error}"))) + .transpose()?; + + // Keep the native dialog result as a PathBuf until after the write so + // Windows Unicode paths do not cross an extra frontend IPC boundary. + save_external_sql_file_content_async(path, content).await +} + #[derive(Default)] pub struct ExternalSqlOpenState { pending: Mutex>, @@ -99,6 +121,26 @@ async fn write_external_sql_file_content_async(path: PathBuf, content: String) - tokio::fs::write(&path, content).await.map_err(|e| format!("Failed to save SQL file: {e}")) } +async fn save_external_sql_file_content_async( + path: Option, + content: String, +) -> Result, String> { + let Some(path) = path else { + return Ok(None); + }; + write_external_sql_file_content_async(path.clone(), content).await?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[cfg(test)] +fn save_external_sql_file_content(path: Option<&Path>, content: &str) -> Result, String> { + let Some(path) = path else { + return Ok(None); + }; + write_external_sql_file_content(path, content)?; + Ok(Some(path.to_string_lossy().into_owned())) +} + fn dedupe_paths(paths: Vec) -> Vec { let mut unique = Vec::new(); for path in paths { @@ -181,6 +223,45 @@ mod tests { assert_eq!(content, "select 2;"); } + #[test] + fn saves_external_sql_file_with_unicode_name() { + let path = std::env::temp_dir().join(format!("查询-{}.sql", uuid::Uuid::new_v4())); + + let result = save_external_sql_file_content(Some(&path), "select 3;"); + + let content = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned())); + assert_eq!(content, "select 3;"); + } + + #[test] + fn saves_external_sql_file_with_ascii_name() { + let path = std::env::temp_dir().join(format!("query-{}.sql", uuid::Uuid::new_v4())); + + let result = save_external_sql_file_content(Some(&path), "select 4;"); + + let _ = std::fs::remove_file(&path); + assert_eq!(result.unwrap(), Some(path.to_string_lossy().into_owned())); + } + + #[test] + fn cancelling_external_sql_file_save_does_not_write() { + let result = save_external_sql_file_content(None, "select 5;"); + + assert_eq!(result.unwrap(), None); + } + + #[test] + fn reports_external_sql_file_write_failure() { + let directory = std::env::temp_dir().join(format!("dbx-missing-parent-{}", uuid::Uuid::new_v4())); + let path = directory.join("query.sql"); + + let result = save_external_sql_file_content(Some(&path), "select 6;"); + + assert!(result.unwrap_err().contains("Failed to save SQL file")); + } + #[test] fn rejects_external_non_sql_file_save() { let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4())); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f9ee3376..1f9e9be06 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1209,6 +1209,7 @@ pub fn run() { commands::external_sql::pending_open_sql_files, commands::external_sql::read_external_sql_file, commands::external_sql::write_external_sql_file, + commands::external_sql::save_external_sql_file, commands::list_sql_files::list_sql_files_in_folder, commands::external_db::pending_open_db_files, commands::keychain::read_keychain_password,