diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index b710a4ac4..4d5a95e01 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -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 diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index bc484094d..df6340ad1 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 125e4568e..3cde9fa4c 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9285b1148..17ea936f3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index a8594fa0a..b9f9271e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index ca9bf7a7b..40f7b6f07 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -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: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 870d68c63..24afa4fdf 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() diff --git a/src/renderer/src/lib/tab-number-shortcuts.test.ts b/src/renderer/src/lib/tab-number-shortcuts.test.ts new file mode 100644 index 000000000..ef22db29d --- /dev/null +++ b/src/renderer/src/lib/tab-number-shortcuts.test.ts @@ -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 & Pick): 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() + }) +}) diff --git a/src/renderer/src/lib/tab-number-shortcuts.ts b/src/renderer/src/lib/tab-number-shortcuts.ts new file mode 100644 index 000000000..6552295cf --- /dev/null +++ b/src/renderer/src/lib/tab-number-shortcuts.ts @@ -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 +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 11498a095..088e85ac7 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1648,6 +1648,7 @@ function createWebUiApi(): NonNullable['ui']> { onOpenTasks: () => noopUnsubscribe, onOpenNewWorkspace: () => noopUnsubscribe, onJumpToWorktreeIndex: () => noopUnsubscribe, + onJumpToTabIndex: () => noopUnsubscribe, onWorktreeHistoryNavigate: () => noopUnsubscribe, onNewBrowserTab: () => noopUnsubscribe, onRequestTabCreate: () => noopUnsubscribe, diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index 66e43ce2b..2ae8fc6dd 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -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', () => { diff --git a/src/shared/window-shortcut-policy.ts b/src/shared/window-shortcut-policy.ts index e889e84e3..9b3e68f48 100644 --- a/src/shared/window-shortcut-policy.ts +++ b/src/shared/window-shortcut-policy.ts @@ -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)