From d65d7120894f824100e8c72e42415c30a0c2d940 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 5 Aug 2026 13:04:56 +0800 Subject: [PATCH] feat(tabs): add configurable data tab reuse modes --- .../editor/EditorSettingsDialog.vue | 70 ++++++++- apps/desktop/src/components/grid/DataGrid.vue | 10 ++ .../sidebar/SidebarTreeRuntimeHost.vue | 2 +- .../__tests__/useNavigationTargets.spec.ts | 2 +- .../useNavigationTargets.store.spec.ts | 30 +++- .../useSidebarDataOpenRuntime.spec.ts | 139 +++++++++++++----- .../src/composables/useNavigationTargets.ts | 4 +- .../composables/useSidebarDataOpenRuntime.ts | 15 +- apps/desktop/src/i18n/locales/en.ts | 8 +- apps/desktop/src/i18n/locales/es.ts | 8 +- apps/desktop/src/i18n/locales/it.ts | 8 +- apps/desktop/src/i18n/locales/ja.ts | 8 +- apps/desktop/src/i18n/locales/ko.ts | 8 +- apps/desktop/src/i18n/locales/pt-BR.ts | 8 +- apps/desktop/src/i18n/locales/zh-CN.ts | 8 +- apps/desktop/src/i18n/locales/zh-TW.ts | 8 +- .../sidebar/dataTabOpenPolicy.spec.ts | 45 +++++- .../__tests__/editorSettingsDraft.spec.ts | 20 +++ .../src/lib/settings/editorSettingsDraft.ts | 2 +- .../src/lib/sidebar/dataTabOpenPolicy.ts | 36 ++++- .../desktop/src/lib/tabs/dataTabActivation.ts | 5 +- apps/desktop/src/lib/tabs/dataTabReuseMode.ts | 14 ++ .../stores/__tests__/settingsStore.spec.ts | 9 ++ apps/desktop/src/stores/settingsStore.ts | 17 ++- apps/desktop/src/types/database.ts | 1 + packages/app-tests/dataTabActivation.test.ts | 6 +- 26 files changed, 393 insertions(+), 98 deletions(-) create mode 100644 apps/desktop/src/lib/tabs/dataTabReuseMode.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a393457398..08043cbf1b 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -37,6 +37,7 @@ import { type DesktopIconTheme, type InterfaceLayout, type DisconnectTabHandlingMode, + type DataTabReuseMode, type OpenTabsRestoreMode, type SidebarObjectInfoMode, type SqlSemanticDiagnosticsMode, @@ -383,12 +384,13 @@ const editingShortcutId = ref(null); const editSidebarActivation = ref(settingsStore.editorSettings.sidebarActivation); const editSidebarObjectDisplay = ref(settingsStore.editorSettings.sidebarObjectDisplay); const sidebarObjectDisplayHelp = ref<"grouped" | "simple" | null>(null); +const dataTabReuseModeHelp = ref(null); const editRoutineSourceOpenMode = ref(settingsStore.editorSettings.routineSourceOpenMode); const editSidebarTableSearchEnabled = ref(settingsStore.editorSettings.sidebarTableSearchEnabled); const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode); const editOpenTabsRestoreMode = ref(settingsStore.editorSettings.openTabsRestoreMode); const editDisconnectTabHandlingMode = ref(settingsStore.editorSettings.disconnectTabHandlingMode); -const editReuseDataTab = ref(settingsStore.editorSettings.reuseDataTab); +const editDataTabReuseMode = ref(settingsStore.editorSettings.dataTabReuseMode); const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect); const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); @@ -499,7 +501,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { autoSelectActiveSidebarNode: editAutoSelectActiveSidebarNode.value, openTabsRestoreMode: editOpenTabsRestoreMode.value, disconnectTabHandlingMode: editDisconnectTabHandlingMode.value, - reuseDataTab: editReuseDataTab.value, + dataTabReuseMode: editDataTabReuseMode.value, prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, sidebarObjectInfoMode: editSidebarObjectInfoMode.value, @@ -767,7 +769,7 @@ function syncEditorSettingsDraftFromStore() { editAutoSelectActiveSidebarNode.value = settingsStore.editorSettings.autoSelectActiveSidebarNode; editOpenTabsRestoreMode.value = settingsStore.editorSettings.openTabsRestoreMode; editDisconnectTabHandlingMode.value = settingsStore.editorSettings.disconnectTabHandlingMode; - editReuseDataTab.value = settingsStore.editorSettings.reuseDataTab; + editDataTabReuseMode.value = settingsStore.editorSettings.dataTabReuseMode; editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect; editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; @@ -974,7 +976,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode; editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; - editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab; + editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; @@ -1067,7 +1069,7 @@ function resetAllDefaults() { editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode; editOpenTabsRestoreMode.value = DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; editDisconnectTabHandlingMode.value = DEFAULT_EDITOR_SETTINGS.disconnectTabHandlingMode; - editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab; + editDataTabReuseMode.value = DEFAULT_EDITOR_SETTINGS.dataTabReuseMode; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; @@ -4434,14 +4436,66 @@ onUnmounted(() => { -
+
- + {{ t("settings.reuseDataTabDescription") }}
- +
+ + + +
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b8b9fc2487..3d7dc43862 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -3054,6 +3054,16 @@ watch([pendingChangeCount, pendingChangesVersion], () => { schedulePreviewRefresh(); }); +watch( + pendingChangeCount, + (count) => { + if (props.context !== "table-data" || !props.cacheKey) return; + const tab = queryStore.tabs.find((item) => item.id === props.cacheKey); + if (tab?.mode === "data") tab.pendingDataChangeCount = count || undefined; + }, + { immediate: true }, +); + const saveActionMode = computed(() => dataGridSaveActionMode({ pendingChangeCount: pendingChangeCount.value, diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index e1ad07aa6c..19692291db 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1075,7 +1075,7 @@ function activateDataTableFromDoubleClick() { if (node.type !== "table" || !hasNodeDatabaseContext(node)) return; const activation = settingsStore.editorSettings.sidebarActivation; const existingSameTableTab = findExistingSameTableDataTab(); - const action = dataTableDoubleClickAction(existingSameTableTab, activation, settingsStore.editorSettings.reuseDataTab); + const action = dataTableDoubleClickAction(existingSameTableTab, activation, settingsStore.editorSettings.dataTabReuseMode); if (action === "none") return; if (action === "open") { openDataImmediately(node); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts index 8d615bcdad..134e214825 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.spec.ts @@ -79,7 +79,7 @@ vi.mock("@/stores/queryStore", () => ({ })); vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ editorSettings: { reuseDataTab: true } }), + useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: "same-table" } }), })); vi.mock("@/lib/table/tableSelectSql", () => ({ diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index 004dc78a0e..ead9fb769d 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -1,5 +1,6 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; import type { QueryResult } from "@/types/database"; const mocks = vi.hoisted(() => ({ @@ -17,7 +18,7 @@ const mocks = vi.hoisted(() => ({ continueOnErrorOnBatch: false, openTabsRestoreMode: "all", pageSize: 100, - reuseDataTab: true, + dataTabReuseMode: "same-table" as DataTabReuseMode, tableOpenPageSize: 100, }, }, @@ -100,7 +101,7 @@ describe("useNavigationTargets with the real query store", () => { installLocalStorage(); mocks.connectionStore.activeConnectionId = ""; mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "postgres" })); - mocks.settingsStore.editorSettings.reuseDataTab = true; + mocks.settingsStore.editorSettings.dataTabReuseMode = "same-table"; mocks.ensureConnected?.mockResolvedValue?.(undefined); mocks.connectionStore.ensureConnected.mockResolvedValue(undefined); mocks.loadOpenTabsState.mockResolvedValue(null); @@ -148,8 +149,8 @@ describe("useNavigationTargets with the real query store", () => { expect(mocks.connectionStore.activeConnectionId).toBe("connection-1"); }); - it("keeps object-browser tabs independent when data-tab reuse is disabled", async () => { - mocks.settingsStore.editorSettings.reuseDataTab = false; + it("keeps object-browser tabs independent in always-new mode", async () => { + mocks.settingsStore.editorSettings.dataTabReuseMode = "always-new"; const { navigation, queryStore } = await setupNavigation(); const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users", tableType: "TABLE" }; @@ -159,8 +160,8 @@ describe("useNavigationTargets with the real query store", () => { expect(queryStore.tabs).toHaveLength(2); }); - it("keeps repeated sidebar opens independent when data-tab reuse is disabled", async () => { - mocks.settingsStore.editorSettings.reuseDataTab = false; + it("keeps repeated sidebar opens independent in always-new mode", async () => { + mocks.settingsStore.editorSettings.dataTabReuseMode = "always-new"; const { queryStore } = await setupNavigation(); const { useSidebarDataOpenRuntime } = await import("@/composables/useSidebarDataOpenRuntime"); const runtime = useSidebarDataOpenRuntime(); @@ -173,7 +174,7 @@ describe("useNavigationTargets with the real query store", () => { expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2); }); - it("keeps different sidebar tables independent when data-tab reuse is enabled", async () => { + it("keeps different sidebar tables independent in same-table mode", async () => { const { queryStore } = await setupNavigation(); const { useSidebarDataOpenRuntime } = await import("@/composables/useSidebarDataOpenRuntime"); const runtime = useSidebarDataOpenRuntime(); @@ -186,6 +187,21 @@ describe("useNavigationTargets with the real query store", () => { expect(queryStore.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual(["users", "orders"]); }); + it("reuses the active data tab for different object-browser tables in active-tab mode", async () => { + mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab"; + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users", tableType: "TABLE" }; + + await navigation.openObjectBrowserTableTarget(target); + const originalTabId = queryStore.activeTabId; + await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" }); + + expect(queryStore.tabs).toHaveLength(1); + expect(queryStore.activeTabId).toBe(originalTabId); + expect(queryStore.tabs[0]?.tableMeta?.tableName).toBe("orders"); + expect(queryStore.tabs[0]?.sql).toBe("SELECT * FROM orders"); + }); + it("reuses a sidebar table when the same table is opened from the object browser", async () => { mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mysql" })); const { navigation, queryStore } = await setupNavigation(); diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index c9aa46f70f..cfabec7f85 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -1,13 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; import type { QueryTab, TreeNode } from "@/types/database"; const mocks = vi.hoisted(() => ({ databaseType: "oceanbase" as string, callOrder: [] as string[], tabs: [] as QueryTab[], + activeTabId: null as string | null, cachedMetadata: undefined as unknown, - reuseDataTab: false, + dataTabReuseMode: "same-table" as DataTabReuseMode, ensureConnected: vi.fn(), executeTabSql: vi.fn(), loadTableMetadata: vi.fn(), @@ -27,10 +29,16 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tabs: mocks.tabs, + get activeTabId() { + return mocks.activeTabId; + }, createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string, _initialSql?: string, catalog?: string, options: { forceNew?: boolean } = {}) => { if (!options.forceNew) { const existing = mocks.tabs.find((tab) => tab.connectionId === connectionId && tab.database === database && tab.title === title && tab.mode === mode && (tab.schema || "") === (schema || "") && (tab.catalog || "") === (catalog || "")); - if (existing) return existing.id; + if (existing) { + mocks.activeTabId = existing.id; + return existing.id; + } } const tab = { id: `tab-${mocks.tabs.length + 1}`, @@ -47,9 +55,12 @@ vi.mock("@/stores/queryStore", () => ({ isExplaining: false, } as QueryTab; mocks.tabs.push(tab); + mocks.activeTabId = tab.id; return tab.id; }, - switchTab: vi.fn(), + switchTab: vi.fn((id: string) => { + mocks.activeTabId = id; + }), cancelTabExecution: mocks.cancelTabExecution, setExecutingWithId: (id: string, executionId: string) => { const tab = mocks.tabs.find((item) => item.id === id); @@ -76,7 +87,7 @@ vi.mock("@/stores/queryStore", () => ({ })); vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ editorSettings: { reuseDataTab: mocks.reuseDataTab, pageSize: 100 } }), + useSettingsStore: () => ({ editorSettings: { dataTabReuseMode: mocks.dataTabReuseMode, pageSize: 100 } }), })); vi.mock("@/lib/database/jdbcDialect", () => ({ @@ -127,8 +138,9 @@ describe("useSidebarDataOpenRuntime", () => { mocks.databaseType = "oceanbase"; mocks.callOrder.length = 0; mocks.tabs.length = 0; + mocks.activeTabId = null; mocks.cachedMetadata = undefined; - mocks.reuseDataTab = false; + mocks.dataTabReuseMode = "same-table"; mocks.ensureConnected.mockResolvedValue(undefined); mocks.buildTableSelectSql.mockResolvedValue("SELECT * FROM users"); mocks.executeTabSql.mockImplementation(async () => { @@ -153,24 +165,23 @@ describe("useSidebarDataOpenRuntime", () => { }); }); - it("creates a new sidebar tab for the same table when reuse is disabled", async () => { + it("creates a new sidebar tab for the same table in always-new mode", async () => { + mocks.dataTabReuseMode = "always-new"; + await useSidebarDataOpenRuntime().openData(tableNode); await useSidebarDataOpenRuntime().openData(tableNode); expect(mocks.tabs).toHaveLength(2); }); - it("reuses a sidebar tab for the same table when reuse is enabled", async () => { - mocks.reuseDataTab = true; - + it("reuses a sidebar tab for the same table in same-table mode", async () => { await useSidebarDataOpenRuntime().openData(tableNode); await useSidebarDataOpenRuntime().openData(tableNode); expect(mocks.tabs).toHaveLength(1); }); - it("keeps different sidebar tables independent when reuse is enabled", async () => { - mocks.reuseDataTab = true; + it("keeps different sidebar tables independent in same-table mode", async () => { const ordersNode = { ...tableNode, id: "table-orders", label: "orders" }; await useSidebarDataOpenRuntime().openData(tableNode); @@ -180,8 +191,68 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs.map((tab) => tab.title)).toEqual(["users", "orders"]); }); - it("creates a new HBase tab for the same table when reuse is disabled", async () => { + it("reuses the active safe data tab for a different table in active-tab mode", async () => { + mocks.dataTabReuseMode = "active-tab"; + const ordersNode = { ...tableNode, id: "table-orders", label: "orders" }; + mocks.loadTableMetadata.mockImplementation(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({ + metadata: { + schema: request.schema, + tableName: request.tableName, + tableType: request.tableType ?? "TABLE", + database: request.database, + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + indexes: [], + primaryKeys: ["id"], + cachedAt: Date.now(), + }, + cacheStatus: "miss", + ageMs: 0, + })); + + await useSidebarDataOpenRuntime().openData(tableNode); + mocks.tabs[0]!.isExecuting = false; + mocks.tabs[0]!.executionId = undefined; + await useSidebarDataOpenRuntime().openData(ordersNode); + + expect(mocks.tabs).toHaveLength(1); + expect(mocks.tabs[0]?.title).toBe("orders"); + expect(mocks.tabs[0]?.tableMeta?.tableName).toBe("orders"); + }); + + it.each([ + ["pinned", { pinned: true }], + ["executing", { isExecuting: true, executionId: "running" }], + ["pending edits", { pendingDataChangeCount: 1 }], + ])("opens a new tab instead of replacing an active %s data tab", async (_label, patch) => { + mocks.dataTabReuseMode = "active-tab"; + const activeTab = { + id: "active-users", + connectionId: "connection-1", + database: "app", + title: "users", + mode: "data", + schema: "public", + sql: "SELECT * FROM users", + isExecuting: false, + isCancelling: false, + isExplaining: false, + tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }, + ...patch, + } as QueryTab; + mocks.tabs.push(activeTab); + mocks.activeTabId = activeTab.id; + + await useSidebarDataOpenRuntime().openData({ ...tableNode, id: "table-orders", label: "orders" }); + + expect(mocks.tabs).toHaveLength(2); + expect(mocks.tabs[0]?.title).toBe("users"); + expect(mocks.tabs[1]?.title).toBe("orders"); + expect(mocks.cancelTabExecution).not.toHaveBeenCalledWith(activeTab.id); + }); + + it("creates a new HBase tab for the same table in always-new mode", async () => { mocks.databaseType = "hbase"; + mocks.dataTabReuseMode = "always-new"; await useSidebarDataOpenRuntime().openData(tableNode); await useSidebarDataOpenRuntime().openData(tableNode); @@ -189,9 +260,8 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs).toHaveLength(2); }); - it("keeps different HBase tables independent when reuse is enabled", async () => { + it("keeps different HBase tables independent in same-table mode", async () => { mocks.databaseType = "hbase"; - mocks.reuseDataTab = true; await useSidebarDataOpenRuntime().openData(tableNode); await useSidebarDataOpenRuntime().openData({ ...tableNode, id: "table-orders", label: "orders" }); @@ -352,11 +422,7 @@ describe("useSidebarDataOpenRuntime", () => { expect(mocks.tabs[0]?.tableMetaPending).toBe(true); }); - it("aborts when a newer navigation takes over the tab during the cancel wait", async () => { - mocks.reuseDataTab = true; - const { beginDataTabNavigation } = await import("@/lib/tabs/dataTabNavigationGeneration"); - // 已存在同表 data tab 且有在途执行:真实 findExistingDataTabCandidate 会 - // 选中它(same-table 复用分支),openData 需先等待取消 + it("activates an already-running same-table tab without cancelling it", async () => { mocks.tabs.push({ id: "existing-tab", connectionId: "connection-1", @@ -370,29 +436,22 @@ describe("useSidebarDataOpenRuntime", () => { executionId: "stale-execution", isCancelling: false, isExplaining: false, - tableMeta: { schema: "public", tableName: "users", tableType: "TABLE", columns: [], primaryKeys: [] }, + tableMeta: { + schema: "public", + tableName: "users", + tableType: "TABLE", + columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + primaryKeys: ["id"], + }, + tableMetaUpdatedAt: Date.now(), } as QueryTab); - let releaseCancel: () => void = () => {}; - mocks.cancelTabExecution.mockReturnValueOnce( - new Promise((resolve) => { - releaseCancel = resolve; - }), - ); - const open = useSidebarDataOpenRuntime().openData(tableNode); - await vi.waitFor(() => expect(mocks.cancelTabExecution).toHaveBeenCalledWith("existing-tab")); - // 复用既有 tab,而非新建 + mocks.activeTabId = null; + + await useSidebarDataOpenRuntime().openData(tableNode); + expect(mocks.tabs).toHaveLength(1); - - // 取消等待期间,更晚的导航(如 openTableTarget)接管该 tab - beginDataTabNavigation("existing-tab"); - const takeoverMeta = { schema: "public", tableName: "other_table", tableType: "TABLE", columns: [], primaryKeys: [] }; - mocks.tabs[0]!.tableMeta = takeoverMeta; - - releaseCancel(); - await open; - - // 旧 openData 必须让位:不得覆盖新导航写入的身份,不得启动查询 - expect(mocks.tabs[0]?.tableMeta).toBe(takeoverMeta); + expect(mocks.activeTabId).toBe("existing-tab"); + expect(mocks.cancelTabExecution).not.toHaveBeenCalled(); expect(mocks.executeTabSql).not.toHaveBeenCalled(); expect(mocks.loadTableMetadata).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 339ab32055..10a26fc5b4 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -258,7 +258,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: const { openData } = useSidebarDataOpenRuntime(); async function openObjectBrowserTableTarget(target: NavigationTarget) { - if (!settingsStore.editorSettings.reuseDataTab) { + if (settingsStore.editorSettings.dataTabReuseMode === "always-new") { await openTableTarget(target); return; } @@ -278,7 +278,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: }, undefined, "default", - { reuseScope: "same-table" }, + { reuseMode: settingsStore.editorSettings.dataTabReuseMode }, ); } diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 00b0ea0f00..6cec81274e 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -7,7 +7,7 @@ import { uuid } from "@/lib/common/utils"; import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { effectiveDatabaseTypeForConnection, connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema } from "@/lib/database/jdbcDialect"; import { getCachedTableMetadata, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS, tableMetadataToDataTabMeta } from "@/lib/metadata/tableMetadataCache"; -import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, type DataTabOpenMode, type DataTabReuseScope } from "@/lib/sidebar/dataTabOpenPolicy"; +import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, type DataTabOpenMode, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy"; import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; @@ -27,13 +27,13 @@ export function useSidebarDataOpenRuntime() { const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); - async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: { reuseScope?: DataTabReuseScope } = {}) { + async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: { reuseMode?: DataTabReuseMode } = {}) { if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return; const config = connectionStore.getConfig(node.connectionId); - const reuseScope = options.reuseScope ?? (settingsStore.editorSettings.reuseDataTab ? "same-table" : "none"); + const reuseMode = options.reuseMode ?? settingsStore.editorSettings.dataTabReuseMode; if (config?.db_type === "hbase") { await connectionStore.ensureConnected(node.connectionId); - const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" || reuseScope === "none" }); + const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" || reuseMode === "always-new" }); queryStore.updateSql(tabId, node.label); return; } @@ -60,6 +60,7 @@ export function useSidebarDataOpenRuntime() { type: node.type, dbType: config?.db_type, openMode, + reuseMode, }); const tableSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema); const tableType = node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : (node.tableType ?? "TABLE"); @@ -126,7 +127,7 @@ export function useSidebarDataOpenRuntime() { openDataLog("warn", "metadata:error", { traceId, tabId: targetTabId, elapsed: elapsed(), error }); } }; - const existingDataTabCandidate = findExistingDataTabCandidate(queryStore.tabs, dataTabTarget, { openMode, reuseScope }); + const existingDataTabCandidate = findExistingDataTabCandidate(queryStore.tabs, dataTabTarget, { openMode, reuseMode, activeTabId: queryStore.activeTabId }); const existingSameTableTab = existingDataTabCandidate?.match === "same-table" ? existingDataTabCandidate.tab : undefined; const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => { tab.title = node.label; @@ -152,7 +153,7 @@ export function useSidebarDataOpenRuntime() { tab.queryEditabilityReason = undefined; }; - if (existingSameTableTab && canActivateExistingDataTableTab(existingSameTableTab, { activateExecuting: false })) { + if (existingSameTableTab && (existingSameTableTab.isExecuting || canActivateExistingDataTableTab(existingSameTableTab, { activateExecuting: false }))) { queryStore.switchTab(existingSameTableTab.id); logPhase("existing-tab-activated", { table: node.label }); if (dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) { @@ -171,7 +172,7 @@ export function useSidebarDataOpenRuntime() { resetReusedDataTabState(existingDataTabCandidate.tab); return existingDataTabCandidate.tab.id; } - return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema, undefined, node.catalog, { forceNew: reuseScope === "none" || openMode === "new-tab" }); + return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema, undefined, node.catalog, { forceNew: true }); })(); openDataLog("info", "tab-created", { traceId, tabId, elapsed: elapsed() }); logPhase("tab-created", { tabId }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0fc63ed12c..b759a5155f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5053,7 +5053,13 @@ export default { disconnectTabHandlingModeKeepTabsKeepResults: "Do not close related tabs", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Keep related tabs, SQL text, and current results without extra cleanup.", reuseDataTab: "Reuse data tab", - reuseDataTabDescription: "Switch to the existing data tab when reopening the same table; different tables always use separate tabs.", + reuseDataTabDescription: "Choose how opening a table uses data tabs.", + dataTabReuseAlwaysNew: "Always open a new tab", + dataTabReuseAlwaysNewDescription: "Create a new data tab every time, even when the table is already open.", + dataTabReuseSameTable: "Reuse the same table", + dataTabReuseSameTableDescription: "Switch to an existing tab for the same table and open different tables separately.", + dataTabReuseActiveTab: "Reuse the active data tab", + dataTabReuseActiveTabDescription: "Reuse the active safe data tab for a different table; protected tabs open separately.", sidebarHiddenTablePrefixes: "Hidden table name prefixes", sidebarHiddenTablePrefixesDescription: "One prefix per line. Only sidebar table, view, and collection labels are shortened; tooltips and actions still use the full name.", sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ca35be19f7..4366846d62 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4826,7 +4826,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "No cerrar pestañas relacionadas", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Conserva las pestañas relacionadas, el texto SQL y los resultados actuales sin limpieza adicional.", reuseDataTab: "Reutilizar pestaña de datos", - reuseDataTabDescription: "Al volver a abrir la misma tabla, cambia a su pestaña de datos existente; las tablas diferentes siempre usan pestañas separadas.", + reuseDataTabDescription: "Elige cómo se usan las pestañas de datos al abrir una tabla.", + dataTabReuseAlwaysNew: "Abrir siempre una pestaña nueva", + dataTabReuseAlwaysNewDescription: "Crea una pestaña de datos nueva cada vez, aunque la tabla ya esté abierta.", + dataTabReuseSameTable: "Reutilizar solo la misma tabla", + dataTabReuseSameTableDescription: "Cambia a la pestaña existente de la misma tabla y abre las demás por separado.", + dataTabReuseActiveTab: "Reutilizar la pestaña activa", + dataTabReuseActiveTabDescription: "Reutiliza la pestaña de datos activa cuando sea seguro; las pestañas protegidas se abren por separado.", sidebarHiddenTablePrefixes: "Prefijos ocultos de tablas", sidebarHiddenTablePrefixesDescription: "Un prefijo por linea. Solo acorta etiquetas de tablas, vistas y colecciones en la barra lateral; las acciones y ayudas usan el nombre completo.", sidebarHiddenTablePrefixesPlaceholder: "Ejemplo:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c3a1f4ce1d..a9604a4826 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4826,7 +4826,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "Non chiudere le schede correlate", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Mantieni le schede correlate, il testo SQL e i risultati correnti senza ulteriore pulizia.", reuseDataTab: "Riusa scheda dati", - reuseDataTabDescription: "Quando riapri la stessa tabella, passa alla scheda dati esistente; tabelle diverse usano sempre schede separate.", + reuseDataTabDescription: "Scegli come usare le schede dati quando apri una tabella.", + dataTabReuseAlwaysNew: "Apri sempre una nuova scheda", + dataTabReuseAlwaysNewDescription: "Crea ogni volta una nuova scheda dati, anche se la tabella è già aperta.", + dataTabReuseSameTable: "Riusa solo la stessa tabella", + dataTabReuseSameTableDescription: "Passa alla scheda esistente per la stessa tabella e apre separatamente le altre.", + dataTabReuseActiveTab: "Riusa la scheda dati attiva", + dataTabReuseActiveTabDescription: "Riusa la scheda dati attiva quando è sicuro; le schede protette vengono aperte separatamente.", sidebarHiddenTablePrefixes: "Prefissi dei nomi delle tabelle nascosti", sidebarHiddenTablePrefixesDescription: "Un prefisso per riga. Solo le etichette di tabelle, viste e collezioni della barra laterale vengono abbreviate; i suggerimenti e le azioni utilizzano ancora il nome completo.", sidebarHiddenTablePrefixesPlaceholder: "Esempio:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 52c518f197..0459018427 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4855,7 +4855,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "関連タブを閉じない", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。", reuseDataTab: "データタブを再利用", - reuseDataTabDescription: "同じテーブルを再度開くと既存のデータタブに切り替え、異なるテーブルは常に別のタブで開きます。", + reuseDataTabDescription: "テーブルを開くときのデータタブの使い方を選択します。", + dataTabReuseAlwaysNew: "常に新しいタブを開く", + dataTabReuseAlwaysNewDescription: "同じテーブルが開いていても、毎回新しいデータタブを作成します。", + dataTabReuseSameTable: "同じテーブルだけ再利用", + dataTabReuseSameTableDescription: "同じテーブルは既存のタブに切り替え、異なるテーブルは別々に開きます。", + dataTabReuseActiveTab: "アクティブなデータタブを再利用", + dataTabReuseActiveTabDescription: "安全な場合は異なるテーブルにもアクティブなデータタブを再利用し、保護されたタブは別に開きます。", sidebarHiddenTablePrefixes: "非表示テーブル名プレフィックス", sidebarHiddenTablePrefixesDescription: "1行に1つのプレフィックス。サイドバーのテーブル、ビュー、コレクションラベルのみ短縮されます。ツールチップと操作は完全な名前を使用します。", sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 94b324177d..4312f725f3 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4588,7 +4588,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "관련 탭 닫지 않기", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.", reuseDataTab: "데이터 탭 재사용", - reuseDataTabDescription: "같은 테이블을 다시 열면 기존 데이터 탭으로 전환하고, 다른 테이블은 항상 별도 탭에서 엽니다.", + reuseDataTabDescription: "테이블을 열 때 데이터 탭을 사용하는 방식을 선택합니다.", + dataTabReuseAlwaysNew: "항상 새 탭 열기", + dataTabReuseAlwaysNewDescription: "테이블이 이미 열려 있어도 매번 새 데이터 탭을 만듭니다.", + dataTabReuseSameTable: "같은 테이블만 재사용", + dataTabReuseSameTableDescription: "같은 테이블은 기존 탭으로 전환하고 다른 테이블은 별도로 엽니다.", + dataTabReuseActiveTab: "활성 데이터 탭 재사용", + dataTabReuseActiveTabDescription: "안전한 경우 다른 테이블에 활성 데이터 탭을 재사용하며 보호된 탭은 별도로 엽니다.", sidebarHiddenTablePrefixes: "숨겨진 테이블 이름 접두사", sidebarHiddenTablePrefixesDescription: "한 줄에 하나의 접두사. 사이드바의 테이블, 뷰, 컬렉션 라벨만 줄이며 툴팁과 작업은 전체 이름을 계속 사용합니다.", sidebarHiddenTablePrefixesPlaceholder: "예:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c682fc64d2..8b0cdc96ca 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4828,7 +4828,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "Não fechar abas relacionadas", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "Manter abas relacionadas, texto SQL e resultados atuais sem limpeza adicional.", reuseDataTab: "Reutilizar aba de dados", - reuseDataTabDescription: "Ao reabrir a mesma tabela, alterna para a aba de dados existente; tabelas diferentes sempre usam abas separadas.", + reuseDataTabDescription: "Escolha como as abas de dados são usadas ao abrir uma tabela.", + dataTabReuseAlwaysNew: "Sempre abrir uma nova aba", + dataTabReuseAlwaysNewDescription: "Cria uma nova aba de dados todas as vezes, mesmo que a tabela já esteja aberta.", + dataTabReuseSameTable: "Reutilizar apenas a mesma tabela", + dataTabReuseSameTableDescription: "Alterna para a aba existente da mesma tabela e abre tabelas diferentes separadamente.", + dataTabReuseActiveTab: "Reutilizar a aba de dados ativa", + dataTabReuseActiveTabDescription: "Reutiliza a aba de dados ativa quando for seguro; abas protegidas são abertas separadamente.", sidebarHiddenTablePrefixes: "Prefixos de nome de tabela ocultos", sidebarHiddenTablePrefixesDescription: "Um prefixo por linha. Apenas os rótulos de tabela, view e coleção da barra lateral são encurtados; tooltips e ações ainda usam o nome completo.", sidebarHiddenTablePrefixesPlaceholder: "Exemplo:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ad586567a6..d923d790fe 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5051,7 +5051,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "不关闭相关页签", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。", reuseDataTab: "复用数据标签页", - reuseDataTabDescription: "重复打开同一张表时切换到已有数据标签页,不同表始终使用独立标签页。", + reuseDataTabDescription: "选择打开表时如何使用数据标签页。", + dataTabReuseAlwaysNew: "始终新建标签页", + dataTabReuseAlwaysNewDescription: "每次打开表都新建标签页,即使该表已经打开。", + dataTabReuseSameTable: "仅复用同一张表", + dataTabReuseSameTableDescription: "同表跳转到已有标签页,不同表分别打开。", + dataTabReuseActiveTab: "复用当前数据标签页", + dataTabReuseActiveTabDescription: "不同表复用当前安全的数据标签页;受保护的标签页仍会新建。", sidebarHiddenTablePrefixes: "隐藏表名前缀", sidebarHiddenTablePrefixesDescription: "每行一个前缀,仅影响侧边栏表、视图和集合的显示名称,悬浮提示和实际操作仍使用完整名称。", sidebarHiddenTablePrefixesPlaceholder: "例如:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9c96931960..c50988b315 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4289,7 +4289,13 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "不關閉相關分頁", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。", reuseDataTab: "重複使用資料分頁", - reuseDataTabDescription: "重複開啟同一資料表時切換到現有資料分頁,不同資料表一律使用獨立分頁。", + reuseDataTabDescription: "選擇開啟資料表時如何使用資料分頁。", + dataTabReuseAlwaysNew: "一律開啟新分頁", + dataTabReuseAlwaysNewDescription: "每次開啟資料表都建立新分頁,即使該資料表已經開啟。", + dataTabReuseSameTable: "僅重複使用同一資料表", + dataTabReuseSameTableDescription: "同一資料表切換到現有分頁,不同資料表分別開啟。", + dataTabReuseActiveTab: "重複使用目前資料分頁", + dataTabReuseActiveTabDescription: "不同資料表重複使用目前安全的資料分頁;受保護的分頁仍會另行開啟。", sidebarHiddenTablePrefixes: "隱藏資料表名稱字首", sidebarHiddenTablePrefixesDescription: "每行一個字首。只縮短側邊欄中的資料表、檢視和集合標籤;工具提示和實際操作仍使用完整名稱。", sidebarHiddenTablePrefixesPlaceholder: "範例:\nODS_\nT8Y2_LONG_", diff --git a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts index 1b53b84ba7..5f915365d0 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts @@ -52,8 +52,8 @@ describe("dataTabOpenPolicy", () => { const existing = dataTab("users", "users"); existing.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; - expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseScope: "same-table" })).toBeUndefined(); - expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseScope: "none" })).toBeUndefined(); + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseMode: "same-table" })).toBeUndefined(); + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseMode: "active-tab", activeTabId: existing.id })).toBeUndefined(); }); it("only reuses the same table when reuse is enabled", () => { @@ -61,16 +61,45 @@ describe("dataTabOpenPolicy", () => { sameTable.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; const otherTable = dataTab("orders", "orders"); - expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseScope: "none" })).toBeUndefined(); - expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseScope: "same-table" })).toEqual({ tab: sameTable, match: "same-table" }); - expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseScope: "same-table" })).toBeUndefined(); + expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseMode: "always-new" })).toBeUndefined(); + expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseMode: "same-table" })).toEqual({ tab: sameTable, match: "same-table" }); + expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseMode: "same-table" })).toBeUndefined(); + }); + + it("reuses the active safe data tab for a different table", () => { + const active = dataTab("orders", "orders"); + + expect(findExistingDataTabCandidate([active], usersTarget, { openMode: "default", reuseMode: "active-tab", activeTabId: active.id })).toEqual({ tab: active, match: "active-tab" }); + }); + + it.each([ + ["pinned", { pinned: true }], + ["executing", { isExecuting: true }], + ["cancelling", { isCancelling: true }], + ["explaining", { isExplaining: true }], + ["manual transaction", { txnSessionId: "txn-1" }], + ["pending edits", { pendingDataChangeCount: 1 }], + ])("does not reuse the active tab when it is %s", (_label, patch) => { + const active = Object.assign(dataTab("orders", "orders"), patch); + + expect(findExistingDataTabCandidate([active], usersTarget, { openMode: "default", reuseMode: "active-tab", activeTabId: active.id })).toBeUndefined(); + }); + + it("does not reuse an active tab from another database or catalog", () => { + const otherDatabase = dataTab("orders", "orders"); + otherDatabase.database = "archive"; + const otherCatalog = dataTab("catalog-orders", "orders"); + otherCatalog.catalog = "analytics"; + + expect(findExistingDataTabCandidate([otherDatabase], usersTarget, { openMode: "default", reuseMode: "active-tab", activeTabId: otherDatabase.id })).toBeUndefined(); + expect(findExistingDataTabCandidate([otherCatalog], usersTarget, { openMode: "default", reuseMode: "active-tab", activeTabId: otherCatalog.id })).toBeUndefined(); }); it("does not reuse a same-name table from another schema", () => { const archiveUsers = dataTab("archive-users", "users", "archive"); archiveUsers.tableMeta = { schema: "archive", tableName: "users", columns: [], primaryKeys: [] }; - expect(findExistingDataTabCandidate([archiveUsers], usersTarget, { openMode: "default", reuseScope: "same-table" })).toBeUndefined(); + expect(findExistingDataTabCandidate([archiveUsers], usersTarget, { openMode: "default", reuseMode: "same-table" })).toBeUndefined(); }); it("allows metadata to update a tab that still points to the requested table", () => { @@ -86,7 +115,7 @@ describe("dataTabOpenPolicy", () => { tab.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; expect(canApplyDataTabMetadata(tab, usersTarget, new AbortController().signal)).toBe(true); - expect(findExistingDataTabCandidate([tab], usersTarget, { openMode: "default", reuseScope: "same-table" })).toEqual({ tab, match: "same-table" }); + expect(findExistingDataTabCandidate([tab], usersTarget, { openMode: "default", reuseMode: "same-table" })).toEqual({ tab, match: "same-table" }); }); it("ignores metadata query schemas for database-scoped tables", () => { @@ -96,7 +125,7 @@ describe("dataTabOpenPolicy", () => { const mysqlTarget = { connectionId: "conn", database: "app", tableName: "users" }; expect(canApplyDataTabMetadata(tab, mysqlTarget, new AbortController().signal)).toBe(true); - expect(findExistingDataTabCandidate([tab], mysqlTarget, { openMode: "default", reuseScope: "same-table" })).toEqual({ tab, match: "same-table" }); + expect(findExistingDataTabCandidate([tab], mysqlTarget, { openMode: "default", reuseMode: "same-table" })).toEqual({ tab, match: "same-table" }); }); it("rejects metadata after its request is cancelled", () => { diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index c4f2ba26d3..cbc9c28e04 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -45,6 +45,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { it("includes the regular expression match limit", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("regexMaxMatchCount"); }); + + it("includes the data-tab reuse mode", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataTabReuseMode"); + }); }); describe("editorSettingsDraftFromSettings", () => { @@ -115,6 +119,14 @@ describe("editorSettingsDraftChanged", () => { draft.savedSqlOpenTargetMode = "current"; expect(editorSettingsDraftChanged(draft, base)).toBe(true); }); + + it("detects a data-tab reuse mode change", () => { + const settings = makeSettings({ dataTabReuseMode: "same-table" }); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.dataTabReuseMode = "active-tab"; + expect(editorSettingsDraftChanged(draft, base)).toBe(true); + }); }); describe("editorSettingsPatchFromDraft", () => { @@ -150,6 +162,14 @@ describe("editorSettingsPatchFromDraft", () => { draft.savedSqlOpenTargetMode = "current"; expect(editorSettingsPatchFromDraft(draft, base).savedSqlOpenTargetMode).toBe("current"); }); + + it("includes the data-tab reuse mode when changed", () => { + const settings = makeSettings({ dataTabReuseMode: "same-table" }); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.dataTabReuseMode = "always-new"; + expect(editorSettingsPatchFromDraft(draft, base).dataTabReuseMode).toBe("always-new"); + }); }); describe("EDITOR_SETTINGS_DRAFT_KEYS - tabLayout", () => { diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index d239303f04..1d277bd8fe 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -46,7 +46,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "autoSelectActiveSidebarNode", "openTabsRestoreMode", "disconnectTabHandlingMode", - "reuseDataTab", + "dataTabReuseMode", "prefillNewQueryWithSelect", "updateNotificationsEnabled", "sidebarObjectInfoMode", diff --git a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts index 08ddf4eeef..5dddd2f89d 100644 --- a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts +++ b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts @@ -1,10 +1,11 @@ import { matchesModifierOnlyShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; import type { QueryTab, TreeNodeType } from "@/types/database"; export type DataTabOpenMode = "default" | "new-tab"; -export type DataTabReuseScope = "none" | "same-table"; +export type { DataTabReuseMode }; -type DataTabLike = Pick; +type DataTabLike = Pick; export interface DataTabTarget { connectionId: string; @@ -16,7 +17,7 @@ export interface DataTabTarget { export type ExistingDataTabCandidate = { tab: T; - match: "same-table"; + match: "same-table" | "active-tab"; }; const dataNodeTypes = new Set(["table", "view", "materialized_view"]); @@ -34,9 +35,27 @@ function isSameDatabase(tab: DataTabLike, target: Pick= maxAgeMs; } -export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseScope: DataTabReuseScope }): ExistingDataTabCandidate | undefined { - if (options.openMode === "new-tab" || options.reuseScope === "none") return undefined; +export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseMode: DataTabReuseMode; activeTabId?: string | null }): ExistingDataTabCandidate | undefined { + if (options.openMode === "new-tab" || options.reuseMode === "always-new") return undefined; const sameTable = tabs.find((tab) => isSameTable(tab, target)); if (sameTable) return { tab: sameTable, match: "same-table" }; + + if (options.reuseMode === "active-tab") { + const activeTab = tabs.find((tab) => tab.id === options.activeTabId); + if (activeTab && canReuseActiveDataTab(activeTab, target)) return { tab: activeTab, match: "active-tab" }; + } return undefined; } diff --git a/apps/desktop/src/lib/tabs/dataTabActivation.ts b/apps/desktop/src/lib/tabs/dataTabActivation.ts index 131728bfbf..509411e967 100644 --- a/apps/desktop/src/lib/tabs/dataTabActivation.ts +++ b/apps/desktop/src/lib/tabs/dataTabActivation.ts @@ -1,5 +1,6 @@ import type { QueryTab } from "@/types/database"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; export type DataTableDoubleClickAction = "activate" | "open" | "none"; @@ -9,9 +10,9 @@ export function canActivateExistingDataTableTab(tab: QueryTab, options: { activa return !!tab.result || !!tab.results?.length; } -export function dataTableDoubleClickAction(tab: QueryTab | undefined, activation: "single" | "double", reuseDataTab = true): DataTableDoubleClickAction { +export function dataTableDoubleClickAction(tab: QueryTab | undefined, activation: "single" | "double", reuseMode: DataTabReuseMode = "same-table"): DataTableDoubleClickAction { if (activation === "single") return "none"; - if (!reuseDataTab) return "open"; + if (reuseMode === "always-new") return "open"; if (!tab) return activation === "double" ? "open" : "none"; if (!canActivateExistingDataTableTab(tab)) return "open"; return "activate"; diff --git a/apps/desktop/src/lib/tabs/dataTabReuseMode.ts b/apps/desktop/src/lib/tabs/dataTabReuseMode.ts new file mode 100644 index 0000000000..05d22f371a --- /dev/null +++ b/apps/desktop/src/lib/tabs/dataTabReuseMode.ts @@ -0,0 +1,14 @@ +export const DATA_TAB_REUSE_MODES = ["always-new", "same-table", "active-tab"] as const; + +export type DataTabReuseMode = (typeof DATA_TAB_REUSE_MODES)[number]; + +export const DEFAULT_DATA_TAB_REUSE_MODE: DataTabReuseMode = "same-table"; + +const dataTabReuseModes = new Set(DATA_TAB_REUSE_MODES); + +export function normalizeDataTabReuseMode(value: unknown, legacyReuseDataTab?: unknown): DataTabReuseMode { + if (typeof value === "string" && dataTabReuseModes.has(value as DataTabReuseMode)) return value as DataTabReuseMode; + if (legacyReuseDataTab === true) return "same-table"; + if (legacyReuseDataTab === false) return "always-new"; + return DEFAULT_DATA_TAB_REUSE_MODE; +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index dfaed967d1..01b9a73fa9 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -5,6 +5,15 @@ import { isProxy } from "vue"; import type { AiConfigItem } from "@/types/ai"; describe("normalizeEditorSettings", () => { + it("defaults and migrates the data-tab reuse mode", () => { + expect(normalizeEditorSettings({}).dataTabReuseMode).toBe("same-table"); + expect(normalizeEditorSettings({ dataTabReuseMode: "always-new" }).dataTabReuseMode).toBe("always-new"); + expect(normalizeEditorSettings({ dataTabReuseMode: "active-tab" }).dataTabReuseMode).toBe("active-tab"); + expect(normalizeEditorSettings({ dataTabReuseMode: "invalid" } as any).dataTabReuseMode).toBe("same-table"); + expect(normalizeEditorSettings({ reuseDataTab: false } as any).dataTabReuseMode).toBe("always-new"); + expect(normalizeEditorSettings({ reuseDataTab: true } as any).dataTabReuseMode).toBe("same-table"); + }); + it("defaults and bounds the regular expression match limit", () => { expect(normalizeEditorSettings({}).regexMaxMatchCount).toBe(1000); expect(normalizeEditorSettings({ regexMaxMatchCount: 2500 }).regexMaxMatchCount).toBe(2500); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 36d9fb4dba..dfc4f00855 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -12,6 +12,7 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type Sql import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax"; import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick"; +import { DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode, type DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; import type { SqlSnippet, TableInfoTab } from "@/types/database"; import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion"; import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; @@ -22,6 +23,7 @@ import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLe export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem, AiChatSelectionState, AiEffortSelection }; export type { SavedSqlOpenTargetMode }; +export type { DataTabReuseMode }; export interface DesktopSettings { show_tray_icon: boolean; @@ -500,7 +502,7 @@ export interface EditorSettings { autoSelectActiveSidebarNode: boolean; openTabsRestoreMode: OpenTabsRestoreMode; disconnectTabHandlingMode: DisconnectTabHandlingMode; - reuseDataTab: boolean; + dataTabReuseMode: DataTabReuseMode; prefillNewQueryWithSelect: boolean; updateNotificationsEnabled: boolean; sidebarHiddenTablePrefixes: string[]; @@ -678,7 +680,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { autoSelectActiveSidebarNode: false, openTabsRestoreMode: "all", disconnectTabHandlingMode: "close-tabs", - reuseDataTab: false, + dataTabReuseMode: DEFAULT_DATA_TAB_REUSE_MODE, prefillNewQueryWithSelect: true, updateNotificationsEnabled: true, sidebarHiddenTablePrefixes: [], @@ -1001,7 +1003,14 @@ export function normalizeEditorSettings(settings: Partial, exist } ).closeQueryTabsOnDisconnect, ), - reuseDataTab: settings.reuseDataTab ?? DEFAULT_EDITOR_SETTINGS.reuseDataTab, + dataTabReuseMode: normalizeDataTabReuseMode( + settings.dataTabReuseMode, + ( + settings as Partial & { + reuseDataTab?: boolean; + } + ).reuseDataTab, + ), prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), @@ -1473,7 +1482,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.autoSelectActiveSidebarNode !== undefined) editorSettings.value.autoSelectActiveSidebarNode = partial.autoSelectActiveSidebarNode; if (partial.openTabsRestoreMode !== undefined) editorSettings.value.openTabsRestoreMode = normalizeOpenTabsRestoreMode(partial.openTabsRestoreMode); if (partial.disconnectTabHandlingMode !== undefined) editorSettings.value.disconnectTabHandlingMode = normalizeDisconnectTabHandlingMode(partial.disconnectTabHandlingMode); - if (partial.reuseDataTab !== undefined) editorSettings.value.reuseDataTab = partial.reuseDataTab; + if (partial.dataTabReuseMode !== undefined) editorSettings.value.dataTabReuseMode = normalizeDataTabReuseMode(partial.dataTabReuseMode); if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect; if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled; if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index f91edae25a..a280b029e0 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1058,6 +1058,7 @@ export interface QueryTab { primaryKeys: string[]; }; tableMetaUpdatedAt?: number; + pendingDataChangeCount?: number; /** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */ tableMetaPending?: boolean; /** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清), diff --git a/packages/app-tests/dataTabActivation.test.ts b/packages/app-tests/dataTabActivation.test.ts index 83c9aaea8f..aef2e6612b 100644 --- a/packages/app-tests/dataTabActivation.test.ts +++ b/packages/app-tests/dataTabActivation.test.ts @@ -102,8 +102,8 @@ test("double activation opens a missing table without a first-click snapshot", ( assert.equal(dataTableDoubleClickAction(undefined, "double"), "open"); }); -test("double activation opens a new table when data tab reuse is disabled", () => { - assert.equal(dataTableDoubleClickAction(dataTab({ isExecuting: true }), "double", false), "open"); +test("double activation opens a new table in always-new mode", () => { + assert.equal(dataTableDoubleClickAction(dataTab({ isExecuting: true }), "double", "always-new"), "open"); assert.equal( dataTableDoubleClickAction( dataTab({ @@ -115,7 +115,7 @@ test("double activation opens a new table when data tab reuse is disabled", () = }, }), "double", - false, + "always-new", ), "open", );