mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
✨ feat(tabs): 支持工作区标签宽度自适应
- 根据工作区可用宽度和标签数量动态分配标签宽度 - 使用 ResizeObserver 响应布局变化并限制可读宽度范围 - 通过 CSS 变量同步标签宽度并补充边界回归测试
This commit is contained in:
54
frontend/src/components/TabManager.adaptive-width.test.ts
Normal file
54
frontend/src/components/TabManager.adaptive-width.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user