fix(tabs): honor data tab reuse across activation paths
This commit is contained in:
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 });
|
||||
|
||||
Reference in new issue
Block a user