diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2ff48992cee..1c41f158c1e 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1454,6 +1454,16 @@ function Terminal(): React.JSX.Element | null { return } + // Cmd/Ctrl+Alt+W - close every editor file tab in the active worktree. + // Why: reuse the context-menu close-all path so pinned and dirty-file + // rules stay identical; terminal focus still honors shortcut policy. + if (!e.repeat && matchShortcut('tab.closeAll')) { + e.preventDefault() + notifyTerminalCapture('tab.closeAll') + handleCloseAllFiles() + return + } + // Ctrl+Tab - quick-toggle to the previously focused tab in this group. if ( matchesRecentTabSwitcherChord(e, shortcutPlatform, keybindings, { @@ -1567,6 +1577,7 @@ function Terminal(): React.JSX.Element | null { handleCloseBrowserTab, closeBrowserTab, handleCloseFile, + handleCloseAllFiles, keybindings, mobileEmulatorEnabled, terminalShortcutPolicy diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx new file mode 100644 index 00000000000..8ea86228a91 --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: function DropdownMenu(props: { children?: unknown }) { + return { type: 'DropdownMenu', props } + }, + DropdownMenuContent: function DropdownMenuContent(props: { children?: unknown }) { + return { type: 'DropdownMenuContent', props } + }, + DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { + return { type: 'DropdownMenuItem', props } + }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, + DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { + return { type: 'DropdownMenuShortcut', props } + }, + DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) { + return { type: 'DropdownMenuTrigger', props } + } +})) + +vi.mock('lucide-react', () => ({ + Copy: function Copy(props: Record) { + return { type: 'Copy', props } + }, + ExternalLink: function ExternalLink(props: Record) { + return { type: 'ExternalLink', props } + }, + Columns2: function Columns2(props: Record) { + return { type: 'Columns2', props } + }, + Rows2: function Rows2(props: Record) { + return { type: 'Rows2', props } + }, + Pencil: function Pencil(props: Record) { + return { type: 'Pencil', props } + }, + Pin: function Pin(props: Record) { + return { type: 'Pin', props } + }, + PinOff: function PinOff(props: Record) { + return { type: 'PinOff', props } + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed +// label so the test asserts the shortcut is surfaced, not its platform glyphs. +vi.mock('@/hooks/useShortcutLabel', () => ({ + useShortcutLabel: shortcutLabelMock +})) + +const useAppStoreMock = Object.assign( + (selector: (state: { settings: Record }) => unknown) => + selector({ settings: {} }), + { getState: () => ({ settings: {} }) } +) + +vi.mock('@/store', () => ({ + useAppStore: useAppStoreMock +})) + +vi.mock('@/lib/local-path-open-guard', () => ({ + showLocalPathOpenBlockedToast: vi.fn() +})) + +vi.mock('./editor-tab-local-open-guard', () => ({ + shouldBlockEditorTabLocalOpen: () => false +})) + +type ReactElementLike = { + type: unknown + props: Record +} + +function expandNode(node: unknown): unknown { + if (node == null || typeof node === 'string' || typeof node === 'number') { + return node + } + if (Array.isArray(node)) { + return node.map(expandNode) + } + const el = node as ReactElementLike + if (typeof el.type === 'function') { + return expandNode((el.type as (props: unknown) => unknown)(el.props)) + } + return { + ...el, + props: { + ...el.props, + children: expandNode(el.props?.children) + } + } +} + +function findElementsByType(node: unknown, typeName: string): ReactElementLike[] { + const results: ReactElementLike[] = [] + const visit = (current: unknown): void => { + if (current == null || typeof current === 'string' || typeof current === 'number') { + return + } + if (Array.isArray(current)) { + for (const child of current) { + visit(child) + } + return + } + const el = current as ReactElementLike + if (el.type === typeName) { + results.push(el) + } + visit(el.props?.children) + } + visit(node) + return results +} + +function extractText(node: unknown): string { + if (node == null) { + return '' + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return node.map(extractText).join('') + } + const el = node as ReactElementLike + return el.props && 'children' in el.props ? extractText(el.props.children) : '' +} + +async function renderMenu(): Promise { + const module = await import('./EditorFileTabContextMenu') + return module.EditorFileTabContextMenu({ + open: true, + menuPoint: { x: 0, y: 0 }, + file: { + id: 'file-1', + tabId: 'tab-1', + filePath: '/repo/foo.ts', + relativePath: 'foo.ts', + worktreeId: 'wt-1', + language: 'typescript', + isDirty: false, + mode: 'edit' + }, + isPinned: false, + isRenaming: false, + hasTabsToRight: false, + canRename: true, + canShowMarkdownPreview: false, + resolvedLanguage: 'typescript', + repoConnectionId: null, + skipMenuFocusRestoreRef: { current: false }, + onOpenChange: vi.fn(), + onActivate: vi.fn(), + onOpenRenameInput: vi.fn(), + onTogglePin: vi.fn(), + onClose: vi.fn(), + onCloseAll: vi.fn(), + onCloseToRight: vi.fn(), + onSplitGroup: vi.fn(), + onOpenMarkdownPreview: vi.fn() + }) +} + +describe('EditorFileTabContextMenu close-all shortcut', () => { + beforeEach(() => { + vi.resetModules() + shortcutLabelMock.mockReturnValue('⌘⌥W') + vi.stubGlobal('navigator', { userAgent: 'Mac' }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + const tree = expandNode(await renderMenu()) + + const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + extractText(item.props.children).includes('Close All Editor Tabs') + ) + + expect(closeAllItem).toBeTruthy() + + const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + + // Why: the shortcut hint is exclusive to Close All; sibling items (Close, + // Close Tabs To The Right) must not sprout their own chips. + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + }) + + it('hides the shortcut chip when close-all is unassigned', async () => { + shortcutLabelMock.mockReturnValue('Unassigned') + + const tree = expandNode(await renderMenu()) + + const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + extractText(item.props.children).includes('Close All Editor Tabs') + ) + + expect(closeAllItem).toBeTruthy() + expect(findElementsByType(closeAllItem, 'DropdownMenuShortcut')).toHaveLength(0) + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 875a9068523..2602c532e60 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -4,10 +4,12 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -77,6 +79,8 @@ export function EditorFileTabContextMenu({ onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { const sourceVisibleTabId = file.tabId ?? file.id + const closeAllShortcut = useShortcutLabel('tab.closeAll') + const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' return ( @@ -144,6 +148,9 @@ export function EditorFileTabContextMenu({ 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} + {showCloseAllShortcut ? ( + {closeAllShortcut} + ) : null} {translate( diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 8ebe634e8dc..738945dfd68 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -238,6 +238,74 @@ describe('keybindings', () => { ]) }) + it('binds close-all editor tabs to Mod+Alt+W beside tab.close', () => { + expect(getEffectiveKeybindingsForAction('tab.closeAll', 'darwin')).toEqual(['Mod+Alt+W']) + expect(getEffectiveKeybindingsForAction('tab.closeAll', 'linux')).toEqual(['Mod+Alt+W']) + expect(getEffectiveKeybindingsForAction('tab.closeAll', 'win32')).toEqual(['Mod+Alt+W']) + expect(formatKeybindingList(['Mod+Alt+W'], 'darwin')).toBe('⌘⌥W') + expect(formatKeybindingList(['Mod+Alt+W'], 'linux')).toBe('Ctrl+Alt+W') + + // Why: macOS Option+W composes to a glyph (∑), so the chord must resolve + // through the physical-code fallback rather than the logical key. + const macComposedCloseAll = { + key: '∑', + code: 'KeyW', + meta: true, + control: false, + alt: true, + shift: false + } + expect(keybindingMatchesAction('tab.closeAll', macComposedCloseAll, 'darwin')).toBe(true) + const linuxCloseAll = { + key: 'w', + code: 'KeyW', + meta: false, + control: true, + alt: true, + shift: false + } + expect(keybindingMatchesAction('tab.closeAll', linuxCloseAll, 'linux')).toBe(true) + expect( + keybindingMatchesAction('tab.closeAll', linuxCloseAll, 'linux', undefined, { + context: 'terminal', + terminalShortcutPolicy: 'orca-first' + }) + ).toBe(true) + // Why: close-all is a workspace tab command, so terminal-first mode should + // keep passing the chord through to shells and TUIs. + expect( + keybindingMatchesAction('tab.closeAll', linuxCloseAll, 'linux', undefined, { + context: 'terminal', + terminalShortcutPolicy: 'terminal-first' + }) + ).toBe(false) + + // Why: Mod+Alt+W and Mod+W are neighbors; the extra Alt must keep the two + // actions from firing on each other's chord. + const macCloseActive = { + key: 'w', + code: 'KeyW', + meta: true, + control: false, + alt: false, + shift: false + } + expect(keybindingMatchesAction('tab.close', macComposedCloseAll, 'darwin')).toBe(false) + expect(keybindingMatchesAction('tab.closeAll', macCloseActive, 'darwin')).toBe(false) + + // Stays in the Tabs group/scope so Settings → Shortcuts lists it for rebinding. + const definition = getKeybindingDefinition('tab.closeAll') + expect(definition?.group).toBe('Tabs') + expect(definition?.scope).toBe('tabs') + + // Why: both live in the Tabs scope, so rebinding closeAll onto Mod+W must + // surface as a conflict with tab.close in Settings. + expect(findKeybindingConflicts('darwin', { 'tab.closeAll': ['Mod+W'] })).toContainEqual({ + binding: 'Mod+W', + actionIds: expect.arrayContaining(['tab.close', 'tab.closeAll']) + }) + }) + it('keeps equalize pane sizes unassigned until users customize it', () => { expect(getEffectiveKeybindingsForAction('terminal.equalizePaneSizes', 'darwin')).toEqual([]) expect( diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4ceab94fcb7..1c302df5e4c 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -62,6 +62,7 @@ export type KeybindingActionId = | 'tab.newMarkdown' | 'tab.openMarkdown' | 'tab.close' + | 'tab.closeAll' | 'tab.rename' | 'tab.reopenClosed' | 'tab.nextSameType' @@ -482,6 +483,14 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ searchKeywords: ['shortcut', 'close', 'tab', 'pane'], defaultBindings: platformBindings(['Mod+W']) }, + { + id: 'tab.closeAll', + title: 'Close all editor tabs', + group: 'Tabs', + scope: 'tabs', + searchKeywords: ['shortcut', 'close', 'all', 'tabs', 'files', 'editors'], + defaultBindings: platformBindings(['Mod+Alt+W']) + }, { id: 'tab.rename', title: 'Rename active tab',