fix(tab-bar): keep tab menu items on one line and give every item an icon (#10882)
This commit is contained in:
parent
97e4776dfe
commit
b59f893ee2
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ vi.mock('lucide-react', () => ({
|
|||
ListX: () => null,
|
||||
MessageSquare: () => null,
|
||||
PanelBottomClose: () => null,
|
||||
PanelLeftClose: () => null,
|
||||
PanelRightClose: () => null,
|
||||
Pencil: () => null,
|
||||
Pin: () => null,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)]')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
Loading…
Reference in New Issue