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

This commit is contained in:
zipg 2026-07-31 14:25:21 +08:00 committed by GitHub
parent 9b8f3956b6
commit dd383ff3ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 157 additions and 34 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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: [

View File

@ -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 需先等待取消

View File

@ -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 };
}

View File

@ -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 });

View File

@ -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_",

View File

@ -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_",

View File

@ -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_",

View File

@ -4484,7 +4484,7 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsKeepResults: "関連タブを閉じない",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。",
reuseDataTab: "データタブを再利用",
reuseDataTabDescription: "サイドバーでテーブルをクリックした際、毎回新しいタブを作成せずに既存のデータタブを再利用します。",
reuseDataTabDescription: "サイドバーからテーブルを開く際はデータタブを再利用し、オブジェクトブラウザーから同じテーブルを再度開く際は既存のタブに切り替えます。",
sidebarHiddenTablePrefixes: "非表示テーブル名プレフィックス",
sidebarHiddenTablePrefixesDescription: "1行に1つのプレフィックス。サイドバーのテーブル、ビュー、コレクションラベルのみ短縮されます。ツールチップと操作は完全な名前を使用します。",
sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_",

View File

@ -4467,7 +4467,7 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsKeepResults: "관련 탭 닫지 않기",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.",
reuseDataTab: "데이터 탭 재사용",
reuseDataTabDescription: "사이드바에서 테이블을 클릭할 때 매번 새로 만드는 대신 기존 데이터 탭을 재사용합니다.",
reuseDataTabDescription: "사이드바에서 테이블을 열 때 데이터 탭을 재사용하고, 개체 브라우저에서 같은 테이블을 다시 열면 기존 탭으로 전환합니다.",
sidebarHiddenTablePrefixes: "숨겨진 테이블 이름 접두사",
sidebarHiddenTablePrefixesDescription: "한 줄에 하나의 접두사. 사이드바의 테이블, 뷰, 컬렉션 라벨만 줄이며 툴팁과 작업은 전체 이름을 계속 사용합니다.",
sidebarHiddenTablePrefixesPlaceholder: "예:\nODS_\nT8Y2_LONG_",

View File

@ -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_",

View File

@ -4717,7 +4717,7 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsKeepResults: "不关闭相关页签",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。",
reuseDataTab: "复用数据标签页",
reuseDataTabDescription: "单击侧边栏表名时,复用已有的数据标签页而不是创建新标签页,避免打开过多标签。",
reuseDataTabDescription: "从侧边栏打开表时复用数据标签页;从浏览对象重复打开同一张表时切换到已有标签页。",
sidebarHiddenTablePrefixes: "隐藏表名前缀",
sidebarHiddenTablePrefixesDescription: "每行一个前缀,仅影响侧边栏表、视图和集合的显示名称,悬浮提示和实际操作仍使用完整名称。",
sidebarHiddenTablePrefixesPlaceholder: "例如:\nODS_\nT8Y2_LONG_",

View File

@ -3956,7 +3956,7 @@ export default withEnglishFallback({
disconnectTabHandlingModeKeepTabsKeepResults: "不關閉相關分頁",
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。",
reuseDataTab: "重複使用資料分頁",
reuseDataTabDescription: "點擊側邊欄資料表名稱時,重複使用現有的資料分頁而不是建立新分頁,避免開啟過多分頁。",
reuseDataTabDescription: "從側邊欄開啟資料表時重複使用資料分頁;從瀏覽物件重複開啟同一資料表時切換到現有分頁。",
sidebarHiddenTablePrefixes: "隱藏資料表名稱字首",
sidebarHiddenTablePrefixesDescription: "每行一個字首。只縮短側邊欄中的資料表、檢視和集合標籤;工具提示和實際操作仍使用完整名稱。",
sidebarHiddenTablePrefixesPlaceholder: "範例:\nODS_\nT8Y2_LONG_",

View File

@ -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", () => {

View File

@ -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<QueryTab, "id" | "mode" | "connectionId" | "database" | "schema" | "title" | "tableMeta" | "tableMetaUpdatedAt">;
@ -47,12 +48,12 @@ 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; reuseDataTab: boolean }): ExistingDataTabCandidate<T> | undefined {
if (options.openMode === "new-tab") return undefined;
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;
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;

View File

@ -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";

View File

@ -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(