diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 1d26b7d96f..a70a9929dd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -917,9 +917,7 @@ 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, []) : ""); - const tabId = queryStore.createTab(connectionId, database, sqlFileTitleFromPath(path), "query"); - queryStore.updateSql(tabId, content); - queryStore.linkExternalSqlPath(tabId, path, sqlFileTitleFromPath(path)); + queryStore.openExternalSqlFile(connectionId, database, path, content); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index 45c31f0cc8..fcc161988b 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -216,10 +216,7 @@ 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, []) : ""; - const name = path.split("\\").pop()?.split("/").pop() || "script.sql"; - const tabId = queryStore.createTab(connectionId, database, name, "query"); - queryStore.updateSql(tabId, content); - queryStore.linkExternalSqlPath(tabId, path, name); + queryStore.openExternalSqlFile(connectionId, database, path, content); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); } diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index c3039a28d2..67172a05a0 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -26,9 +26,12 @@ export function useFileDrop() { 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); + if (path) { + queryStore.openExternalSqlFile(connectionId, database, path, content); + } else { + const tabId = queryStore.createTab(connectionId, database, name, "query"); + queryStore.updateSql(tabId, content); + } toast(t("welcome.fileOpened", { name })); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4c6d8346e7..99ffa8b54f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -581,6 +581,7 @@ export default { users: "Users & Privileges", executionSummary: "Summary", tooltipTitle: "Title:", + tooltipFilePath: "File Path:", tooltipConnection: "Connection:", tooltipDatabase: "Database:", tooltipTable: "Table:", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a764bb8d6f..48b902b40f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -559,6 +559,7 @@ export default withEnglishFallback({ users: "ユーザーと権限", executionSummary: "サマリー", tooltipTitle: "タイトル:", + tooltipFilePath: "ファイルパス:", tooltipConnection: "接続:", tooltipDatabase: "データベース:", tooltipTable: "テーブル:", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c1e7200c30..8c578ee643 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -562,6 +562,7 @@ export default withEnglishFallback({ users: "Usuários e Privilégios", executionSummary: "Resumo", tooltipTitle: "Título:", + tooltipFilePath: "Caminho do arquivo:", tooltipConnection: "Conexão:", tooltipDatabase: "Banco de dados:", tooltipTable: "Tabela:", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 03a61720e0..0ce032bed3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -582,6 +582,7 @@ export default withEnglishFallback({ users: "用户与权限", executionSummary: "摘要", tooltipTitle: "标题:", + tooltipFilePath: "文件路径:", tooltipConnection: "连接:", tooltipDatabase: "数据库:", tooltipTable: "表:", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index cac5dfbd58..f17faeec13 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -561,6 +561,7 @@ export default withEnglishFallback({ users: "使用者與權限", executionSummary: "摘要", tooltipTitle: "標題:", + tooltipFilePath: "檔案路徑:", tooltipConnection: "連線:", tooltipDatabase: "資料庫:", tooltipTable: "資料表:", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts new file mode 100644 index 0000000000..80e8822c2b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; + +describe("external SQL file paths", () => { + it("normalizes Windows separators for identity checks", () => { + expect(normalizeExternalSqlPath(" C:\\work\\demo.sql ")).toBe("C:/work/demo.sql"); + }); + + it("uses the shortest unique parent path for duplicate filenames", () => { + expect(externalSqlFileDisplayTitles(["/work/demo/create.sql", "/work/learn/create.sql", "/work/query.sql"])).toEqual(["demo/create.sql", "learn/create.sql", "query.sql"]); + }); + + it("adds more parent segments when immediate parents also collide", () => { + expect(externalSqlFileDisplayTitles(["/one/sql/create.sql", "/two/sql/create.sql"])).toEqual(["one/sql/create.sql", "two/sql/create.sql"]); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlFileOpen.ts b/apps/desktop/src/lib/sql/sqlFileOpen.ts index c5508d69f7..8926e30179 100644 --- a/apps/desktop/src/lib/sql/sqlFileOpen.ts +++ b/apps/desktop/src/lib/sql/sqlFileOpen.ts @@ -3,12 +3,46 @@ export function isSqlFilePath(path: string): boolean { } export function sqlFileTitleFromPath(path: string): string { - const trimmed = path.trim(); - const normalized = trimmed.replace(/\\/g, "/"); + const normalized = normalizeExternalSqlPath(path); const name = normalized.split("/").filter(Boolean).pop(); return name || "script.sql"; } +export function normalizeExternalSqlPath(path: string): string { + const normalized = path + .trim() + .replace(/\\/g, "/") + .replace(/\/{2,}/g, "/"); + return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized; +} + +export function externalSqlFileDisplayTitles(paths: string[]): string[] { + const segments = paths.map((path) => normalizeExternalSqlPath(path).split("/").filter(Boolean)); + const titles = segments.map((parts) => parts[parts.length - 1] || "script.sql"); + const collisions = new Map(); + + titles.forEach((title, index) => { + const indexes = collisions.get(title) ?? []; + indexes.push(index); + collisions.set(title, indexes); + }); + + for (const indexes of collisions.values()) { + if (indexes.length < 2) continue; + const maxDepth = Math.max(...indexes.map((index) => segments[index].length)); + for (let depth = 2; depth <= maxDepth; depth++) { + const candidates = indexes.map((index) => segments[index].slice(-depth).join("/")); + if (new Set(candidates).size !== candidates.length) continue; + indexes.forEach((index, candidateIndex) => { + titles[index] = candidates[candidateIndex]; + }); + break; + } + } + + return titles; +} + export function externalSqlFilePaths(paths: string[]): string[] { return paths.filter(isSqlFilePath); } diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index 2e5f066c89..9802f0102f 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -117,6 +117,9 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v if (tab.mode === "query" && queryTitle(tab)) { lines.unshift({ label: t("tabs.tooltipTitle"), value: tab.title }); } + if (tab.mode === "query" && tab.externalSqlPath) { + lines.push({ label: t("tabs.tooltipFilePath"), value: tab.externalSqlPath }); + } if (tab.mode === "data" && tab.tableMeta?.tableName) { lines.push({ label: t("tabs.tooltipTable"), value: tab.tableMeta.tableName }); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ef4cb0b692..423c661cbe 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -41,6 +41,7 @@ import { queryResultSourceLabel } from "@/lib/sql/queryResultSource"; import { sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; +import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { queryResultBaseSql, queryResultExecutionSql } from "@/lib/tabs/tabPresentation"; @@ -873,6 +874,46 @@ export const useQueryStore = defineStore("query", () => { return id; } + function refreshExternalSqlFileTitles() { + const externalTabs = tabs.value.filter((tab) => tab.mode === "query" && tab.externalSqlPath); + const titles = externalSqlFileDisplayTitles(externalTabs.map((tab) => tab.externalSqlPath!)); + externalTabs.forEach((tab, index) => { + tab.title = titles[index]; + tab.customTitle = true; + }); + } + + function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string) { + const normalizedPath = normalizeExternalSqlPath(path); + const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath); + if (existing) { + switchTab(existing.id); + return existing.id; + } + + // File-backed tabs are identified by their full path, not their basename. + // Bypassing createTab avoids overwriting another file with the same name. + const id = uuid(); + const tab: QueryTab = { + id, + title: "", + customTitle: true, + connectionId, + database, + sql, + originalSql: sql, + externalSqlPath: path, + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "query", + }; + tabs.value.push(tab); + activeTabId.value = id; + refreshExternalSqlFileTitles(); + return id; + } + function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string) { const title = catalog ? `${catalog}.${database} objects` : schema ? `${schema} objects` : `${database} objects`; const existing = tabs.value.find((tab) => tab.mode === "objects" && tab.connectionId === connectionId && tab.database === database && (tab.objectBrowser?.catalog || "") === (catalog || "") && (tab.objectBrowser?.schema || "") === (schema || "")); @@ -1257,6 +1298,7 @@ export const useQueryStore = defineStore("query", () => { clearResultRunSnapshots(tabs.value[idx]); clearResultPayload(tabs.value[idx]); tabs.value.splice(idx, 1); + if (tab.externalSqlPath) refreshExternalSqlFileTitles(); if (activeTabId.value === id) { activeTabId.value = fallbackActiveTabAfterClose(id, idx); } @@ -1749,6 +1791,7 @@ export const useQueryStore = defineStore("query", () => { tab.customTitle = true; } markTabClean(tab); + refreshExternalSqlFileTitles(); } function openSavedSql(file: SavedSqlFile) { @@ -3585,6 +3628,7 @@ export const useQueryStore = defineStore("query", () => { openTableStructure, linkSavedSql, linkExternalSqlPath, + openExternalSqlFile, openSavedSql, hydrateSavedSqlTabs, togglePinnedTab, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index daf537113d..b4ad12ecbd 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -145,6 +145,45 @@ test("linkExternalSqlPath records the local path and detaches saved SQL", () => assert.equal(store.isTabDirty(tab!), true); }); +test("external SQL files use full paths as tab identity", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + + const demoId = store.openExternalSqlFile("conn-1", "db", "/work/demo/create.sql", "select 'demo';"); + const learnId = store.openExternalSqlFile("conn-1", "db", "/work/learn/create.sql", "select 'learn';"); + + assert.notEqual(demoId, learnId); + assert.equal(store.tabs.find((tab) => tab.id === demoId)?.title, "demo/create.sql"); + assert.equal(store.tabs.find((tab) => tab.id === learnId)?.title, "learn/create.sql"); + assert.equal(store.tabs.find((tab) => tab.id === demoId)?.sql, "select 'demo';"); + assert.equal(store.tabs.find((tab) => tab.id === learnId)?.sql, "select 'learn';"); +}); + +test("reopening an external SQL path preserves unsaved editor content", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const tabId = store.openExternalSqlFile("conn-1", "db", "C:\\work\\draft.sql", "select 1;"); + store.updateSql(tabId, "select 2;"); + + const reopenedId = store.openExternalSqlFile("conn-2", "other", "C:/work/draft.sql", "select 3;"); + + assert.equal(reopenedId, tabId); + assert.equal(store.tabs.length, 1); + assert.equal(store.tabs[0].sql, "select 2;"); + assert.equal(store.tabs[0].connectionId, "conn-1"); +}); + +test("external SQL titles collapse after a duplicate filename tab closes", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + const firstId = store.openExternalSqlFile("conn-1", "db", "/work/demo/create.sql", "select 1;"); + const secondId = store.openExternalSqlFile("conn-1", "db", "/work/learn/create.sql", "select 2;"); + + store.closeTab(secondId, { force: true }); + + assert.equal(store.tabs.find((tab) => tab.id === firstId)?.title, "create.sql"); +}); + test("external SQL file paths persist with open query tabs", async () => { const restoreStorage = installMemoryStorage(); try {