Add Ctrl+Tab recent tab switcher (#1916)

This commit is contained in:
Neil 2026-05-15 18:49:08 -07:00 committed by GitHub
parent 43e3a5598b
commit 5bcfa27581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 791 additions and 8 deletions

View File

@ -22,6 +22,14 @@ function isTerminalTabSwitchChord(input: Electron.Input): boolean {
)
}
function isCtrlTabSwitchKey(input: Electron.Input): boolean {
return input.code === 'Tab' && input.control && !input.meta && !input.alt
}
function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
export function setupGuestContextMenu(args: {
browserTabId: string
guest: Electron.WebContents
@ -221,7 +229,26 @@ export function setupGuestShortcutForwarding(args: {
shouldForwardDictationShortcut?: ShouldForwardDictationShortcut
}): () => void {
const { browserTabId, guest, resolveRenderer, shouldForwardDictationShortcut } = args
let ctrlTabSwitching = false
const handler = (event: Electron.Event, input: Electron.Input): void => {
if (isCtrlTabSwitchKey(input)) {
event.preventDefault()
if (input.type === 'keyDown') {
ctrlTabSwitching = true
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
}
return
}
if (ctrlTabSwitching && isControlKeyRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
const renderer = resolveRenderer(browserTabId)
renderer?.send('ui:ctrlTabKeyUp')
return
}
if (input.type !== 'keyDown') {
return
}

View File

@ -1108,6 +1108,75 @@ describe('browserManager', () => {
expect(rendererSendMock).toHaveBeenNthCalledWith(9, 'ui:hardReloadBrowserPage')
})
it('forwards browser guest Ctrl+Tab keydown and Ctrl release', () => {
const rendererSendMock = vi.fn()
const guest = {
id: 407,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === guest.id) {
return guest
}
if (id === rendererWebContentsId) {
return { isDestroyed: vi.fn(() => false), send: rendererSendMock }
}
return null
})
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-1',
webContentsId: guest.id,
rendererWebContentsId
})
const beforeInputHandler = guestOnMock.mock.calls
.filter(([event]) => event === 'before-input-event')
.at(-1)?.[1] as
| ((event: { preventDefault: () => void }, input: Record<string, unknown>) => void)
| undefined
const keyDownPreventDefault = vi.fn()
beforeInputHandler?.(
{ preventDefault: keyDownPreventDefault },
{
type: 'keyDown',
code: 'Tab',
key: 'Tab',
meta: false,
control: true,
alt: false,
shift: false
}
)
const keyUpPreventDefault = vi.fn()
beforeInputHandler?.(
{ preventDefault: keyUpPreventDefault },
{
type: 'keyUp',
code: 'ControlRight',
key: 'Control',
meta: false,
control: false,
alt: false,
shift: false
}
)
expect(keyDownPreventDefault).toHaveBeenCalledTimes(1)
expect(keyUpPreventDefault).toHaveBeenCalledTimes(1)
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', { shiftKey: false })
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyUp')
})
it('cleans up prior guest listeners before re-registering the same tab', () => {
const guest = {
id: 808,

View File

@ -320,6 +320,89 @@ describe('createMainWindow', () => {
expect(webContents.send).not.toHaveBeenCalled()
})
it('forwards Ctrl+Tab keydown and Ctrl release to the renderer switcher', () => {
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 beforeInputEvent = windowHandlers['before-input-event']
const firstPreventDefault = vi.fn()
beforeInputEvent(
{ preventDefault: firstPreventDefault } as never,
{
type: 'keyDown',
code: 'Tab',
key: 'Tab',
control: true,
meta: false,
alt: false,
shift: false
} as never
)
const secondPreventDefault = vi.fn()
beforeInputEvent(
{ preventDefault: secondPreventDefault } as never,
{
type: 'keyDown',
code: 'Tab',
key: 'Tab',
control: true,
meta: false,
alt: false,
shift: true
} as never
)
const releasePreventDefault = vi.fn()
beforeInputEvent(
{ preventDefault: releasePreventDefault } as never,
{
type: 'keyUp',
code: 'ControlLeft',
key: 'Control',
control: false,
meta: false,
alt: false,
shift: false
} as never
)
expect(firstPreventDefault).toHaveBeenCalledTimes(1)
expect(secondPreventDefault).toHaveBeenCalledTimes(1)
expect(releasePreventDefault).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', { shiftKey: false })
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyDown', { shiftKey: true })
expect(webContents.send).toHaveBeenNthCalledWith(3, 'ui:ctrlTabKeyUp')
})
it('only intercepts the dictation chord when enabled toggle mode can handle it', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {

View File

@ -32,6 +32,14 @@ function forceRepaint(window: BrowserWindow): void {
}, 32)
}
function isCtrlTabSwitchKey(input: Electron.Input): boolean {
return input.code === 'Tab' && input.control && !input.meta && !input.alt
}
function isControlKeyRelease(input: Electron.Input): boolean {
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
}
// Why: the titlebar is 36px (border-box, 1px border-bottom). The visual
// center of the CSS-centered content sits at ~18 CSS px from the top.
// At zoom factor z that becomes 18·z window px. Traffic lights are
@ -476,6 +484,7 @@ export function createMainWindow(
rendererProcessGone = false
})
let ctrlTabSwitching = false
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.type === 'keyDown' && is.dev && input.code === 'F12') {
event.preventDefault()
@ -487,6 +496,25 @@ export function createMainWindow(
return
}
if (isCtrlTabSwitchKey(input)) {
// Why: Ctrl+Tab is a held-key interaction. Route both press and release
// through IPC so renderer keyup suppression from preventDefault cannot
// leave the switcher overlay stranded.
event.preventDefault()
if (input.type === 'keyDown') {
ctrlTabSwitching = true
mainWindow.webContents.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
}
return
}
if (ctrlTabSwitching && isControlKeyRelease(input)) {
event.preventDefault()
ctrlTabSwitching = false
mainWindow.webContents.send('ui:ctrlTabKeyUp')
return
}
// Why: TipTap owns bare Cmd/Ctrl+B for bold while the markdown editor is
// focused — skip interception so its keymap runs. Scoped to the bare chord
// (no Shift/Alt): any extra modifier signals different intent and must

View File

@ -1456,6 +1456,8 @@ export type PreloadApi = {
onSwitchTab: (callback: (direction: 1 | -1) => void) => () => void
onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void) => () => void
onSwitchTerminalTab: (callback: (direction: 1 | -1) => void) => () => void
onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void) => () => void
onCtrlTabKeyUp: (callback: () => void) => () => void
onToggleStatusBar: (callback: () => void) => () => void
onDictationKeyDown: (callback: () => void) => () => void
onExportPdfRequested: (callback: () => void) => () => void

View File

@ -2120,6 +2120,17 @@ const api = {
ipcRenderer.on('ui:switchTerminalTab', listener)
return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener)
},
onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) =>
callback(data)
ipcRenderer.on('ui:ctrlTabKeyDown', listener)
return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener)
},
onCtrlTabKeyUp: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:ctrlTabKeyUp', listener)
return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener)
},
onToggleStatusBar: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:toggleStatusBar', listener)

