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:
parent
50b324a8c8
commit
826f99787b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue