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).
This commit is contained in:
Atlas Zeng
2026-08-22 19:57:34 +08:00
committed by GitHub
parent 67271e54c8
commit d3c0f22345
5 changed files with 257 additions and 2 deletions
+1
View File
@@ -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 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
+1
View File
@@ -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 |
+58 -2
View File
@@ -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<HTMLDivElement>(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) }}
>
<IconPlusOutline16 />
</button>
)}
/>
{/*
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.
*/}
<Menu
open={tabMenu !== null && tabMenuIndex >= 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={<span />}
/>
</div>
</div>
)
+6
View File
@@ -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<keyof typeof zh, string> = {
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',
+191
View File
@@ -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<string, unknown>).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<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
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<HTMLElement>('[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 <button role="menuitem">.
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
// Clicking the disabled row must not close anything.
act(() => { items[1]!.click() })
expect(single.onClose).not.toHaveBeenCalled()
} finally {
single.unmount()
}
const four = mountBar(fourTabs())
try {
act(() => { rightClick(four.tabEls[0]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
act(() => { rightClick(four.tabEls[3]!) })
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
} finally {
four.unmount()
}
})
})