Add terminal shortcut routing policy (#2610)
This commit is contained in:
parent
31859d47fa
commit
81fcfea965
|
|
@ -381,6 +381,12 @@ export function setupGuestShortcutForwarding(args: {
|
|||
renderer.send('ui:openNewWorkspace')
|
||||
} else if (action?.type === 'openTasks') {
|
||||
renderer.send('ui:openTasks')
|
||||
} else if (action?.type === 'openSettings') {
|
||||
renderer.send('ui:openSettings')
|
||||
} else if (action?.type === 'exportPdf') {
|
||||
renderer.send('export:requestPdf')
|
||||
} else if (action?.type === 'forceReload') {
|
||||
renderer.reloadIgnoringCache()
|
||||
} else if (action?.type === 'jumpToWorktreeIndex') {
|
||||
renderer.send('ui:jumpToWorktreeIndex', action.index)
|
||||
} else if (action?.type === 'dictationKeyDown') {
|
||||
|
|
|
|||
|
|
@ -346,7 +346,13 @@ function openMainWindow(): BrowserWindow {
|
|||
}),
|
||||
deferLoad: true,
|
||||
title: devInstanceIdentity.name,
|
||||
getKeybindings: () => keybindings?.getOverrides()
|
||||
getKeybindings: () => keybindings?.getOverrides(),
|
||||
onBeforeReload: ({ ignoreCache, webContentsId }) => {
|
||||
if (mainWindow?.webContents.id === webContentsId) {
|
||||
markExpectedRendererReload(webContentsId)
|
||||
}
|
||||
recordCrashBreadcrumb('manual_reload_requested', { ignoreCache })
|
||||
}
|
||||
})
|
||||
recordCrashBreadcrumb('main_window_created')
|
||||
|
||||
|
|
|
|||
|
|
@ -64,21 +64,22 @@ describe('registerAppMenu', () => {
|
|||
buildFromTemplateMock.mockImplementation((template) => ({ template }))
|
||||
})
|
||||
|
||||
it('uses a reload menu item without a ctrl/cmd+r accelerator', () => {
|
||||
it('shows reload shortcuts as policy-routed menu hints', () => {
|
||||
registerAppMenu(buildMenuOptions())
|
||||
|
||||
expect(buildFromTemplateMock).toHaveBeenCalledTimes(1)
|
||||
const viewSubmenu = getSubmenu(getTemplate(), 'View')
|
||||
const expectedForceReloadLabel = `Force Reload\t${isMac ? '⌘⇧R' : 'Ctrl+Shift+R'}`
|
||||
|
||||
expect(viewSubmenu).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Reload' }),
|
||||
expect.objectContaining({ label: 'Force Reload', accelerator: 'CmdOrCtrl+Shift+R' })
|
||||
])
|
||||
expect.arrayContaining([expect.objectContaining({ label: 'Reload' })])
|
||||
)
|
||||
|
||||
const reloadItem = viewSubmenu.find((item) => item.label === 'Reload')
|
||||
expect(reloadItem?.accelerator).toBeUndefined()
|
||||
const forceReloadItem = viewSubmenu.find((item) => item.label === expectedForceReloadLabel)
|
||||
expect(forceReloadItem).toBeDefined()
|
||||
expect(forceReloadItem?.accelerator).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reloads the focused window from the view menu', () => {
|
||||
|
|
@ -119,8 +120,8 @@ describe('registerAppMenu', () => {
|
|||
|
||||
registerAppMenu(options)
|
||||
|
||||
const forceReloadItem = getSubmenu(getTemplate(), 'View').find(
|
||||
(item) => item.label === 'Force Reload'
|
||||
const forceReloadItem = getSubmenu(getTemplate(), 'View').find((item) =>
|
||||
item.label?.startsWith('Force Reload\t')
|
||||
)
|
||||
forceReloadItem?.click?.({} as never, {} as never, {} as never)
|
||||
|
||||
|
|
@ -179,7 +180,13 @@ describe('registerAppMenu', () => {
|
|||
expect(template.find((item) => item.label === 'Orca')).toBeUndefined()
|
||||
|
||||
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
|
||||
expect(fileLabels).toEqual(expect.arrayContaining(['Export as PDF...', 'Settings', 'Exit']))
|
||||
expect(fileLabels).toEqual(
|
||||
expect.arrayContaining([
|
||||
`Export as PDF...\t${isMac ? '⌘⇧E' : 'Ctrl+Shift+E'}`,
|
||||
`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`,
|
||||
'Exit'
|
||||
])
|
||||
)
|
||||
|
||||
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
|
||||
expect(helpLabels).toEqual(
|
||||
|
|
@ -193,11 +200,13 @@ describe('registerAppMenu', () => {
|
|||
const template = getTemplate()
|
||||
const appSubmenu = getSubmenu(template, 'Orca')
|
||||
const appLabels = appSubmenu.map((item) => item.label)
|
||||
expect(appLabels).toEqual(expect.arrayContaining(['Check for Updates...', 'Settings']))
|
||||
expect(appLabels).toEqual(
|
||||
expect.arrayContaining(['Check for Updates...', `Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`])
|
||||
)
|
||||
// Why: on macOS File should NOT duplicate Settings/Exit — those live in
|
||||
// the system app menu, so only Export belongs under File.
|
||||
const fileLabels = getSubmenu(template, 'File').map((item) => item.label)
|
||||
expect(fileLabels).not.toContain('Settings')
|
||||
expect(fileLabels).not.toContain(`Settings\t${isMac ? '⌘,' : 'Ctrl+,'}`)
|
||||
expect(fileLabels).not.toContain('Exit')
|
||||
const helpLabels = getSubmenu(template, 'Help').map((item) => item.label)
|
||||
expect(helpLabels).toEqual(['Report Crash...', undefined, 'Feature tour'])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { BrowserWindow, Menu, app } from 'electron'
|
||||
import {
|
||||
formatElectronAccelerator,
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
type KeybindingActionId,
|
||||
|
|
@ -58,14 +57,6 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
)
|
||||
return formatKeybindingList(bindings, process.platform)
|
||||
}
|
||||
const shortcutAccelerator = (actionId: KeybindingActionId): string | undefined => {
|
||||
const binding = getEffectiveKeybindingsForAction(
|
||||
actionId,
|
||||
process.platform,
|
||||
getKeybindings?.()
|
||||
)[0]
|
||||
return binding ? (formatElectronAccelerator(binding) ?? undefined) : undefined
|
||||
}
|
||||
|
||||
const reloadFocusedWindow = (ignoreCache: boolean): void => {
|
||||
const webContents = BrowserWindow.getFocusedWindow()?.webContents
|
||||
|
|
@ -101,8 +92,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
}
|
||||
|
||||
const settingsItem: Electron.MenuItemConstructorOptions = {
|
||||
label: 'Settings',
|
||||
accelerator: shortcutAccelerator('app.settings'),
|
||||
label: `Settings\t${shortcutLabel('app.settings')}`,
|
||||
click: () => onOpenSettings()
|
||||
}
|
||||
|
||||
|
|
@ -117,8 +107,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
}
|
||||
|
||||
const exportPdfItem: Electron.MenuItemConstructorOptions = {
|
||||
label: 'Export as PDF...',
|
||||
accelerator: shortcutAccelerator('file.exportPdf'),
|
||||
label: `Export as PDF...\t${shortcutLabel('file.exportPdf')}`,
|
||||
click: () => {
|
||||
// Why: fire a one-way event into the focused renderer. The renderer
|
||||
// owns the knowledge of whether a markdown surface is active and
|
||||
|
|
@ -240,8 +229,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
|||
click: () => reloadFocusedWindow(false)
|
||||
},
|
||||
{
|
||||
label: 'Force Reload',
|
||||
accelerator: shortcutAccelerator('app.forceReload'),
|
||||
label: `Force Reload\t${shortcutLabel('app.forceReload')}`,
|
||||
click: () => reloadFocusedWindow(true)
|
||||
},
|
||||
{ role: 'toggleDevTools' },
|
||||
|
|
|
|||
|
|
@ -766,6 +766,21 @@ describe('Store', () => {
|
|||
expect(store.getSettings().visibleTaskProviders).toEqual(['gitlab'])
|
||||
})
|
||||
|
||||
it('normalizes malformed terminal shortcut policy on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: { terminalShortcutPolicy: 'terminal-maybe' },
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
|
||||
})
|
||||
|
||||
it('repairs drifted task provider defaults on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
|
@ -1499,6 +1514,16 @@ describe('Store', () => {
|
|||
expect(store.getSettings().sourceControlViewMode).toBe('tree')
|
||||
})
|
||||
|
||||
it('updateSettings normalizes terminal shortcut policy', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
store.updateSettings({ terminalShortcutPolicy: 'terminal-first' })
|
||||
expect(store.getSettings().terminalShortcutPolicy).toBe('terminal-first')
|
||||
|
||||
store.updateSettings({ terminalShortcutPolicy: 'terminal-maybe' as never })
|
||||
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
|
||||
})
|
||||
|
||||
it('reloads sourceControlViewMode from global settings without touching workspace state', async () => {
|
||||
const workspaceSession = {
|
||||
activeRepoId: 'r1',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../share
|
|||
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
|
||||
import { normalizeTaskProviderSettings } from '../shared/task-providers'
|
||||
import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import {
|
||||
DEFAULT_WORKSPACE_STATUS_ID,
|
||||
clampWorkspaceBoardColumnWidth,
|
||||
|
|
@ -1450,6 +1451,9 @@ export class Store {
|
|||
),
|
||||
defaultTaskSource: taskProviderSettings.defaultTaskSource,
|
||||
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
|
||||
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
|
||||
parsed.settings?.terminalShortcutPolicy
|
||||
),
|
||||
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications),
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
voice: {
|
||||
|
|
@ -2344,6 +2348,11 @@ export class Store {
|
|||
if ('openInApplications' in updates) {
|
||||
sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications)
|
||||
}
|
||||
if ('terminalShortcutPolicy' in updates) {
|
||||
sanitizedUpdates.terminalShortcutPolicy = normalizeTerminalShortcutPolicy(
|
||||
updates.terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
|
||||
// partial updates from the Privacy pane / consent flow (e.g., flipping
|
||||
// only `optedIn`) must not clobber sibling fields like `installId` or
|
||||
|
|
|
|||
|
|
@ -708,6 +708,133 @@ describe('createMainWindow', () => {
|
|||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
|
||||
})
|
||||
|
||||
it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => {
|
||||
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({
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' })
|
||||
} as never)
|
||||
|
||||
const setFocusedListener = vi
|
||||
.mocked(ipcMain.on)
|
||||
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
|
||||
expect(setFocusedListener).toBeTypeOf('function')
|
||||
setFocusedListener?.({ sender: webContents } as never, true)
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
const isDarwin = process.platform === 'darwin'
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault } as never,
|
||||
{
|
||||
type: 'keyDown',
|
||||
code: 'KeyJ',
|
||||
key: 'j',
|
||||
meta: isDarwin,
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: !isDarwin
|
||||
} as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('notifies before Orca-first captures a risky terminal-focused shortcut', () => {
|
||||
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({
|
||||
getUI: () => ({}),
|
||||
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
|
||||
} as never)
|
||||
|
||||
const setFocusedListener = vi
|
||||
.mocked(ipcMain.on)
|
||||
.mock.calls.find(([channel]) => channel === 'ui:setTerminalInputFocused')?.[1]
|
||||
expect(setFocusedListener).toBeTypeOf('function')
|
||||
setFocusedListener?.({ sender: webContents } as never, true)
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
const isDarwin = process.platform === 'darwin'
|
||||
windowHandlers['before-input-event'](
|
||||
{ preventDefault } as never,
|
||||
{
|
||||
type: 'keyDown',
|
||||
code: 'KeyJ',
|
||||
key: 'j',
|
||||
meta: isDarwin,
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: !isDarwin
|
||||
} as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(webContents.send).toHaveBeenNthCalledWith(1, 'ui:terminalShortcutCaptured', {
|
||||
actionId: 'worktree.palette'
|
||||
})
|
||||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:toggleWorktreePalette')
|
||||
})
|
||||
|
||||
it('toggles devtools on F12 in development', () => {
|
||||
isMock.dev = true
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,17 @@ import {
|
|||
} from '../../shared/browser-url'
|
||||
import { isCrashReportReason } from '../../shared/crash-reporting'
|
||||
import {
|
||||
getWindowShortcutActionId,
|
||||
matchesRecentTabSwitcherChord,
|
||||
resolveWindowShortcutAction
|
||||
resolveWindowShortcutAction,
|
||||
windowShortcutActionCapturesTerminal
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
normalizeTerminalShortcutPolicy,
|
||||
type KeybindingMatchOptions,
|
||||
type KeybindingOverrides
|
||||
} from '../../shared/keybindings'
|
||||
import { getMainE2EConfig } from '../e2e-config'
|
||||
import { buildEditableContextMenuTemplate } from './editable-context-menu'
|
||||
|
||||
|
|
@ -44,7 +51,8 @@ function isControlKeyRelease(input: Electron.Input): boolean {
|
|||
function nativeZoomCommandMatchesKeybindings(
|
||||
direction: 'in' | 'out',
|
||||
platform: NodeJS.Platform,
|
||||
keybindings?: KeybindingOverrides
|
||||
keybindings?: KeybindingOverrides,
|
||||
options: KeybindingMatchOptions = {}
|
||||
): boolean {
|
||||
const primary =
|
||||
platform === 'darwin' ? { meta: true, control: false } : { meta: false, control: true }
|
||||
|
|
@ -66,7 +74,8 @@ function nativeZoomCommandMatchesKeybindings(
|
|||
actionId,
|
||||
{ ...primary, alt: false, ...candidate },
|
||||
platform,
|
||||
keybindings
|
||||
keybindings,
|
||||
options
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -122,6 +131,7 @@ type CreateMainWindowOptions = {
|
|||
deferLoad?: boolean
|
||||
title?: string
|
||||
getKeybindings?: () => KeybindingOverrides | undefined
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
}
|
||||
|
||||
export function loadMainWindow(mainWindow: BrowserWindow): void {
|
||||
|
|
@ -496,6 +506,7 @@ export function createMainWindow(
|
|||
// Cmd+B so browser guests and other editable surfaces keep the existing
|
||||
// global shortcut behavior.
|
||||
let markdownEditorFocused = false
|
||||
let terminalInputFocused = false
|
||||
let floatingTerminalInputFocused = false
|
||||
|
||||
const markdownFocusChannel = 'ui:setMarkdownEditorFocused'
|
||||
|
|
@ -512,13 +523,20 @@ export function createMainWindow(
|
|||
markdownEditorFocused = focused === true
|
||||
}
|
||||
ipcMain.on(markdownFocusChannel, onMarkdownEditorFocused)
|
||||
const terminalInputFocusChannel = 'ui:setTerminalInputFocused'
|
||||
// Why: before-input-event resolves shortcuts before renderer keydown. Mirror
|
||||
// regular xterm focus so Terminal-first can let shells/TUIs own app chords.
|
||||
const onTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
terminalInputFocused = focused === true
|
||||
}
|
||||
ipcMain.on(terminalInputFocusChannel, onTerminalInputFocused)
|
||||
const floatingTerminalInputFocusChannel = 'ui:setFloatingTerminalInputFocused'
|
||||
// Why: main before-input-event runs before renderer keydown handlers. Mirror
|
||||
// floating xterm focus so Ctrl+B/L and related shell chords can reach SSH/tmux.
|
||||
const onFloatingTerminalInputFocused = (
|
||||
event: Electron.IpcMainEvent,
|
||||
focused: unknown
|
||||
): void => {
|
||||
const onFloatingTerminalInputFocused = (event: Electron.IpcMainEvent, focused: unknown): void => {
|
||||
if (event.sender !== mainWindow.webContents) {
|
||||
return
|
||||
}
|
||||
|
|
@ -544,6 +562,9 @@ export function createMainWindow(
|
|||
const resetMarkdownEditorFocus = (): void => {
|
||||
markdownEditorFocused = false
|
||||
}
|
||||
const resetTerminalInputFocus = (): void => {
|
||||
terminalInputFocused = false
|
||||
}
|
||||
const resetFloatingTerminalInputFocus = (): void => {
|
||||
floatingTerminalInputFocused = false
|
||||
}
|
||||
|
|
@ -586,6 +607,7 @@ export function createMainWindow(
|
|||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
rendererProcessGone = true
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
if (opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false) {
|
||||
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
|
||||
|
|
@ -595,11 +617,13 @@ export function createMainWindow(
|
|||
})
|
||||
mainWindow.webContents.on('destroyed', () => {
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
})
|
||||
mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => {
|
||||
if (isMainFrame) {
|
||||
resetMarkdownEditorFocus()
|
||||
resetTerminalInputFocus()
|
||||
resetFloatingTerminalInputFocus()
|
||||
}
|
||||
})
|
||||
|
|
@ -621,7 +645,15 @@ export function createMainWindow(
|
|||
}
|
||||
|
||||
const keybindings = opts?.getKeybindings?.()
|
||||
if (matchesRecentTabSwitcherChord(input, process.platform, keybindings)) {
|
||||
const terminalShortcutContext: KeybindingMatchOptions = {
|
||||
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
|
||||
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
|
||||
store?.getSettings().terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
if (
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings, terminalShortcutContext)
|
||||
) {
|
||||
// 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.
|
||||
|
|
@ -659,7 +691,12 @@ export function createMainWindow(
|
|||
// Why: keep the main-process interception surface as an explicit allowlist.
|
||||
// Anything outside this helper must continue to the renderer/PTTY so
|
||||
// readline control chords are not silently stolen above the terminal.
|
||||
const action = resolveWindowShortcutAction(input, process.platform, keybindings)
|
||||
const action = resolveWindowShortcutAction(
|
||||
input,
|
||||
process.platform,
|
||||
keybindings,
|
||||
terminalShortcutContext
|
||||
)
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
|
@ -677,6 +714,13 @@ export function createMainWindow(
|
|||
return
|
||||
}
|
||||
|
||||
const capturedTerminalActionId =
|
||||
terminalShortcutContext.context === 'terminal' &&
|
||||
terminalShortcutContext.terminalShortcutPolicy === 'orca-first' &&
|
||||
windowShortcutActionCapturesTerminal(action)
|
||||
? getWindowShortcutActionId(action)
|
||||
: null
|
||||
|
||||
// Why: in hold mode, Cmd+E must NOT be intercepted here. Calling
|
||||
// preventDefault() in before-input-event suppresses ALL subsequent DOM
|
||||
// events for the key combo — including the keyUp the renderer needs to
|
||||
|
|
@ -697,17 +741,46 @@ export function createMainWindow(
|
|||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
mainWindow.webContents.send('ui:dictationKeyDown')
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
if (capturedTerminalActionId) {
|
||||
mainWindow.webContents.send('ui:terminalShortcutCaptured', {
|
||||
actionId: capturedTerminalActionId
|
||||
})
|
||||
}
|
||||
|
||||
if (action.type === 'zoom') {
|
||||
mainWindow.webContents.send('terminal:zoom', action.direction)
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'openSettings') {
|
||||
mainWindow.webContents.send('ui:openSettings')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'exportPdf') {
|
||||
mainWindow.webContents.send('export:requestPdf')
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'forceReload') {
|
||||
opts?.onBeforeReload?.({
|
||||
ignoreCache: true,
|
||||
webContentsId: mainWindow.webContents.id
|
||||
})
|
||||
mainWindow.webContents.reloadIgnoringCache()
|
||||
return
|
||||
}
|
||||
|
||||
if (action.type === 'toggleLeftSidebar') {
|
||||
mainWindow.webContents.send('ui:toggleLeftSidebar')
|
||||
return
|
||||
|
|
@ -779,7 +852,13 @@ export function createMainWindow(
|
|||
!nativeZoomCommandMatchesKeybindings(
|
||||
zoomDirection,
|
||||
process.platform,
|
||||
opts?.getKeybindings?.()
|
||||
opts?.getKeybindings?.(),
|
||||
{
|
||||
context: terminalInputFocused || floatingTerminalInputFocused ? 'terminal' : 'app',
|
||||
terminalShortcutPolicy: normalizeTerminalShortcutPolicy(
|
||||
store?.getSettings().terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
return
|
||||
|
|
@ -907,6 +986,7 @@ export function createMainWindow(
|
|||
// stale-true flag can't leak past subsequent state transitions. Paired
|
||||
// with the webContents lifecycle resets above.
|
||||
markdownEditorFocused = false
|
||||
terminalInputFocused = false
|
||||
floatingTerminalInputFocused = false
|
||||
clearRendererRecoveryTimer()
|
||||
ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights)
|
||||
|
|
@ -918,6 +998,7 @@ export function createMainWindow(
|
|||
ipcMain.removeHandler(isMaximizedChannel)
|
||||
ipcMain.removeListener(confirmCloseChannel, onConfirmClose)
|
||||
ipcMain.removeListener(markdownFocusChannel, onMarkdownEditorFocused)
|
||||
ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused)
|
||||
ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused)
|
||||
// Why: on updater-triggered shutdown, BrowserWindow can emit `closed`
|
||||
// after its webContents has already been destroyed. The destroyed
|
||||
|
|
|
|||
|
|
@ -1694,6 +1694,9 @@ export type PreloadApi = {
|
|||
onToggleRightSidebar: (callback: () => void) => () => void
|
||||
onToggleWorktreePalette: (callback: () => void) => () => void
|
||||
onToggleFloatingTerminal: (callback: () => void) => () => void
|
||||
onTerminalShortcutCaptured: (
|
||||
callback: (data: { actionId: KeybindingActionId }) => void
|
||||
) => () => void
|
||||
onOpenQuickOpen: (callback: () => void) => () => void
|
||||
onOpenNewWorkspace: (callback: () => void) => () => void
|
||||
onOpenTasks: (callback: () => void) => () => void
|
||||
|
|
@ -1842,6 +1845,7 @@ export type PreloadApi = {
|
|||
setZoomLevel: (level: number) => void
|
||||
syncTrafficLights: (zoomFactor: number) => void
|
||||
setMarkdownEditorFocused: (focused: boolean) => void
|
||||
setTerminalInputFocused: (focused: boolean) => void
|
||||
setFloatingTerminalInputFocused: (focused: boolean) => void
|
||||
onRichMarkdownContextCommand: (
|
||||
callback: (payload: RichMarkdownContextMenuCommandPayload) => void
|
||||
|
|
|
|||
|
|
@ -2253,6 +2253,16 @@ const api = {
|
|||
ipcRenderer.on('ui:toggleFloatingTerminal', listener)
|
||||
return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener)
|
||||
},
|
||||
onTerminalShortcutCaptured: (
|
||||
callback: (data: { actionId: KeybindingActionId }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { actionId: KeybindingActionId }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('ui:terminalShortcutCaptured', listener)
|
||||
return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener)
|
||||
},
|
||||
onOpenQuickOpen: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('ui:openQuickOpen', listener)
|
||||
|
|
@ -2663,6 +2673,9 @@ const api = {
|
|||
setMarkdownEditorFocused: (focused: boolean): void => {
|
||||
ipcRenderer.send('ui:setMarkdownEditorFocused', focused)
|
||||
},
|
||||
setTerminalInputFocused: (focused: boolean): void => {
|
||||
ipcRenderer.send('ui:setTerminalInputFocused', focused)
|
||||
},
|
||||
setFloatingTerminalInputFocused: (focused: boolean): void => {
|
||||
ipcRenderer.send('ui:setFloatingTerminalInputFocused', focused)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -114,8 +114,13 @@ import type { RemoteWorkspacePatchResult } from '../../shared/remote-workspace-t
|
|||
import type { OnboardingState } from '../../shared/types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
|
||||
import { getFeatureTipsAppOpenDecision } from './components/feature-tips/feature-tip-startup-gate'
|
||||
import { keybindingMatchesAction, type KeybindingContext } from '../../shared/keybindings'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingContext
|
||||
} from '../../shared/keybindings'
|
||||
import { isGitRepoKind } from '../../shared/repo-kind'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isWindows = !isMac && navigator.userAgent.includes('Windows')
|
||||
|
|
@ -1023,6 +1028,25 @@ function App(): React.JSX.Element {
|
|||
// browser guest has focus. The renderer keeps matching handlers for
|
||||
// local-focus cases and to preserve the same guards in one place.
|
||||
|
||||
const matchShortcut = (actionId: KeybindingActionId): boolean =>
|
||||
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy
|
||||
})
|
||||
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
|
||||
if (
|
||||
context !== 'terminal' ||
|
||||
(settings?.terminalShortcutPolicy ?? 'orca-first') !== 'orca-first'
|
||||
) {
|
||||
return
|
||||
}
|
||||
showTerminalShortcutCaptureNotification({
|
||||
actionId,
|
||||
platform: shortcutPlatform,
|
||||
keybindings
|
||||
})
|
||||
}
|
||||
|
||||
const canRevealRightSidebar =
|
||||
activeView !== 'tasks' &&
|
||||
activeView !== 'activity' &&
|
||||
|
|
@ -1038,12 +1062,7 @@ function App(): React.JSX.Element {
|
|||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.search.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
}) &&
|
||||
canRevealRightSidebar
|
||||
) {
|
||||
if (matchShortcut('sidebar.search.toggle') && canRevealRightSidebar) {
|
||||
// Why: when focus is inside the file explorer and a folder is selected,
|
||||
// Cmd/Ctrl+Shift+F means "Find in Folder" — seed the include pattern
|
||||
// with that folder instead of treating the chord as a text-search seed.
|
||||
|
|
@ -1053,6 +1072,7 @@ function App(): React.JSX.Element {
|
|||
: null
|
||||
if (selectedFolderRelativePath !== null && activeWorktreeId) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
actions.seedFileSearchIncludePattern(
|
||||
activeWorktreeId,
|
||||
folderRelativePathToIncludeGlob(selectedFolderRelativePath)
|
||||
|
|
@ -1065,6 +1085,7 @@ function App(): React.JSX.Element {
|
|||
const selectedText = getSelectedTextForFileSearch()
|
||||
if (selectedText) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
openSearchSidebar(selectedText)
|
||||
return
|
||||
}
|
||||
|
|
@ -1089,14 +1110,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+Alt+Arrow — worktree history back/forward. This stays before
|
||||
// right-sidebar shortcuts because it is navigation, not sidebar reveal.
|
||||
if (
|
||||
keybindingMatchesAction('worktree.history.back', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
}) ||
|
||||
keybindingMatchesAction('worktree.history.forward', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) {
|
||||
// Why: Back/Forward traverse mixed worktree + page visits, so the
|
||||
// shortcut is active wherever the titlebar button cluster is (terminal
|
||||
// or stack-backed pages). Still suppressed in Settings.
|
||||
|
|
@ -1105,11 +1119,7 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
e.preventDefault()
|
||||
const store = useAppStore.getState()
|
||||
if (
|
||||
keybindingMatchesAction('worktree.history.back', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('worktree.history.back')) {
|
||||
store.goBackWorktree()
|
||||
} else {
|
||||
store.goForwardWorktree()
|
||||
|
|
@ -1131,7 +1141,7 @@ function App(): React.JSX.Element {
|
|||
// Why: after the last floating tab is closed, the empty overlay has no
|
||||
// pane-level handler; Cmd/Ctrl+W should minimize only that landing state.
|
||||
if (
|
||||
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, { context }) &&
|
||||
matchShortcut('tab.close') &&
|
||||
shouldMinimizeFloatingWorkspacePanelOnCloseShortcut({
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
|
|
@ -1145,12 +1155,9 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
|
||||
// Cmd/Ctrl+B — toggle left sidebar
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.left.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.left.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.left.toggle')
|
||||
actions.toggleSidebar()
|
||||
return
|
||||
}
|
||||
|
|
@ -1163,13 +1170,11 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Why: full-page navigation surfaces should not reveal the right sidebar;
|
||||
// they are designed as distraction-free content areas.
|
||||
if (
|
||||
keybindingMatchesAction('view.tasks', e, shortcutPlatform, keybindings, { context }) &&
|
||||
activeView !== 'settings'
|
||||
) {
|
||||
if (matchShortcut('view.tasks') && activeView !== 'settings') {
|
||||
const store = useAppStore.getState()
|
||||
if (store.repos.some((repo) => isGitRepoKind(repo))) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('view.tasks')
|
||||
store.openTaskPage()
|
||||
}
|
||||
return
|
||||
|
|
@ -1180,35 +1185,26 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
|
||||
// Cmd/Ctrl+L — toggle right sidebar
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.right.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.right.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.right.toggle')
|
||||
actions.toggleRightSidebar()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.explorer.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.explorer.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.explorer.toggle')
|
||||
actions.setRightSidebarTab('explorer')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.search.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.search.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.search.toggle')
|
||||
openSearchSidebar(null)
|
||||
return
|
||||
}
|
||||
|
|
@ -1218,26 +1214,20 @@ function App(): React.JSX.Element {
|
|||
// in that context (handled by keyboard-handlers.ts). Both listeners share
|
||||
// the window capture phase and registration order can vary with React
|
||||
// effect re-runs, so a DOM check is the reliable coordination mechanism.
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.sourceControl.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.sourceControl.toggle')) {
|
||||
if (document.querySelector('[data-terminal-search-root]')) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.sourceControl.toggle')
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.checks.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.checks.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.checks.toggle')
|
||||
actions.setRightSidebarTab('checks')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
|
|
@ -1246,12 +1236,9 @@ function App(): React.JSX.Element {
|
|||
// Cmd+Shift+I — toggle right sidebar / ports tab (macOS only).
|
||||
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
|
||||
// intercepting it would break an essential developer tool.
|
||||
if (
|
||||
keybindingMatchesAction('sidebar.ports.toggle', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
) {
|
||||
if (matchShortcut('sidebar.ports.toggle')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('sidebar.ports.toggle')
|
||||
actions.setRightSidebarTab('ports')
|
||||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
|
|
@ -1266,6 +1253,7 @@ function App(): React.JSX.Element {
|
|||
floatingTerminalOpen,
|
||||
floatingUnifiedTabCount,
|
||||
keybindings,
|
||||
settings?.terminalShortcutPolicy,
|
||||
setFloatingTerminalOpenWithFocus
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -78,8 +78,13 @@ import {
|
|||
createFloatingWorkspaceTerminalTab,
|
||||
isFloatingWorkspacePanelVisible
|
||||
} from '@/lib/floating-workspace-terminal-actions'
|
||||
import { keybindingMatchesAction, type KeybindingContext } from '../../../shared/keybindings'
|
||||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingContext
|
||||
} from '../../../shared/keybindings'
|
||||
import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
|
||||
|
|
@ -119,6 +124,9 @@ function Terminal(): React.JSX.Element | null {
|
|||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
const activeTabType = useAppStore((s) => s.activeTabType)
|
||||
const keybindings = useAppStore((s) => s.keybindings)
|
||||
const terminalShortcutPolicy = useAppStore(
|
||||
(s) => s.settings?.terminalShortcutPolicy ?? 'orca-first'
|
||||
)
|
||||
const setActiveTabType = useAppStore((s) => s.setActiveTabType)
|
||||
const setActiveFile = useAppStore((s) => s.setActiveFile)
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
|
|
@ -1086,15 +1094,28 @@ function Terminal(): React.JSX.Element | null {
|
|||
: 'linux'
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
const context = getKeybindingContext(e.target)
|
||||
const matchShortcut = (actionId: KeybindingActionId): boolean =>
|
||||
keybindingMatchesAction(actionId, e, shortcutPlatform, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy
|
||||
})
|
||||
const notifyTerminalCapture = (actionId: KeybindingActionId): void => {
|
||||
if (context !== 'terminal' || terminalShortcutPolicy !== 'orca-first') {
|
||||
return
|
||||
}
|
||||
showTerminalShortcutCaptureNotification({
|
||||
actionId,
|
||||
platform: shortcutPlatform,
|
||||
keybindings
|
||||
})
|
||||
}
|
||||
// Why: Cmd/Ctrl+T always opens a new terminal, regardless of which
|
||||
// surface is active. Browser-tab creation has its own shortcut
|
||||
// (Cmd/Ctrl+Shift+B) so users have a predictable way to spawn a
|
||||
// terminal from anywhere in the central pane.
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.newTerminal', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('tab.newTerminal')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('tab.newTerminal')
|
||||
if (isFloatingWorkspacePanelVisible()) {
|
||||
void createFloatingWorkspaceTerminalTab(useAppStore.getState())
|
||||
return
|
||||
|
|
@ -1105,11 +1126,9 @@ function Terminal(): React.JSX.Element | null {
|
|||
|
||||
// Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active,
|
||||
// otherwise reopen the most recently closed editor tab (VS Code–style).
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.reopenClosed', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('tab.reopenClosed')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('tab.reopenClosed')
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeTabType === 'browser') {
|
||||
const restored = state.reopenClosedBrowserTab(activeWorktreeId)
|
||||
|
|
@ -1123,11 +1142,9 @@ function Terminal(): React.JSX.Element | null {
|
|||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+B - new browser tab
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.newBrowser', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('tab.newBrowser')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('tab.newBrowser')
|
||||
handleNewBrowserTab()
|
||||
return
|
||||
}
|
||||
|
|
@ -1136,10 +1153,7 @@ function Terminal(): React.JSX.Element | null {
|
|||
// outside the editor content area, e.g. on the tab bar or sidebar).
|
||||
// When the editor itself has focus, editor-local handlers own the save
|
||||
// shortcut, so we skip this when the target is editable.
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('editor.save', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('editor.save')) {
|
||||
const target = e.target as HTMLElement | null
|
||||
const inEditor =
|
||||
target?.closest('.monaco-editor, [contenteditable]') !== null ||
|
||||
|
|
@ -1148,6 +1162,7 @@ function Terminal(): React.JSX.Element | null {
|
|||
const state = useAppStore.getState()
|
||||
if (state.activeTabType === 'editor' && state.activeFileId) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('editor.save')
|
||||
window.dispatchEvent(new Event(ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT))
|
||||
return
|
||||
}
|
||||
|
|
@ -1155,11 +1170,9 @@ function Terminal(): React.JSX.Element | null {
|
|||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+M - new markdown file
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.newMarkdown', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('tab.newMarkdown')) {
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('tab.newMarkdown')
|
||||
void handleNewFile()
|
||||
return
|
||||
}
|
||||
|
|
@ -1169,15 +1182,13 @@ function Terminal(): React.JSX.Element | null {
|
|||
// in keyboard-handlers.ts so it can close individual split panes and
|
||||
// show a confirmation dialog. We still preventDefault here so Electron
|
||||
// doesn't close the window as its default Cmd+W action.
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.close', e, shortcutPlatform, keybindings, { context })
|
||||
) {
|
||||
if (!e.repeat && matchShortcut('tab.close')) {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeTabType === 'terminal' && context === 'terminal') {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
notifyTerminalCapture('tab.close')
|
||||
if (state.activeTabType === 'editor' && state.activeFileId) {
|
||||
handleCloseFile(state.activeFileId)
|
||||
} else if (state.activeTabType === 'browser' && state.activeBrowserTabId) {
|
||||
|
|
@ -1187,15 +1198,15 @@ function Terminal(): React.JSX.Element | null {
|
|||
}
|
||||
|
||||
// Ctrl+Tab - quick-toggle to the previously focused tab in this group.
|
||||
if (matchesRecentTabSwitcherChord(e, shortcutPlatform, keybindings)) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!e.repeat &&
|
||||
keybindingMatchesAction('tab.previousRecent', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
matchesRecentTabSwitcherChord(e, shortcutPlatform, keybindings, {
|
||||
context,
|
||||
terminalShortcutPolicy
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!e.repeat && matchShortcut('tab.previousRecent')) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
|
|
@ -1211,30 +1222,14 @@ function Terminal(): React.JSX.Element | null {
|
|||
// as the key value (the shifted character), not '['. Option+[ also
|
||||
// composes to dead-key / punctuation on many layouts, so matching on
|
||||
// event.key would miss the chord entirely on non-US layouts.
|
||||
const switchSameTypeDirection = keybindingMatchesAction(
|
||||
'tab.nextSameType',
|
||||
e,
|
||||
shortcutPlatform,
|
||||
keybindings,
|
||||
{ context }
|
||||
)
|
||||
const switchSameTypeDirection = matchShortcut('tab.nextSameType')
|
||||
? 1
|
||||
: keybindingMatchesAction('tab.previousSameType', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
: matchShortcut('tab.previousSameType')
|
||||
? -1
|
||||
: null
|
||||
const switchAllTypesDirection = keybindingMatchesAction(
|
||||
'tab.nextAllTypes',
|
||||
e,
|
||||
shortcutPlatform,
|
||||
keybindings,
|
||||
{ context }
|
||||
)
|
||||
const switchAllTypesDirection = matchShortcut('tab.nextAllTypes')
|
||||
? 1
|
||||
: keybindingMatchesAction('tab.previousAllTypes', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
: matchShortcut('tab.previousAllTypes')
|
||||
? -1
|
||||
: null
|
||||
if (!e.repeat && (switchSameTypeDirection !== null || switchAllTypesDirection !== null)) {
|
||||
|
|
@ -1246,6 +1241,15 @@ function Terminal(): React.JSX.Element | null {
|
|||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
notifyTerminalCapture(
|
||||
switchAllTypesDirection !== null
|
||||
? switchAllTypesDirection === 1
|
||||
? 'tab.nextAllTypes'
|
||||
: 'tab.previousAllTypes'
|
||||
: switchSameTypeDirection === 1
|
||||
? 'tab.nextSameType'
|
||||
: 'tab.previousSameType'
|
||||
)
|
||||
if (switchAllTypesDirection !== null) {
|
||||
handleSwitchTabAcrossAllTypes(switchAllTypesDirection)
|
||||
} else {
|
||||
|
|
@ -1260,17 +1264,9 @@ function Terminal(): React.JSX.Element | null {
|
|||
// for focused terminal / editor consumers and matches the unshifted
|
||||
// predicate in browser-guest-ui.ts and the chord advertised in
|
||||
// ShortcutsPane.
|
||||
const terminalTabDirection = keybindingMatchesAction(
|
||||
'tab.nextTerminal',
|
||||
e,
|
||||
shortcutPlatform,
|
||||
keybindings,
|
||||
{ context }
|
||||
)
|
||||
const terminalTabDirection = matchShortcut('tab.nextTerminal')
|
||||
? 1
|
||||
: keybindingMatchesAction('tab.previousTerminal', e, shortcutPlatform, keybindings, {
|
||||
context
|
||||
})
|
||||
: matchShortcut('tab.previousTerminal')
|
||||
? -1
|
||||
: null
|
||||
if (!e.repeat && terminalTabDirection !== null) {
|
||||
|
|
@ -1302,7 +1298,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
handleCloseBrowserTab,
|
||||
closeBrowserTab,
|
||||
handleCloseFile,
|
||||
keybindings
|
||||
keybindings,
|
||||
terminalShortcutPolicy
|
||||
])
|
||||
|
||||
// Warn on window close if there are unsaved editor files
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef } from 'react'
|
||||
import { Ban, Keyboard, RotateCcw } from 'lucide-react'
|
||||
import { Ban, Keyboard, RotateCcw, Terminal } from 'lucide-react'
|
||||
import {
|
||||
formatKeybinding,
|
||||
type KeybindingActionId,
|
||||
|
|
@ -22,6 +22,7 @@ type ShortcutBindingRowProps = {
|
|||
error?: string
|
||||
warnings: readonly string[]
|
||||
recording: boolean
|
||||
terminalStatus?: ShortcutTerminalStatus
|
||||
onStartRecording: (actionId: KeybindingActionId) => void
|
||||
onCancelRecording: () => void
|
||||
onCapture: (actionId: KeybindingActionId, input: KeybindingInput) => void
|
||||
|
|
@ -30,6 +31,11 @@ type ShortcutBindingRowProps = {
|
|||
onReset: (actionId: KeybindingActionId) => void
|
||||
}
|
||||
|
||||
export type ShortcutTerminalStatus = {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function BindingPreview({
|
||||
bindings,
|
||||
platform
|
||||
|
|
@ -62,6 +68,7 @@ export function ShortcutBindingRow({
|
|||
error,
|
||||
warnings,
|
||||
recording,
|
||||
terminalStatus,
|
||||
onStartRecording,
|
||||
onCancelRecording,
|
||||
onCapture,
|
||||
|
|
@ -125,6 +132,22 @@ export function ShortcutBindingRow({
|
|||
Modified
|
||||
</Badge>
|
||||
) : null}
|
||||
{terminalStatus ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
{terminalStatus.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{terminalStatus.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -6,19 +6,23 @@ import {
|
|||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
getKeybindingDefinition,
|
||||
isKeybindingAllowedInTerminal,
|
||||
isKeybindingPotentialTerminalConflict,
|
||||
keybindingFromInputForAction,
|
||||
keybindingIsActiveInContext,
|
||||
normalizeKeybindingListForAction,
|
||||
type KeybindingActionId,
|
||||
type KeybindingDefinition,
|
||||
type KeybindingInput,
|
||||
type KeybindingOverrides
|
||||
type KeybindingOverrides,
|
||||
type TerminalShortcutPolicy
|
||||
} from '../../../../shared/keybindings'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { KeybindingsFileActions } from './KeybindingsFileActions'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { ShortcutBindingRow } from './ShortcutBindingRow'
|
||||
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
|
||||
type ShortcutGroup = {
|
||||
|
|
@ -39,12 +43,29 @@ const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
|
|||
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
|
||||
}
|
||||
|
||||
const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
|
||||
title: 'Shortcuts in Terminal',
|
||||
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
|
||||
keywords: [
|
||||
'shortcut',
|
||||
'keyboard',
|
||||
'terminal',
|
||||
'tui',
|
||||
'shell',
|
||||
'agent',
|
||||
'conflict',
|
||||
'orca first',
|
||||
'terminal first'
|
||||
]
|
||||
}
|
||||
|
||||
export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...KEYBINDING_DEFINITIONS.map((item) => ({
|
||||
title: item.title,
|
||||
description: `${item.group} shortcut`,
|
||||
keywords: [...item.searchKeywords]
|
||||
})),
|
||||
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
|
||||
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
|
||||
]
|
||||
|
||||
|
|
@ -83,9 +104,50 @@ function hasCommonBindingOverride(
|
|||
return hasOwnBindingOverride(snapshot?.commonOverrides ?? {}, actionId)
|
||||
}
|
||||
|
||||
function getShortcutTerminalStatus(
|
||||
definition: KeybindingDefinition,
|
||||
terminalShortcutPolicy: TerminalShortcutPolicy,
|
||||
hasEffectiveBinding: boolean
|
||||
): ShortcutTerminalStatus | undefined {
|
||||
if (!hasEffectiveBinding) {
|
||||
return undefined
|
||||
}
|
||||
if (definition.scope === 'terminal') {
|
||||
return {
|
||||
label: 'Terminal',
|
||||
description: 'Runs from terminal panes.'
|
||||
}
|
||||
}
|
||||
if (isKeybindingAllowedInTerminal(definition)) {
|
||||
return {
|
||||
label: 'Terminal active',
|
||||
description: 'Still runs while a terminal has keyboard focus.'
|
||||
}
|
||||
}
|
||||
if (!isKeybindingPotentialTerminalConflict(definition)) {
|
||||
return undefined
|
||||
}
|
||||
const activeInTerminal = keybindingIsActiveInContext(definition, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy
|
||||
})
|
||||
return activeInTerminal
|
||||
? {
|
||||
label: 'Orca first',
|
||||
description: 'Also runs while a terminal or TUI has keyboard focus.'
|
||||
}
|
||||
: {
|
||||
label: 'Terminal first',
|
||||
description: 'Disabled while a terminal or TUI has keyboard focus.'
|
||||
}
|
||||
}
|
||||
|
||||
export function ShortcutsPane(): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const ctrlTabOrderMode = useAppStore((state) => state.settings?.ctrlTabOrderMode ?? 'mru')
|
||||
const terminalShortcutPolicy = useAppStore(
|
||||
(state) => state.settings?.terminalShortcutPolicy ?? 'orca-first'
|
||||
)
|
||||
const updateSettings = useAppStore((state) => state.updateSettings)
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
const keybindingSnapshot = useAppStore((state) => state.keybindingSnapshot)
|
||||
|
|
@ -235,6 +297,40 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{matchesSettingsSearch(searchQuery, TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY) ? (
|
||||
<SearchableSetting
|
||||
id="terminal-shortcut-policy"
|
||||
title="Shortcuts in Terminal"
|
||||
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
|
||||
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Shortcuts in Terminal</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orca first keeps app shortcuts active in TUIs. Terminal first lets shell shortcuts
|
||||
win unless a shortcut is marked terminal-active.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={terminalShortcutPolicy}
|
||||
onValueChange={(value) =>
|
||||
void updateSettings({
|
||||
terminalShortcutPolicy: value as TerminalShortcutPolicy
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="orca-first">Orca first</SelectItem>
|
||||
<SelectItem value="terminal-first">Terminal first</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
|
||||
{matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRY) ? (
|
||||
<SearchableSetting
|
||||
title="Recent Tab Order"
|
||||
|
|
@ -284,6 +380,11 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
)
|
||||
const modified = hasOwnBindingOverride(keybindings, item.id)
|
||||
const warnings = conflictByAction.get(item.id) ?? []
|
||||
const terminalStatus = getShortcutTerminalStatus(
|
||||
item,
|
||||
terminalShortcutPolicy,
|
||||
effective.length > 0
|
||||
)
|
||||
|
||||
return (
|
||||
<ShortcutBindingRow
|
||||
|
|
@ -296,6 +397,7 @@ export function ShortcutsPane(): React.JSX.Element {
|
|||
error={errors[item.id]}
|
||||
warnings={warnings}
|
||||
recording={recordingActionId === item.id}
|
||||
terminalStatus={terminalStatus}
|
||||
onStartRecording={(actionId) => {
|
||||
setRecordingActionId(actionId)
|
||||
clearError(actionId)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ function formatClipboardImagePasteError(error: unknown): string {
|
|||
return `Image paste failed: ${detail}`
|
||||
}
|
||||
|
||||
function isXtermHelperTextarea(target: EventTarget | null): target is HTMLElement {
|
||||
return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea')
|
||||
}
|
||||
|
||||
export default function TerminalPane({
|
||||
tabId,
|
||||
worktreeId,
|
||||
|
|
@ -970,7 +974,8 @@ export default function TerminalPane({
|
|||
searchOpenRef,
|
||||
searchStateRef,
|
||||
macOptionAsAltRef,
|
||||
keybindings
|
||||
keybindings,
|
||||
terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first'
|
||||
})
|
||||
|
||||
useTerminalPaneGlobalEffects({
|
||||
|
|
@ -993,6 +998,49 @@ export default function TerminalPane({
|
|||
toggleExpandPane
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
const syncFocused = (focused: boolean): void => {
|
||||
window.api.ui.setTerminalInputFocused?.(focused)
|
||||
}
|
||||
const onFocusIn = (event: FocusEvent): void => {
|
||||
if (isXtermHelperTextarea(event.target)) {
|
||||
syncFocused(true)
|
||||
}
|
||||
}
|
||||
const onFocusOut = (event: FocusEvent): void => {
|
||||
if (!isXtermHelperTextarea(event.target)) {
|
||||
return
|
||||
}
|
||||
if (isXtermHelperTextarea(event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
syncFocused(false)
|
||||
}
|
||||
|
||||
if (
|
||||
isXtermHelperTextarea(document.activeElement) &&
|
||||
container.contains(document.activeElement)
|
||||
) {
|
||||
syncFocused(true)
|
||||
}
|
||||
container.addEventListener('focusin', onFocusIn)
|
||||
container.addEventListener('focusout', onFocusOut)
|
||||
return () => {
|
||||
container.removeEventListener('focusin', onFocusIn)
|
||||
container.removeEventListener('focusout', onFocusOut)
|
||||
if (
|
||||
isXtermHelperTextarea(document.activeElement) &&
|
||||
container.contains(document.activeElement)
|
||||
) {
|
||||
syncFocused(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Intercept paste at the keydown level (configurable terminal paste chords)
|
||||
// AND as a fallback
|
||||
// on the paste event. We must handle keydown because Chromium does not fire
|
||||
|
|
|
|||
|
|
@ -125,6 +125,17 @@ describe('matchFileSearchShortcut', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
|
||||
it('lets terminal-first pass the file-search shortcut through to the terminal', () => {
|
||||
expect(
|
||||
matchFileSearchShortcut(
|
||||
makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true }),
|
||||
'darwin',
|
||||
undefined,
|
||||
'terminal-first'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match when file search is disabled', () => {
|
||||
expect(
|
||||
matchFileSearchShortcut(makeKeyEvent({ key: 'F', metaKey: true, shiftKey: true }), 'darwin', {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import type { MacOptionAsAlt } from './terminal-shortcut-policy'
|
|||
import {
|
||||
keybindingMatchesAction,
|
||||
type KeybindingOverrides,
|
||||
type KeybindingPlatform
|
||||
type KeybindingPlatform,
|
||||
type TerminalShortcutPolicy
|
||||
} from '../../../../shared/keybindings'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
|
||||
|
|
@ -77,13 +78,15 @@ export function matchSearchNavigate(
|
|||
export function matchFileSearchShortcut(
|
||||
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'repeat'>,
|
||||
platform: KeybindingPlatform,
|
||||
keybindings?: KeybindingOverrides
|
||||
keybindings?: KeybindingOverrides,
|
||||
terminalShortcutPolicy: TerminalShortcutPolicy = 'orca-first'
|
||||
): boolean {
|
||||
if (e.repeat) {
|
||||
return false
|
||||
}
|
||||
return keybindingMatchesAction('sidebar.search.toggle', e, platform, keybindings, {
|
||||
context: 'terminal'
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +111,7 @@ type KeyboardHandlersDeps = {
|
|||
searchStateRef: React.RefObject<SearchState>
|
||||
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
|
||||
keybindings?: KeybindingOverrides
|
||||
terminalShortcutPolicy?: TerminalShortcutPolicy
|
||||
}
|
||||
|
||||
export function useTerminalKeyboardShortcuts({
|
||||
|
|
@ -129,7 +133,8 @@ export function useTerminalKeyboardShortcuts({
|
|||
searchOpenRef,
|
||||
searchStateRef,
|
||||
macOptionAsAltRef,
|
||||
keybindings
|
||||
keybindings,
|
||||
terminalShortcutPolicy = 'orca-first'
|
||||
}: KeyboardHandlersDeps): void {
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
|
|
@ -166,7 +171,7 @@ export function useTerminalKeyboardShortcuts({
|
|||
return
|
||||
}
|
||||
|
||||
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings)) {
|
||||
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) {
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection())
|
||||
if (selectedText) {
|
||||
|
|
@ -402,6 +407,7 @@ export function useTerminalKeyboardShortcuts({
|
|||
searchOpenRef,
|
||||
searchStateRef,
|
||||
macOptionAsAltRef,
|
||||
keybindings
|
||||
keybindings,
|
||||
terminalShortcutPolicy
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,17 @@ import {
|
|||
resetAgentHookCompletionNotificationCoordinators,
|
||||
syncAgentHookCompletionNotificationSettings
|
||||
} from './agent-hook-completion-notifications'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
|
||||
function getShortcutPlatform(): NodeJS.Platform {
|
||||
if (navigator.userAgent.includes('Mac')) {
|
||||
return 'darwin'
|
||||
}
|
||||
if (navigator.userAgent.includes('Windows')) {
|
||||
return 'win32'
|
||||
}
|
||||
return 'linux'
|
||||
}
|
||||
|
||||
export { resolveZoomTarget } from './resolve-zoom-target'
|
||||
|
||||
|
|
@ -660,6 +671,18 @@ export function useIpcEvents(): void {
|
|||
})
|
||||
)
|
||||
|
||||
if (window.api.ui.onTerminalShortcutCaptured) {
|
||||
unsubs.push(
|
||||
window.api.ui.onTerminalShortcutCaptured(({ actionId }) => {
|
||||
showTerminalShortcutCaptureNotification({
|
||||
actionId,
|
||||
platform: getShortcutPlatform(),
|
||||
keybindings: useAppStore.getState().keybindings
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onOpenQuickOpen(() => {
|
||||
const store = useAppStore.getState()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import { Keyboard } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
formatKeybindingList,
|
||||
getEffectiveKeybindingsForAction,
|
||||
getKeybindingDefinition,
|
||||
isKeybindingPotentialTerminalConflict,
|
||||
type KeybindingActionId,
|
||||
type KeybindingOverrides
|
||||
} from '../../../shared/keybindings'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
const STORAGE_PREFIX = 'orca.terminalShortcutCapturedNotice.'
|
||||
|
||||
function hasShownNotice(actionId: KeybindingActionId): boolean {
|
||||
try {
|
||||
return localStorage.getItem(`${STORAGE_PREFIX}${actionId}`) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markNoticeShown(actionId: KeybindingActionId): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${actionId}`, 'true')
|
||||
} catch {
|
||||
// Ignore storage failures; the notification still gives the user the path.
|
||||
}
|
||||
}
|
||||
|
||||
function openShortcutSettings(): void {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsPage()
|
||||
store.openSettingsTarget({
|
||||
pane: 'shortcuts',
|
||||
repoId: null,
|
||||
sectionId: 'terminal-shortcut-policy'
|
||||
})
|
||||
}
|
||||
|
||||
export function showTerminalShortcutCaptureNotification({
|
||||
actionId,
|
||||
platform,
|
||||
keybindings
|
||||
}: {
|
||||
actionId: KeybindingActionId
|
||||
platform: NodeJS.Platform
|
||||
keybindings?: KeybindingOverrides
|
||||
}): void {
|
||||
const definition = getKeybindingDefinition(actionId)
|
||||
if (!definition || !isKeybindingPotentialTerminalConflict(definition)) {
|
||||
return
|
||||
}
|
||||
if (hasShownNotice(actionId)) {
|
||||
return
|
||||
}
|
||||
markNoticeShown(actionId)
|
||||
|
||||
const bindingLabel = formatKeybindingList(
|
||||
getEffectiveKeybindingsForAction(actionId, platform, keybindings),
|
||||
platform
|
||||
)
|
||||
toast.message('Orca handled a terminal shortcut', {
|
||||
description: `${definition.title} (${bindingLabel}) can be changed in Keyboard Shortcuts.`,
|
||||
icon: <Keyboard className="size-4 text-muted-foreground" />,
|
||||
action: {
|
||||
label: 'Open Shortcuts',
|
||||
onClick: openShortcutSettings
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1155,6 +1155,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onToggleRightSidebar: () => noopUnsubscribe,
|
||||
onToggleWorktreePalette: () => noopUnsubscribe,
|
||||
onToggleFloatingTerminal: () => noopUnsubscribe,
|
||||
onTerminalShortcutCaptured: () => noopUnsubscribe,
|
||||
onOpenQuickOpen: () => noopUnsubscribe,
|
||||
onOpenTasks: () => noopUnsubscribe,
|
||||
onOpenNewWorkspace: () => noopUnsubscribe,
|
||||
|
|
@ -1202,6 +1203,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
|
|||
onFileDrop: () => noopUnsubscribe,
|
||||
syncTrafficLights: () => {},
|
||||
setMarkdownEditorFocused: () => {},
|
||||
setTerminalInputFocused: () => {},
|
||||
setFloatingTerminalInputFocused: () => {},
|
||||
onRichMarkdownContextCommand: () => noopUnsubscribe,
|
||||
onFullscreenChanged: () => noopUnsubscribe,
|
||||
|
|
|
|||
|
|
@ -217,6 +217,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
ctrlTabOrderMode: 'mru',
|
||||
// Why: switching worktrees and opening command surfaces from a focused
|
||||
// terminal is a core Orca workflow; users who prefer TUI ownership opt in.
|
||||
terminalShortcutPolicy: 'orca-first',
|
||||
floatingTerminalEnabled: true,
|
||||
floatingTerminalDefaultedForAllUsers: true,
|
||||
floatingTerminalCwd: '~',
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ describe('keybindings', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('matches shortcuts from the same defaults regardless of caller context', () => {
|
||||
it('keeps Orca-first terminal context backward compatible', () => {
|
||||
const ctrlP = {
|
||||
key: 'p',
|
||||
code: 'KeyP',
|
||||
|
|
@ -145,16 +145,53 @@ describe('keybindings', () => {
|
|||
expect(keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux')).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux', undefined, {
|
||||
context: 'terminal'
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'orca-first'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction('worktree.quickOpen', ctrlP, 'linux', undefined, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'terminal-first'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'terminal.search',
|
||||
{ key: 'f', code: 'KeyF', control: true, meta: false, alt: false, shift: false },
|
||||
'linux',
|
||||
undefined,
|
||||
{ context: 'terminal' }
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => {
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'floatingTerminal.toggle',
|
||||
{ key: 't', code: 'KeyT', control: true, meta: false, alt: true, shift: false },
|
||||
'linux',
|
||||
undefined,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'tab.previousRecent',
|
||||
{ key: 'Tab', code: 'Tab', control: true, meta: false, alt: false, shift: false },
|
||||
'linux',
|
||||
undefined,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'worktree.palette',
|
||||
{ key: 'j', code: 'KeyJ', control: false, meta: true, alt: false, shift: false },
|
||||
'darwin',
|
||||
undefined,
|
||||
{ context: 'app', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export type KeybindingContext = 'app' | 'terminal' | 'browser'
|
|||
|
||||
export type KeybindingPlatform = 'darwin' | 'linux' | 'win32'
|
||||
|
||||
export type TerminalShortcutPolicy = 'orca-first' | 'terminal-first'
|
||||
|
||||
export type KeybindingMatchOptions = {
|
||||
context?: KeybindingContext
|
||||
terminalShortcutPolicy?: TerminalShortcutPolicy
|
||||
}
|
||||
|
||||
export type KeybindingActionId =
|
||||
| 'worktree.quickOpen'
|
||||
| 'worktree.palette'
|
||||
|
|
@ -787,7 +794,14 @@ function normalizeKeyToken(token: string): string | null {
|
|||
BRACKETLEFT: 'BracketLeft',
|
||||
BRACKETRIGHT: 'BracketRight',
|
||||
NUMPADADD: 'NumpadAdd',
|
||||
NUMPADSUBTRACT: 'NumpadSubtract'
|
||||
NUMPADSUBTRACT: 'NumpadSubtract',
|
||||
COMMA: 'Comma',
|
||||
PERIOD: 'Period',
|
||||
SLASH: 'Slash',
|
||||
BACKSLASH: 'Backslash',
|
||||
SEMICOLON: 'Semicolon',
|
||||
QUOTE: 'Quote',
|
||||
BACKQUOTE: 'Backquote'
|
||||
}
|
||||
|
||||
return simple[upper] ?? null
|
||||
|
|
@ -1101,6 +1115,35 @@ export function getKeybindingDefinition(actionId: KeybindingActionId): Keybindin
|
|||
return DEFINITIONS_BY_ID.get(actionId) ?? null
|
||||
}
|
||||
|
||||
export function normalizeTerminalShortcutPolicy(
|
||||
policy: TerminalShortcutPolicy | null | undefined
|
||||
): TerminalShortcutPolicy {
|
||||
return policy === 'terminal-first' ? 'terminal-first' : 'orca-first'
|
||||
}
|
||||
|
||||
export function isKeybindingAllowedInTerminal(definition: KeybindingDefinition): boolean {
|
||||
return definition.scope === 'terminal' || definition.allowInTerminal === true
|
||||
}
|
||||
|
||||
export function isKeybindingPotentialTerminalConflict(definition: KeybindingDefinition): boolean {
|
||||
return definition.scope !== 'terminal' && definition.allowInTerminal !== true
|
||||
}
|
||||
|
||||
export function keybindingIsActiveInContext(
|
||||
definition: KeybindingDefinition,
|
||||
options: KeybindingMatchOptions = {}
|
||||
): boolean {
|
||||
if (options.context !== 'terminal') {
|
||||
return true
|
||||
}
|
||||
// Why: Orca-first preserves existing app shortcut behavior inside terminals.
|
||||
// Terminal-first is the explicit escape hatch for shells and TUIs.
|
||||
if (normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first') {
|
||||
return true
|
||||
}
|
||||
return isKeybindingAllowedInTerminal(definition)
|
||||
}
|
||||
|
||||
function platformModifiers(
|
||||
parsed: ParsedKeybinding,
|
||||
platform: NodeJS.Platform
|
||||
|
|
@ -1189,12 +1232,15 @@ export function keybindingMatchesAction(
|
|||
input: KeybindingInput,
|
||||
platform: NodeJS.Platform,
|
||||
overrides?: KeybindingOverrides,
|
||||
_options: { context?: KeybindingContext } = {}
|
||||
options: KeybindingMatchOptions = {}
|
||||
): boolean {
|
||||
const definition = DEFINITIONS_BY_ID.get(actionId)
|
||||
if (!definition) {
|
||||
return false
|
||||
}
|
||||
if (!keybindingIsActiveInContext(definition, options)) {
|
||||
return false
|
||||
}
|
||||
return getEffectiveKeybindingsForAction(actionId, platform, overrides).some((binding) =>
|
||||
keybindingMatchesInput(binding, input, platform)
|
||||
)
|
||||
|
|
@ -1279,6 +1325,13 @@ function formatKeyToken(token: string): string {
|
|||
PageDown: 'PageDown',
|
||||
NumpadAdd: 'Numpad +',
|
||||
NumpadSubtract: 'Numpad -',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Backquote: '`',
|
||||
Enter: 'Enter',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { GitLabProjectSettings } from './gitlab-types'
|
|||
import type { TaskProvider } from './task-providers'
|
||||
import type { FeatureTipId } from './feature-tips'
|
||||
import type { GitBranchChangeStatus } from './git-status-types'
|
||||
import type { KeybindingOverrides } from './keybindings'
|
||||
import type { KeybindingOverrides, TerminalShortcutPolicy } from './keybindings'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
|
|
@ -1627,6 +1627,9 @@ export type GlobalSettings = {
|
|||
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
|
||||
* profiles saved before this setting existed; readers default to MRU. */
|
||||
ctrlTabOrderMode?: CtrlTabOrderMode
|
||||
/** Why: Orca-first preserves fast workspace/app control from agent TUIs.
|
||||
* Terminal-first is opt-in for users who want shell/TUI bindings to win. */
|
||||
terminalShortcutPolicy?: TerminalShortcutPolicy
|
||||
/** Why: Floating Workspace is the default global surface so users can
|
||||
* reach terminal, browser, and markdown tabs outside repo/worktree context. */
|
||||
floatingTerminalEnabled: boolean
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ describe('resolveWindowShortcutAction', () => {
|
|||
})
|
||||
|
||||
it('resolves the explicit window shortcut allowlist on macOS', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'Comma', key: ',', meta: true, control: false, alt: false, shift: false },
|
||||
'darwin'
|
||||
)
|
||||
).toEqual({ type: 'openSettings' })
|
||||
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyJ', key: 'j', meta: true, control: false, alt: false, shift: false },
|
||||
|
|
@ -66,6 +73,69 @@ describe('resolveWindowShortcutAction', () => {
|
|||
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
|
||||
})
|
||||
|
||||
it('keeps Orca-first active in terminal context but lets Terminal-first pass risky app chords', () => {
|
||||
const macWorktreePalette = {
|
||||
code: 'KeyJ',
|
||||
key: 'j',
|
||||
meta: true,
|
||||
control: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
expect(
|
||||
resolveWindowShortcutAction(macWorktreePalette, 'darwin', undefined, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'orca-first'
|
||||
})
|
||||
).toEqual({ type: 'toggleWorktreePalette' })
|
||||
expect(
|
||||
resolveWindowShortcutAction(macWorktreePalette, 'darwin', undefined, {
|
||||
context: 'terminal',
|
||||
terminalShortcutPolicy: 'terminal-first'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'Digit3', key: '3', meta: true, control: false, alt: false, shift: false },
|
||||
'darwin',
|
||||
undefined,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'Tab', key: 'Tab', meta: false, control: true, alt: false, shift: false },
|
||||
'linux',
|
||||
undefined,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toEqual({ type: 'switchRecentTab' })
|
||||
})
|
||||
|
||||
it('routes menu-backed actions through the same window shortcut policy', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyE', key: 'e', meta: true, control: false, alt: false, shift: true },
|
||||
'darwin'
|
||||
)
|
||||
).toEqual({ type: 'exportPdf' })
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },
|
||||
'linux'
|
||||
)
|
||||
).toEqual({ type: 'forceReload' })
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: true },
|
||||
'linux',
|
||||
undefined,
|
||||
{ context: 'terminal', terminalShortcutPolicy: 'terminal-first' }
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('requires shift for the non-mac worktree palette shortcut', () => {
|
||||
expect(
|
||||
resolveWindowShortcutAction(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { keybindingMatchesAction, type KeybindingOverrides } from './keybindings'
|
||||
import {
|
||||
getKeybindingDefinition,
|
||||
isKeybindingAllowedInTerminal,
|
||||
isKeybindingPotentialTerminalConflict,
|
||||
keybindingMatchesAction,
|
||||
normalizeTerminalShortcutPolicy,
|
||||
type KeybindingActionId,
|
||||
type KeybindingMatchOptions,
|
||||
type KeybindingOverrides
|
||||
} from './keybindings'
|
||||
|
||||
export type WindowShortcutInput = {
|
||||
type?: string
|
||||
|
|
@ -16,6 +25,9 @@ export type WindowShortcutInput = {
|
|||
|
||||
export type WindowShortcutAction =
|
||||
| { type: 'zoom'; direction: 'in' | 'out' | 'reset' }
|
||||
| { type: 'openSettings' }
|
||||
| { type: 'exportPdf' }
|
||||
| { type: 'forceReload' }
|
||||
| { type: 'toggleWorktreePalette' }
|
||||
| { type: 'toggleFloatingTerminal' }
|
||||
| { type: 'toggleLeftSidebar' }
|
||||
|
|
@ -28,6 +40,8 @@ export type WindowShortcutAction =
|
|||
| { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' }
|
||||
| { type: 'dictationKeyDown' }
|
||||
|
||||
type WindowShortcutResolveOptions = KeybindingMatchOptions
|
||||
|
||||
function platformPrimaryModifier(
|
||||
input: Pick<WindowShortcutInput, 'meta' | 'control'>,
|
||||
platform: NodeJS.Platform
|
||||
|
|
@ -45,7 +59,8 @@ export function isWindowShortcutModifierChord(
|
|||
export function matchesRecentTabSwitcherChord(
|
||||
input: WindowShortcutInput,
|
||||
platform: NodeJS.Platform,
|
||||
keybindings?: KeybindingOverrides
|
||||
keybindings?: KeybindingOverrides,
|
||||
options: WindowShortcutResolveOptions = {}
|
||||
): boolean {
|
||||
const control = Boolean(input.control ?? input.ctrlKey)
|
||||
const meta = Boolean(input.meta ?? input.metaKey)
|
||||
|
|
@ -70,58 +85,89 @@ export function matchesRecentTabSwitcherChord(
|
|||
shiftKey: false
|
||||
},
|
||||
platform,
|
||||
keybindings
|
||||
keybindings,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
function actionMatches(
|
||||
actionId: KeybindingActionId,
|
||||
input: WindowShortcutInput,
|
||||
platform: NodeJS.Platform,
|
||||
keybindings: KeybindingOverrides | undefined,
|
||||
options: WindowShortcutResolveOptions
|
||||
): boolean {
|
||||
return keybindingMatchesAction(actionId, input, platform, keybindings, options)
|
||||
}
|
||||
|
||||
function implicitWorktreeIndexShortcutAllowed(options: WindowShortcutResolveOptions): boolean {
|
||||
if (options.context !== 'terminal') {
|
||||
return true
|
||||
}
|
||||
return normalizeTerminalShortcutPolicy(options.terminalShortcutPolicy) === 'orca-first'
|
||||
}
|
||||
|
||||
export function resolveWindowShortcutAction(
|
||||
input: WindowShortcutInput,
|
||||
platform: NodeJS.Platform,
|
||||
keybindings?: KeybindingOverrides
|
||||
keybindings?: KeybindingOverrides,
|
||||
options: WindowShortcutResolveOptions = {}
|
||||
): WindowShortcutAction | null {
|
||||
if (keybindingMatchesAction('worktree.history.back', input, platform, keybindings)) {
|
||||
if (actionMatches('worktree.history.back', input, platform, keybindings, options)) {
|
||||
return {
|
||||
type: 'worktreeHistoryNavigate',
|
||||
direction: 'back'
|
||||
}
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('worktree.history.forward', input, platform, keybindings)) {
|
||||
if (actionMatches('worktree.history.forward', input, platform, keybindings, options)) {
|
||||
return {
|
||||
type: 'worktreeHistoryNavigate',
|
||||
direction: 'forward'
|
||||
}
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('floatingTerminal.toggle', input, platform, keybindings)) {
|
||||
if (actionMatches('floatingTerminal.toggle', input, platform, keybindings, options)) {
|
||||
return { type: 'toggleFloatingTerminal' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('zoom.in', input, platform, keybindings)) {
|
||||
if (actionMatches('zoom.in', input, platform, keybindings, options)) {
|
||||
return { type: 'zoom', direction: 'in' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('zoom.out', input, platform, keybindings)) {
|
||||
if (actionMatches('zoom.out', input, platform, keybindings, options)) {
|
||||
return { type: 'zoom', direction: 'out' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('zoom.reset', input, platform, keybindings)) {
|
||||
if (actionMatches('zoom.reset', input, platform, keybindings, options)) {
|
||||
return { type: 'zoom', direction: 'reset' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('worktree.palette', input, platform, keybindings)) {
|
||||
if (actionMatches('app.settings', input, platform, keybindings, options)) {
|
||||
return { type: 'openSettings' }
|
||||
}
|
||||
|
||||
if (actionMatches('file.exportPdf', input, platform, keybindings, options)) {
|
||||
return { type: 'exportPdf' }
|
||||
}
|
||||
|
||||
if (actionMatches('app.forceReload', input, platform, keybindings, options)) {
|
||||
return { type: 'forceReload' }
|
||||
}
|
||||
|
||||
if (actionMatches('worktree.palette', input, platform, keybindings, options)) {
|
||||
return { type: 'toggleWorktreePalette' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('sidebar.left.toggle', input, platform, keybindings)) {
|
||||
if (actionMatches('sidebar.left.toggle', input, platform, keybindings, options)) {
|
||||
return { type: 'toggleLeftSidebar' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('sidebar.right.toggle', input, platform, keybindings)) {
|
||||
if (actionMatches('sidebar.right.toggle', input, platform, keybindings, options)) {
|
||||
return { type: 'toggleRightSidebar' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('worktree.quickOpen', input, platform, keybindings)) {
|
||||
if (actionMatches('worktree.quickOpen', input, platform, keybindings, options)) {
|
||||
return { type: 'openQuickOpen' }
|
||||
}
|
||||
|
||||
|
|
@ -131,23 +177,24 @@ export function resolveWindowShortcutAction(
|
|||
// webContents, both of which bypass the renderer's window-level keydown.
|
||||
// Shift is accepted for compatibility with the former Create-from shortcut;
|
||||
// the unified composer now exposes source switching inside the name field.
|
||||
if (keybindingMatchesAction('workspace.create', input, platform, keybindings)) {
|
||||
if (actionMatches('workspace.create', input, platform, keybindings, options)) {
|
||||
return { type: 'openNewWorkspace' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('voice.dictation', input, platform, keybindings)) {
|
||||
if (actionMatches('voice.dictation', input, platform, keybindings, options)) {
|
||||
return { type: 'dictationKeyDown' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('view.tasks', input, platform, keybindings)) {
|
||||
if (actionMatches('view.tasks', input, platform, keybindings, options)) {
|
||||
return { type: 'openTasks' }
|
||||
}
|
||||
|
||||
if (keybindingMatchesAction('tab.previousRecent', input, platform, keybindings)) {
|
||||
if (actionMatches('tab.previousRecent', input, platform, keybindings, options)) {
|
||||
return { type: 'switchRecentTab' }
|
||||
}
|
||||
|
||||
if (
|
||||
implicitWorktreeIndexShortcutAllowed(options) &&
|
||||
platformPrimaryModifier(input, platform) &&
|
||||
!input.alt &&
|
||||
!input.shift &&
|
||||
|
|
@ -164,3 +211,57 @@ export function resolveWindowShortcutAction(
|
|||
// terminals own focus.
|
||||
return null
|
||||
}
|
||||
|
||||
export function getWindowShortcutActionId(action: WindowShortcutAction): KeybindingActionId | null {
|
||||
switch (action.type) {
|
||||
case 'zoom':
|
||||
return action.direction === 'in'
|
||||
? 'zoom.in'
|
||||
: action.direction === 'out'
|
||||
? 'zoom.out'
|
||||
: 'zoom.reset'
|
||||
case 'openSettings':
|
||||
return 'app.settings'
|
||||
case 'exportPdf':
|
||||
return 'file.exportPdf'
|
||||
case 'forceReload':
|
||||
return 'app.forceReload'
|
||||
case 'toggleWorktreePalette':
|
||||
return 'worktree.palette'
|
||||
case 'toggleFloatingTerminal':
|
||||
return 'floatingTerminal.toggle'
|
||||
case 'toggleLeftSidebar':
|
||||
return 'sidebar.left.toggle'
|
||||
case 'toggleRightSidebar':
|
||||
return 'sidebar.right.toggle'
|
||||
case 'openQuickOpen':
|
||||
return 'worktree.quickOpen'
|
||||
case 'openNewWorkspace':
|
||||
return 'workspace.create'
|
||||
case 'openTasks':
|
||||
return 'view.tasks'
|
||||
case 'switchRecentTab':
|
||||
return 'tab.previousRecent'
|
||||
case 'worktreeHistoryNavigate':
|
||||
return action.direction === 'back' ? 'worktree.history.back' : 'worktree.history.forward'
|
||||
case 'dictationKeyDown':
|
||||
return 'voice.dictation'
|
||||
case 'jumpToWorktreeIndex':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function windowShortcutActionCapturesTerminal(action: WindowShortcutAction): boolean {
|
||||
if (action.type === 'jumpToWorktreeIndex') {
|
||||
return true
|
||||
}
|
||||
const actionId = getWindowShortcutActionId(action)
|
||||
if (!actionId) {
|
||||
return false
|
||||
}
|
||||
const definition = getKeybindingDefinition(actionId)
|
||||
if (!definition || isKeybindingAllowedInTerminal(definition)) {
|
||||
return false
|
||||
}
|
||||
return isKeybindingPotentialTerminalConflict(definition)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue