diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index f886a88b2..5e9533133 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -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 } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 3244e0a2d..716707d62 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -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) => 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, diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 2f184b7f8..384f1981c 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 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 void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index d0c6f054c..efa434229 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a022386b0..69dfe6e13 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index eb8fcd8b8..936464def 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7fc9c535c..1fc9a7e48 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { ) : null} + {/* 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 diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index 12d91c320..404ad93f0 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -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 {

Keyboard Shortcuts

- View common hotkeys used across the application. Shortcuts customization is not - currently supported. + View common hotkeys used across the application and configure tab switching.

+ {matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRIES) ? ( + +
+ +

+ Choose whether Ctrl+Tab follows recent use or the tab strip order. +

+
+ +
+ ) : null} +
{groups .filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? [])) diff --git a/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx b/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx new file mode 100644 index 000000000..acae89cef --- /dev/null +++ b/src/renderer/src/components/tab-bar/RecentTabSwitcher.tsx @@ -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 + } + if (item.type === 'browser') { + return + } + if (item.contentType === 'diff' || item.contentType === 'conflict-review') { + return + } + return +} + +export default function RecentTabSwitcher(): React.JSX.Element | null { + const [switcher, setSwitcher] = useState(null) + const switcherRef = useRef(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( +
+
+
+ Switch Tab +
+
+ {switcher.items.map((item, index) => { + const selected = index === switcher.selectedIndex + return ( +
+ + {item.label} + {item.isDirty ? ( + + ) : null} +
+ ) + })} +
+
+
, + document.body + ) +} diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts new file mode 100644 index 000000000..7f52e4ae1 --- /dev/null +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/tab-bar/recent-tab-switching.ts b/src/renderer/src/components/tab-bar/recent-tab-switching.ts new file mode 100644 index 000000000..0b146d478 --- /dev/null +++ b/src/renderer/src/components/tab-bar/recent-tab-switching.ts @@ -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, + 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, + dirtyFileIds: ReadonlySet +): 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, + 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() + + 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 +} diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 632d65e53..747c2f651 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -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 } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 0ef640ef3..43da686a3 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -718,6 +718,8 @@ function createWebUiApi(): NonNullable['ui']> { onSwitchTab: () => noopUnsubscribe, onSwitchTabAcrossAllTypes: () => noopUnsubscribe, onSwitchTerminalTab: () => noopUnsubscribe, + onCtrlTabKeyDown: () => noopUnsubscribe, + onCtrlTabKeyUp: () => noopUnsubscribe, onToggleStatusBar: () => noopUnsubscribe, onDictationKeyDown: () => noopUnsubscribe, onExportPdfRequested: () => noopUnsubscribe, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5dc421f1d..b5c5b77ee 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -213,6 +213,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showTitlebarAppName: true, showTasksButton: true, + ctrlTabOrderMode: 'mru', floatingTerminalEnabled: true, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: '~', diff --git a/src/shared/types.ts b/src/shared/types.ts index 8b43e9610..29ede1fbe 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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