fix(tabs): honor data tab reuse across activation paths
This commit is contained in:
parent
9b8f3956b6
commit
dd383ff3ea
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
|
|
|
|||
|
|
@ -4484,7 +4484,7 @@ export default withEnglishFallback({
|
|||
disconnectTabHandlingModeKeepTabsKeepResults: "関連タブを閉じない",
|
||||
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "関連タブ、SQLテキスト、現在の結果を追加のクリーンアップなしで保持します。",
|
||||
reuseDataTab: "データタブを再利用",
|
||||
reuseDataTabDescription: "サイドバーでテーブルをクリックした際、毎回新しいタブを作成せずに既存のデータタブを再利用します。",
|
||||
reuseDataTabDescription: "サイドバーからテーブルを開く際はデータタブを再利用し、オブジェクトブラウザーから同じテーブルを再度開く際は既存のタブに切り替えます。",
|
||||
sidebarHiddenTablePrefixes: "非表示テーブル名プレフィックス",
|
||||
sidebarHiddenTablePrefixesDescription: "1行に1つのプレフィックス。サイドバーのテーブル、ビュー、コレクションラベルのみ短縮されます。ツールチップと操作は完全な名前を使用します。",
|
||||
sidebarHiddenTablePrefixesPlaceholder: "Example:\nODS_\nT8Y2_LONG_",
|
||||
|
|
|
|||
|
|
@ -4467,7 +4467,7 @@ export default withEnglishFallback({
|
|||
disconnectTabHandlingModeKeepTabsKeepResults: "관련 탭 닫지 않기",
|
||||
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "관련 탭, SQL 텍스트, 현재 결과를 추가 정리 없이 유지합니다.",
|
||||
reuseDataTab: "데이터 탭 재사용",
|
||||
reuseDataTabDescription: "사이드바에서 테이블을 클릭할 때 매번 새로 만드는 대신 기존 데이터 탭을 재사용합니다.",
|
||||
reuseDataTabDescription: "사이드바에서 테이블을 열 때 데이터 탭을 재사용하고, 개체 브라우저에서 같은 테이블을 다시 열면 기존 탭으로 전환합니다.",
|
||||
sidebarHiddenTablePrefixes: "숨겨진 테이블 이름 접두사",
|
||||
sidebarHiddenTablePrefixesDescription: "한 줄에 하나의 접두사. 사이드바의 테이블, 뷰, 컬렉션 라벨만 줄이며 툴팁과 작업은 전체 이름을 계속 사용합니다.",
|
||||
sidebarHiddenTablePrefixesPlaceholder: "예:\nODS_\nT8Y2_LONG_",
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
|
|
|
|||
|
|
@ -4717,7 +4717,7 @@ export default withEnglishFallback({
|
|||
disconnectTabHandlingModeKeepTabsKeepResults: "不关闭相关页签",
|
||||
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相关页签、SQL 文本和当前结果,不做额外处理。",
|
||||
reuseDataTab: "复用数据标签页",
|
||||
reuseDataTabDescription: "单击侧边栏表名时,复用已有的数据标签页而不是创建新标签页,避免打开过多标签。",
|
||||
reuseDataTabDescription: "从侧边栏打开表时复用数据标签页;从浏览对象重复打开同一张表时切换到已有标签页。",
|
||||
sidebarHiddenTablePrefixes: "隐藏表名前缀",
|
||||
sidebarHiddenTablePrefixesDescription: "每行一个前缀,仅影响侧边栏表、视图和集合的显示名称,悬浮提示和实际操作仍使用完整名称。",
|
||||
sidebarHiddenTablePrefixesPlaceholder: "例如:\nODS_\nT8Y2_LONG_",
|
||||
|
|
|
|||
|
|
@ -3956,7 +3956,7 @@ export default withEnglishFallback({
|
|||
disconnectTabHandlingModeKeepTabsKeepResults: "不關閉相關分頁",
|
||||
disconnectTabHandlingModeKeepTabsKeepResultsDescription: "保留相關分頁、SQL 文字與目前結果,不另外做清理。",
|
||||
reuseDataTab: "重複使用資料分頁",
|
||||
reuseDataTabDescription: "點擊側邊欄資料表名稱時,重複使用現有的資料分頁而不是建立新分頁,避免開啟過多分頁。",
|
||||
reuseDataTabDescription: "從側邊欄開啟資料表時重複使用資料分頁;從瀏覽物件重複開啟同一資料表時切換到現有分頁。",
|
||||
sidebarHiddenTablePrefixes: "隱藏資料表名稱字首",
|
||||
sidebarHiddenTablePrefixesDescription: "每行一個字首。只縮短側邊欄中的資料表、檢視和集合標籤;工具提示和實際操作仍使用完整名稱。",
|
||||
sidebarHiddenTablePrefixesPlaceholder: "範例:\nODS_\nT8Y2_LONG_",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue