diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 6c8a4dc05..c1e994301 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -60,6 +60,9 @@ vi.mock('lucide-react', () => ({ Copy: function Copy(props: Record) { return { type: 'Copy', props } }, + CopyX: function CopyX(props: Record) { + return { type: 'CopyX', props } + }, ExternalLink: function ExternalLink(props: Record) { return { type: 'ExternalLink', props } }, @@ -72,6 +75,9 @@ vi.mock('lucide-react', () => ({ PinOff: function PinOff(props: Record) { return { type: 'PinOff', props } }, + PanelLeftClose: function PanelLeftClose(props: Record) { + return { type: 'PanelLeftClose', props } + }, PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 185057c5d..87a268f75 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -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({ /> @@ -309,6 +324,7 @@ export default function BrowserTab({ {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + {translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')} @@ -316,6 +332,7 @@ export default function BrowserTab({ {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} + {translate('components.tab.bar.BrowserTab.closeTabsToLeft', 'Close Tabs To The Left')} ({ Columns2: function Columns2(props: Record) { return { type: 'Columns2', props } }, + CopyX: function CopyX(props: Record) { + return { type: 'CopyX', props } + }, + PanelLeftClose: function PanelLeftClose(props: Record) { + return { type: 'PanelLeftClose', props } + }, Copy: function Copy(props: Record) { return { type: 'Copy', props } }, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index eaff4b93b..00b2d2a21 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -51,6 +51,9 @@ vi.mock('lucide-react', () => ({ Copy: function Copy(props: Record) { return { type: 'Copy', props } }, + CopyX: function CopyX(props: Record) { + return { type: 'CopyX', props } + }, ExternalLink: function ExternalLink(props: Record) { return { type: 'ExternalLink', props } }, @@ -60,6 +63,9 @@ vi.mock('lucide-react', () => ({ ListX: function ListX(props: Record) { return { type: 'ListX', props } }, + PanelLeftClose: function PanelLeftClose(props: Record) { + return { type: 'PanelLeftClose', props } + }, PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 1e386f4c0..32e295957 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -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({ /> { @@ -156,6 +159,7 @@ export function EditorFileTabContextMenu({ {closeShortcut ? {closeShortcut} : null} + {translate('components.tab.bar.EditorFileTabContextMenu.closeOthers', 'Close Others')} @@ -176,6 +180,7 @@ export function EditorFileTabContextMenu({ )} + {translate( 'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft', 'Close Tabs To The Left' diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index eca3a27c5..0a5444f97 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -93,6 +93,9 @@ vi.mock('lucide-react', () => ({ PanelBottomClose: function PanelBottomClose(props: Record) { return { type: 'PanelBottomClose', props } }, + PanelLeftClose: function PanelLeftClose(props: Record) { + return { type: 'PanelLeftClose', props } + }, PanelRightClose: function PanelRightClose(props: Record) { return { type: 'PanelRightClose', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 1022ccc7d..cd7c43096 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -58,6 +58,7 @@ vi.mock('lucide-react', () => ({ ListX: () => null, MessageSquare: () => null, PanelBottomClose: () => null, + PanelLeftClose: () => null, PanelRightClose: () => null, Pencil: () => null, Pin: () => null, diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 40f250285..86d489612 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -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 }} /> - + onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}> + {translate( 'components.tab.bar.SortableTabContextMenu.closeTabsToLeft', 'Close Tabs To The Left' diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx index b1ae2d1cf..faa784240 100644 --- a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -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' )} - + {PANE_COLUMN_DIRECTIONS.map((direction) => ( - + splitActiveTerminalPane('vertical')}> {translate( diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx new file mode 100644 index 000000000..c81eae348 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx @@ -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 = / { + 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)]') + } + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-sizing.ts b/src/renderer/src/components/tab-bar/tab-context-menu-sizing.ts new file mode 100644 index 000000000..43e2ebfe0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-context-menu-sizing.ts @@ -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'