From d3c0f223452355dbb107ec94f6a86abd3d5a9cd3 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 19:57:34 +0800 Subject: [PATCH] feat(tab-bar): add right-click close context menu on tabs (#331) Right-clicking a tab now takes over the browser menu and shows four close operations scoped to the current pane: - Close - Close Other Tabs - Close Tabs to the Left - Close Tabs to the Right Items gray out when nothing is eligible (single tab / strip ends). Bulk closes reuse the per-tab onClose path, so descriptor onClose callbacks and terminal pty release still run for every close. Labels are i18n'd (zh/en). --- README.md | 1 + README_EN.md | 1 + src/client/TabBar.tsx | 60 ++++++++- src/client/locales.ts | 6 + tests/tab-bar-context-menu.spec.tsx | 191 ++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tests/tab-bar-context-menu.spec.tsx diff --git a/README.md b/README.md index e8df579..c9ae6d8 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar | 保存编辑 | `Ctrl/Cmd + S` | | Git 提交 | `Ctrl + Enter` | | 关闭 Tab | 鼠标中键 | +| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) | | 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 | | 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 | | 复制文件路径 | 右键行 → 复制相对/绝对地址 | diff --git a/README_EN.md b/README_EN.md index 42f2795..4412e2b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -413,6 +413,7 @@ All changes since v0.14.0: | Save edits | `Ctrl/Cmd + S` | | Git commit | `Ctrl + Enter` | | Close tab | Middle mouse button | +| Tab context menu (right-click) | Close / Close Other Tabs / Close Tabs to the Left / Close Tabs to the Right (current pane) | | Split / merge panes | Drag tab to pane edge / middle | | Reference file to input | Hover the `@file` button at end of line | | Copy file path | Right-click row → copy relative/absolute path | diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 8cd92bf..8fd8f39 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -3,7 +3,9 @@ * overflow scrolls horizontally, a close button per tab, a four-way split * button cluster, and the + menu that opens new tabs (explorer / git / * terminal). Tabs are draggable; dropping onto another tab inserts before it, - * dropping on the strip background appends to this pane. + * dropping on the strip background appends to this pane. Right-clicking a + * tab opens the tab context menu (close / close others / close to the left / + * close to the right, all scoped to this pane). */ import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' @@ -71,8 +73,14 @@ export function TabBar(props: { paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge, } = props const [menuOpen, setMenuOpen] = useState(false) + // The tab right-click context menu: the target tab plus the cursor + // position (the portaled Menu anchors there, following GitView/FileTree). + const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null) const [dragOver, setDragOver] = useState(false) const listRef = useRef(null) + // The context target's index in the render-time tab snapshot; -1 when the + // tab disappeared since the menu opened (the menu hides then). + const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId) // Middle-click close: the press target is recorded on middle mousedown // (preventDefaulted to disarm Chrome's middle-click autoscroll — its @@ -194,6 +202,14 @@ export function TabBar(props: { middlePressed.current = { id: tab.id, node: event.currentTarget } } }} + onContextMenu={(event) => { + // Take over the browser menu: the tab context menu offers the + // close operations for this pane. Opening it also dismisses + // the + menu (only one menu at a time). + event.preventDefault() + setMenuOpen(false) + setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY }) + }} > {getTabIcon?.(tab) ?? null} {getTabBadge?.(tab) ?? null} @@ -237,12 +253,52 @@ export function TabBar(props: { className={css.tabBarPlus} aria-label={t('newTab')} title={t('newTab')} - onClick={() => { setMenuOpen(v => !v) }} + onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }} > )} /> + {/* + The tab context menu, positioned at the right-click cursor (portal + so the panel's overflow clip cannot crop it). Close operations are + scoped to THIS pane: "close others/left/right" walk the render-time + tab snapshot and reuse the per-tab onClose path (which routes + through the service and releases terminals), so the target tab is + never closed and the pane never empties mid-loop. + */} + = 0} + onClose={() => { setTabMenu(null) }} + items={[ + { id: 'close', label: t('close') }, + { id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) }, + { id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) }, + { id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) }, + ]} + onSelect={(id) => { + const target = tabMenu + if (target === null) return + setTabMenu(null) + const index = tabs.findIndex(tab => tab.id === target.tabId) + if (index < 0) return + if (id === 'close') { + onClose(target.tabId) + } else if (id === 'closeOthers') { + for (const tab of tabs) { + if (tab.id !== target.tabId) onClose(tab.id) + } + } else if (id === 'closeLeft') { + for (const tab of tabs.slice(0, index)) onClose(tab.id) + } else if (id === 'closeRight') { + for (const tab of tabs.slice(index + 1)) onClose(tab.id) + } + }} + portal + align="start" + getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))} + anchor={} + /> ) diff --git a/src/client/locales.ts b/src/client/locales.ts index fdffc7f..cd045c3 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -37,6 +37,9 @@ export const zh = { newTerminal: '新终端', terminalLimit: '终端数量已达上限 (3)', close: '关闭', + closeOtherTabs: '关闭其他页签', + closeLeftTabs: '关闭左侧页签', + closeRightTabs: '关闭右侧页签', collapse: '折叠侧边栏', expand: '展开侧边栏', collapseBottomPanel: '折叠底部面板', @@ -344,6 +347,9 @@ export const en: Record = { newTerminal: 'New terminal', terminalLimit: 'Terminal limit reached (3)', close: 'Close', + closeOtherTabs: 'Close Other Tabs', + closeLeftTabs: 'Close Tabs to the Left', + closeRightTabs: 'Close Tabs to the Right', collapse: 'Collapse sidebar', expand: 'Expand sidebar', collapseBottomPanel: 'Collapse bottom panel', diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx new file mode 100644 index 0000000..af4e817 --- /dev/null +++ b/tests/tab-bar-context-menu.spec.tsx @@ -0,0 +1,191 @@ +/** + * Tab-strip right-click context-menu tests. Right-clicking a tab takes over + * the browser menu (preventDefault) and shows the tab context menu with + * exactly four items: close / close others / close to the left / close to + * the right. All bulk operations are scoped to the CURRENT pane (the render + * time tab snapshot) and reuse the per-tab onClose path, so the target tab + * is never closed and the pane never empties mid-loop. The menu items gray + * out when there is nothing to close (single tab → close others; leftmost → + * close left; rightmost → close right). Opening the menu must not activate + * the right-clicked tab. + */ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +import { TabBar } from '../src/client/TabBar.tsx' +import type { SidebarTab } from '../src/client/state.ts' + +/** Point the browser-language fallback at Chinese so the menu labels assert. */ +function stubZh(): void { + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'zh-CN' }, + configurable: true, + }) +} + +const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签'] + +function mountBar(tabs: SidebarTab[]): { + tabEls: HTMLElement[] + onClose: ReturnType + onActivate: ReturnType + unmount: () => void +} { + const container = document.createElement('div') + document.body.append(container) + const onClose = vi.fn() + const onActivate = vi.fn() + const root: Root = createRoot(container) + act(() => { + root.render(createElement(TabBar, { + paneId: 'pane:1', + tabs, + active: tabs[0]?.id ?? null, + onActivate, + onClose, + onNewTab: () => {}, + newTabOptions: [], + onDropTab: () => {}, + })) + }) + const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[] + return { + tabEls, + onClose, + onActivate, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +function fourTabs(): SidebarTab[] { + return [ + { id: 't1', type: 'editor', title: 'Tab 1' }, + { id: 't2', type: 'git', title: 'Tab 2' }, + { id: 't3', type: 'terminal', title: 'Tab 3' }, + { id: 't4', type: 'browser', title: 'Tab 4' }, + ] +} + +/** Dispatch a native right-click (contextmenu) and return the event. */ +function rightClick(target: EventTarget): MouseEvent { + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 40 }) + target.dispatchEvent(event) + return event +} + +/** The portaled menu rows (empty when the menu is closed). */ +function menuItems(): HTMLElement[] { + return [...document.querySelectorAll('[role="menuitem"]')] +} + +afterEach(() => { + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +describe('TabBar right-click context menu', () => { + it('opens the four-item menu at the cursor, prevents the browser menu, and does not activate', () => { + stubZh() + const { tabEls, onActivate, unmount } = mountBar(fourTabs()) + try { + let event: MouseEvent | null = null + act(() => { event = rightClick(tabEls[1]!) }) + expect(event!.defaultPrevented).toBe(true) + expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS) + expect(onActivate).not.toHaveBeenCalled() + } finally { + unmount() + } + }) + + it('close closes only the target tab and closes the menu', () => { + stubZh() + const { tabEls, onClose, unmount } = mountBar(fourTabs()) + try { + act(() => { rightClick(tabEls[1]!) }) + act(() => { menuItems()[0]!.click() }) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledWith('t2') + expect(menuItems()).toHaveLength(0) + } finally { + unmount() + } + }) + + it('close others closes every tab in the pane except the target, in visual order', () => { + stubZh() + const { tabEls, onClose, unmount } = mountBar(fourTabs()) + try { + act(() => { rightClick(tabEls[1]!) }) + act(() => { menuItems()[1]!.click() }) + expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4']) + expect(onClose).not.toHaveBeenCalledWith('t2') + expect(menuItems()).toHaveLength(0) + } finally { + unmount() + } + }) + + it('close left closes only the tabs to the left of the target', () => { + stubZh() + const { tabEls, onClose, unmount } = mountBar(fourTabs()) + try { + act(() => { rightClick(tabEls[2]!) }) + act(() => { menuItems()[2]!.click() }) + expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2']) + expect(menuItems()).toHaveLength(0) + } finally { + unmount() + } + }) + + it('close right closes only the tabs to the right of the target', () => { + stubZh() + const { tabEls, onClose, unmount } = mountBar(fourTabs()) + try { + act(() => { rightClick(tabEls[1]!) }) + act(() => { menuItems()[3]!.click() }) + expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4']) + expect(menuItems()).toHaveLength(0) + } finally { + unmount() + } + }) + + it('grays out close others on a single tab and close left/right at the strip ends', () => { + stubZh() + const single = mountBar([ + { id: 'only', type: 'editor', title: 'Only' }, + ]) + try { + act(() => { rightClick(single.tabEls[0]!) }) + const items = menuItems() + // The Menu renders each row as a disabled