diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c5634d3075..22e273456c 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -432,14 +432,42 @@ function defaultSavedSqlName(title: string) { return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`; } +function canSaveSqlTab(tab: QueryTab): boolean { + return !!tab.externalSqlPath || !!tab.sql.trim(); +} + +function closePendingSavedTab() { + if (!pendingSaveAndCloseTabId.value) return; + const closeId = pendingSaveAndCloseTabId.value; + pendingSaveAndCloseTabId.value = null; + if (pendingPrevActiveTabId.value) queryStore.activeTabId = pendingPrevActiveTabId.value; + pendingPrevActiveTabId.value = null; + queryStore.closeTab(closeId, { force: true }); +} + +async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: boolean } = {}): Promise { + if (!tab.externalSqlPath || !isTauriRuntime()) return false; + try { + await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql); + queryStore.markTabClean(tab); + toast(t("savedSql.saved"), 2000); + if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true }); + return true; + } catch (e: any) { + toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000); + return true; + } +} + async function handleSaveTab(tabId: string) { const tab = queryStore.tabs.find((t) => t.id === tabId); - if (!tab || !tab.sql.trim()) return; + if (!tab || !canSaveSqlTab(tab)) return; if (tab.objectSource) { const saved = await saveActiveObjectSource(tab); if (saved) queryStore.closeTab(tabId, { force: true }); return; } + if (await saveExternalSqlPath(tab, { closeAfterSave: true })) return; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { const updated = await savedSqlStore.saveFile({ @@ -469,11 +497,12 @@ async function handleSaveTab(tabId: string) { async function openSaveSqlDialog() { const tab = activeTab.value; - if (!tab || !tab.sql.trim()) return; + if (!tab || !canSaveSqlTab(tab)) return; if (tab.objectSource) { await saveActiveObjectSource(tab); return; } + if (await saveExternalSqlPath(tab)) return; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { const updated = await savedSqlStore.saveFile({ @@ -542,19 +571,33 @@ async function confirmSaveSqlToLibrary() { queryStore.linkSavedSql(tab.id, saved.id, saved.name); queryStore.markTabClean(tab); showSaveSqlDialog.value = false; - if (pendingSaveAndCloseTabId.value) { - const closeId = pendingSaveAndCloseTabId.value; - pendingSaveAndCloseTabId.value = null; - if (pendingPrevActiveTabId.value) queryStore.activeTabId = pendingPrevActiveTabId.value; - pendingPrevActiveTabId.value = null; - queryStore.closeTab(closeId, { force: true }); - } + closePendingSavedTab(); toast(t("savedSql.saved"), 2000); } catch (e: any) { toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000); } } +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"] }], + }); + if (!path) return; + await api.writeExternalSqlFile(path, tab.sql); + queryStore.linkExternalSqlPath(tab.id, path, sqlFileTitleFromPath(path)); + showSaveSqlDialog.value = false; + closePendingSavedTab(); + toast(t("savedSql.saved"), 2000); + } catch (e: any) { + toast(t("toolbar.sqlSaveFailed", { message: e?.message || String(e) }), 5000); + } +} + async function openSqlFile() { const tab = activeTab.value; if (!tab) return; @@ -566,8 +609,10 @@ async function openSqlFile() { multiple: false, }); if (path) { - const content = await api.readExternalSqlFile(path as string); + const sqlPath = path as string; + const content = await api.readExternalSqlFile(sqlPath); queryStore.updateSql(tab.id, content); + queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath)); } } else { const input = document.createElement("input"); @@ -616,6 +661,7 @@ async function openSqlFilePath(path: string) { const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : ""); const tabId = queryStore.createTab(connectionId, database, sqlFileTitleFromPath(path), "query"); queryStore.updateSql(tabId, content); + queryStore.linkExternalSqlPath(tabId, path, sqlFileTitleFromPath(path)); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); } @@ -1610,6 +1656,7 @@ onUnmounted(() => { + diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 6c1d407160..564f4e44b6 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -22,12 +22,13 @@ export function useFileDrop() { const queryStore = useQueryStore(); const { toast } = useToast(); - async function openDroppedSqlFile(name: string, content: string) { + async function openDroppedSqlFile(name: string, content: string, path?: string) { const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = connection?.database || ""; const tabId = queryStore.createTab(connectionId, database, name, "query"); queryStore.updateSql(tabId, content); + if (path) queryStore.linkExternalSqlPath(tabId, path, name); toast(t("welcome.fileOpened", { name })); } @@ -66,7 +67,7 @@ export function useFileDrop() { if (isSqlFilePath(path)) { try { const content = await api.readExternalSqlFile(path); - await openDroppedSqlFile(name, content); + await openDroppedSqlFile(name, content, path); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 02effcf4a8..6e1951b7b7 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -92,6 +92,7 @@ export default { }, savedSql: { saveToLibrary: "Save to SQL Library", + saveToFile: "Save Locally", save: "Save", saved: "SQL saved", saveFailed: "Failed to save SQL: {message}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b1127124b3..22cfa9f2ab 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -90,6 +90,7 @@ export default { }, savedSql: { saveToLibrary: "Guardar en biblioteca SQL", + saveToFile: "Guardar localmente", save: "Guardar", saved: "SQL guardado", saveFailed: "Error al guardar el SQL: {message}", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 29cba3dd83..b414edc803 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -90,6 +90,7 @@ export default { }, savedSql: { saveToLibrary: "Salva nella Libreria SQL", + saveToFile: "Salva localmente", save: "Salva", saved: "SQL salvato", saveFailed: "Impossibile salvare SQL: {message}", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1d29088f4a..95de4e783d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -92,6 +92,7 @@ export default { }, savedSql: { saveToLibrary: "SQLライブラリに保存", + saveToFile: "ローカルに保存", save: "保存", saved: "SQLを保存しました", saveFailed: "SQLの保存に失敗しました: {message}", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 61703633b2..50961a4e20 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -90,6 +90,7 @@ export default { }, savedSql: { saveToLibrary: "Salvar na Biblioteca SQL", + saveToFile: "Salvar localmente", save: "Salvar", saved: "SQL salvo", saveFailed: "Falha ao salvar SQL: {message}", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index e56f3c10c3..57bffde6cf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -92,6 +92,7 @@ export default { }, savedSql: { saveToLibrary: "保存到 SQL 库", + saveToFile: "保存到本地", save: "保存", saved: "SQL 已保存", saveFailed: "保存 SQL 失败:{message}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 36fa70e5ad..fe257d3a7a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -90,6 +90,7 @@ export default { }, savedSql: { saveToLibrary: "儲存到 SQL 庫", + saveToFile: "儲存到本機", save: "儲存", saved: "SQL 已儲存", saveFailed: "儲存 SQL 失敗:{message}", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 1204b80f96..b447938d3b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -228,6 +228,7 @@ export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles"); export const pendingOpenDbFiles = forward("pendingOpenDbFiles"); export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks"); export const readExternalSqlFile = forward("readExternalSqlFile"); +export const writeExternalSqlFile = forward("writeExternalSqlFile"); // Nacos export const nacosTestConnection = forward("nacosTestConnection"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 65c0725131..b8ab27c184 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1118,6 +1118,10 @@ export async function readExternalSqlFile(_path: string): Promise { throw new Error("Opening external SQL file paths is only available in the desktop app"); } +export async function writeExternalSqlFile(_path: string, _content: string): Promise { + throw new Error("Saving external SQL file paths is only available in the desktop app"); +} + // --------------------------------------------------------------------------- // Data Transfer // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/openTabsPersistence.ts b/apps/desktop/src/lib/openTabsPersistence.ts index 65f2f53d63..aef8da2e6e 100644 --- a/apps/desktop/src/lib/openTabsPersistence.ts +++ b/apps/desktop/src/lib/openTabsPersistence.ts @@ -20,6 +20,7 @@ export interface SavedOpenTab { schema?: string; sql: string; savedSqlId?: string; + externalSqlPath?: string; lastExecutedSql?: string; resultBaseSql?: string; resultSortedSql?: string; @@ -62,6 +63,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { schema: tab.schema, sql: tab.sql, savedSqlId: tab.savedSqlId, + externalSqlPath: tab.externalSqlPath, ...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}), ...(tab.resultBaseSql !== undefined ? { resultBaseSql: tab.resultBaseSql } : {}), ...(tab.resultSortedSql !== undefined ? { resultSortedSql: tab.resultSortedSql } : {}), @@ -138,6 +140,7 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str editorViewport: undefined, editorSelection: undefined, isExplaining: false, + originalSql: mode === "query" && tab.externalSqlPath ? tab.sql : undefined, resultEvicted: mode === "data" ? undefined : tab.resultEvicted, resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey, resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined, diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 17fb5b23d9..8566f0cf85 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -430,6 +430,10 @@ export async function readExternalSqlFile(path: string): Promise { return invoke("read_external_sql_file", { path }); } +export async function writeExternalSqlFile(path: string, content: string): Promise { + return invoke("write_external_sql_file", { path, content }); +} + // --- AI Conversations --- export interface AiChatMessage { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index cabc93e6dd..b3b3714e39 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -499,6 +499,7 @@ export const useQueryStore = defineStore("query", () => { schema: t.schema, sql: t.sql, savedSqlId: t.savedSqlId, + externalSqlPath: t.externalSqlPath, lastExecutedSql: t.lastExecutedSql, resultBaseSql: t.resultBaseSql, resultSortedSql: t.resultSortedSql, @@ -713,7 +714,7 @@ export const useQueryStore = defineStore("query", () => { function isTabDirty(tab: QueryTab): boolean { if (tab.mode !== "query") return false; - if (!tab.sql.trim()) return false; + if (!tab.externalSqlPath && !tab.sql.trim()) return false; const original = tab.originalSql; if (original === undefined) return !!tab.savedSqlId; return tab.sql !== original; @@ -951,12 +952,25 @@ export const useQueryStore = defineStore("query", () => { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.savedSqlId = savedSqlId; + tab.externalSqlPath = undefined; if (title) { tab.title = title; tab.customTitle = true; } } + function linkExternalSqlPath(id: string, path: string, title?: string) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab) return; + tab.externalSqlPath = path; + tab.savedSqlId = undefined; + if (title) { + tab.title = title; + tab.customTitle = true; + } + markTabClean(tab); + } + function openSavedSql(file: SavedSqlFile) { const existing = tabs.value.find((tab) => tab.savedSqlId === file.id); if (existing) { @@ -2325,6 +2339,7 @@ export const useQueryStore = defineStore("query", () => { openNacosAdmin, openTableStructure, linkSavedSql, + linkExternalSqlPath, openSavedSql, togglePinnedTab, reorderTab, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index ae4f7b4db0..95b2f3ca71 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -559,6 +559,7 @@ export interface QueryTab { schema?: string; sql: string; savedSqlId?: string; + externalSqlPath?: string; originalSql?: string; lastExecutedSql?: string; resultBaseSql?: string; diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 055d7d2ae5..a575418197 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -92,6 +92,52 @@ test("renames query tab titles", () => { assert.equal(tab?.customTitle, true); }); +test("linkExternalSqlPath records the local path and detaches saved SQL", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const tabId = store.createTab("conn-1", "db", "draft.sql"); + + store.updateSql(tabId, "select 1;"); + store.linkSavedSql(tabId, "saved-1", "library.sql"); + store.linkExternalSqlPath(tabId, "/tmp/draft.sql", "draft.sql"); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(tab?.externalSqlPath, "/tmp/draft.sql"); + assert.equal(tab?.savedSqlId, undefined); + assert.equal(tab?.title, "draft.sql"); + assert.equal(store.isTabDirty(tab!), false); + + store.updateSql(tabId, ""); + assert.equal(store.isTabDirty(tab!), true); +}); + +test("external SQL file paths persist with open query tabs", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + let store = useQueryStore(); + const tabId = store.createTab("conn-1", "db", "draft.sql"); + store.updateSql(tabId, "select 1;"); + store.linkExternalSqlPath(tabId, "/tmp/draft.sql", "draft.sql"); + + await waitFor(() => !!globalThis.localStorage.getItem("dbx-open-tabs"), 1000); + + setActivePinia(createPinia()); + store = useQueryStore(); + const tab = store.tabs.find((item) => item.id === tabId); + + assert.equal(tab?.externalSqlPath, "/tmp/draft.sql"); + assert.equal(tab?.savedSqlId, undefined); + assert.equal(tab?.sql, "select 1;"); + assert.equal(store.isTabDirty(tab!), false); + + store.updateSql(tabId, "select 2;"); + assert.equal(store.isTabDirty(tab!), true); + } finally { + restoreStorage(); + } +}); + test("marked-clean object source tabs close without unsaved confirmation", () => { setActivePinia(createPinia()); const store = useQueryStore(); diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index 64b1f477e1..c7e91979a4 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -16,6 +16,11 @@ pub async fn read_external_sql_file(path: String) -> Result { read_external_sql_file_content_async(PathBuf::from(path)).await } +#[tauri::command] +pub async fn write_external_sql_file(path: String, content: String) -> Result<(), String> { + write_external_sql_file_content_async(PathBuf::from(path), content).await +} + #[derive(Default)] pub struct ExternalSqlOpenState { pending: Mutex>, @@ -79,6 +84,21 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result Result<(), String> { + if !is_sql_file_path(path) { + return Err("Only .sql files can be saved this way".to_string()); + } + std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}")) +} + +async fn write_external_sql_file_content_async(path: PathBuf, content: String) -> Result<(), String> { + if !is_sql_file_path(&path) { + return Err("Only .sql files can be saved this way".to_string()); + } + tokio::fs::write(&path, content).await.map_err(|e| format!("Failed to save SQL file: {e}")) +} + fn dedupe_paths(paths: Vec) -> Vec { let mut unique = Vec::new(); for path in paths { @@ -148,4 +168,26 @@ mod tests { let _ = std::fs::remove_file(&path); assert!(result.unwrap_err().contains(".sql")); } + + #[test] + fn writes_external_sql_file_content() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); + + let result = write_external_sql_file_content(&path, "select 2;"); + + let content = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(result.is_ok()); + assert_eq!(content, "select 2;"); + } + + #[test] + fn rejects_external_non_sql_file_save() { + let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4())); + + let result = write_external_sql_file_content(&path, "select 2;"); + + assert!(result.unwrap_err().contains(".sql")); + assert!(!path.exists()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 370bdb4f76..ec67051253 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -535,6 +535,7 @@ pub fn run() { commands::sql_file::cancel_sql_file_execution, commands::external_sql::pending_open_sql_files, commands::external_sql::read_external_sql_file, + commands::external_sql::write_external_sql_file, commands::external_db::pending_open_db_files, commands::keychain::read_keychain_password, commands::keychain::read_keychain_passwords,