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
+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',