From 184e2359996f973eb04ef07a213d6213198cda3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E9=A2=97=E7=BA=A2=E5=BF=83?= Date: Tue, 7 Jul 2026 18:09:01 +0800 Subject: [PATCH] feat: synchronize tab titles with saved SQL file renaming (#2739) - Bidirectional sync between SQL library and tab titles - Case-insensitive .sql extension via shared ensureSqlExtension - Revert tab title when rename persistence fails Close #2648 Co-authored-by: Cursor --- .../src/components/layout/SqlLibraryPanel.vue | 11 +- .../savedSql/savedSqlFileName.spec.ts | 20 ++++ .../src/lib/savedSql/savedSqlFileName.ts | 9 ++ apps/desktop/src/stores/queryStore.ts | 15 ++- apps/desktop/src/stores/savedSqlStore.ts | 14 ++- packages/app-tests/savedSqlStore.test.ts | 108 ++++++++++++++++++ 6 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts create mode 100644 apps/desktop/src/lib/savedSql/savedSqlFileName.ts diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 4a1c520dd..280961abf 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -16,6 +16,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts"; +import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import type { SavedSqlFile, SavedSqlFolder } from "@/types/database"; const { t } = useI18n(); @@ -73,18 +74,10 @@ function importConnectionIdForFolder(folder?: SavedSqlFolder) { return folder?.connectionId || activeImportConnectionId(); } -function ensureSqlExtension(name: string) { - return /\.sql$/i.test(name) ? name : `${name}.sql`; -} - function sanitizeFileSystemSegment(name: string) { return name.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_").trim() || "untitled"; } -function stripSqlExtension(name: string) { - return name.replace(/\.sql$/i, ""); -} - function relativeImportName(baseDir: string, filePath: string) { const normalizedBase = baseDir.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedFile = filePath.replace(/\\/g, "/"); @@ -596,7 +589,7 @@ async function confirmRename() { if (type === "folder") { await savedSqlStore.renameFolder(id, name); } else { - await savedSqlStore.renameFile(id, name.endsWith(".sql") ? name : `${name}.sql`); + await savedSqlStore.renameFile(id, ensureSqlExtension(name)); } } diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts new file mode 100644 index 000000000..b23cbd5d6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlFileName.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; + +describe("savedSqlFileName", () => { + it("appends .sql when missing", () => { + expect(ensureSqlExtension("report")).toBe("report.sql"); + }); + + it("preserves lowercase .sql extension", () => { + expect(ensureSqlExtension("report.sql")).toBe("report.sql"); + }); + + it("preserves uppercase .SQL extension without double-appending", () => { + expect(ensureSqlExtension("report.SQL")).toBe("report.SQL"); + }); + + it("strips .sql extension case-insensitively", () => { + expect(stripSqlExtension("report.SQL")).toBe("report"); + }); +}); diff --git a/apps/desktop/src/lib/savedSql/savedSqlFileName.ts b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts new file mode 100644 index 000000000..9cca7f96d --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlFileName.ts @@ -0,0 +1,9 @@ +export function ensureSqlExtension(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return trimmed; + return /\.sql$/i.test(trimmed) ? trimmed : `${trimmed}.sql`; +} + +export function stripSqlExtension(name: string): string { + return name.replace(/\.sql$/i, ""); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ac6257525..eb20601de 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -46,6 +46,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; +import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; import type { SavedSqlFile } from "@/types/database"; @@ -1468,8 +1469,20 @@ export const useQueryStore = defineStore("query", () => { if (!trimmed) return false; const tab = tabs.value.find((t) => t.id === id); if (!tab || tab.mode !== "query") return false; - tab.title = trimmed; + const normalizedTitle = tab.savedSqlId ? ensureSqlExtension(trimmed) : trimmed; + const previousTitle = tab.title; + tab.title = normalizedTitle; tab.customTitle = true; + if (tab.savedSqlId) { + const savedSqlStore = useSavedSqlStore(); + const existing = savedSqlStore.getFile(tab.savedSqlId); + if (existing && existing.name !== normalizedTitle) { + void savedSqlStore.renameFile(tab.savedSqlId, normalizedTitle).catch((error) => { + console.warn("[DBX][saved-sql:rename:error]", error); + tab.title = previousTitle; + }); + } + } return true; } diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index 314eedd94..417e72a5a 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -3,6 +3,7 @@ import { computed, ref } from "vue"; import { uuid } from "@/lib/common/utils"; import * as api from "@/lib/backend/api"; import { forgetSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; +import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useSettingsStore } from "@/stores/settingsStore"; import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "@/types/database"; @@ -258,9 +259,20 @@ export const useSavedSqlStore = defineStore("savedSql", () => { async function renameFile(id: string, name: string) { const existing = getFile(id); if (!existing) return; - const saved = await api.saveSavedSqlFile({ ...existing, name, updatedAt: nowIso() }); + const normalizedName = ensureSqlExtension(name); + const saved = await api.saveSavedSqlFile({ ...existing, name: normalizedName, updatedAt: nowIso() }); files.value = files.value.map((file) => (file.id === id ? { ...saved, sql: file.sql, sqlLoaded: file.sqlLoaded } : file)); bumpVersion(); + + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + for (const tab of queryStore.tabs) { + if (tab.savedSqlId === id) { + tab.title = saved.name; + tab.customTitle = true; + } + } + await syncToLocalDirectory(); } diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index 450c36184..d90d4c24f 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, test, vi } from "vitest"; import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "../../apps/desktop/src/types/database.ts"; import { useSavedSqlStore } from "../../apps/desktop/src/stores/savedSqlStore.ts"; +import { useQueryStore } from "../../apps/desktop/src/stores/queryStore.ts"; const apiMock = vi.hoisted(() => ({ loadSavedSqlLibrary: vi.fn<() => Promise>(), @@ -227,3 +228,110 @@ test("moving selected files already in the target folder keeps them in place", a ["sql-2", "sql-1"], ); }); + +test("renaming a saved SQL file syncs linked tab titles", async () => { + const file: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "draft.sql", + database: "db", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] }); + + const savedSqlStore = useSavedSqlStore(); + await savedSqlStore.initFromStorage(); + + const queryStore = useQueryStore(); + const tabId = queryStore.openSavedSql(file); + const tab = queryStore.tabs.find((item) => item.id === tabId); + assert.equal(tab?.title, "draft.sql"); + + await savedSqlStore.renameFile("sql-1", "revenue.sql"); + + assert.equal(savedSqlStore.getFile("sql-1")?.name, "revenue.sql"); + assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.title, "revenue.sql"); +}); + +test("renaming a saved SQL tab syncs the library file name", async () => { + const file: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "draft.sql", + database: "db", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] }); + + const savedSqlStore = useSavedSqlStore(); + await savedSqlStore.initFromStorage(); + + const queryStore = useQueryStore(); + const tabId = queryStore.openSavedSql(file); + + assert.equal(queryStore.renameTab(tabId, " Revenue checks "), true); + await Promise.resolve(); + + assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.title, "Revenue checks.sql"); + assert.equal(savedSqlStore.getFile("sql-1")?.name, "Revenue checks.sql"); + assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].name, "Revenue checks.sql"); +}); + +test("renaming a saved SQL tab keeps uppercase .SQL extension without double-appending", async () => { + const file: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "report.SQL", + database: "db", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] }); + + const savedSqlStore = useSavedSqlStore(); + await savedSqlStore.initFromStorage(); + + const queryStore = useQueryStore(); + const tabId = queryStore.openSavedSql(file); + + assert.equal(queryStore.renameTab(tabId, "report.SQL"), true); + await Promise.resolve(); + + assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.title, "report.SQL"); + assert.equal(savedSqlStore.getFile("sql-1")?.name, "report.SQL"); + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0); +}); + +test("renaming a saved SQL tab reverts title when persistence fails", async () => { + const file: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "draft.sql", + database: "db", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] }); + + const savedSqlStore = useSavedSqlStore(); + await savedSqlStore.initFromStorage(); + + const queryStore = useQueryStore(); + const tabId = queryStore.openSavedSql(file); + + apiMock.saveSavedSqlFile.mockRejectedValueOnce(new Error("disk full")); + assert.equal(queryStore.renameTab(tabId, "broken"), true); + await vi.waitFor(() => queryStore.tabs.find((item) => item.id === tabId)?.title === "draft.sql"); + + assert.equal(savedSqlStore.getFile("sql-1")?.name, "draft.sql"); +});