From ca003c5e9300c0b737db0ca99215c66d70eab675 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Fri, 4 Sep 2026 14:45:04 +0800 Subject: [PATCH] fix(tabs): restore saved sql library content in detached windows --- .../__tests__/queryStore.detachedTab.spec.ts | 96 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 15 +++ 2 files changed, 111 insertions(+) create mode 100644 apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts diff --git a/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts new file mode 100644 index 0000000000..1c3d326778 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.detachedTab.spec.ts @@ -0,0 +1,96 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DetachedTabHandoff } from "@/lib/app/detachedTabHandoff"; + +const mocks = vi.hoisted(() => ({ + loadSavedSqlFile: vi.fn(), +})); + +vi.mock("@/lib/backend/api", async (importOriginal) => ({ + ...(await importOriginal()), + loadSavedSqlFile: mocks.loadSavedSqlFile, +})); + +describe("queryStore adoptDetachedTab", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + mocks.loadSavedSqlFile.mockReset(); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + setActivePinia(createPinia()); + }); + + function handoffFor(overrides: Partial = {}): DetachedTabHandoff { + return { + schemaVersion: 1, + tabId: "tab-1", + sourceWindowLabel: "main", + revision: 1, + tab: { + id: "tab-1", + title: "query_1.sql", + customTitle: true, + connectionId: "conn-1", + database: "main", + // A clean saved-SQL-library tab serializes `sql` as "" (see openTabsPersistence's + // shouldPersistTabSql) to avoid duplicating on-disk state across app restarts. + sql: "", + savedSqlId: "saved-1", + mode: "query", + ...overrides, + }, + runtime: {}, + updatedAt: Date.now(), + }; + } + + it("hydrates a clean saved-SQL-library tab's content from the backend instead of opening empty", async () => { + mocks.loadSavedSqlFile.mockResolvedValue({ + id: "saved-1", + connectionId: "conn-1", + name: "query_1.sql", + database: "main", + sql: "SELECT 1;", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }); + + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + + const tabId = await queryStore.adoptDetachedTab(handoffFor()); + + expect(mocks.loadSavedSqlFile).toHaveBeenCalledWith("saved-1"); + const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!; + expect(tab.sql).toBe("SELECT 1;"); + expect(tab.originalSql).toBe("SELECT 1;"); + expect(queryStore.isTabDirty(tab)).toBe(false); + }); + + it("does not touch the backend when the handoff already carries unsaved edits", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + + const tabId = await queryStore.adoptDetachedTab(handoffFor({ sql: "SELECT 2;", originalSql: "SELECT 1;" })); + + expect(mocks.loadSavedSqlFile).not.toHaveBeenCalled(); + const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!; + expect(tab.sql).toBe("SELECT 2;"); + expect(queryStore.isTabDirty(tab)).toBe(true); + }); + + it("leaves an unsaved (never-linked-to-library) tab's empty content as-is", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + + const tabId = await queryStore.adoptDetachedTab(handoffFor({ savedSqlId: undefined })); + + expect(mocks.loadSavedSqlFile).not.toHaveBeenCalled(); + const tab = queryStore.tabs.find((candidate) => candidate.id === tabId)!; + expect(tab.sql).toBe(""); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f6c28e9358..8d2bd48f0a 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2376,6 +2376,21 @@ export const useQueryStore = defineStore("query", () => { const restored = restoreOpenTabsPayload({ tabs: [handoff.tab], activeTabId: handoff.tabId }); const restoredTab = restored.tabs[0]; if (!restoredTab) throw new Error("Unable to restore detached tab"); + // serializeOpenTabs() blanks `sql` for a clean saved-SQL-library tab to avoid + // duplicating on-disk state across app restarts, relying on hydrateSavedSqlTabs() + // to refill it afterwards. The detach handoff goes through the same serialization + // but skips that hydration step, so do it here too or the new window opens empty. + // A freshly created detached window never runs the main-window bootstrap that + // populates savedSqlStore's local file index, so useSavedSqlStore().ensureFileContent() + // can't find the file locally either — go straight to the backend instead. + if (restoredTab.savedSqlId && restoredTab.mode === "query" && !restoredTab.sql) { + const file = await api.loadSavedSqlFile(restoredTab.savedSqlId).catch(() => undefined); + if (file) { + restoredTab.title = restoredTab.customTitle ? restoredTab.title : file.name; + restoredTab.sql = file.sql; + restoredTab.originalSql = file.sql; + } + } Object.assign(restoredTab, handoff.runtime); if (handoff.resultCacheKey) { restoredTab.resultCacheKey = handoff.resultCacheKey;