View File

@ -56,6 +56,7 @@ import {
} from './components/floating-terminal/FloatingTerminalPanel'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { DictationController } from './components/dictation/DictationController'
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent'
@ -1462,6 +1463,7 @@ function App(): React.JSX.Element {
</Suspense>
) : null}
<DictationController />
<RecentTabSwitcher />
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and

View File

@ -1,8 +1,11 @@
import React, { useMemo } from 'react'
import type { CtrlTabOrderMode } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
type ShortcutItem = {
action: string
@ -139,6 +142,16 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [
{
title: 'Tab Navigation',
items: [
{
action: 'Cycle tabs forward',
searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'recent', 'ctrl'],
keys: () => ['Ctrl', 'Tab']
},
{
action: 'Cycle tabs backward',
searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'recent', 'ctrl'],
keys: ({ shift }) => ['Ctrl', shift, 'Tab']
},
{
action: 'Next tab (same type)',
searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'],
@ -227,19 +240,31 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [
}
]
const CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Ctrl+Tab Order',
description: 'Choose recent or sequential tab switching.',
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
}
]
// Why: search is supposed to stay in lockstep with the rendered shortcuts. Deriving
// both from one definition prevents the registry drift regression this branch introduced.
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] =
SHORTCUT_GROUP_DEFINITIONS.flatMap((group) =>
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...SHORTCUT_GROUP_DEFINITIONS.flatMap((group) =>
group.items.map((item) => ({
title: item.action,
description: `${group.title} shortcut`,
keywords: item.searchKeywords
}))
)
),
...CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES
]
export function ShortcutsPane(): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const ctrlTabOrderMode = useAppStore((state) => state.settings?.ctrlTabOrderMode ?? 'mru')
const updateSettings = useAppStore((state) => state.updateSettings)
const isMac = navigator.userAgent.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl'
const shift = isMac ? '⇧' : 'Shift'
@ -282,11 +307,40 @@ export function ShortcutsPane(): React.JSX.Element {
<div className="space-y-1">
<h2 className="text-sm font-semibold">Keyboard Shortcuts</h2>
<p className="text-xs text-muted-foreground">
View common hotkeys used across the application. Shortcuts customization is not
currently supported.
View common hotkeys used across the application and configure tab switching.
</p>
</div>
{matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES) ? (
<SearchableSetting
title="Ctrl+Tab Order"
description="Choose recent or sequential tab switching."
keywords={CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES[0].keywords}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Ctrl+Tab Order</Label>
<p className="text-xs text-muted-foreground">
Choose whether Ctrl+Tab follows recent use or the tab strip order.
</p>
</div>
<Select
value={ctrlTabOrderMode}
onValueChange={(value) =>
void updateSettings({ ctrlTabOrderMode: value as CtrlTabOrderMode })
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mru">Most recent</SelectItem>
<SelectItem value="sequential">Tab strip order</SelectItem>
</SelectContent>
</Select>
</SearchableSetting>
) : null}
<div className="grid gap-8">
{groups
.filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? []))

