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(