fix(tabs): honor data tab reuse across activation paths

This commit is contained in:
zipg authored and GitHub committed 2026-07-31 14:25:21 +08:00
1 parent 9b8f3956b6
commit dd383ff3ea
18 files changed
+157 -34

No files matched your search

@@ -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: [
@@ -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 需先等待取消
@@ -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 };
}
@@ -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 });