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:
@@ -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 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user