diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index 6a9180c4..33e58945 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -179,8 +179,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 - 已打开实例位于一个语义化 `tablist` 中。 - 每个实例使用独立 `tab` 和 `tabpanel`。 - “+”固定在 Tab 行末尾,但位于 `tablist` 之外。 -- Tab 过多时实例区域单行横向滚动,“+”保持可见;不换行、不自动隐藏实例。 -- 标签全部可见时隐藏左右滚动按钮,“+”紧随最后一个标签;溢出时两端显示左右箭头,点击滚动约一屏,边界方向禁用。“+”固定在右箭头外侧。切换或新增激活标签时将其滚入可见区域。未激活标签使用灰蓝底色和边框,激活标签保留亮底与强调色顶线。 +- Tab 过多时实例区域单行横向滚动,不换行、不自动隐藏实例。“+”紧随最后一个标签并随标签一起滚动,滚到末尾时可见。 +- 标签全部可见时隐藏左右滚动按钮;溢出时左右箭头透明浮动于工作区两端,不占标签布局宽度。鼠标移入标签栏时显示箭头,移出后隐藏;键盘聚焦箭头时显示,无悬停能力的设备保持可见。悬停箭头时显示轻微底色。点击滚动约一屏,边界方向禁用且不拦截下方标签的指针操作。切换或新增激活标签时将其滚入可见区域。未激活标签使用低对比度灰蓝底色和细边框,激活标签保留亮底与强调色顶线。 - 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、 工作目录和 Shell。用户可以重命名终端。 - 任务中心、工作区、成果及新建终端的默认标题随界面语言切换,恢复布局时也使用当前语言。 @@ -560,7 +560,7 @@ Schema、假数据终端或不可用入口的中间产品。 - [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。 - [x] 连续选择终端会创建多个互相独立的终端 Tab。 - [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。 -- [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。 +- [ ] Tab 溢出时单行横向滚动,两端箭头固定,“+”跟随最后一个 Tab,滚到末尾可见,不把 Tab 换成下拉框。 - [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。 - [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停 设备上出现,且不引发布局位移。 diff --git a/eslint.config.js b/eslint.config.js index 2f9b29c1..5dd03b78 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,7 @@ export default tseslint.config( '.runtime-resources/**', 'coverage/**', 'dist/**', + 'docs/**/*-demo.*', 'node_modules/**', 'out/**', 'shareserver/**' diff --git a/src/renderer/src/WorkbarShell.test.tsx b/src/renderer/src/WorkbarShell.test.tsx index bdc1bdc2..88bf3bff 100644 --- a/src/renderer/src/WorkbarShell.test.tsx +++ b/src/renderer/src/WorkbarShell.test.tsx @@ -1,4 +1,5 @@ import { + act, cleanup, fireEvent, render, @@ -23,7 +24,10 @@ import { type WorkbarInstanceCreateRequest } from './WorkbarShell' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) const terminalOne: WorkbarTabInstance = { id: '20000000-0000-4000-8000-000000000001', @@ -82,6 +86,70 @@ function ControlledShell({ } describe('WorkbarShell', () => { + it('shows overflow controls, scrolls without switching tabs, and hides them after widening', () => { + let resize = (): void => {} + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe(): void {} + disconnect(): void {} + }) + render() + expect(screen.queryByRole('button', { name: '向左滚动标签栏' })).toBeNull() + const list = screen.getByRole('tablist') + const strip = list.parentElement! + const scroll = strip.parentElement! + const row = scroll.parentElement! + let rowWidth = 340 + let viewportWidth = 230 + let left = 0 + Object.defineProperty(row, 'clientWidth', { get: () => rowWidth }) + Object.defineProperty(strip, 'scrollWidth', { get: () => 600 }) + Object.defineProperty(scroll, 'scrollWidth', { get: () => 600 }) + Object.defineProperty(scroll, 'clientWidth', { get: () => viewportWidth }) + Object.defineProperty(scroll, 'scrollLeft', { + get: () => left, + set: (value: number) => { left = Math.max(0, Math.min(600 - viewportWidth, value)) } + }) + act(() => resize()) + const previous = screen.getByRole('button', { name: '向左滚动标签栏' }) + const next = screen.getByRole('button', { name: '向右滚动标签栏' }) + expect(previous).toBeDisabled() + expect(next).toBeEnabled() + fireEvent.click(next) + expect(left).toBe(184) + expect(previous).toBeEnabled() + fireEvent.click(next) + fireEvent.click(next) + expect(next).toBeDisabled() + expect(screen.getByRole('tab', { name: '任务中心' })).toHaveAttribute('aria-selected', 'true') + fireEvent.click(previous) + expect(left).toBe(186) + expect(next).toBeEnabled() + left = 0 + fireEvent.scroll(scroll) + expect(previous).toBeDisabled() + const add = screen.getByRole('button', { name: '打开工作栏应用' }) + expect(add.parentElement).toBe(strip) + expect(list.nextElementSibling).toBe(add) + expect(row.firstElementChild).toBe(previous) + expect(row.lastElementChild).toBe(next) + rowWidth = 800 + viewportWidth = 600 + act(() => resize()) + expect(screen.queryByRole('button', { name: '向右滚动标签栏' })).toBeNull() + }) + + it('reveals an activated tab outside the scroll viewport', () => { + render() + const scroll = screen.getByRole('tablist').parentElement!.parentElement! + const workspace = screen.getByRole('tab', { name: '工作区' }) + vi.spyOn(scroll, 'getBoundingClientRect').mockReturnValue({ left: 20, right: 220 } as DOMRect) + vi.spyOn(workspace.parentElement!, 'getBoundingClientRect').mockReturnValue({ left: 200, right: 320 } as DOMRect) + fireEvent.click(workspace) + expect(scroll.scrollLeft).toBe(100) + expect(workspace).toHaveAttribute('aria-selected', 'true') + }) + it('keeps tabs and their controls rounded', () => { expect(stylesheet).toMatch( /\.workbar-shell__tab-item\s*\{[^}]*border-radius:\s*var\(--radius-control\) var\(--radius-control\) 0 0;/u @@ -114,7 +182,7 @@ describe('WorkbarShell', () => { expect( screen.getByRole('button', { name: '打开工作栏应用' }) ).toBeVisible() - expect(tablist.parentElement).toHaveClass( + expect(tablist.parentElement!.parentElement).toHaveClass( 'workbar-shell__tab-scroll' ) expect( diff --git a/src/renderer/src/WorkbarShell.tsx b/src/renderer/src/WorkbarShell.tsx index 95616b1c..fba11213 100644 --- a/src/renderer/src/WorkbarShell.tsx +++ b/src/renderer/src/WorkbarShell.tsx @@ -1,4 +1,6 @@ import { + ChevronLeft, + ChevronRight, ClipboardCheck, FileOutput, FolderTree, @@ -116,6 +118,14 @@ export function WorkbarShell({ const { t } = useTranslation('workspace') const idPrefix = useId() const addButtonRef = useRef(null) + const tabRowRef = useRef(null) + const tabScrollRef = useRef(null) + const tabListRef = useRef(null) + const [tabOverflow, setTabOverflow] = useState({ + visible: false, + left: false, + right: false + }) const catalogRef = useRef(null) const returnInstanceIdRef = useRef(null) const pendingTabFocusRef = useRef(undefined) @@ -137,6 +147,59 @@ export function WorkbarShell({ const panelId = (instanceId: string): string => `${idPrefix}-panel-${instanceId}` + const updateTabOverflow = useCallback(() => { + const row = tabRowRef.current + const scroll = tabScrollRef.current + const list = tabListRef.current + if (!row || !scroll || !list) return + const style = getComputedStyle(row) + // Measure without the arrows so their appearance cannot sustain overflow. + const available = row.clientWidth - + (parseFloat(style.paddingLeft) || 0) - + (parseFloat(style.paddingRight) || 0) + const next = { + visible: list.scrollWidth > available + 1, + left: scroll.scrollLeft > 1, + right: scroll.scrollLeft + scroll.clientWidth < scroll.scrollWidth - 1 + } + setTabOverflow((current) => + current.visible === next.visible && current.left === next.left && + current.right === next.right ? current : next + ) + }, []) + + useEffect(() => { + const observer = new ResizeObserver(updateTabOverflow) + for (const element of [tabRowRef.current, tabScrollRef.current, tabListRef.current]) { + if (element) observer.observe(element) + } + updateTabOverflow() + return () => observer.disconnect() + }, [updateTabOverflow]) + + useEffect(() => { + const scroll = tabScrollRef.current + const tab = activeInstanceId && !catalogOpen + ? document.getElementById(tabId(activeInstanceId))?.parentElement + : null + if (scroll && tab) { + const viewport = scroll.getBoundingClientRect() + const bounds = tab.getBoundingClientRect() + const delta = bounds.left < viewport.left + ? bounds.left - viewport.left + : Math.max(0, bounds.right - viewport.right) + if (delta) scroll.scrollLeft += delta + } + updateTabOverflow() + }, [activeInstanceId, catalogOpen, unorderedInstances, tabId, tabOverflow.visible, updateTabOverflow]) + + const scrollTabs = (direction: number): void => { + const scroll = tabScrollRef.current + if (!scroll) return + scroll.scrollLeft += direction * scroll.clientWidth * 0.8 + updateTabOverflow() + } + useEffect(() => { if (!catalogOpen) { return @@ -327,8 +390,21 @@ export function WorkbarShell({ aria-label={t('sidebar.workbar.ariaLabel')} className={joinClassNames('workbar-shell', className)} > -
-
+
+ {tabOverflow.visible && ( + + )} +
+
-
+
+
+ {tabOverflow.visible && ( + + )}
diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index 8765182c..b7fb046b 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -230,6 +230,8 @@ export const workspace = { ariaLabel: 'Workbar applications', tablist: 'Open workbar applications', add: 'Open workbar application', + scrollLeft: 'Scroll tabs left', + scrollRight: 'Scroll tabs right', close: 'Close {{title}}', catalogTitle: 'New workbar application', catalogDescription: 'Choose an application to open in the workbar.', diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index f8d2ac95..473f8710 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -224,6 +224,8 @@ export const workspace = { ariaLabel: '工作栏应用', tablist: '已打开的工作栏应用', add: '打开工作栏应用', + scrollLeft: '向左滚动标签栏', + scrollRight: '向右滚动标签栏', close: '关闭{{title}}', catalogTitle: '新建工作栏应用', catalogDescription: '选择要在工作栏中打开的应用。', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 4763f27e..fd7f7bcd 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -3937,6 +3937,7 @@ button > svg { } .workbar-shell__tab-row { + position: relative; display: flex; min-width: 0; min-height: 46px; @@ -4127,8 +4128,31 @@ button > svg { } .workbar-shell__scroll-button { - flex: 0 0 28px; - margin-bottom: 5px; + position: absolute; + z-index: 2; + bottom: 5px; + background: transparent; + opacity: 0; + pointer-events: none; + transition: opacity var(--motion-fast) ease-out; +} + +.workbar-shell__tab-row:hover .workbar-shell__scroll-button:not(:disabled), +.workbar-shell__scroll-button:focus-visible { + opacity: 1; + pointer-events: auto; +} + +.workbar-shell__tab-row:hover .workbar-shell__scroll-button:disabled { + opacity: 0.4; +} + +.workbar-shell__scroll-button:first-child { + left: 0; +} + +.workbar-shell__scroll-button:last-child { + right: 0; } .workbar-shell__scroll-button:hover:not(:disabled) { @@ -4137,8 +4161,19 @@ button > svg { } .workbar-shell__scroll-button:disabled { - opacity: 0.4; cursor: default; + pointer-events: none; +} + +@media (hover: none) { + .workbar-shell__scroll-button:not(:disabled) { + opacity: 1; + pointer-events: auto; + } + + .workbar-shell__scroll-button:disabled { + opacity: 0.4; + } } .workbar-shell__content,