Add Ctrl+Tab recent tab switcher (#1916)
This commit is contained in:
parent
43e3a5598b
commit
5bcfa27581
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] ?? []))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
rightSidebarOpenByDefault: true,
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
ctrlTabOrderMode: 'mru',
|
||||
floatingTerminalEnabled: true,
|
||||
floatingTerminalDefaultedForAllUsers: true,
|
||||
floatingTerminalCwd: '~',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue