feat(tabs): add configurable data tab reuse modes

This commit is contained in:
t8y2 committed 2026-08-05 13:05:26 +08:00
1 parent 1a818563b7
commit d65d712089
26 files changed
+393 -98

No files matched your search

@@ -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<ShortcutActionId | null>(null);
const editSidebarActivation = ref(settingsStore.editorSettings.sidebarActivation);
const editSidebarObjectDisplay = ref(settingsStore.editorSettings.sidebarObjectDisplay);
const sidebarObjectDisplayHelp = ref<"grouped" | "simple" | null>(null);
const dataTabReuseModeHelp = ref<DataTabReuseMode | null>(null);
const editRoutineSourceOpenMode = ref(settingsStore.editorSettings.routineSourceOpenMode);
const editSidebarTableSearchEnabled = ref(settingsStore.editorSettings.sidebarTableSearchEnabled);
const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode);
const editOpenTabsRestoreMode = ref<OpenTabsRestoreMode>(settingsStore.editorSettings.openTabsRestoreMode);
const editDisconnectTabHandlingMode = ref<DisconnectTabHandlingMode>(settingsStore.editorSettings.disconnectTabHandlingMode);
const editReuseDataTab = ref(settingsStore.editorSettings.reuseDataTab);
const editDataTabReuseMode = ref<DataTabReuseMode>(settingsStore.editorSettings.dataTabReuseMode);
const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect);
const editClickTableNavigationTarget = ref<ClickTableNavigationTarget>(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(() => {
</Button>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-2">
<div class="flex items-center gap-2">
<Label for="reuse-data-tab">{{ t("settings.reuseDataTab") }}</Label>
<Label>{{ t("settings.reuseDataTab") }}</Label>
<HelpTooltip :label="t('settings.reuseDataTab')">
{{ t("settings.reuseDataTabDescription") }}
</HelpTooltip>
</div>
<Switch id="reuse-data-tab" v-model="editReuseDataTab" />
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Button type="button" variant="outline" class="h-auto items-start justify-start border p-3" :class="editDataTabReuseMode === 'always-new' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'always-new'">
<div class="text-left">
<div class="flex items-center gap-2">
<div class="text-sm font-medium">{{ t("settings.dataTabReuseAlwaysNew") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'always-new'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'always-new'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseAlwaysNewDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="h-auto items-start justify-start border p-3" :class="editDataTabReuseMode === 'same-table' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'same-table'">
<div class="text-left">
<div class="flex items-center gap-2">
<div class="text-sm font-medium">{{ t("settings.dataTabReuseSameTable") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'same-table'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'same-table'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseSameTableDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
<Button type="button" variant="outline" class="h-auto items-start justify-start border p-3" :class="editDataTabReuseMode === 'active-tab' ? 'dbx-choice-selected' : ''" @click="editDataTabReuseMode = 'active-tab'">
<div class="text-left">
<div class="flex items-center gap-2">
<div class="text-sm font-medium">{{ t("settings.dataTabReuseActiveTab") }}</div>
<Tooltip :open="dataTabReuseModeHelp === 'active-tab'">
<TooltipTrigger as-child>
<span class="inline-flex shrink-0 cursor-help text-muted-foreground hover:text-foreground" @click.stop @pointerdown.stop @mouseenter="dataTabReuseModeHelp = 'active-tab'" @mouseleave="dataTabReuseModeHelp = null">
<CircleHelp class="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="center" :side-offset="8">
{{ t("settings.dataTabReuseActiveTabDescription") }}
</TooltipContent>
</Tooltip>
</div>
</div>
</Button>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("settings.sidebarObjectDisplay") }}</Label>
@@ -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,
@@ -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);
@@ -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", () => ({
@@ -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();
@@ -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<void>((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();
});
@@ -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 },
);
}
@@ -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 });
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
+7 -1
View File
@@ -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_",
@@ -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", () => {
@@ -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", () => {
@@ -46,7 +46,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"autoSelectActiveSidebarNode",
"openTabsRestoreMode",
"disconnectTabHandlingMode",
"reuseDataTab",
"dataTabReuseMode",
"prefillNewQueryWithSelect",
"updateNotificationsEnabled",
"sidebarObjectInfoMode",
@@ -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<QueryTab, "id" | "mode" | "connectionId" | "database" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt">;
type DataTabLike = Pick<QueryTab, "id" | "mode" | "connectionId" | "database" | "catalog" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt" | "pinned" | "isExecuting" | "isCancelling" | "isExplaining" | "txnSessionId" | "pendingDataChangeCount">;
export interface DataTabTarget {
connectionId: string;
@@ -16,7 +17,7 @@ export interface DataTabTarget {
export type ExistingDataTabCandidate<T extends DataTabLike> = {
tab: T;
match: "same-table";
match: "same-table" | "active-tab";
};
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
@@ -34,9 +35,27 @@ function isSameDatabase(tab: DataTabLike, target: Pick<DataTabTarget, "connectio
return tab.mode === "data" && tab.connectionId === target.connectionId && tab.database === target.database;
}
function dataTabCatalog(tab: DataTabLike): string {
return tab.tableMeta?.catalog || tab.catalog || "";
}
function isSameTable(tab: DataTabLike, target: DataTabTarget): boolean {
const tabSchema = tab.schema || (target.schema ? tab.tableMeta?.schema : undefined) || "";
return isSameDatabase(tab, target) && (tab.tableMeta?.catalog || "") === (target.catalog || "") && tabSchema === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName;
return isSameDatabase(tab, target) && dataTabCatalog(tab) === (target.catalog || "") && tabSchema === (target.schema || "") && (tab.tableMeta?.tableName || tab.title) === target.tableName;
}
export function canReuseActiveDataTab(tab: DataTabLike | undefined, target: DataTabTarget): boolean {
return (
tab !== undefined &&
isSameDatabase(tab, target) &&
dataTabCatalog(tab) === (target.catalog || "") &&
!tab.pinned &&
!tab.isExecuting &&
!tab.isCancelling &&
!tab.isExplaining &&
!tab.txnSessionId &&
!tab.pendingDataChangeCount
);
}
export function canApplyDataTabMetadata(tab: DataTabLike | undefined, target: DataTabTarget, signal?: AbortSignal): boolean {
@@ -48,10 +67,15 @@ export function dataTabMetadataNeedsRefresh(tab: DataTabLike, maxAgeMs: number,
return now - tab.tableMetaUpdatedAt >= maxAgeMs;
}
export function findExistingDataTabCandidate<T extends DataTabLike>(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseScope: DataTabReuseScope }): ExistingDataTabCandidate<T> | undefined {
if (options.openMode === "new-tab" || options.reuseScope === "none") return undefined;
export function findExistingDataTabCandidate<T extends DataTabLike>(tabs: T[], target: DataTabTarget, options: { openMode: DataTabOpenMode; reuseMode: DataTabReuseMode; activeTabId?: string | null }): ExistingDataTabCandidate<T> | 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;
}
@@ -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";
@@ -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<DataTabReuseMode>(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;
}
@@ -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);
+13 -4
View File
@@ -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<EditorSettings>, exist
}
).closeQueryTabsOnDisconnect,
),
reuseDataTab: settings.reuseDataTab ?? DEFAULT_EDITOR_SETTINGS.reuseDataTab,
dataTabReuseMode: normalizeDataTabReuseMode(
settings.dataTabReuseMode,
(
settings as Partial<EditorSettings> & {
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);
+1
View File
@@ -1058,6 +1058,7 @@ export interface QueryTab {
primaryKeys: string[];
};
tableMetaUpdatedAt?: number;
pendingDataChangeCount?: number;
/** 冷缓存打开表数据时元数据仍在途:行标识未知,编辑/保存必须等待其落地 */
tableMetaPending?: boolean;
/** 取消请求单调计数:isCancelling 是瞬态的(取消失败/查询先完成会被清),