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:
Brennan Benson
2026-07-12 16:17:48 -07:00
11 changed files with 246 additions and 24 deletions
@@ -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
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "ファイルタブを開く",
"f0fd4174b5": "ファイル タブを開いてリッチ markdown 編集を使用する",
"a10d9b8337": "ファイルを開く",
"2076ecfc9c": "Previous change",
"631dab0df3": "Next change"
"2076ecfc9c": "前の変更",
"631dab0df3": "次の変更"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDFとしてエクスポート",
+2 -2
View File
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "파일 탭 열기",
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
"a10d9b8337": "파일 열기",
"2076ecfc9c": "Previous change",
"631dab0df3": "Next change"
"2076ecfc9c": "이전 변경",
"631dab0df3": "다음 변경"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDF로 내보내기",
+2 -2
View File
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "打开文件选项卡",
"f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑",
"a10d9b8337": "打开文件",
"2076ecfc9c": "Previous change",
"631dab0df3": "Next change"
"2076ecfc9c": "上一个更改",
"631dab0df3": "下一个更改"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "导出为 PDF",
+18
View File
@@ -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
View File
@@ -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(