From dd383ff3ea088c67d324223c4c6d1cf657299f53 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 31 Jul 2026 14:25:21 +0800 Subject: [PATCH] fix(tabs): honor data tab reuse across activation paths --- apps/desktop/src/App.vue | 4 +- .../sidebar/SidebarTreeRuntimeHost.vue | 2 +- .../useNavigationTargets.store.spec.ts | 40 ++++++++++++++++++ .../useSidebarDataOpenRuntime.spec.ts | 42 ++++++++++++++++--- .../src/composables/useNavigationTargets.ts | 30 ++++++++++++- .../composables/useSidebarDataOpenRuntime.ts | 11 ++--- apps/desktop/src/i18n/locales/en.ts | 2 +- apps/desktop/src/i18n/locales/es.ts | 2 +- apps/desktop/src/i18n/locales/it.ts | 2 +- apps/desktop/src/i18n/locales/ja.ts | 2 +- apps/desktop/src/i18n/locales/ko.ts | 2 +- apps/desktop/src/i18n/locales/pt-BR.ts | 2 +- apps/desktop/src/i18n/locales/zh-CN.ts | 2 +- apps/desktop/src/i18n/locales/zh-TW.ts | 2 +- .../sidebar/dataTabOpenPolicy.spec.ts | 17 ++++---- .../src/lib/sidebar/dataTabOpenPolicy.ts | 7 ++-- .../desktop/src/lib/tabs/dataTabActivation.ts | 3 +- packages/app-tests/dataTabActivation.test.ts | 19 +++++++++ 18 files changed, 157 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d6c8e220b..03c028013 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -343,7 +343,7 @@ function requestActiveEditorExecuteInNewResultTab() { const dialogs = useDialogSources(); const { getDatabaseOptions } = useDatabaseOptions(); -const { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs); +const { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, openObjectBrowserTableTarget, onStructureEditorSaved, openTableTarget } = useNavigationTargets(dialogs); const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(activeTab); const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openTableTarget, @@ -2371,7 +2371,7 @@ onUnmounted(() => { @open-object-table=" (target) => activeTab && - openTableTarget({ + openObjectBrowserTableTarget({ connectionId: activeTab.connectionId, database: activeTab.database, schema: target.schema, diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index c81292094..3357c7c5f 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1042,7 +1042,7 @@ function activateDataTableFromDoubleClick() { if (node.type !== "table" || !hasNodeDatabaseContext(node)) return; const activation = settingsStore.editorSettings.sidebarActivation; const existingSameTableTab = findExistingSameTableDataTab(); - const action = dataTableDoubleClickAction(existingSameTableTab, activation); + const action = dataTableDoubleClickAction(existingSameTableTab, activation, settingsStore.editorSettings.reuseDataTab); if (action === "none") return; if (action === "open") { openDataImmediately(node); diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index c368bc319..22e6b754d 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -98,6 +98,7 @@ describe("useNavigationTargets with the real query store", () => { vi.unstubAllGlobals(); installLocalStorage(); mocks.connectionStore.activeConnectionId = ""; + mocks.settingsStore.editorSettings.reuseDataTab = true; mocks.ensureConnected?.mockResolvedValue?.(undefined); mocks.connectionStore.ensureConnected.mockResolvedValue(undefined); mocks.loadOpenTabsState.mockResolvedValue(null); @@ -131,6 +132,45 @@ describe("useNavigationTargets with the real query store", () => { expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(['SELECT * FROM users WHERE "id" = 1', 'SELECT * FROM users WHERE "id" = 2']); }); + it("reuses the same object-browser table without reusing tabs across different tables", async () => { + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users", tableType: "TABLE" }; + + await navigation.openObjectBrowserTableTarget(target); + await navigation.openObjectBrowserTableTarget(target); + await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" }); + + expect(queryStore.tabs).toHaveLength(2); + expect(queryStore.tabs.map((tab) => tab.tableMeta?.tableName)).toEqual(["users", "orders"]); + expect(queryStore.tabs.map((tab) => tab.sql)).toEqual(["SELECT * FROM users", "SELECT * FROM orders"]); + 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; + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users", tableType: "TABLE" }; + + await navigation.openObjectBrowserTableTarget(target); + await navigation.openObjectBrowserTableTarget(target); + + expect(queryStore.tabs).toHaveLength(2); + }); + + it("keeps repeated sidebar opens independent when data-tab reuse is disabled", async () => { + mocks.settingsStore.editorSettings.reuseDataTab = false; + const { queryStore } = await setupNavigation(); + const { useSidebarDataOpenRuntime } = await import("@/composables/useSidebarDataOpenRuntime"); + const runtime = useSidebarDataOpenRuntime(); + const node = { id: "users", label: "users", type: "table" as const, connectionId: "connection-1", database: "app", schema: "public", tableType: "TABLE" }; + + await runtime.openData(node); + await runtime.openData(node); + + expect(queryStore.tabs).toHaveLength(2); + expect(new Set(queryStore.tabs.map((tab) => tab.id))).toHaveLength(2); + }); + it("creates a new target tab even when the same table was restored", async () => { mocks.loadOpenTabsState.mockResolvedValue({ tabs: [ diff --git a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts index 4510b9660..ea0f70abf 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDataOpenRuntime.spec.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ callOrder: [] as string[], tabs: [] as QueryTab[], cachedMetadata: undefined as unknown, + reuseDataTab: false, ensureConnected: vi.fn(), executeTabSql: vi.fn(), loadTableMetadata: vi.fn(), @@ -25,14 +26,19 @@ vi.mock("@/stores/connectionStore", () => ({ vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tabs: mocks.tabs, - createTab: (connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string) => { + 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; + } const tab = { - id: "tab-1", + id: `tab-${mocks.tabs.length + 1}`, connectionId, database, title, mode, schema, + catalog, sql: "", isDirty: false, isExecuting: false, @@ -69,7 +75,7 @@ vi.mock("@/stores/queryStore", () => ({ })); vi.mock("@/stores/settingsStore", () => ({ - useSettingsStore: () => ({ editorSettings: { reuseDataTab: false, pageSize: 100 } }), + useSettingsStore: () => ({ editorSettings: { reuseDataTab: mocks.reuseDataTab, pageSize: 100 } }), })); vi.mock("@/lib/database/jdbcDialect", () => ({ @@ -89,8 +95,7 @@ vi.mock("@/lib/metadata/tableMetadataCache", async (importOriginal) => { vi.mock("@/lib/common/utils", () => ({ uuid: () => "open-data-id" })); vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn(), isDebugLoggingEnabled: () => false })); -// dataTabOpenPolicy 使用真实实现:beforeEach 清空 tabs 时无候选可复用, -// 取消窗口测试则依赖真实 findExistingDataTabCandidate 选中同表 tab +// dataTabOpenPolicy 使用真实实现,覆盖设置开关对应的复用范围 vi.mock("@/lib/sidebar/treeNodeContext", () => ({ hasTreeNodeDatabaseContext: () => true })); vi.mock("@/lib/table/tableSelectSql", () => ({ buildTableSelectSql: async () => "SELECT * FROM users" })); vi.mock("@/lib/table/tableEditing", () => ({ usesSyntheticRowIdKey: () => false })); @@ -114,6 +119,7 @@ describe("useSidebarDataOpenRuntime", () => { mocks.callOrder.length = 0; mocks.tabs.length = 0; mocks.cachedMetadata = undefined; + mocks.reuseDataTab = false; mocks.ensureConnected.mockResolvedValue(undefined); mocks.executeTabSql.mockImplementation(async () => { mocks.callOrder.push("query"); @@ -137,6 +143,31 @@ describe("useSidebarDataOpenRuntime", () => { }); }); + it("creates a new sidebar tab for the same table when reuse is disabled", async () => { + 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; + + await useSidebarDataOpenRuntime().openData(tableNode); + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs).toHaveLength(1); + }); + + it("creates a new HBase tab for the same table when reuse is disabled", async () => { + mocks.databaseType = "hbase"; + + await useSidebarDataOpenRuntime().openData(tableNode); + await useSidebarDataOpenRuntime().openData(tableNode); + + expect(mocks.tabs).toHaveLength(2); + }); + it("starts cold-cache OceanBase metadata before the table query", async () => { await useSidebarDataOpenRuntime().openData(tableNode); @@ -242,6 +273,7 @@ describe("useSidebarDataOpenRuntime", () => { }); 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 需先等待取消 diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index aa9eefe0d..8a1f2d93b 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -8,6 +8,7 @@ import { editableRowIdentifierColumns, usesSyntheticRowIdKey } from "@/lib/table import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { uuid } from "@/lib/common/utils"; import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration"; +import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -253,6 +254,33 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: boolean }; showDatabaseSearchDialog: { value: boolean }; showDiagramDialog: { value: boolean } }) { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); + const settingsStore = useSettingsStore(); + const { openData } = useSidebarDataOpenRuntime(); + + async function openObjectBrowserTableTarget(target: NavigationTarget) { + if (!settingsStore.editorSettings.reuseDataTab) { + await openTableTarget(target); + return; + } + connectionStore.activeConnectionId = target.connectionId; + const normalizedTableType = target.tableType?.trim().toUpperCase().replaceAll(" ", "_"); + const nodeType = normalizedTableType === "VIEW" ? "view" : normalizedTableType === "MATERIALIZED_VIEW" ? "materialized_view" : "table"; + await openData( + { + id: uuid(), + label: target.tableName, + type: nodeType, + connectionId: target.connectionId, + database: target.database, + schema: target.schema, + catalog: target.catalog, + tableType: target.tableType, + }, + undefined, + "default", + { reuseScope: "same-table" }, + ); + } async function openLineageTarget(target: NavigationTarget) { dialogs.showFieldLineageDialog.value = false; @@ -332,5 +360,5 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: } } - return { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, onStructureEditorSaved, openTableTarget }; + return { openLineageTarget, openDatabaseSearchTarget, openDiagramTarget, openObjectBrowserTableTarget, onStructureEditorSaved, openTableTarget }; } diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 212c313e4..cebde0a41 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 } from "@/lib/sidebar/dataTabOpenPolicy"; +import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, type DataTabOpenMode, type DataTabReuseScope } from "@/lib/sidebar/dataTabOpenPolicy"; import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; @@ -27,12 +27,13 @@ export function useSidebarDataOpenRuntime() { const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); - async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") { + async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: { reuseScope?: DataTabReuseScope } = {}) { 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 ? "database" : "none"); 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" }); + const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" || reuseScope === "none" }); queryStore.updateSql(tabId, node.label); return; } @@ -125,7 +126,7 @@ export function useSidebarDataOpenRuntime() { openDataLog("warn", "metadata:error", { traceId, tabId: targetTabId, elapsed: elapsed(), error }); } }; - const existingDataTabCandidate = findExistingDataTabCandidate(queryStore.tabs, dataTabTarget, { openMode, reuseDataTab: settingsStore.editorSettings.reuseDataTab }); + const existingDataTabCandidate = findExistingDataTabCandidate(queryStore.tabs, dataTabTarget, { openMode, reuseScope }); const existingSameTableTab = existingDataTabCandidate?.match === "same-table" ? existingDataTabCandidate.tab : undefined; const resetReusedDataTabState = (tab: (typeof queryStore.tabs)[number]) => { tab.title = node.label; @@ -170,7 +171,7 @@ export function useSidebarDataOpenRuntime() { resetReusedDataTabState(existingDataTabCandidate.tab); return existingDataTabCandidate.tab.id; } - return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema); + return queryStore.createTab(node.connectionId, node.database, node.label, "data", tableSchema, undefined, node.catalog, { forceNew: reuseScope === "none" || openMode === "new-tab" }); })(); 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 f6cc9628b..45bb9421a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4717,7 +4717,7 @@ 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: "When clicking a table in the sidebar, reuse the existing data tab instead of creating a new one each time.", + reuseDataTabDescription: "Reuse data tabs when opening tables from the sidebar; when reopening the same table from the object browser, switch to its existing tab.", 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 c8fe020ad..264b00f0d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4493,7 +4493,7 @@ 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 hacer clic en una tabla en la barra lateral, reutiliza la pestaña de datos existente en lugar de crear una nueva cada vez.", + reuseDataTabDescription: "Reutiliza las pestañas de datos al abrir tablas desde la barra lateral; al volver a abrir la misma tabla desde el explorador de objetos, cambia a su pestaña existente.", 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 c7eac48df..2327653c6 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4493,7 +4493,7 @@ 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 fai clic su una tabella nella barra laterale, riutilizza la scheda dati esistente invece di crearne una nuova ogni volta.", + reuseDataTabDescription: "Riutilizza le schede dati quando apri tabelle dalla barra laterale; quando riapri la stessa tabella dal browser degli oggetti, passa alla scheda esistente.", 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 98e088559..9f751c29c 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4484,7 +4484,7 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "関連タブを閉じない", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。", reuseDataTab: "データタブを再利用", - reuseDataTabDescription: "サイドバーでテーブルをクリックした際、毎回新しいタブを作成せずに既存のデータタブを再利用します。", + reuseDataTabDescription: "サイドバーからテーブルを開く際はデータタブを再利用し、オブジェクトブラウザーから同じテーブルを再度開く際は既存のタブに切り替えます。", 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 c3b66bcc9..491a9867f 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4467,7 +4467,7 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "관련 탭 닫지 않기", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.", reuseDataTab: "데이터 탭 재사용", - reuseDataTabDescription: "사이드바에서 테이블을 클릭할 때 매번 새로 만드는 대신 기존 데이터 탭을 재사용합니다.", + reuseDataTabDescription: "사이드바에서 테이블을 열 때 데이터 탭을 재사용하고, 개체 브라우저에서 같은 테이블을 다시 열면 기존 탭으로 전환합니다.", 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 255d423e0..910d81a9e 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4495,7 +4495,7 @@ 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 clicar em uma tabela na barra lateral, reutilizar a aba de dados existente em vez de criar uma nova a cada vez.", + reuseDataTabDescription: "Reutiliza abas de dados ao abrir tabelas pela barra lateral; ao reabrir a mesma tabela pelo navegador de objetos, alterna para a aba existente.", 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 e2821da78..fa2df8ff1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4717,7 +4717,7 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "不关闭相关页签", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。", reuseDataTab: "复用数据标签页", - reuseDataTabDescription: "单击侧边栏表名时,复用已有的数据标签页而不是创建新标签页,避免打开过多标签。", + reuseDataTabDescription: "从侧边栏打开表时复用数据标签页;从浏览对象重复打开同一张表时切换到已有标签页。", 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 807e07deb..8168a086b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3956,7 +3956,7 @@ export default withEnglishFallback({ disconnectTabHandlingModeKeepTabsKeepResults: "不關閉相關分頁", disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。", reuseDataTab: "重複使用資料分頁", - reuseDataTabDescription: "點擊側邊欄資料表名稱時,重複使用現有的資料分頁而不是建立新分頁,避免開啟過多分頁。", + reuseDataTabDescription: "從側邊欄開啟資料表時重複使用資料分頁;從瀏覽物件重複開啟同一資料表時切換到現有分頁。", 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 eaaae262b..399597332 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/dataTabOpenPolicy.spec.ts @@ -52,18 +52,19 @@ describe("dataTabOpenPolicy", () => { const existing = dataTab("users", "users"); existing.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; - expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: true })).toBeUndefined(); - expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseDataTab: false })).toBeUndefined(); + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseScope: "database" })).toBeUndefined(); + expect(findExistingDataTabCandidate([existing], usersTarget, { openMode: "new-tab", reuseScope: "none" })).toBeUndefined(); }); - it("preserves same-table activation and configured database-tab reuse for ordinary opens", () => { + it("applies none, same-table, and database reuse scopes independently", () => { const sameTable = dataTab("users", "users"); sameTable.tableMeta = { schema: "public", tableName: "users", columns: [], primaryKeys: [] }; const otherTable = dataTab("orders", "orders"); - expect(findExistingDataTabCandidate([sameTable], usersTarget, { openMode: "default", reuseDataTab: false })).toEqual({ tab: sameTable, match: "same-table" }); - expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: true })).toEqual({ tab: otherTable, match: "database" }); - expect(findExistingDataTabCandidate([otherTable], usersTarget, { openMode: "default", reuseDataTab: false })).toBeUndefined(); + 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([otherTable], usersTarget, { openMode: "default", reuseScope: "database" })).toEqual({ tab: otherTable, match: "database" }); }); it("allows metadata to update a tab that still points to the requested table", () => { @@ -79,7 +80,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", reuseDataTab: false })).toEqual({ tab, match: "same-table" }); + expect(findExistingDataTabCandidate([tab], usersTarget, { openMode: "default", reuseScope: "same-table" })).toEqual({ tab, match: "same-table" }); }); it("ignores metadata query schemas for database-scoped tables", () => { @@ -89,7 +90,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", reuseDataTab: false })).toEqual({ tab, match: "same-table" }); + expect(findExistingDataTabCandidate([tab], mysqlTarget, { openMode: "default", reuseScope: "same-table" })).toEqual({ tab, match: "same-table" }); }); it("rejects metadata after its request is cancelled", () => { diff --git a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts index 814508d29..5ba9752d1 100644 --- a/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts +++ b/apps/desktop/src/lib/sidebar/dataTabOpenPolicy.ts @@ -2,6 +2,7 @@ import { matchesModifierOnlyShortcut, type ShortcutLikeEvent } from "@/lib/edito import type { QueryTab, TreeNodeType } from "@/types/database"; export type DataTabOpenMode = "default" | "new-tab"; +export type DataTabReuseScope = "none" | "same-table" | "database"; type DataTabLike = Pick; @@ -47,12 +48,12 @@ export function dataTabMetadataNeedsRefresh(tab: DataTabLike, maxAgeMs: number, return now - tab.tableMetaUpdatedAt >= maxAgeMs; } -export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseDataTab: boolean }): ExistingDataTabCandidate | undefined { - if (options.openMode === "new-tab") return undefined; +export function findExistingDataTabCandidate(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseScope: DataTabReuseScope }): ExistingDataTabCandidate | undefined { + if (options.openMode === "new-tab" || options.reuseScope === "none") return undefined; const sameTable = tabs.find((tab) => isSameTable(tab, target)); if (sameTable) return { tab: sameTable, match: "same-table" }; - if (!options.reuseDataTab) return undefined; + if (options.reuseScope === "same-table") return undefined; const sameDatabase = tabs.find((tab) => isSameDatabase(tab, target)); return sameDatabase ? { tab: sameDatabase, match: "database" } : undefined; diff --git a/apps/desktop/src/lib/tabs/dataTabActivation.ts b/apps/desktop/src/lib/tabs/dataTabActivation.ts index e4268e2be..0284497c9 100644 --- a/apps/desktop/src/lib/tabs/dataTabActivation.ts +++ b/apps/desktop/src/lib/tabs/dataTabActivation.ts @@ -12,8 +12,9 @@ export function canActivateExistingDataTableTab(tab: QueryTab, options: { activa return !!tab.result || !!tab.results?.length; } -export function dataTableDoubleClickAction(tab: QueryTab | undefined, activation: "single" | "double"): DataTableDoubleClickAction { +export function dataTableDoubleClickAction(tab: QueryTab | undefined, activation: "single" | "double", reuseDataTab = true): DataTableDoubleClickAction { if (activation === "single") return "none"; + if (!reuseDataTab) return "open"; if (!tab) return activation === "double" ? "open" : "none"; if (!canActivateExistingDataTableTab(tab)) return "open"; return "activate"; diff --git a/packages/app-tests/dataTabActivation.test.ts b/packages/app-tests/dataTabActivation.test.ts index cae75fce2..f570ab4b9 100644 --- a/packages/app-tests/dataTabActivation.test.ts +++ b/packages/app-tests/dataTabActivation.test.ts @@ -85,6 +85,25 @@ 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"); + assert.equal( + dataTableDoubleClickAction( + dataTab({ + result: { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + }, + }), + "double", + false, + ), + "open", + ); +}); + test("double activation reuses loading and successful tabs without refreshing", () => { assert.equal(dataTableDoubleClickAction(dataTab({ isExecuting: true }), "double"), "activate"); assert.equal(