diff --git a/frontend/src/components/TabManager.adaptive-width.test.ts b/frontend/src/components/TabManager.adaptive-width.test.ts new file mode 100644 index 00000000..981ae392 --- /dev/null +++ b/frontend/src/components/TabManager.adaptive-width.test.ts @@ -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, + ); + }); +}); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 1cf097d4..dbe68ceb 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -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 = React.memo(({ onF [detachedTabIdSet, tabs], ); const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)'; + const tabWorkbenchRef = useRef(null); + const [v2TabWidth, setV2TabWidth] = useState(V2_WORKBENCH_TAB_MAX_WIDTH); const [draggingTabId, setDraggingTabId] = useState(null); const [detachDragPreview, setDetachDragPreview] = useState(null); const [openingRecentSQLFileKey, setOpeningRecentSQLFileKey] = useState(null); @@ -736,6 +757,37 @@ const TabManager: React.FC = React.memo(({ 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 = React.memo(({ onF ); return ( -
+