fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882)

This commit is contained in:
Neil 2026-07-27 01:14:57 -07:00 committed by GitHub
parent 97e4776dfe
commit b59f893ee2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 159 additions and 6 deletions

View File

@ -60,6 +60,9 @@ vi.mock('lucide-react', () => ({
Copy: function Copy(props: Record<string, unknown>) {
return { type: 'Copy', props }
},
CopyX: function CopyX(props: Record<string, unknown>) {
return { type: 'CopyX', props }
},
ExternalLink: function ExternalLink(props: Record<string, unknown>) {
return { type: 'ExternalLink', props }
},
@ -72,6 +75,9 @@ vi.mock('lucide-react', () => ({
PinOff: function PinOff(props: Record<string, unknown>) {
return { type: 'PinOff', props }
},
PanelLeftClose: function PanelLeftClose(props: Record<string, unknown>) {
return { type: 'PanelLeftClose', props }
},
PanelRightClose: function PanelRightClose(props: Record<string, unknown>) {
return { type: 'PanelRightClose', props }
},

View File

@ -1,6 +1,16 @@
import { useEffect, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { Globe, X, ExternalLink, Copy, Pin, PinOff, PanelRightClose } from 'lucide-react'
import {
Globe,
X,
ExternalLink,
Copy,
CopyX,
Pin,
PinOff,
PanelLeftClose,
PanelRightClose
} from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@ -27,6 +37,8 @@ import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
import { cn } from '@/lib/utils'
function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@ -283,7 +295,10 @@ export default function BrowserTab({
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
className={cn(
'rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]',
TAB_CONTEXT_MENU_CONTENT_CLASS
)}
sideOffset={0}
align="start"
>
@ -309,6 +324,7 @@ export default function BrowserTab({
{translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={tabCount <= 1}>
<CopyX className="size-3.5" />
{translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
@ -316,6 +332,7 @@ export default function BrowserTab({
{translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate('components.tab.bar.BrowserTab.closeTabsToLeft', 'Close Tabs To The Left')}
</DropdownMenuItem>
<DropdownMenuItem

View File

@ -73,6 +73,12 @@ vi.mock('lucide-react', () => ({
Columns2: function Columns2(props: Record<string, unknown>) {
return { type: 'Columns2', props }
},
CopyX: function CopyX(props: Record<string, unknown>) {
return { type: 'CopyX', props }
},
PanelLeftClose: function PanelLeftClose(props: Record<string, unknown>) {
return { type: 'PanelLeftClose', props }
},
Copy: function Copy(props: Record<string, unknown>) {
return { type: 'Copy', props }
},

View File

@ -51,6 +51,9 @@ vi.mock('lucide-react', () => ({
Copy: function Copy(props: Record<string, unknown>) {
return { type: 'Copy', props }
},
CopyX: function CopyX(props: Record<string, unknown>) {
return { type: 'CopyX', props }
},
ExternalLink: function ExternalLink(props: Record<string, unknown>) {
return { type: 'ExternalLink', props }
},
@ -60,6 +63,9 @@ vi.mock('lucide-react', () => ({
ListX: function ListX(props: Record<string, unknown>) {
return { type: 'ListX', props }
},
PanelLeftClose: function PanelLeftClose(props: Record<string, unknown>) {
return { type: 'PanelLeftClose', props }
},
PanelRightClose: function PanelRightClose(props: Record<string, unknown>) {
return { type: 'PanelRightClose', props }
},

View File

@ -1,8 +1,10 @@
import {
Copy,
CopyX,
ExternalLink,
Eye,
ListX,
PanelLeftClose,
PanelRightClose,
Pencil,
Pin,
@ -24,6 +26,7 @@ import type { OpenFile } from '../../store/slices/editor'
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
import { translate } from '@/i18n/i18n'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@ -114,7 +117,7 @@ export function EditorFileTabContextMenu({
/>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
className={TAB_CONTEXT_MENU_CONTENT_CLASS}
sideOffset={0}
align="start"
onCloseAutoFocus={(event) => {
@ -156,6 +159,7 @@ export function EditorFileTabContextMenu({
{closeShortcut ? <DropdownMenuShortcut>{closeShortcut}</DropdownMenuShortcut> : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseOthers} disabled={tabCount <= 1}>
<CopyX className="size-3.5" />
{translate('components.tab.bar.EditorFileTabContextMenu.closeOthers', 'Close Others')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>
@ -176,6 +180,7 @@ export function EditorFileTabContextMenu({
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToLeft} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate(
'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft',
'Close Tabs To The Left'

View File

@ -93,6 +93,9 @@ vi.mock('lucide-react', () => ({
PanelBottomClose: function PanelBottomClose(props: Record<string, unknown>) {
return { type: 'PanelBottomClose', props }
},
PanelLeftClose: function PanelLeftClose(props: Record<string, unknown>) {
return { type: 'PanelLeftClose', props }
},
PanelRightClose: function PanelRightClose(props: Record<string, unknown>) {
return { type: 'PanelRightClose', props }
},

View File

@ -58,6 +58,7 @@ vi.mock('lucide-react', () => ({
ListX: () => null,
MessageSquare: () => null,
PanelBottomClose: () => null,
PanelLeftClose: () => null,
PanelRightClose: () => null,
Pencil: () => null,
Pin: () => null,

View File

@ -1,5 +1,6 @@
import {
MessageSquare,
PanelLeftClose,
PanelRightClose,
Pin,
PinOff,
@ -21,6 +22,7 @@ import { useAppStore } from '../../store'
import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { translate } from '@/i18n/i18n'
import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection'
import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing'
const TAB_COLORS = [
{
@ -156,7 +158,7 @@ export function SortableTabContextMenu({
style={{ left: point.x, top: point.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" sideOffset={0} align="start">
<DropdownMenuContent className={TAB_CONTEXT_MENU_CONTENT_CLASS} sideOffset={0} align="start">
<TerminalTabSplitMenuSection
unifiedTabId={unifiedTabId}
groupId={groupId}
@ -216,6 +218,7 @@ export function SortableTabContextMenu({
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}>
<PanelLeftClose className="size-3.5" />
{translate(
'components.tab.bar.SortableTabContextMenu.closeTabsToLeft',
'Close Tabs To The Left'

View File

@ -9,6 +9,7 @@ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Columns2 } from 'lucide-reac
import type { TabSplitDirection } from '../../store/slices/tabs'
import { translate } from '@/i18n/i18n'
import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column'
import { TAB_CONTEXT_SUBMENU_CONTENT_CLASS } from './tab-context-menu-sizing'
const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up']
@ -61,7 +62,7 @@ export function TabWorkspaceLayoutMenuSection({
'Move Tab to Split'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuSubContent className={TAB_CONTEXT_SUBMENU_CONTENT_CLASS}>
{PANE_COLUMN_DIRECTIONS.map((direction) => (
<DropdownMenuItem
key={direction}

View File

@ -10,6 +10,8 @@ import { PanelBottomClose, PanelRightClose, SquareTerminal } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
import { TAB_CONTEXT_SUBMENU_CONTENT_CLASS } from './tab-context-menu-sizing'
import { cn } from '@/lib/utils'
export function TerminalTabSplitMenuSection({
unifiedTabId,
@ -48,7 +50,7 @@ export function TerminalTabSplitMenuSection({
'Split terminal'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[12rem]">
<DropdownMenuSubContent className={cn('min-w-[12rem]', TAB_CONTEXT_SUBMENU_CONTENT_CLASS)}>
<DropdownMenuItem onSelect={() => splitActiveTerminalPane('vertical')}>
<PanelRightClose className="size-3.5 shrink-0" />
{translate(

View File

@ -0,0 +1,94 @@
/**
* Guards the two tab-context-menu invariants: every action item carries an
* icon, and no item can wrap onto a second line on any platform.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
TAB_CONTEXT_MENU_CONTENT_CLASS,
TAB_CONTEXT_SUBMENU_CONTENT_CLASS
} from './tab-context-menu-sizing'
const TAB_MENU_SOURCES = [
'EditorFileTabContextMenu.tsx',
'SortableTabContextMenu.tsx',
'BrowserTab.tsx',
'TabWorkspaceLayoutMenuSection.tsx',
'TerminalTabSplitMenuSection.tsx'
] as const
function readSource(fileName: string): string {
return readFileSync(join(__dirname, fileName), 'utf8')
}
/** Opening tags of each menu/submenu surface, so props can be asserted directly. */
function menuSurfaceTags(source: string): { name: string; tag: string }[] {
const tags: { name: string; tag: string }[] = []
const opening = /<(DropdownMenuContent|DropdownMenuSubContent)\b/g
let match: RegExpExecArray | null
while ((match = opening.exec(source)) !== null) {
const end = source.indexOf('>', match.index)
tags.push({ name: match[1], tag: source.slice(match.index, end === -1 ? undefined : end + 1) })
}
return tags
}
/** Item bodies, minus the color swatches which are deliberately icon-free. */
function actionItemBodies(source: string): string[] {
const bodies: string[] = []
const opening = /<DropdownMenu(?:Item|SubTrigger)\b/g
let match: RegExpExecArray | null
while ((match = opening.exec(source)) !== null) {
const closing = source.indexOf('</DropdownMenu', match.index + 1)
const body = source.slice(match.index, closing === -1 ? source.length : closing)
if (body.includes('rounded-full')) {
continue
}
bodies.push(body)
}
return bodies
}
describe('tab context menu consistency', () => {
it.each(TAB_MENU_SOURCES)('gives every action item an icon in %s', (fileName) => {
const bodies = actionItemBodies(readSource(fileName))
expect(bodies.length).toBeGreaterThan(0)
for (const body of bodies) {
// Icons render as a self-closing lucide element or an icon-returning helper.
const hasIcon = /<[A-Z][A-Za-z0-9]*\s+className="size-3\.5/.test(body) || /Icon\(/.test(body)
expect(hasIcon, `menu item without an icon in ${fileName}:\n${body}`).toBe(true)
}
})
it.each(TAB_MENU_SOURCES)('sizes every menu surface from the shared rule in %s', (fileName) => {
const source = readSource(fileName)
const surfaces = menuSurfaceTags(source)
if (surfaces.length === 0) {
return
}
for (const { tag, name } of surfaces) {
const expected =
name === 'DropdownMenuSubContent'
? 'TAB_CONTEXT_SUBMENU_CONTENT_CLASS'
: 'TAB_CONTEXT_MENU_CONTENT_CLASS'
expect(tag, `<${name}> in ${fileName} does not apply ${expected}:\n${tag}`).toContain(
expected
)
// Why: a fixed `w-*` on the surface reintroduces wrapping once a label or
// shortcut grows, and it survives merging with the shared rule.
expect(tag, `<${name}> in ${fileName} pins a fixed width:\n${tag}`).not.toMatch(
/(?:^|[\s'"`])w-(?:\d+|\[)/
)
}
})
it('keeps labels on one line and bounded by the viewport', () => {
for (const rule of [TAB_CONTEXT_MENU_CONTENT_CLASS, TAB_CONTEXT_SUBMENU_CONTENT_CLASS]) {
expect(rule).toContain('whitespace-nowrap')
expect(rule).toContain('max-w-[calc(100vw-1rem)]')
}
})
})

View File

@ -0,0 +1,9 @@
// Why: a fixed menu width wraps labels onto a second line once the label or its
// shortcut chip grows — worst on Windows/Linux, where `Ctrl+Alt+W` is far wider
// than `⌘⌥W`, and in locales with longer copy. Size to content instead, capped
// so a long label still can't run off screen.
export const TAB_CONTEXT_MENU_CONTENT_CLASS =
'min-w-[13rem] max-w-[calc(100vw-1rem)] whitespace-nowrap'
/** Submenus portal out of the parent menu, so they can't inherit its nowrap. */
export const TAB_CONTEXT_SUBMENU_CONTENT_CLASS = 'max-w-[calc(100vw-1rem)] whitespace-nowrap'