feat: tab context menu pin submenu

M2 of pinned terminals (v0.17.0):
- TabBar: onPinTab prop (optional, hidden when unset for legacy callers)
- Terminal tabs get a 'Pin ▸' submenu (workspace/global) or 'Unpin' row
- UI vs Agent label distinction via isAgentTabId
- Non-terminal tabs get no pin entry (5-item legacy menu preserved)
- WorkbenchActions.pinTab threaded through split-pane LeafView
- Sidebar.onPinTab wires store.reduce(setTabPin) with cwd snapshot
- 12 new i18n keys synced across all 19 language dictionaries
This commit is contained in:
HuanLinOTO
2026-08-26 18:33:02 +08:00
parent 68f771e65f
commit 5d019d78ac
24 changed files with 412 additions and 10 deletions
+101 -1
View File
@@ -31,11 +31,12 @@ function stubZh(): void {
const MENU_LABELS = ['移动到自由窗口', '关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
function mountBar(tabs: SidebarTab[]): {
function mountBar(tabs: SidebarTab[], opts: { onPinTab?: ReturnType<typeof vi.fn> } = {}): {
tabEls: HTMLElement[]
onClose: ReturnType<typeof vi.fn>
onActivate: ReturnType<typeof vi.fn>
onFloatTab: ReturnType<typeof vi.fn>
onPinTab?: ReturnType<typeof vi.fn>
unmount: () => void
} {
const container = document.createElement('div')
@@ -43,6 +44,7 @@ function mountBar(tabs: SidebarTab[]): {
const onClose = vi.fn()
const onActivate = vi.fn()
const onFloatTab = vi.fn()
const onPinTab = opts.onPinTab
const root: Root = createRoot(container)
act(() => {
root.render(createElement(TabBar, {
@@ -54,6 +56,7 @@ function mountBar(tabs: SidebarTab[]): {
onNewTab: () => {},
newTabOptions: [],
onFloatTab,
...(onPinTab !== undefined ? { onPinTab } : {}),
onDropTab: () => {},
}))
})
@@ -63,6 +66,7 @@ function mountBar(tabs: SidebarTab[]): {
onClose,
onActivate,
onFloatTab,
...(onPinTab !== undefined ? { onPinTab } : {}),
unmount: () => {
act(() => { root.unmount() })
container.remove()
@@ -208,3 +212,99 @@ describe('TabBar right-click context menu', () => {
}
})
})
describe('TabBar pin submenu (v0.17.0)', () => {
/** The portaled submenu rows (aria-expanded parent + nested menuitem rows). */
function submenuItems(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"], [role="menuitem"]')]
}
it('hides the pin entry when onPinTab is not provided (legacy callers)', () => {
stubZh()
const { tabEls, unmount } = mountBar(fourTabs())
try {
act(() => { rightClick(tabEls[2]!) }) // t3 = terminal
// Exactly the legacy 5-item menu, no pin row.
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
} finally {
unmount()
}
})
it('adds a "Pin Terminal ▸" submenu for an unpinned UI terminal', () => {
stubZh()
const onPinTab = vi.fn()
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
try {
act(() => { rightClick(tabEls[2]!) }) // t3 = UI terminal
const labels = menuItems().map(item => item.textContent)
// float | pin (with submenu indicator) | close | closeOthers | closeLeft | closeRight
expect(labels[0]).toBe('移动到自由窗口')
expect(labels[1]).toBe('固定终端')
expect(labels[2]).toBe('关闭')
// Hovering the pin row reveals the submenu items. React synthesizes
// onMouseEnter from the bubbling mouseover event, so dispatch that.
act(() => { menuItems()[1]!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
const sub = submenuItems().map(item => item.textContent)
expect(sub).toContain('固定到工作区')
expect(sub).toContain('固定到全局')
// Clicking "pin to workspace" fires the callback.
const wsItem = submenuItems().find(item => item.textContent === '固定到工作区')!
act(() => { wsItem.click() })
expect(onPinTab).toHaveBeenCalledWith('t3', 'workspace')
} finally {
unmount()
}
})
it('uses the "Pin Agent Terminal" label for an agent terminal tab', () => {
stubZh()
const onPinTab = vi.fn()
const tabs: SidebarTab[] = [
{ id: 'agent:abc-123', type: 'terminal', title: 'Agent T' },
]
const { tabEls, unmount } = mountBar(tabs, { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) })
const labels = menuItems().map(item => item.textContent)
expect(labels[1]).toBe('固定 Agent 终端')
} finally {
unmount()
}
})
it('shows "Unpin" instead of the submenu for an already-pinned terminal', () => {
stubZh()
const onPinTab = vi.fn()
const tabs: SidebarTab[] = [
{ id: 'terminal:1', type: 'terminal', title: 'T', pin: { scope: 'global' } },
]
const { tabEls, unmount } = mountBar(tabs, { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) })
const labels = menuItems().map(item => item.textContent)
expect(labels).toContain('取消固定')
expect(labels).not.toContain('固定终端')
const unpinItem = menuItems().find(item => item.textContent === '取消固定')!
act(() => { unpinItem.click() })
expect(onPinTab).toHaveBeenCalledWith('terminal:1', null)
} finally {
unmount()
}
})
it('does not add a pin entry for non-terminal tabs even with onPinTab provided', () => {
stubZh()
const onPinTab = vi.fn()
const { tabEls, unmount } = mountBar(fourTabs(), { onPinTab })
try {
act(() => { rightClick(tabEls[0]!) }) // t1 = editor
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
act(() => { rightClick(tabEls[1]!) }) // t2 = git
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
expect(onPinTab).not.toHaveBeenCalled()
} finally {
unmount()
}
})
})