diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx
index 44816a40ed0..31124a99725 100644
--- a/src/renderer/src/components/editor/EditorPanelHeader.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx
@@ -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 (
@@ -224,6 +228,13 @@ export function EditorPanelHeader({
{translate('auto.components.editor.EditorPanelHeader.2076ecfc9c', 'Previous change')}
+ {previousChangeShortcut.keys.length > 0 && (
+
+ )}
@@ -247,6 +258,13 @@ export function EditorPanelHeader({
{translate('auto.components.editor.EditorPanelHeader.631dab0df3', 'Next change')}
+ {nextChangeShortcut.keys.length > 0 && (
+
+ )}
diff --git a/src/renderer/src/components/editor/diff-navigation-context.test.tsx b/src/renderer/src/components/editor/diff-navigation-context.test.tsx
index 581d091b5e1..d2efce0bd36 100644
--- a/src/renderer/src/components/editor/diff-navigation-context.test.tsx
+++ b/src/renderer/src/components/editor/diff-navigation-context.test.tsx
@@ -16,6 +16,7 @@ type FakeDiffEditor = editor.IStandaloneDiffEditor & {
fireUpdate: () => void
goToDiff: ReturnType
disposeUpdate: ReturnType
+ 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)
+ })
})
diff --git a/src/renderer/src/components/editor/diff-navigation-context.tsx b/src/renderer/src/components/editor/diff-navigation-context.tsx
index da1f37c9032..9bd1175de45 100644
--- a/src/renderer/src/components/editor/diff-navigation-context.tsx
+++ b/src/renderer/src/components/editor/diff-navigation-context.tsx
@@ -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(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
}
}, [])
diff --git a/src/renderer/src/components/editor/editor-shortcuts.test.ts b/src/renderer/src/components/editor/editor-shortcuts.test.ts
index daacea7f647..51c89878aef 100644
--- a/src/renderer/src/components/editor/editor-shortcuts.test.ts
+++ b/src/renderer/src/components/editor/editor-shortcuts.test.ts
@@ -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
+ onDownstreamKeyDown: ReturnType
+ } {
+ 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')
+ })
+})
diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts
index 86a7e721226..825caf6f1ee 100644
--- a/src/renderer/src/components/editor/editor-shortcuts.ts
+++ b/src/renderer/src/components/editor/editor-shortcuts.ts
@@ -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 } | null
getContainerDomNode: () => HTMLElement
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 5ea1e331e18..c1370333fbb 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index ea6894df23e..1ec6011c5ee 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "ファイルタブを開く",
"f0fd4174b5": "ファイル タブを開いてリッチ markdown 編集を使用する",
"a10d9b8337": "ファイルを開く",
- "2076ecfc9c": "Previous change",
- "631dab0df3": "Next change"
+ "2076ecfc9c": "前の変更",
+ "631dab0df3": "次の変更"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDFとしてエクスポート",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 1a5da97b828..bd3b543235a 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "파일 탭 열기",
"f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.",
"a10d9b8337": "파일 열기",
- "2076ecfc9c": "Previous change",
- "631dab0df3": "Next change"
+ "2076ecfc9c": "이전 변경",
+ "631dab0df3": "다음 변경"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "PDF로 내보내기",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 9fc7fc0cee3..22a01c9933d 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -11441,8 +11441,8 @@
"9b80bbe1de": "打开文件选项卡",
"f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑",
"a10d9b8337": "打开文件",
- "2076ecfc9c": "Previous change",
- "631dab0df3": "Next change"
+ "2076ecfc9c": "上一个更改",
+ "631dab0df3": "下一个更改"
},
"EditorPanelMarkdownActionsMenu": {
"3e0ce48c24": "导出为 PDF",
diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts
index 41b3fe75901..7437d19dd82 100644
--- a/src/shared/keybindings.test.ts
+++ b/src/shared/keybindings.test.ts
@@ -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')
diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts
index a7750fafbbe..daa8721a973 100644
--- a/src/shared/keybindings.ts
+++ b/src/shared/keybindings.ts
@@ -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 = {
'[': '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(