Add tab index shortcuts (#3193)

This commit is contained in:
Neil 2026-05-30 12:29:20 -07:00 committed by GitHub
parent c98eaf7d53
commit bd37c9ccb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 370 additions and 1 deletions

View File

@ -389,6 +389,8 @@ export function setupGuestShortcutForwarding(args: {
renderer.reloadIgnoringCache()
} else if (action?.type === 'jumpToWorktreeIndex') {
renderer.send('ui:jumpToWorktreeIndex', action.index)
} else if (action?.type === 'jumpToTabIndex') {
renderer.send('ui:jumpToTabIndex', action.index)
} else if (action?.type === 'dictationKeyDown') {
if (!shouldForwardDictationShortcut?.()) {
return

View File

@ -415,6 +415,51 @@ describe('createMainWindow', () => {
expect(webContents.send).not.toHaveBeenCalled()
})
it('forwards the platform tab-number jump shortcut to the renderer', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => true),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null)
const input =
process.platform === 'darwin'
? { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: true, alt: false }
: { type: 'keyDown', code: 'Digit5', key: '5', meta: false, control: false, alt: true }
const preventDefault = vi.fn()
windowHandlers['before-input-event']({ preventDefault } as never, input as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('ui:jumpToTabIndex', 4)
})
it('forwards Ctrl+Tab keydown and Ctrl release to the renderer switcher', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {

View File

@ -854,6 +854,11 @@ export function createMainWindow(
return
}
if (action.type === 'jumpToTabIndex') {
mainWindow.webContents.send('ui:jumpToTabIndex', action.index)
return
}
if (action.type === 'worktreeHistoryNavigate') {
// Why: routed through main so the chord reaches the renderer even when
// a terminal (xterm.js) or a browser guest has focus — both surfaces

View File

@ -1827,6 +1827,7 @@ export type PreloadApi = {
onOpenNewWorkspace: (callback: () => void) => () => void
onOpenTasks: (callback: () => void) => () => void
onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void
onJumpToTabIndex: (callback: (index: number) => void) => () => void
onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void
onNewBrowserTab: (callback: () => void) => () => void
onRequestTabCreate: (

View File

@ -2428,6 +2428,11 @@ const api = {
ipcRenderer.on('ui:jumpToWorktreeIndex', listener)
return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener)
},
onJumpToTabIndex: (callback: (index: number) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index)
ipcRenderer.on('ui:jumpToTabIndex', listener)
return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener)
},
onWorktreeHistoryNavigate: (
callback: (direction: 'back' | 'forward') => void
): (() => void) => {

View File

@ -253,6 +253,7 @@ describe('useIpcEvents browser tab create routing', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -456,6 +457,7 @@ describe('useIpcEvents updater integration', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -691,6 +693,7 @@ describe('useIpcEvents updater integration', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -1073,6 +1076,7 @@ describe('useIpcEvents updater integration', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onActivateWorktree: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onCreateTerminal: (
@ -1589,6 +1593,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -1801,6 +1806,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -2008,6 +2014,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},
@ -2233,6 +2240,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: (
listener: (data: {
@ -2462,6 +2470,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onOpenNewWorkspace: () => () => {},
onOpenTasks: () => () => {},
onJumpToWorktreeIndex: () => () => {},
onJumpToTabIndex: () => () => {},
onWorktreeHistoryNavigate: () => () => {},
onActivateWorktree: () => () => {},
onCreateTerminal: () => () => {},

View File

@ -12,6 +12,7 @@ import {
} from '@/constants/terminal'
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
import type {
TerminalLayoutSnapshot,
@ -803,6 +804,12 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onJumpToTabIndex((index) => {
activateTabNumberShortcut(index)
})
)
unsubs.push(
window.api.ui.onWorktreeHistoryNavigate((direction) => {
const store = useAppStore.getState()

View File

@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import type { Tab, TabGroup } from '../../../shared/types'
import type { AppState } from '@/store/types'
import { resolveTabNumberShortcutTarget } from './tab-number-shortcuts'
function tab(overrides: Partial<Tab> & Pick<Tab, 'id' | 'groupId'>): Tab {
return {
id: overrides.id,
entityId: overrides.entityId ?? overrides.id,
groupId: overrides.groupId,
worktreeId: overrides.worktreeId ?? 'wt-1',
contentType: overrides.contentType ?? 'terminal',
label: overrides.label ?? overrides.id,
customLabel: overrides.customLabel ?? null,
color: overrides.color ?? null,
sortOrder: overrides.sortOrder ?? 0,
createdAt: overrides.createdAt ?? 0,
isPreview: overrides.isPreview,
isPinned: overrides.isPinned
}
}
function state(overrides: {
activeView?: AppState['activeView']
activeWorktreeId?: string | null
activeGroupId?: string
groups?: TabGroup[]
tabs?: Tab[]
}): Pick<
AppState,
| 'activeGroupIdByWorktree'
| 'activeView'
| 'activeWorktreeId'
| 'groupsByWorktree'
| 'unifiedTabsByWorktree'
> {
const worktreeId = overrides.activeWorktreeId ?? 'wt-1'
return {
activeView: overrides.activeView ?? 'terminal',
activeWorktreeId: worktreeId,
activeGroupIdByWorktree:
worktreeId === null ? {} : { [worktreeId]: overrides.activeGroupId ?? 'group-a' },
groupsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.groups ?? [] },
unifiedTabsByWorktree: worktreeId === null ? {} : { [worktreeId]: overrides.tabs ?? [] }
}
}
describe('resolveTabNumberShortcutTarget', () => {
it('resolves by the active group tab order', () => {
const first = tab({ id: 'tab-1', groupId: 'group-a' })
const second = tab({ id: 'tab-2', groupId: 'group-a' })
const third = tab({ id: 'tab-3', groupId: 'group-a' })
expect(
resolveTabNumberShortcutTarget(
state({
groups: [
{
id: 'group-a',
worktreeId: 'wt-1',
activeTabId: null,
tabOrder: ['tab-2', 'tab-3', 'tab-1']
}
],
tabs: [first, second, third]
}),
1
)
).toBe(third)
})
it('ignores stale duplicate ids and appends current group tabs missing from tabOrder', () => {
const first = tab({ id: 'tab-1', groupId: 'group-a' })
const second = tab({ id: 'tab-2', groupId: 'group-a' })
expect(
resolveTabNumberShortcutTarget(
state({
groups: [
{
id: 'group-a',
worktreeId: 'wt-1',
activeTabId: null,
tabOrder: ['stale', 'tab-1', 'tab-1']
}
],
tabs: [first, second]
}),
1
)
).toBe(second)
})
it('uses only the active split group', () => {
const otherGroupTab = tab({ id: 'tab-other', groupId: 'group-a' })
const activeGroupTab = tab({ id: 'tab-active', groupId: 'group-b' })
expect(
resolveTabNumberShortcutTarget(
state({
activeGroupId: 'group-b',
groups: [
{ id: 'group-a', worktreeId: 'wt-1', activeTabId: null, tabOrder: ['tab-other'] },
{ id: 'group-b', worktreeId: 'wt-1', activeTabId: null, tabOrder: ['tab-active'] }
],
tabs: [otherGroupTab, activeGroupTab]
}),
0
)
).toBe(activeGroupTab)
})
it('returns null outside terminal workspaces or out of range', () => {
const only = tab({ id: 'tab-1', groupId: 'group-a' })
const base = state({
groups: [{ id: 'group-a', worktreeId: 'wt-1', activeTabId: null, tabOrder: ['tab-1'] }],
tabs: [only]
})
expect(resolveTabNumberShortcutTarget(base, 2)).toBeNull()
expect(resolveTabNumberShortcutTarget({ ...base, activeView: 'settings' }, 0)).toBeNull()
expect(resolveTabNumberShortcutTarget({ ...base, activeWorktreeId: null }, 0)).toBeNull()
expect(resolveTabNumberShortcutTarget(base, -1)).toBeNull()
})
})

View File

@ -0,0 +1,95 @@
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { dedupeTabOrder } from '@/store/slices/tab-group-state'
import type { Tab } from '../../../shared/types'
import {
activateWebRuntimeSessionTab,
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
type TabNumberShortcutState = Pick<
AppState,
| 'activeGroupIdByWorktree'
| 'activeView'
| 'activeWorktreeId'
| 'groupsByWorktree'
| 'unifiedTabsByWorktree'
>
export function resolveTabNumberShortcutTarget(
state: TabNumberShortcutState,
index: number
): Tab | null {
if (state.activeView !== 'terminal' || state.activeWorktreeId === null || index < 0) {
return null
}
const worktreeId = state.activeWorktreeId
const groupId = state.activeGroupIdByWorktree[worktreeId]
const group =
state.groupsByWorktree[worktreeId]?.find((candidate) => candidate.id === groupId) ??
state.groupsByWorktree[worktreeId]?.[0] ??
null
if (!group) {
return null
}
const groupTabs = (state.unifiedTabsByWorktree[worktreeId] ?? []).filter(
(tab) => tab.groupId === group.id
)
const tabById = new Map(groupTabs.map((tab) => [tab.id, tab]))
// Why: mirror TabBar's reconcile behavior. Stored group tabOrder is the
// visible left-to-right source, but stale/missing entries can happen during
// hydration and drag races, so append currently mounted group tabs.
const orderedIds = dedupeTabOrder([
...group.tabOrder.filter((tabId) => tabById.has(tabId)),
...groupTabs.map((tab) => tab.id)
])
return tabById.get(orderedIds[index] ?? '') ?? null
}
export function activateTabNumberShortcut(index: number): boolean {
const store = useAppStore.getState()
const target = resolveTabNumberShortcutTarget(store, index)
if (!target) {
return false
}
const runtimeEnvironmentId = store.settings?.activeRuntimeEnvironmentId?.trim()
const worktreeId = target.worktreeId
store.focusGroup(worktreeId, target.groupId)
store.activateTab(target.id)
if (target.contentType === 'terminal') {
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: target.entityId,
environmentId: runtimeEnvironmentId
})
}
store.setActiveTab(target.entityId)
store.setActiveTabType('terminal')
focusTerminalTabSurface(target.entityId)
return true
}
if (target.contentType === 'browser') {
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: target.id,
environmentId: runtimeEnvironmentId
})
}
store.setActiveBrowserTab(target.entityId)
store.setActiveTabType('browser')
return true
}
store.setActiveFile(target.entityId)
store.setActiveTabType('editor')
return true
}

View File

@ -1648,6 +1648,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
onJumpToWorktreeIndex: () => noopUnsubscribe,
onJumpToTabIndex: () => noopUnsubscribe,
onWorktreeHistoryNavigate: () => noopUnsubscribe,
onNewBrowserTab: () => noopUnsubscribe,
onRequestTabCreate: () => noopUnsubscribe,

View File

@ -71,6 +71,36 @@ describe('resolveWindowShortcutAction', () => {
'darwin'
)
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: false, control: true, alt: false, shift: false },
'darwin'
)
).toEqual({ type: 'jumpToTabIndex', index: 2 })
})
it('uses Alt+number for tab jumps on Windows/Linux without stealing workspace jumps', () => {
expect(
resolveWindowShortcutAction(
{ code: 'Digit4', key: '4', meta: false, control: true, alt: false, shift: false },
'linux'
)
).toEqual({ type: 'jumpToWorktreeIndex', index: 3 })
expect(
resolveWindowShortcutAction(
{ code: 'Digit4', key: '4', meta: false, control: false, alt: true, shift: false },
'linux'
)
).toEqual({ type: 'jumpToTabIndex', index: 3 })
expect(
resolveWindowShortcutAction(
{ code: 'Digit4', key: '4', meta: false, control: false, alt: true, shift: true },
'win32'
)
).toBeNull()
})
it('keeps Orca-first active in terminal context but lets Terminal-first pass risky app chords', () => {
@ -111,6 +141,22 @@ describe('resolveWindowShortcutAction', () => {
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toEqual({ type: 'switchRecentTab' })
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: false, control: true, alt: false, shift: false },
'darwin',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
)
).toBeNull()
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: false, control: true, alt: false, shift: false },
'darwin',
undefined,
{ context: 'terminal', terminalShortcutPolicy: 'orca-first' }
)
).toEqual({ type: 'jumpToTabIndex', index: 2 })
})
it('routes menu-backed actions through the same window shortcut policy', () => {

View File

@ -37,6 +37,7 @@ export type WindowShortcutAction =
| { type: 'openTasks' }
| { type: 'switchRecentTab' }
| { type: 'jumpToWorktreeIndex'; index: number }
| { type: 'jumpToTabIndex'; index: number }
| { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' }
| { type: 'dictationKeyDown' }
@ -107,6 +108,21 @@ function implicitWorktreeIndexShortcutAllowed(options: WindowShortcutResolveOpti
return normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first'
}
function implicitTabIndexShortcutAllowed(options: WindowShortcutResolveOptions): boolean {
return implicitWorktreeIndexShortcutAllowed(options)
}
function tabIndexModifierPressed(input: WindowShortcutInput, platform: NodeJS.Platform): boolean {
const meta = Boolean(input.meta ?? input.metaKey)
const control = Boolean(input.control ?? input.ctrlKey)
const alt = Boolean(input.alt ?? input.altKey)
// Why: Ctrl+1-9 is free on macOS because workspace jumps use Cmd+1-9.
// On Windows/Linux Ctrl+1-9 is already the workspace jump, so Alt+1-9
// gives tab indexing a non-conflicting hardcoded chord.
return platform === 'darwin' ? control && !meta && !alt : alt && !meta && !control
}
export function resolveWindowShortcutAction(
input: WindowShortcutInput,
platform: NodeJS.Platform,
@ -205,6 +221,17 @@ export function resolveWindowShortcutAction(
return { type: 'jumpToWorktreeIndex', index: parseInt(input.key, 10) - 1 }
}
if (
implicitTabIndexShortcutAllowed(options) &&
tabIndexModifierPressed(input, platform) &&
!input.shift &&
input.key &&
input.key >= '1' &&
input.key <= '9'
) {
return { type: 'jumpToTabIndex', index: parseInt(input.key, 10) - 1 }
}
// Why: this helper is the explicit allowlist for main-process interception.
// Anything not listed here must keep flowing to the renderer/PTTY so readline
// chords like Ctrl+R, Ctrl+U, and Ctrl+E are not accidentally stolen while
@ -247,12 +274,13 @@ export function getWindowShortcutActionId(action: WindowShortcutAction): Keybind
case 'dictationKeyDown':
return 'voice.dictation'
case 'jumpToWorktreeIndex':
case 'jumpToTabIndex':
return null
}
}
export function windowShortcutActionCapturesTerminal(action: WindowShortcutAction): boolean {
if (action.type === 'jumpToWorktreeIndex') {
if (action.type === 'jumpToWorktreeIndex' || action.type === 'jumpToTabIndex') {
return true
}
const actionId = getWindowShortcutActionId(action)