From c4b21cf66626de0d682db56e139aed348dae18d6 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 27 Jul 2026 20:02:39 +0800 Subject: [PATCH] fix(navigation): create independent table target tabs --- .../__tests__/useNavigationTargets.spec.ts | 53 +---- .../useNavigationTargets.store.spec.ts | 195 ++++++++++++++++++ .../src/composables/useNavigationTargets.ts | 19 +- apps/desktop/src/stores/queryStore.ts | 4 +- 4 files changed, 209 insertions(+), 62 deletions(-) create mode 100644 apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index aabdb2f0b1..c3cea8eae0 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -5,7 +5,6 @@ import type { QueryTab } from "@/types/database"; const mocks = vi.hoisted(() => ({ tabs: [] as QueryTab[], - reuseDataTab: true, activeTabId: "", databaseType: "postgres" as string, ensureConnected: vi.fn(), @@ -78,7 +77,7 @@ vi.mock("@/stores/queryStore", () => ({ })); vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ editorSettings: { reuseDataTab: mocks.reuseDataTab } }), + useSettingsStore: () => ({ editorSettings: { reuseDataTab: true } }), })); vi.mock("@/lib/table/tableSelectSql", () => ({ @@ -100,7 +99,6 @@ describe("useNavigationTargets openTableTarget", () => { clearTableMetadataCache(); vi.clearAllMocks(); mocks.tabs.length = 0; - mocks.reuseDataTab = true; mocks.databaseType = "postgres"; mocks.ensureConnected.mockResolvedValue(undefined); mocks.executeTabSql.mockResolvedValue(undefined); @@ -138,8 +136,6 @@ describe("useNavigationTargets openTableTarget", () => { await navigation.openLineageTarget(target); expect(mocks.getColumns).toHaveBeenCalledTimes(1); - const firstTab = mocks.tabs[0]!; - mocks.tabs.push({ ...firstTab, id: "tab-2", tableMeta: { ...firstTab.tableMeta! } }); mocks.getColumns.mockResolvedValueOnce([column("fresh_id")]); await navigation.onStructureEditorSaved(vi.fn().mockResolvedValue(undefined), vi.fn(), { @@ -172,8 +168,7 @@ describe("useNavigationTargets openTableTarget", () => { expect(mocks.getColumns.mock.calls.some(([connectionId, database, schema, tableName]) => connectionId === "connection-1" && database === "analytics" && schema === "analytics" && tableName === "events")).toBe(true); }); - it("does not let a stale navigation land metadata over a newer target on a reused tab", async () => { - // A 的 getColumns 挂起;B 复用同一 tab 后 A 才返回 + it("opens different targets in separate tabs even when sidebar data-tab reuse is enabled", async () => { const columnGates = new Map void>(); mocks.getColumns.mockImplementation( (_connectionId: string, _database: string, _schema: string, tableName: string) => @@ -182,25 +177,19 @@ describe("useNavigationTargets openTableTarget", () => { }), ); const navigation = useNavigationTargets(dialogs); - const openA = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" }); + const openA = navigation.openTableTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" }); await vi.waitFor(() => expect(columnGates.has("table_a")).toBe(true)); - const openB = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" }); + const openB = navigation.openTableTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" }); await vi.waitFor(() => expect(columnGates.has("table_b")).toBe(true)); - expect(mocks.tabs).toHaveLength(1); + expect(mocks.tabs).toHaveLength(2); + expect(mocks.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual(["table_a", "table_b"]); - // 旧 A 晚返回:不得覆盖 B 的占位身份,不得解除 B 的行标识等待 columnGates.get("table_a")?.([column("a_id")]); - await openA; - expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("table_b"); - expect(mocks.tabs[0]?.tableMetaPending).toBe(true); - - // B 返回后正常落地 columnGates.get("table_b")?.([column("b_id")]); - await openB; - expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("table_b"); - expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual(["b_id"]); - expect(mocks.tabs[0]?.tableMetaPending).toBe(false); + await Promise.all([openA, openB]); + expect(mocks.tabs.map((tab) => tab.tableMeta?.primaryKeys)).toEqual([["a_id"], ["b_id"]]); + expect(mocks.tabs.every((tab) => tab.tableMetaPending === false)).toBe(true); }); it("stops landing when another entry point takes over the tab, even for the same table", async () => { @@ -257,28 +246,4 @@ describe("useNavigationTargets openTableTarget", () => { // 无取消请求:元数据落地后按既有行为执行第二次查询 expect(mocks.executeTabSql).toHaveBeenCalledTimes(2); }); - - it("invalidates the previous execution id when a new navigation reuses the tab", async () => { - const columnGates = new Map void>(); - mocks.getColumns.mockImplementation( - (_connectionId: string, _database: string, _schema: string, tableName: string) => - new Promise((resolve) => { - columnGates.set(tableName, resolve); - }), - ); - const navigation = useNavigationTargets(dialogs); - const openA = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_a" }); - await vi.waitFor(() => expect(columnGates.has("table_a")).toBe(true)); - const executionIdForA = mocks.tabs[0]?.executionId; - - const openB = navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "table_b" }); - // B 开始即作废 A 的执行代次,A 在途查询结果无法再按旧 executionId 落地 - expect(mocks.tabs[0]?.executionId).toBeDefined(); - expect(mocks.tabs[0]?.executionId).not.toBe(executionIdForA); - - columnGates.get("table_a")?.([column("a_id")]); - await vi.waitFor(() => expect(columnGates.has("table_b")).toBe(true)); - columnGates.get("table_b")?.([column("b_id")]); - await Promise.all([openA, openB]); - }); }); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts new file mode 100644 index 0000000000..cc0549125e --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -0,0 +1,195 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { QueryResult } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + connectionStore: { + activeConnectionId: "", + getConfig: vi.fn((connectionId: string) => ({ id: connectionId, db_type: "postgres" })), + ensureConnected: vi.fn(), + connectionIdentifierQuote: vi.fn(() => undefined), + refreshObjectListTreeNode: vi.fn(), + }, + settingsStore: { + editorSettings: { + autoCalculateTotalRows: false, + continueOnErrorOnBatch: false, + openTabsRestoreMode: "all", + pageSize: 100, + reuseDataTab: true, + tableOpenPageSize: 100, + }, + }, + buildTableSelectSql: vi.fn(), + loadOpenTabsState: vi.fn(), + loadTableMetadata: vi.fn(), + saveOpenTabsState: vi.fn(), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => mocks.connectionStore, +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => mocks.settingsStore, +})); + +vi.mock("@/lib/backend/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadOpenTabsState: mocks.loadOpenTabsState, + saveOpenTabsState: mocks.saveOpenTabsState, + }; +}); + +vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadTableMetadata: mocks.loadTableMetadata, + }; +}); + +vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildTableSelectSql: mocks.buildTableSelectSql, + }; +}); + +const dialogs = { + showFieldLineageDialog: { value: false }, + showDatabaseSearchDialog: { value: false }, + showDiagramDialog: { value: false }, +}; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +async function setupNavigation() { + setActivePinia(createPinia()); + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + vi.spyOn(queryStore, "executeTabSql").mockImplementation(async (tabId: string, sql: string) => { + const tab = queryStore.tabs.find((item) => item.id === tabId); + if (!tab) return; + const result: QueryResult = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }; + tab.result = result; + tab.isExecuting = false; + tab.executionId = undefined; + queryStore.updateSql(tabId, sql); + }); + const { useNavigationTargets } = await import("@/composables/useNavigationTargets"); + return { navigation: useNavigationTargets(dialogs), queryStore }; +} + +describe("useNavigationTargets with the real query store", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + installLocalStorage(); + mocks.connectionStore.activeConnectionId = ""; + mocks.ensureConnected?.mockResolvedValue?.(undefined); + mocks.connectionStore.ensureConnected.mockResolvedValue(undefined); + mocks.loadOpenTabsState.mockResolvedValue(null); + mocks.saveOpenTabsState.mockResolvedValue(undefined); + mocks.buildTableSelectSql.mockImplementation(async ({ tableName, whereInput }: { tableName: string; whereInput?: string }) => `SELECT * FROM ${tableName}${whereInput ? ` WHERE ${whereInput}` : ""}`); + mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string; catalog?: string }) => ({ + metadata: { + database: request.database, + schema: request.schema, + catalog: request.catalog, + tableName: request.tableName, + tableType: request.tableType ?? "TABLE", + columns: [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + })); + }); + + it("opens same-table search results with different predicates in separate tabs", async () => { + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" }; + + await navigation.openDatabaseSearchTarget({ ...target, whereInput: '"id" = 1' }); + await navigation.openDatabaseSearchTarget({ ...target, whereInput: '"id" = 2' }); + + expect(queryStore.tabs).toHaveLength(2); + expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(['SELECT * FROM users WHERE "id" = 1', 'SELECT * FROM users WHERE "id" = 2']); + }); + + it("creates a new target tab even when the same table was restored", async () => { + mocks.loadOpenTabsState.mockResolvedValue({ + tabs: [ + { + id: "restored-users", + title: "public.users", + connectionId: "connection-1", + database: "app", + schema: "public", + mode: "data", + sql: "SELECT * FROM users WHERE restored = true", + tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }, + }, + ], + activeTabId: "restored-users", + }); + const { navigation, queryStore } = await setupNavigation(); + await queryStore.initOpenTabs(); + + expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public")).toBe("restored-users"); + await navigation.openLineageTarget({ connectionId: "connection-1", database: "app", schema: "public", tableName: "users" }); + + expect(queryStore.tabs).toHaveLength(2); + expect(queryStore.tabs[0]?.id).toBe("restored-users"); + expect(queryStore.tabs[0]?.sql).toBe("SELECT * FROM users WHERE restored = true"); + expect(queryStore.tabs[1]?.id).not.toBe("restored-users"); + }); + + it("keeps concurrent same-table opens independent", async () => { + const connectionGates: Array<() => void> = []; + mocks.connectionStore.ensureConnected.mockImplementation( + () => + new Promise((resolve) => { + connectionGates.push(resolve); + }), + ); + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" }; + + const first = navigation.openTableTarget({ ...target, whereInput: '"id" = 1' }); + const second = navigation.openTableTarget({ ...target, whereInput: '"id" = 2' }); + await vi.waitFor(() => expect(queryStore.tabs).toHaveLength(2)); + + connectionGates.splice(0).forEach((release) => release()); + await Promise.all([first, second]); + + expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2); + expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(['SELECT * FROM users WHERE "id" = 1', 'SELECT * FROM users WHERE "id" = 2']); + }); + + it("preserves default identity reuse and cross-scope isolation", async () => { + const { queryStore } = await setupNavigation(); + const base = queryStore.createTab("connection-1", "app", "public.users", "data", "public"); + + expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public")).toBe(base); + expect(queryStore.createTab("connection-2", "app", "public.users", "data", "public")).not.toBe(base); + expect(queryStore.createTab("connection-1", "analytics", "public.users", "data", "public")).not.toBe(base); + expect(queryStore.createTab("connection-1", "app", "archive.users", "data", "archive")).not.toBe(base); + expect(queryStore.createTab("connection-1", "app", "public.orders", "data", "public")).not.toBe(base); + expect(queryStore.createTab("connection-1", "app", "public.users", "data", "public", undefined, undefined, { forceNew: true })).not.toBe(base); + }); +}); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index d394cab5b6..10d4d0dec3 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -40,19 +40,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab queryStore.updateSql(tabId, target.tableName); return; } - const tabId = (() => { - if (settingsStore.editorSettings.reuseDataTab) { - const existing = queryStore.tabs.find((tab) => tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database && (tab.tableMeta?.catalog || "") === (target.catalog || "")); - if (existing) { - existing.title = tabTitle; - existing.schema = tableSchema; - existing.tableInfoTab = options.tableInfoTab; - queryStore.switchTab(existing.id); - return existing.id; - } - } - return queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema); - })(); + const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema, undefined, undefined, { forceNew: true }); const targetTab = queryStore.tabs.find((tab) => tab.id === tabId); if (targetTab) targetTab.tableInfoTab = options.tableInfoTab; // Stamp the new table identity synchronously so SQL rebuilds (refresh, @@ -76,9 +64,8 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab // executionId,后续异步返回后不得再启动查询(同 refreshPreparationId 模式) const preparationId = uuid(); queryStore.setExecutingWithId(tabId, preparationId); - // reuseDataTab 下并发导航会复用同一 tab:每次异步返回后必须校验(1)本次 - // 导航仍是该 tab 的最新代次(区分同表不同 whereInput/连点两次,且侧边栏 - // openData 接管同一 tab 时会作废本代次),(2)tab 未被其他流程改指别的 + // 侧边栏 openData 可能接管同一 tab:每次异步返回后必须校验(1)本次 + // 导航仍是该 tab 的最新代次,(2)tab 未被其他流程改指别的 // 目标,(3)准备期 executionId 未被停止/接管清除。否则旧请求晚返回会用 // A 的元数据覆盖新目标 B 的占位并解除 pending,造成结果属于 B、写入目标 // 却是 A 的错位 diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index dcf2cdc7f3..8bbb0c725c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1131,8 +1131,8 @@ export const useQueryStore = defineStore("query", () => { return tabs.value.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "")); } - function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string) { - if (title) { + function createTab(connectionId: string, database: string, title?: string, mode: QueryTab["mode"] = "query", schema?: string, initialSql?: string, catalog?: string, options: { forceNew?: boolean } = {}) { + if (title && !options.forceNew) { const existing = findTabByIdentity(connectionId, database, title, mode, schema); if (existing) { switchTab(existing.id);