View File

@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { FileText, GitCompare, Globe2, TerminalSquare } from 'lucide-react'
import { useAppStore } from '../../store'
import { activateCyclableTab } from '../../hooks/ipc-tab-switch'
import {
buildRecentTabSwitcherModel,
getNextRecentTabSwitcherIndex,
normalizeCtrlTabOrderMode,
type RecentTabSwitcherItem
} from './recent-tab-switching'
type SwitcherState = {
items: RecentTabSwitcherItem[]
selectedIndex: number
}
function TabIcon({ item }: { item: RecentTabSwitcherItem }): React.JSX.Element {
const className = 'size-4 shrink-0 text-muted-foreground'
if (item.type === 'terminal') {
return <TerminalSquare className={className} />
}
if (item.type === 'browser') {
return <Globe2 className={className} />
}
if (item.contentType === 'diff' || item.contentType === 'conflict-review') {
return <GitCompare className={className} />
}
return <FileText className={className} />
}
export default function RecentTabSwitcher(): React.JSX.Element | null {
const [switcher, setSwitcher] = useState<SwitcherState | null>(null)
const switcherRef = useRef<SwitcherState | null>(null)
const setSwitcherState = useCallback((next: SwitcherState | null): void => {
switcherRef.current = next
setSwitcher(next)
}, [])
const openOrAdvance = useCallback(
(direction: 1 | -1): void => {
const store = useAppStore.getState()
if (store.activeView !== 'terminal' || !store.activeWorktreeId) {
return
}
const model = buildRecentTabSwitcherModel(
store,
store.activeWorktreeId,
normalizeCtrlTabOrderMode(store.settings?.ctrlTabOrderMode)
)
if (!model) {
return
}
const current = switcherRef.current
const selectedKey = current?.items[current.selectedIndex]?.key ?? null
const currentIndex =
selectedKey == null
? model.activeIndex
: model.items.findIndex((item) => item.key === selectedKey)
const selectedIndex = getNextRecentTabSwitcherIndex(
model.items.length,
currentIndex,
direction
)
setSwitcherState({ items: model.items, selectedIndex })
},
[setSwitcherState]
)
const commit = useCallback((): void => {
const current = switcherRef.current
setSwitcherState(null)
const selected = current?.items[current.selectedIndex]
if (!selected) {
return
}
activateCyclableTab(useAppStore.getState(), selected)
}, [setSwitcherState])
const cancel = useCallback((): void => {
setSwitcherState(null)
}, [setSwitcherState])
useEffect(() => {
const unsubscribeKeyDown = window.api.ui.onCtrlTabKeyDown(({ shiftKey }) => {
openOrAdvance(shiftKey ? -1 : 1)
})
const unsubscribeKeyUp = window.api.ui.onCtrlTabKeyUp(commit)
return () => {
unsubscribeKeyDown()
unsubscribeKeyUp()
}
}, [commit, openOrAdvance])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent): void => {
if (event.code === 'Tab' && event.ctrlKey && !event.metaKey && !event.altKey) {
// Why: Electron's native before-input-event path is authoritative, but
// CDP/test-dispatched keys can reach the renderer directly.
event.preventDefault()
event.stopPropagation()
openOrAdvance(event.shiftKey ? -1 : 1)
return
}
if (!switcherRef.current || event.key !== 'Escape') {
return
}
event.preventDefault()
cancel()
}
const onKeyUp = (event: KeyboardEvent): void => {
if (
!switcherRef.current ||
(event.code !== 'ControlLeft' && event.code !== 'ControlRight' && event.key !== 'Control')
) {
return
}
event.preventDefault()
event.stopPropagation()
commit()
}
const onBlur = (): void => cancel()
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
}
}, [cancel, commit, openOrAdvance])
if (!switcher) {
return null
}
return createPortal(
<div className="pointer-events-none fixed inset-0 z-[100] flex items-start justify-center pt-[12vh]">
<div
className="w-[min(520px,calc(100vw-48px))] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)]"
role="listbox"
aria-label="Switch tabs"
>
<div className="border-b border-border px-3 py-2 text-xs font-semibold text-muted-foreground">
Switch Tab
</div>
<div className="max-h-[min(360px,60vh)] overflow-hidden py-1">
{switcher.items.map((item, index) => {
const selected = index === switcher.selectedIndex
return (
<div
key={item.key}
role="option"
aria-selected={selected}
className={`flex h-8 items-center gap-2 px-3 text-sm ${
selected ? 'bg-accent text-accent-foreground' : 'text-foreground'
}`}
>
<TabIcon item={item} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.isDirty ? (
<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground" />
) : null}
</div>
)
})}
</div>
</div>
</div>,
document.body
)
}

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '../../store/types'
import type { Tab } from '../../../../shared/types'
import {
buildRecentTabSwitcherModel,
getNextRecentTabSwitcherIndex,
normalizeCtrlTabOrderMode
} from './recent-tab-switching'
const WT = 'wt-1'
const GROUP = 'group-1'
function tab(id: string, entityId: string, label: string): Tab {
return {
id,
entityId,
groupId: GROUP,
worktreeId: WT,
contentType: 'editor',
label,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function stateWithTabs(
tabOrder: string[],
recentTabIds: string[],
activeTabId: string
): Pick<
AppState,
| 'activeBrowserTabId'
| 'activeFileId'
| 'activeGroupIdByWorktree'
| 'activeTabId'
| 'activeTabType'
| 'browserTabsByWorktree'
| 'groupsByWorktree'
| 'openFiles'
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
> {
const tabs = [
tab('tab-a', 'file-a', 'A'),
tab('tab-b', 'file-b', 'B'),
tab('tab-c', 'file-c', 'C')
]
return {
activeBrowserTabId: null,
activeFileId: tabs.find((entry) => entry.id === activeTabId)?.entityId ?? null,
activeGroupIdByWorktree: { [WT]: GROUP },
activeTabId: null,
activeTabType: 'editor',
browserTabsByWorktree: {},
groupsByWorktree: {
[WT]: [{ id: GROUP, worktreeId: WT, activeTabId, tabOrder, recentTabIds }]
},
openFiles: tabs.map((entry) => ({
id: entry.entityId,
worktreeId: WT
})) as AppState['openFiles'],
tabBarOrderByWorktree: {},
tabsByWorktree: {},
unifiedTabsByWorktree: { [WT]: tabs }
}
}
describe('buildRecentTabSwitcherModel', () => {
it('orders tabs by MRU with the active tab first', () => {
const model = buildRecentTabSwitcherModel(
stateWithTabs(['tab-a', 'tab-b', 'tab-c'], ['tab-a', 'tab-c', 'tab-b'], 'tab-b'),
WT,
'mru'
)
expect(model?.items.map((item) => item.label)).toEqual(['B', 'C', 'A'])
expect(model?.activeIndex).toBe(0)
})
it('appends never-visited tabs after the MRU entries in visual order', () => {
const model = buildRecentTabSwitcherModel(
stateWithTabs(['tab-a', 'tab-b', 'tab-c'], ['tab-a', 'tab-b'], 'tab-b'),
WT,
'mru'
)
expect(model?.items.map((item) => item.label)).toEqual(['B', 'A', 'C'])
})
it('can use sequential tab-strip order instead of MRU order', () => {
const model = buildRecentTabSwitcherModel(
stateWithTabs(['tab-a', 'tab-b', 'tab-c'], ['tab-a', 'tab-c', 'tab-b'], 'tab-b'),
WT,
'sequential'
)
expect(model?.items.map((item) => item.label)).toEqual(['A', 'B', 'C'])
expect(model?.activeIndex).toBe(1)
})
it('returns null when there is no other tab to switch to', () => {
const model = buildRecentTabSwitcherModel(
stateWithTabs(['tab-a'], ['tab-a'], 'tab-a'),
WT,
'mru'
)
expect(model).toBeNull()
})
})
describe('getNextRecentTabSwitcherIndex', () => {
it('wraps in both directions', () => {
expect(getNextRecentTabSwitcherIndex(3, 0, 1)).toBe(1)
expect(getNextRecentTabSwitcherIndex(3, 0, -1)).toBe(2)
expect(getNextRecentTabSwitcherIndex(3, 2, 1)).toBe(0)
})
})
describe('normalizeCtrlTabOrderMode', () => {
it('defaults unknown and absent values to MRU', () => {
expect(normalizeCtrlTabOrderMode(undefined)).toBe('mru')
expect(normalizeCtrlTabOrderMode(null)).toBe('mru')
expect(normalizeCtrlTabOrderMode('sequential')).toBe('sequential')
})
})

View File

@ -0,0 +1,196 @@
import type { CtrlTabOrderMode, Tab, TabContentType, TabGroup } from '../../../../shared/types'
import type { AppState } from '../../store/types'
import { sanitizeRecentTabIds } from '../../store/slices/tab-group-state'
import { getActiveTabNavOrder, type VisibleTabRef } from './group-tab-order'
import { getActiveEntityIdForTabType, type TypeCyclableTab } from '../terminal/tab-type-cycle'
export type RecentTabSwitcherItem = TypeCyclableTab & {
key: string
label: string
contentType: TabContentType
isDirty: boolean
}
export type RecentTabSwitcherModel = {
items: RecentTabSwitcherItem[]
activeIndex: number
}
type RecentTabSwitchingState = Pick<
AppState,
| 'activeBrowserTabId'
| 'activeFileId'
| 'activeGroupIdByWorktree'
| 'activeTabId'
| 'activeTabType'
| 'browserTabsByWorktree'
| 'groupsByWorktree'
| 'openFiles'
| 'tabBarOrderByWorktree'
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
>
export function normalizeCtrlTabOrderMode(
value: CtrlTabOrderMode | null | undefined
): CtrlTabOrderMode {
return value === 'sequential' ? 'sequential' : 'mru'
}
function getVisibleTabKey(tab: VisibleTabRef): string {
return tab.tabId ?? `${tab.type}:${tab.id}`
}
function findActiveGroup(
state: Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree'>,
worktreeId: string
): TabGroup | null {
const groupId = state.activeGroupIdByWorktree[worktreeId]
return groupId
? ((state.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === groupId) ?? null)
: null
}
function getActiveVisibleTabKey(
state: Pick<
AppState,
| 'activeBrowserTabId'
| 'activeFileId'
| 'activeGroupIdByWorktree'
| 'activeTabId'
| 'activeTabType'
| 'groupsByWorktree'
>,
worktreeId: string,
entries: readonly VisibleTabRef[]
): string | null {
const group = findActiveGroup(state, worktreeId)
if (group?.activeTabId && entries.some((entry) => entry.tabId === group.activeTabId)) {
return group.activeTabId
}
const activeEntityId = getActiveEntityIdForTabType(
state.activeTabType,
state.activeTabId,
state.activeFileId,
state.activeBrowserTabId
)
const activeEntry =
activeEntityId == null
? null
: (entries.find(
(entry) => entry.type === state.activeTabType && entry.id === activeEntityId
) ?? null)
return activeEntry ? getVisibleTabKey(activeEntry) : null
}
function getTabLabel(tab: Tab | undefined, fallback: string): string {
return tab?.customLabel?.trim() || tab?.label?.trim() || fallback
}
function toSwitcherItem(
entry: VisibleTabRef,
tabById: ReadonlyMap<string, Tab>,
dirtyFileIds: ReadonlySet<string>
): RecentTabSwitcherItem {
const backingTab = entry.tabId ? tabById.get(entry.tabId) : undefined
return {
...entry,
key: getVisibleTabKey(entry),
label: getTabLabel(backingTab, entry.id),
contentType: backingTab?.contentType ?? (entry.type === 'editor' ? 'editor' : entry.type),
isDirty: entry.type === 'editor' && dirtyFileIds.has(entry.id)
}
}
function orderByMru(
entries: readonly VisibleTabRef[],
tabsByKey: ReadonlyMap<string, RecentTabSwitcherItem>,
group: TabGroup | null,
activeKey: string | null
): RecentTabSwitcherItem[] {
const visibleTabIds = entries.flatMap((entry) => (entry.tabId ? [entry.tabId] : []))
const recentTabIds = group ? sanitizeRecentTabIds(group.recentTabIds, visibleTabIds) : []
const ordered: RecentTabSwitcherItem[] = []
const seen = new Set<string>()
for (let i = recentTabIds.length - 1; i >= 0; i--) {
const item = tabsByKey.get(recentTabIds[i])
if (!item || seen.has(item.key)) {
continue
}
ordered.push(item)
seen.add(item.key)
}
for (const entry of entries) {
const item = tabsByKey.get(getVisibleTabKey(entry))
if (!item || seen.has(item.key)) {
continue
}
ordered.push(item)
seen.add(item.key)
}
const activeIndex = activeKey ? ordered.findIndex((item) => item.key === activeKey) : -1
if (activeIndex > 0) {
// Why: if persisted MRU data is stale, the active tab still belongs at
// the top so the first Ctrl+Tab press quick-toggles to the previous tab.
const [active] = ordered.splice(activeIndex, 1)
ordered.unshift(active)
}
return ordered
}
export function buildRecentTabSwitcherModel(
state: RecentTabSwitchingState,
worktreeId: string,
mode: CtrlTabOrderMode
): RecentTabSwitcherModel | null {
const visibleEntries = getActiveTabNavOrder(state, worktreeId)
if (visibleEntries.length <= 1) {
return null
}
const tabById = new Map(
(state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
const dirtyFileIds = new Set(
state.openFiles
.filter((file) => file.worktreeId === worktreeId && file.isDirty)
.map((file) => file.id)
)
const itemByKey = new Map(
visibleEntries.map((entry) => {
const item = toSwitcherItem(entry, tabById, dirtyFileIds)
return [item.key, item] as const
})
)
const activeKey = getActiveVisibleTabKey(state, worktreeId, visibleEntries)
const group = findActiveGroup(state, worktreeId)
const orderedItems =
mode === 'mru'
? orderByMru(visibleEntries, itemByKey, group, activeKey)
: visibleEntries.map((entry) => itemByKey.get(getVisibleTabKey(entry))!).filter(Boolean)
const activeIndex = activeKey ? orderedItems.findIndex((item) => item.key === activeKey) : -1
return {
items: orderedItems,
activeIndex
}
}
export function getNextRecentTabSwitcherIndex(
itemCount: number,
currentIndex: number,
direction: 1 | -1
): number {
if (itemCount <= 0) {
return -1
}
if (currentIndex < 0) {
return direction > 0 ? 0 : itemCount - 1
}
return (currentIndex + direction + itemCount) % itemCount
}

View File

@ -57,7 +57,7 @@ function resolveCycleContext(): CycleContext | null {
* branches so that when the same entity is open in multiple splits, the
* correct tab instance is focused.
*/
function applyNextTab(store: AppStoreState, next: TypeCyclableTab): void {
export function activateCyclableTab(store: AppStoreState, next: TypeCyclableTab): void {
if (next.type === 'terminal') {
store.setActiveTab(next.id)
store.setActiveTabType('terminal')
@ -102,7 +102,7 @@ export function handleSwitchTab(direction: number): boolean {
if (!next) {
return false
}
applyNextTab(store, next)
activateCyclableTab(store, next)
return true
}
@ -134,7 +134,7 @@ export function handleSwitchTabAcrossAllTypes(direction: number): boolean {
if (!next) {
return false
}
applyNextTab(store, next)
activateCyclableTab(store, next)
return true
}

View File

@ -718,6 +718,8 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onSwitchTab: () => noopUnsubscribe,
onSwitchTabAcrossAllTypes: () => noopUnsubscribe,
onSwitchTerminalTab: () => noopUnsubscribe,
onCtrlTabKeyDown: () => noopUnsubscribe,
onCtrlTabKeyUp: () => noopUnsubscribe,
onToggleStatusBar: () => noopUnsubscribe,
onDictationKeyDown: () => noopUnsubscribe,
onExportPdfRequested: () => noopUnsubscribe,

View File

@ -213,6 +213,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
rightSidebarOpenByDefault: true,
showTitlebarAppName: true,
showTasksButton: true,
ctrlTabOrderMode: 'mru',
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',

View File

@ -284,6 +284,7 @@ export type TabGroupLayoutNode =
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser'
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser'
export type CtrlTabOrderMode = 'mru' | 'sequential'
export type Tab = {
id: string // UUID for terminals, filePath for editors (preserves current convention)
@ -1350,6 +1351,9 @@ export type GlobalSettings = {
* left sidebar free of its button entirely. Hiding the button here also
* removes it from keyboard navigation. */
showTasksButton: boolean
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode
/** Why: Floating Terminal is the default global shell surface so users can
* reach a terminal outside repo/worktree context immediately. */
floatingTerminalEnabled: boolean