mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
# Conflicts: # src/renderer/src/components/editor/EditorPanelHeader.tsx # src/renderer/src/components/editor/diff-navigation-context.test.tsx # src/renderer/src/components/editor/diff-navigation-context.tsx # src/renderer/src/i18n/locales/es.json # src/renderer/src/i18n/locales/ja.json # src/renderer/src/i18n/locales/ko.json # src/renderer/src/i18n/locales/zh.json
This commit is contained in:
@@ -15,6 +15,8 @@ import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { EditorPanelHeaderPath } from './EditorPanelHeaderPath'
|
||||
import { useDiffNavigation } from './diff-navigation-context'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
|
||||
|
||||
type EditorPanelHeaderProps = {
|
||||
activeFile: OpenFile
|
||||
@@ -94,6 +96,8 @@ export function EditorPanelHeader({
|
||||
[activeFile.relativePath, diffComments]
|
||||
)
|
||||
const { changeCount, goToPreviousDiff, goToNextDiff } = useDiffNavigation()
|
||||
const previousChangeShortcut = useShortcutKeyDetails('editor.previousChange')
|
||||
const nextChangeShortcut = useShortcutKeyDetails('editor.nextChange')
|
||||
|
||||
return (
|
||||
<div className="editor-header">
|
||||
@@ -224,6 +228,13 @@ export function EditorPanelHeader({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate('auto.components.editor.EditorPanelHeader.2076ecfc9c', 'Previous change')}
|
||||
{previousChangeShortcut.keys.length > 0 && (
|
||||
<ShortcutKeyCombo
|
||||
keys={previousChangeShortcut.keys}
|
||||
doubleTap={previousChangeShortcut.doubleTap}
|
||||
className="ml-1.5"
|
||||
/>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -247,6 +258,13 @@ export function EditorPanelHeader({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate('auto.components.editor.EditorPanelHeader.631dab0df3', 'Next change')}
|
||||
{nextChangeShortcut.keys.length > 0 && (
|
||||
<ShortcutKeyCombo
|
||||
keys={nextChangeShortcut.keys}
|
||||
doubleTap={nextChangeShortcut.doubleTap}
|
||||
className="ml-1.5"
|
||||
/>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -16,6 +16,7 @@ type FakeDiffEditor = editor.IStandaloneDiffEditor & {
|
||||
fireUpdate: () => void
|
||||
goToDiff: ReturnType<typeof vi.fn>
|
||||
disposeUpdate: ReturnType<typeof vi.fn>
|
||||
containerNode: HTMLElement
|
||||
}
|
||||
|
||||
function createFakeEditor(initialCount: number): FakeDiffEditor {
|
||||
@@ -24,9 +25,11 @@ function createFakeEditor(initialCount: number): FakeDiffEditor {
|
||||
const disposeUpdate = vi.fn(() => {
|
||||
updateCallback = null
|
||||
})
|
||||
const containerNode = document.createElement('div')
|
||||
const editor = {
|
||||
getLineChanges: () => (count > 0 ? Array.from({ length: count }, () => ({})) : []),
|
||||
goToDiff: vi.fn(),
|
||||
getContainerDomNode: () => containerNode,
|
||||
onDidUpdateDiff: (cb: () => void) => {
|
||||
updateCallback = cb
|
||||
return {
|
||||
@@ -37,7 +40,8 @@ function createFakeEditor(initialCount: number): FakeDiffEditor {
|
||||
count = next
|
||||
},
|
||||
fireUpdate: () => updateCallback?.(),
|
||||
disposeUpdate
|
||||
disposeUpdate,
|
||||
containerNode
|
||||
} as unknown as FakeDiffEditor
|
||||
return editor
|
||||
}
|
||||
@@ -153,4 +157,17 @@ describe('DiffNavigationProvider', () => {
|
||||
expect(editor.disposeUpdate).toHaveBeenCalledOnce()
|
||||
root = null
|
||||
})
|
||||
|
||||
it('installs a capture-phase key listener on register and removes it on unregister', () => {
|
||||
mount()
|
||||
const editor = createFakeEditor(2)
|
||||
const addSpy = vi.spyOn(editor.containerNode, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(editor.containerNode, 'removeEventListener')
|
||||
|
||||
act(() => registration?.registerDiffEditor(editor))
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
|
||||
act(() => registration?.unregisterDiffEditor(editor))
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { installMonacoDiffChangeNavigationShortcut } from './editor-shortcuts'
|
||||
|
||||
export type DiffEditorRegistrationContextValue = {
|
||||
registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void
|
||||
@@ -38,6 +39,9 @@ export function DiffNavigationProvider({
|
||||
}): React.JSX.Element {
|
||||
const editorRef = useRef<editor.IStandaloneDiffEditor | null>(null)
|
||||
const updateSubRef = useRef<{ dispose: () => void } | null>(null)
|
||||
// Why: F7/Shift+F7 change navigation shares the registered editor with the
|
||||
// header buttons, so the keyboard listener lives here rather than in DiffViewer.
|
||||
const shortcutCleanupRef = useRef<(() => void) | null>(null)
|
||||
// Why: changeCount must be state, not a ref — the header is a sibling consumer
|
||||
// and only re-renders (enabling the buttons) when the value object identity
|
||||
// changes on the 0 -> N flip once the diff computation lands.
|
||||
@@ -54,6 +58,9 @@ export function DiffNavigationProvider({
|
||||
setChangeCount(countChanges(diffEditor))
|
||||
}
|
||||
})
|
||||
// Hold at most one keyboard listener; replace any prior editor's.
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = installMonacoDiffChangeNavigationShortcut(diffEditor)
|
||||
setChangeCount(countChanges(diffEditor))
|
||||
}, [])
|
||||
|
||||
@@ -65,6 +72,8 @@ export function DiffNavigationProvider({
|
||||
}
|
||||
updateSubRef.current?.dispose()
|
||||
updateSubRef.current = null
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = null
|
||||
editorRef.current = null
|
||||
setChangeCount(0)
|
||||
}, [])
|
||||
@@ -81,6 +90,8 @@ export function DiffNavigationProvider({
|
||||
return () => {
|
||||
updateSubRef.current?.dispose()
|
||||
updateSubRef.current = null
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ vi.mock('@/store', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { installEditorFindShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
|
||||
import {
|
||||
installEditorFindShortcut,
|
||||
installMonacoDiffChangeNavigationShortcut,
|
||||
installMonacoEditorFindShortcut
|
||||
} from './editor-shortcuts'
|
||||
|
||||
type ShortcutFixture = {
|
||||
container: HTMLDivElement
|
||||
@@ -196,3 +200,85 @@ describe('installEditorFindShortcut', () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installMonacoDiffChangeNavigationShortcut', () => {
|
||||
function createDiffNavigationFixture(): {
|
||||
container: HTMLDivElement
|
||||
dispose: () => void
|
||||
input: HTMLTextAreaElement
|
||||
goToDiff: ReturnType<typeof vi.fn>
|
||||
onDownstreamKeyDown: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
const input = document.createElement('textarea')
|
||||
const goToDiff = vi.fn()
|
||||
const onDownstreamKeyDown = vi.fn()
|
||||
container.appendChild(input)
|
||||
document.body.appendChild(container)
|
||||
input.addEventListener('keydown', onDownstreamKeyDown)
|
||||
|
||||
return {
|
||||
container,
|
||||
dispose: installMonacoDiffChangeNavigationShortcut({
|
||||
getContainerDomNode: () => container,
|
||||
goToDiff
|
||||
}),
|
||||
input,
|
||||
goToDiff,
|
||||
onDownstreamKeyDown
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ key: 'F7', shiftKey: false, direction: 'next' },
|
||||
{ key: 'F7', shiftKey: true, direction: 'previous' }
|
||||
] as const)('routes $key (shift=$shiftKey) to $direction', ({ key, shiftKey, direction }) => {
|
||||
const fixture = createDiffNavigationFixture()
|
||||
|
||||
const event = dispatchKeyDown(fixture.input, { key, code: key, shiftKey })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(fixture.goToDiff).toHaveBeenCalledWith(direction)
|
||||
expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled()
|
||||
fixture.dispose()
|
||||
})
|
||||
|
||||
it('consumes repeats without navigating again', () => {
|
||||
const fixture = createDiffNavigationFixture()
|
||||
|
||||
const event = dispatchKeyDown(fixture.input, {
|
||||
key: 'F7',
|
||||
code: 'F7',
|
||||
repeat: true
|
||||
})
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(fixture.goToDiff).not.toHaveBeenCalled()
|
||||
expect(fixture.onDownstreamKeyDown).not.toHaveBeenCalled()
|
||||
fixture.dispose()
|
||||
})
|
||||
|
||||
it('honors custom bindings and removes the listener when disposed', () => {
|
||||
shortcutState.keybindings = { 'editor.nextChange': ['Mod+G'] }
|
||||
const fixture = createDiffNavigationFixture()
|
||||
|
||||
const defaultEvent = dispatchKeyDown(fixture.input, { key: 'F7', code: 'F7' })
|
||||
const customEvent = dispatchKeyDown(fixture.input, {
|
||||
key: 'g',
|
||||
code: 'KeyG',
|
||||
metaKey: true
|
||||
})
|
||||
fixture.dispose()
|
||||
const disposedEvent = dispatchKeyDown(fixture.input, {
|
||||
key: 'g',
|
||||
code: 'KeyG',
|
||||
metaKey: true
|
||||
})
|
||||
|
||||
expect(defaultEvent.defaultPrevented).toBe(false)
|
||||
expect(customEvent.defaultPrevented).toBe(true)
|
||||
expect(disposedEvent.defaultPrevented).toBe(false)
|
||||
expect(fixture.goToDiff).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.goToDiff).toHaveBeenCalledWith('next')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,39 @@ export function installEditorFindShortcut(target: HTMLElement, onFind: () => voi
|
||||
return () => target.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
|
||||
type MonacoDiffNavigationEditor = {
|
||||
getContainerDomNode: () => HTMLElement
|
||||
goToDiff: (target: 'next' | 'previous') => void
|
||||
}
|
||||
|
||||
export function installMonacoDiffChangeNavigationShortcut(
|
||||
editor: MonacoDiffNavigationEditor
|
||||
): () => void {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
let direction: 'next' | 'previous' | null = null
|
||||
if (editorShortcutMatches('editor.nextChange', event)) {
|
||||
direction = 'next'
|
||||
} else if (editorShortcutMatches('editor.previousChange', event)) {
|
||||
direction = 'previous'
|
||||
}
|
||||
if (!direction) {
|
||||
return
|
||||
}
|
||||
// Why: capture-phase preventDefault/stopPropagation beats Monaco's built-in
|
||||
// F7 accessible-review pane, like the find shortcut does for Cmd+F.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Consume matched repeats but navigate once per press (matches find shortcut).
|
||||
if (!event.repeat) {
|
||||
editor.goToDiff(direction)
|
||||
}
|
||||
}
|
||||
|
||||
const target = editor.getContainerDomNode()
|
||||
target.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => target.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
|
||||
type MonacoFindShortcutEditor = {
|
||||
getAction: (id: string) => { run: () => void | Promise<void> } | null
|
||||
getContainerDomNode: () => HTMLElement
|
||||
|
||||
@@ -11441,8 +11441,8 @@
|
||||
"9b80bbe1de": "Abrir pestaña de archivo",
|
||||
"f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown",
|
||||
"a10d9b8337": "Abrir archivo",
|
||||
"2076ecfc9c": "Previous change",
|
||||
"631dab0df3": "Next change"
|
||||
"2076ecfc9c": "Cambio anterior",
|
||||
"631dab0df3": "Cambio siguiente"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "Exportar como PDF",
|
||||
|
||||
@@ -11441,8 +11441,8 @@
|
||||
"9b80bbe1de": "ファイルタブを開く",
|
||||
"f0fd4174b5": "ファイル タブを開いてリッチ markdown 編集を使用する",
|
||||
"a10d9b8337": "ファイルを開く",
|
||||
"2076ecfc9c": "Previous change",
|
||||
"631dab0df3": "Next change"
|
||||
"2076ecfc9c": "前の変更",
|
||||
"631dab0df3": "次の変更"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDFとしてエクスポート",
|
||||
|
||||
@@ -11441,8 +11441,8 @@
|
||||
"9b80bbe1de": "파일 탭 열기",
|
||||
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
|
||||
"a10d9b8337": "파일 열기",
|
||||
"2076ecfc9c": "Previous change",
|
||||
"631dab0df3": "Next change"
|
||||
"2076ecfc9c": "이전 변경",
|
||||
"631dab0df3": "다음 변경"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "PDF로 내보내기",
|
||||
|
||||
@@ -11441,8 +11441,8 @@
|
||||
"9b80bbe1de": "打开文件选项卡",
|
||||
"f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑",
|
||||
"a10d9b8337": "打开文件",
|
||||
"2076ecfc9c": "Previous change",
|
||||
"631dab0df3": "Next change"
|
||||
"2076ecfc9c": "上一个更改",
|
||||
"631dab0df3": "下一个更改"
|
||||
},
|
||||
"EditorPanelMarkdownActionsMenu": {
|
||||
"3e0ce48c24": "导出为 PDF",
|
||||
|
||||
@@ -140,6 +140,24 @@ describe('keybindings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('binds F7 / Shift+F7 for diff-change navigation and matches their events', () => {
|
||||
// Opt-in actions accept bare / Shift-only function keys...
|
||||
expect(normalizeKeybindingListForAction('editor.nextChange', 'F7')).toEqual(['F7'])
|
||||
expect(normalizeKeybindingListForAction('editor.previousChange', 'Shift+F7')).toEqual([
|
||||
'Shift+F7'
|
||||
])
|
||||
// ...but they stay unsafe for actions that do not opt in.
|
||||
expect(normalizeKeybinding('F7')).toMatchObject({ ok: false })
|
||||
expect(normalizeKeybinding('Shift+F7')).toMatchObject({ ok: false })
|
||||
|
||||
const f7 = { key: 'F7', code: 'F7', control: false, meta: false, alt: false, shift: false }
|
||||
const shiftF7 = { ...f7, shift: true }
|
||||
expect(keybindingMatchesAction('editor.nextChange', f7, 'darwin')).toBe(true)
|
||||
expect(keybindingMatchesAction('editor.nextChange', shiftF7, 'darwin')).toBe(false)
|
||||
expect(keybindingMatchesAction('editor.previousChange', shiftF7, 'darwin')).toBe(true)
|
||||
expect(keybindingMatchesAction('editor.previousChange', f7, 'darwin')).toBe(false)
|
||||
})
|
||||
|
||||
it('formats keybindings with platform labels', () => {
|
||||
expect(formatKeybindingList(['Mod+Shift+J'], 'darwin')).toBe('⌘⇧J')
|
||||
expect(formatKeybindingList(['Mod+Shift+J'], 'linux')).toBe('Ctrl+Shift+J')
|
||||
|
||||
+53
-14
@@ -90,6 +90,8 @@ export type KeybindingActionId =
|
||||
| 'editor.save'
|
||||
| 'editor.markdownPreview'
|
||||
| 'editor.copyContext'
|
||||
| 'editor.previousChange'
|
||||
| 'editor.nextChange'
|
||||
| 'fileExplorer.undo'
|
||||
| 'fileExplorer.redo'
|
||||
| 'fileExplorer.copyPath'
|
||||
@@ -826,6 +828,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
||||
searchKeywords: ['shortcut', 'editor', 'copy', 'context'],
|
||||
defaultBindings: platformBindings(['Mod+Alt+C'])
|
||||
},
|
||||
// Why: F7 / Shift+F7 gives VS Code / JetBrains diff-change navigation. Function
|
||||
// keys are safe as bare / Shift bindings, so both opt into allowBareKeybindings.
|
||||
{
|
||||
id: 'editor.previousChange',
|
||||
title: 'Go to Previous Change',
|
||||
group: 'Editors',
|
||||
scope: 'editor',
|
||||
searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'previous'],
|
||||
defaultBindings: platformBindings(['Shift+F7']),
|
||||
allowBareKeybindings: true
|
||||
},
|
||||
{
|
||||
id: 'editor.nextChange',
|
||||
title: 'Go to Next Change',
|
||||
group: 'Editors',
|
||||
scope: 'editor',
|
||||
searchKeywords: ['shortcut', 'editor', 'diff', 'change', 'hunk', 'next'],
|
||||
defaultBindings: platformBindings(['F7']),
|
||||
allowBareKeybindings: true
|
||||
},
|
||||
{
|
||||
id: 'fileExplorer.undo',
|
||||
title: 'Undo file operation',
|
||||
@@ -1089,6 +1111,10 @@ function hasModifier(
|
||||
return Boolean(input.shift ?? input.shiftKey)
|
||||
}
|
||||
|
||||
function isFunctionKeyToken(key: string): boolean {
|
||||
return /^F([1-9]|1[0-9]|2[0-4])$/.test(key)
|
||||
}
|
||||
|
||||
function normalizeKeyToken(token: string): string | null {
|
||||
if (token === ' ') {
|
||||
return 'Space'
|
||||
@@ -1104,6 +1130,10 @@ function normalizeKeyToken(token: string): string | null {
|
||||
if (upper.length === 1 && upper >= '0' && upper <= '9') {
|
||||
return upper
|
||||
}
|
||||
// Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7).
|
||||
if (isFunctionKeyToken(upper)) {
|
||||
return upper
|
||||
}
|
||||
|
||||
const simple: Record<string, string> = {
|
||||
'[': 'BracketLeft',
|
||||
@@ -1303,22 +1333,31 @@ function canonicalizeParsedKeybinding(parsed: ParsedKeybinding): string {
|
||||
}
|
||||
|
||||
function isSafeBareKey(parsed: ParsedKeybinding): boolean {
|
||||
if (parsed.shift || parsed.mod || parsed.meta || parsed.control || parsed.alt) {
|
||||
if (parsed.mod || parsed.meta || parsed.control || parsed.alt) {
|
||||
return false
|
||||
}
|
||||
return [
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Tab',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'PageUp',
|
||||
'PageDown'
|
||||
].includes(parsed.key)
|
||||
// Function keys produce no text, so they're safe standalone or with Shift
|
||||
// (e.g. F7 / Shift+F7 for diff-change navigation). Shift is only permitted
|
||||
// alongside a function key — Shift+letter stays unsafe.
|
||||
if (parsed.shift) {
|
||||
return isFunctionKeyToken(parsed.key)
|
||||
}
|
||||
return (
|
||||
isFunctionKeyToken(parsed.key) ||
|
||||
[
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Tab',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'PageUp',
|
||||
'PageDown'
|
||||
].includes(parsed.key)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeKeybindingWithOptions(
|
||||
|
||||
Reference in New Issue
Block a user