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 <cursoragent@cursor.com>
This commit is contained in:
一颗红心 2026-07-07 18:09:01 +08:00 committed by GitHub
parent b49638ff1e
commit 184e235999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 166 additions and 11 deletions

View File

@ -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));
}
}

View File

@ -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");
});
});

View File

@ -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, "");
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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<SavedSqlLibrary>>(),
@ -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");
});