feat(tabs): 支持工作区标签宽度自适应

- 根据工作区可用宽度和标签数量动态分配标签宽度
- 使用 ResizeObserver 响应布局变化并限制可读宽度范围
- 通过 CSS 变量同步标签宽度并补充边界回归测试
This commit is contained in:
Syngnat
2026-07-24 14:00:18 +08:00
parent aee4da47db
commit 340e7837a3
3 changed files with 115 additions and 5 deletions

View File

@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
V2_WORKBENCH_TAB_MAX_WIDTH,
V2_WORKBENCH_TAB_MIN_WIDTH,
resolveV2WorkbenchTabWidth,
} from './TabManager';
const themeSource = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
const tabManagerSource = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
describe('v2 workbench adaptive tab width', () => {
it('keeps the preferred width when the strip has enough room', () => {
expect(resolveV2WorkbenchTabWidth(1600, 5)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
expect(resolveV2WorkbenchTabWidth(800, 2)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
});
it('shares the available width equally before using overflow', () => {
expect(resolveV2WorkbenchTabWidth(1000, 5)).toBe(199);
expect(resolveV2WorkbenchTabWidth(1200, 10)).toBe(119);
});
it('stops shrinking at the readable minimum', () => {
expect(resolveV2WorkbenchTabWidth(1000, 10)).toBe(V2_WORKBENCH_TAB_MIN_WIDTH);
expect(resolveV2WorkbenchTabWidth(320, 8)).toBe(V2_WORKBENCH_TAB_MIN_WIDTH);
});
it('uses the preferred width until a measurable strip and tab count exist', () => {
expect(resolveV2WorkbenchTabWidth(0, 4)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
expect(resolveV2WorkbenchTabWidth(Number.NaN, 4)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
expect(resolveV2WorkbenchTabWidth(1000, 0)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
expect(resolveV2WorkbenchTabWidth(1000, -2)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
expect(resolveV2WorkbenchTabWidth(1000, Number.NaN)).toBe(V2_WORKBENCH_TAB_MAX_WIDTH);
});
it('observes the stable workbench width instead of the overflow-sensitive nav wrap', () => {
expect(tabManagerSource).toContain('ref={tabWorkbenchRef}');
expect(tabManagerSource).toContain('new ResizeObserver((entries) => {');
expect(tabManagerSource).toContain('return () => observer.disconnect();');
expect(tabManagerSource).toContain('resolveV2WorkbenchTabWidth(availableWidth, dockedTabs.length)');
expect(tabManagerSource).not.toContain("querySelector('.ant-tabs-nav-wrap')");
});
it('applies the measured width to every v2 tab', () => {
expect(themeSource).toMatch(
/\.gn-v2-main-tabs \.ant-tabs-tab \{[^}]*width: var\(--gn-v2-tab-width, 260px\);[^}]*min-width: var\(--gn-v2-tab-width, 260px\);[^}]*max-width: var\(--gn-v2-tab-width, 260px\);/s,
);
expect(themeSource).not.toMatch(
/\.gn-v2-main-tabs \.ant-tabs-tab \{[^}]*width: 260px;[^}]*min-width: 260px;[^}]*max-width: 260px;/s,
);
});
});

View File

@@ -1,5 +1,5 @@
import Modal from './common/ResizableDraggableModal';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Button, Dropdown, message, Tabs, Tooltip } from 'antd';
import { CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, FileTextOutlined, FolderOpenOutlined, HistoryOutlined, PlusOutlined, PushpinOutlined, RightOutlined, RobotOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import type { MenuProps, TabsProps } from 'antd';
@@ -82,6 +82,25 @@ export const isRunningDataImportWorkbenchTab = (
export const TAB_WORKBENCH_CLASS_NAME = 'tab-workbench';
export const V2_WORKBENCH_TAB_MIN_WIDTH = 112;
export const V2_WORKBENCH_TAB_MAX_WIDTH = 260;
const V2_WORKBENCH_TAB_WIDTH_GUARD = 1;
export const resolveV2WorkbenchTabWidth = (availableWidth: number, tabCount: number): number => {
const normalizedTabCount = Number.isFinite(tabCount) ? Math.floor(tabCount) : 0;
if (!Number.isFinite(availableWidth) || availableWidth <= 0 || normalizedTabCount <= 0) {
return V2_WORKBENCH_TAB_MAX_WIDTH;
}
const equalShare = Math.floor(
(availableWidth - V2_WORKBENCH_TAB_WIDTH_GUARD) / normalizedTabCount,
);
return Math.min(
V2_WORKBENCH_TAB_MAX_WIDTH,
Math.max(V2_WORKBENCH_TAB_MIN_WIDTH, equalShare),
);
};
type RecentConnectionShortcut = {
connection: SavedConnection;
dbName?: string;
@@ -712,6 +731,8 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
[detachedTabIdSet, tabs],
);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const tabWorkbenchRef = useRef<HTMLDivElement>(null);
const [v2TabWidth, setV2TabWidth] = useState(V2_WORKBENCH_TAB_MAX_WIDTH);
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [detachDragPreview, setDetachDragPreview] = useState<DetachDragPreviewState | null>(null);
const [openingRecentSQLFileKey, setOpeningRecentSQLFileKey] = useState<string | null>(null);
@@ -736,6 +757,37 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const hasDockedTabs = dockedTabs.length > 0;
useLayoutEffect(() => {
if (!isV2Ui || dockedTabs.length === 0) {
setV2TabWidth(V2_WORKBENCH_TAB_MAX_WIDTH);
return;
}
const target = tabWorkbenchRef.current;
if (!target) return;
const updateWidth = (availableWidth: number) => {
const nextWidth = resolveV2WorkbenchTabWidth(availableWidth, dockedTabs.length);
setV2TabWidth((currentWidth) => currentWidth === nextWidth ? currentWidth : nextWidth);
};
const measure = () => updateWidth(target.getBoundingClientRect().width);
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver((entries) => {
updateWidth(entries[0]?.contentRect.width ?? target.getBoundingClientRect().width);
});
observer.observe(target);
return () => observer.disconnect();
}, [dockedTabs.length, isV2Ui]);
const tabWorkbenchStyle = isV2Ui
? ({ '--gn-v2-tab-width': `${v2TabWidth}px` } as React.CSSProperties)
: undefined;
const detachTabToWindow = useCallback((tabId: string, preferred?: { x?: number; y?: number; width?: number; height?: number }) => {
const tab = tabs.find((item) => item.id === tabId);
if (tab && isBackgroundTaskWorkbenchTab(tab)) {
@@ -1583,7 +1635,11 @@ const TabManager: React.FC<TabManagerProps> = React.memo<TabManagerProps>(({ onF
);
return (
<div className={`${TAB_WORKBENCH_CLASS_NAME}${isV2Ui ? ' gn-v2-tab-workbench' : ''}`}>
<div
ref={tabWorkbenchRef}
className={`${TAB_WORKBENCH_CLASS_NAME}${isV2Ui ? ' gn-v2-tab-workbench' : ''}`}
style={tabWorkbenchStyle}
>
<style>{`
.${TAB_WORKBENCH_CLASS_NAME} {
height: 100%;

View File

@@ -3707,9 +3707,9 @@ body[data-ui-version="v2"] .gn-v2-main-tabs-double .ant-tabs-nav-list {
}
body[data-ui-version="v2"] .gn-v2-main-tabs .ant-tabs-tab {
width: 260px;
min-width: 260px;
max-width: 260px;
width: var(--gn-v2-tab-width, 260px);
min-width: var(--gn-v2-tab-width, 260px);
max-width: var(--gn-v2-tab-width, 260px);
height: 36px;
margin: 0 !important;
padding: 0 !important;