feat(tabs): add Cmd/Ctrl+Alt+W shortcut to close all editor tabs (#5526)

Adds a rebindable close-all editor-tabs shortcut and surfaces it in the editor tab context menu.
This commit is contained in:
AJ 2026-06-16 18:40:31 -03:00 committed by GitHub
parent 50b324a8c8
commit 826f99787b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 311 additions and 0 deletions

View File

@ -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

View File

@ -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<string, unknown>) {
return { type: 'Copy', props }
},
ExternalLink: function ExternalLink(props: Record<string, unknown>) {
return { type: 'ExternalLink', props }
},
Columns2: function Columns2(props: Record<string, unknown>) {
return { type: 'Columns2', props }
},
Rows2: function Rows2(props: Record<string, unknown>) {
return { type: 'Rows2', props }
},
Pencil: function Pencil(props: Record<string, unknown>) {
return { type: 'Pencil', props }
},
Pin: function Pin(props: Record<string, unknown>) {
return { type: 'Pin', props }
},
PinOff: function PinOff(props: Record<string, unknown>) {
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<string, unknown> }) => 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<string, unknown>
}
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<unknown> {
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)
})
})

View File

@ -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 (
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
@ -144,6 +148,9 @@ export function EditorFileTabContextMenu({
'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24',
'Close All Editor Tabs'
)}
{showCloseAllShortcut ? (
<DropdownMenuShortcut>{closeAllShortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
{translate(

View File

@ -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(

View File

@ -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',