diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 4aeff47a6a..0c0156c59e 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -52,6 +52,7 @@ import { uuid } from "@/lib/common/utils"; import { isMacOS, isWindows } from "@/lib/backend/platform"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; +import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, ObjectSourceKind, QueryTab, SavedSqlFile } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; @@ -818,6 +819,7 @@ async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: bo if (!tab.externalSqlPath || !isTauriRuntime()) return false; try { await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql); + rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database }); queryStore.markTabClean(tab); toast(t("savedSql.saved"), 2000); if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true }); @@ -1081,6 +1083,7 @@ async function saveActiveSqlAsLocalFile() { const path = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql); if (!path) return; queryStore.linkExternalSqlPath(tab.id, path, sqlFileTitleFromPath(path)); + rememberExternalSqlFileTarget(path, { connectionId: tab.connectionId, database: tab.database }); invalidateSaveSqlFolderSelection(); showSaveSqlDialog.value = false; closePendingSavedTab(); @@ -1090,6 +1093,18 @@ async function saveActiveSqlAsLocalFile() { } } +function applyExternalSqlFileTarget(tab: QueryTab, path: string) { + const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { + connectionId: tab.connectionId, + database: tab.database, + }); + if (target.connectionId !== tab.connectionId) { + queryStore.updateConnection(tab.id, target.connectionId, target.database); + } else if (target.database !== tab.database) { + queryStore.updateDatabase(tab.id, target.database); + } +} + async function openSqlFile() { const tab = activeTab.value; if (!tab) return; @@ -1105,6 +1120,7 @@ async function openSqlFile() { const content = await api.readExternalSqlFile(sqlPath); queryStore.updateSql(tab.id, content); queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath)); + applyExternalSqlFileTarget(tab, sqlPath); } } else { const input = document.createElement("input"); @@ -1159,7 +1175,8 @@ async function openSqlFilePath(path: string) { const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = activeTab.value?.database || (connection ? resolveDefaultDatabase(connection, []) : ""); - queryStore.openExternalSqlFile(connectionId, database, path, content); + const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId, database }); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, content); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index c97e94850b..d3af6ee4be 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -12,6 +12,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { translateBackendError } from "@/i18n/backend-errors"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; import * as api from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api"; @@ -211,7 +212,8 @@ async function openFile(path: string) { const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined; const database = connection ? resolveDefaultDatabase(connection, []) : ""; - queryStore.openExternalSqlFile(connectionId, database, path, content); + const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId, database }); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, content); } catch (e: any) { if (isExternalSqlFileTooLargeError(e)) { executeFile(path); diff --git a/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts b/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts new file mode 100644 index 0000000000..3b5aa9d72a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/externalSqlFileTarget.spec.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it } from "vitest"; +import { EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, MAX_EXTERNAL_SQL_FILE_TARGETS, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; + +describe("external SQL file targets", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("restores the saved data source across normalized file paths", () => { + rememberExternalSqlFileTarget(" C:\\work\\report.sql ", { connectionId: "saved-connection", database: "analytics" }); + + expect(resolveExternalSqlFileTarget("C:/work/report.sql", () => true, { connectionId: "fallback", database: "default" })).toEqual({ + connectionId: "saved-connection", + database: "analytics", + }); + }); + + it("falls back when the saved connection no longer exists", () => { + rememberExternalSqlFileTarget("/work/report.sql", { connectionId: "deleted-connection", database: "analytics" }); + + expect(resolveExternalSqlFileTarget("/work/report.sql", () => false, { connectionId: "fallback", database: "default" })).toEqual({ + connectionId: "fallback", + database: "default", + }); + }); + + it("ignores malformed persisted state", () => { + localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, "not-json"); + + expect(resolveExternalSqlFileTarget("/work/report.sql", () => true, { connectionId: "fallback", database: "default" })).toEqual({ + connectionId: "fallback", + database: "default", + }); + }); + + it("keeps only the most recently saved targets", () => { + for (let index = 0; index <= MAX_EXTERNAL_SQL_FILE_TARGETS; index += 1) { + rememberExternalSqlFileTarget(`/work/report-${index}.sql`, { connectionId: `connection-${index}`, database: `database-${index}` }); + } + + expect(resolveExternalSqlFileTarget("/work/report-0.sql", () => true, { connectionId: "fallback", database: "default" })).toEqual({ + connectionId: "fallback", + database: "default", + }); + expect(resolveExternalSqlFileTarget(`/work/report-${MAX_EXTERNAL_SQL_FILE_TARGETS}.sql`, () => true, { connectionId: "fallback", database: "default" })).toEqual({ + connectionId: `connection-${MAX_EXTERNAL_SQL_FILE_TARGETS}`, + database: `database-${MAX_EXTERNAL_SQL_FILE_TARGETS}`, + }); + }); +}); diff --git a/apps/desktop/src/lib/sql/externalSqlFileTarget.ts b/apps/desktop/src/lib/sql/externalSqlFileTarget.ts new file mode 100644 index 0000000000..831be5b374 --- /dev/null +++ b/apps/desktop/src/lib/sql/externalSqlFileTarget.ts @@ -0,0 +1,48 @@ +import { normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; + +export const EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY = "dbx-external-sql-file-targets-v1"; +export const MAX_EXTERNAL_SQL_FILE_TARGETS = 200; + +export interface ExternalSqlFileTarget { + connectionId: string; + database: string; +} + +interface StoredExternalSqlFileTarget extends ExternalSqlFileTarget { + path: string; + updatedAt: number; +} + +function loadExternalSqlFileTargets(): StoredExternalSqlFileTarget[] { + try { + const parsed = JSON.parse(localStorage.getItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY) || "[]"); + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is StoredExternalSqlFileTarget => typeof item?.path === "string" && typeof item?.connectionId === "string" && typeof item?.database === "string" && typeof item?.updatedAt === "number"); + } catch { + return []; + } +} + +function saveExternalSqlFileTargets(targets: StoredExternalSqlFileTarget[]) { + try { + localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, JSON.stringify(targets)); + } catch {} +} + +export function rememberExternalSqlFileTarget(path: string, target: ExternalSqlFileTarget) { + const normalizedPath = normalizeExternalSqlPath(path); + if (!normalizedPath) return; + const remaining = loadExternalSqlFileTargets().filter((item) => item.path !== normalizedPath); + if (!target.connectionId) { + saveExternalSqlFileTargets(remaining); + return; + } + saveExternalSqlFileTargets([{ path: normalizedPath, ...target, updatedAt: Date.now() }, ...remaining].slice(0, MAX_EXTERNAL_SQL_FILE_TARGETS)); +} + +export function resolveExternalSqlFileTarget(path: string, connectionExists: (connectionId: string) => boolean, fallback: ExternalSqlFileTarget): ExternalSqlFileTarget { + const normalizedPath = normalizeExternalSqlPath(path); + const saved = loadExternalSqlFileTargets().find((item) => item.path === normalizedPath); + if (!saved || !connectionExists(saved.connectionId)) return fallback; + return { connectionId: saved.connectionId, database: saved.database }; +} diff --git a/packages/app-tests/externalSqlFileTargetPersistence.test.ts b/packages/app-tests/externalSqlFileTargetPersistence.test.ts new file mode 100644 index 0000000000..6f7ce60fbb --- /dev/null +++ b/packages/app-tests/externalSqlFileTargetPersistence.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; + +const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); +const sqlFilePanelSource = readFileSync("apps/desktop/src/components/layout/SqlFilePanel.vue", "utf8"); + +function functionSource(source: string, startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0 && end > start); + return source.slice(start, end); +} + +test("external SQL saves persist their selected data source before closing", () => { + const source = functionSource(appSource, "async function saveExternalSqlPath", "function savedSqlTargetForSave"); + const write = source.indexOf("api.writeExternalSqlFile"); + const remember = source.indexOf("rememberExternalSqlFileTarget"); + const close = source.indexOf("queryStore.closeTab"); + + assert.ok(write >= 0); + assert.ok(write < remember); + assert.ok(remember < close); +}); + +test("external SQL open entry points restore a saved data source", () => { + const startupOpen = functionSource(appSource, "async function openSqlFilePath", "async function openPendingSqlFiles"); + const startupResolve = startupOpen.indexOf("resolveExternalSqlFileTarget"); + const startupTabOpen = startupOpen.indexOf("queryStore.openExternalSqlFile"); + assert.ok(startupResolve >= 0); + assert.ok(startupResolve < startupTabOpen); + + const panelOpen = functionSource(sqlFilePanelSource, "async function openFile", "function executeFile"); + const panelResolve = panelOpen.indexOf("resolveExternalSqlFileTarget"); + const panelTabOpen = panelOpen.indexOf("queryStore.openExternalSqlFile"); + assert.ok(panelResolve >= 0); + assert.ok(panelResolve < panelTabOpen); + + const pickerOpen = functionSource(appSource, "async function openSqlFile()", "async function importResultArchive"); + assert.ok(pickerOpen.includes("applyExternalSqlFileTarget(tab, sqlPath)")); +});