From b93ec86fef52579ae655dde21714bedfbceb2f81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:59:18 -0700 Subject: [PATCH 1/4] Add Previous/Next change navigation buttons to the Diff View (#6668) * feat(diff): add Previous/Next change navigation buttons to Diff View Add up/down navigation buttons to the single-file Diff View toolbar so users can jump between change regions (hunks) without scrolling, matching IntelliJ/Android Studio. The buttons bridge the Monaco diff editor to the header via an instance-scoped DiffNavigationProvider and call Monaco's goToDiff('previous'|'next'); they disable when the file has no changes. Closes #6215 Co-authored-by: Orca * test(diff): cover stale onDidUpdateDiff guard in fast-swap Address CodeRabbit nitpick: prove that an update fired from a replaced editor is ignored after a new editor registers (the subscription is disposed on re-register). The fake editor's dispose now clears its callback, matching real Monaco subscription semantics. Co-authored-by: Orca * fix(diff): isolate navigation registration updates * fix(diff): use arrows for change navigation * fix(editor): tighten header action spacing --------- Co-authored-by: Orca Co-authored-by: Brennan Benson --- src/renderer/src/assets/main.css | 3 +- .../src/components/editor/DiffViewer.tsx | 14 +- .../src/components/editor/EditorPanel.tsx | 104 ++++++------ .../components/editor/EditorPanelHeader.tsx | 50 +++++- .../editor/diff-navigation-context.test.tsx | 156 ++++++++++++++++++ .../editor/diff-navigation-context.tsx | 115 +++++++++++++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- 11 files changed, 403 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/editor/diff-navigation-context.test.tsx create mode 100644 src/renderer/src/components/editor/diff-navigation-context.tsx diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 25a5a4e751b..1d4cbac0572 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -970,7 +970,8 @@ .editor-header { display: flex; align-items: center; - gap: 12px; + /* Keep adjacent actions aligned with the app's compact toolbar spacing. */ + gap: 4px; min-height: 36px; padding: 6px 14px; background: var(--editor-surface); diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index c119abee471..dbeb8fca2bf 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -24,6 +24,7 @@ import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecyc import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action' import type { DiffViewerProps } from './diff-viewer-props' import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options' +import { useDiffEditorRegistration } from './diff-navigation-context' export default function DiffViewer({ modelKey, @@ -72,6 +73,7 @@ export default function DiffViewer({ (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) const diffEditorRef = useRef(null) + const { registerDiffEditor, unregisterDiffEditor } = useDiffEditorRegistration() const diffBodyRef = useRef(null) const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null) const [modifiedEditor, setModifiedEditor] = useState(null) @@ -244,10 +246,16 @@ export default function DiffViewer({ // must not keep comment decorators or save handlers talking to disposed UI. lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = null + // Why: capture before nulling so we unregister the exact instance the + // navigator may still hold (identity guard no-ops a stale dispose). + const fallenBackEditor = diffEditorRef.current diffEditorRef.current = null + if (fallenBackEditor) { + unregisterDiffEditor(fallenBackEditor) + } setModifiedEditor(null) setPopover(null) - }, []) + }, [unregisterDiffEditor]) const handleSubmitComment = async (body: string): Promise => { if (!popover) { @@ -307,6 +315,7 @@ export default function DiffViewer({ const handleMount: DiffOnMount = useCallback( (diffEditor, monaco) => { diffEditorRef.current = diffEditor + registerDiffEditor(diffEditor) lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide) @@ -363,11 +372,12 @@ export default function DiffViewer({ lineNumberOptionsSubRef.current?.dispose() lineNumberOptionsSubRef.current = null diffEditorRef.current = null + unregisterDiffEditor(diffEditor) setModifiedEditor(null) setPopover(null) }) }, - [editable, setupCopy, modelKey, filePath, sideBySide] + [editable, setupCopy, modelKey, filePath, sideBySide, registerDiffEditor, unregisterDiffEditor] ) // Why: VS Code snapshots diff view state on deactivation, not on scroll events. diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 2181dfc13eb..21b116ea246 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -10,6 +10,7 @@ import { requestEditorFileSave } from './editor-autosave' import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' import { EditorPanelShell } from './EditorPanelShell' +import { DiffNavigationProvider } from './diff-navigation-context' import { canUseChangesModeForFile } from './editor-panel-file-mode' import { getEditorPanelRenderModel } from './editor-panel-render-model' import { useClosedEditorTabCleanup } from './useClosedEditorTabCleanup' @@ -351,56 +352,59 @@ function EditorPanelInner({ markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false return ( - void handleCopyPath()} - onOpenDiffTargetFile={handleOpenDiffTargetFile} - onOpenPreviewToSide={handleOpenPreviewToSide} - onOpenMarkdownPreview={handleOpenMarkdownPreview} - onOpenContainingFolder={handleOpenContainingFolder} - onToggleSideBySide={() => setSideBySide((prev) => !prev)} - onEditorToggleChange={handleEditorToggleChange} - onToggleMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible( - markdownDocumentStateFileId, - !isMarkdownTableOfContentsVisible - ) - } - onToggleMarkdownFrontmatter={() => - setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) - } - onExportMarkdownToPdf={() => - void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) - } - onContentChange={handleContentChange} - onContentChangeForFile={handleContentChangeForFile} - onDirtyStateHint={handleDirtyStateHint} - onSave={handleSave} - onSaveForFile={handleSaveForFile} - onReloadContent={reloadContent} - onCloseMarkdownTableOfContents={() => - setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) - } - onCloseRenameDialog={closeRenameDialog} - onRenameConfirm={handleRenameConfirm} - markdownAnnotationsEnabled={markdownAnnotationsEnabled} - /> + // Why: each split pane needs an isolated bridge between its diff editor and header controls. + + void handleCopyPath()} + onOpenDiffTargetFile={handleOpenDiffTargetFile} + onOpenPreviewToSide={handleOpenPreviewToSide} + onOpenMarkdownPreview={handleOpenMarkdownPreview} + onOpenContainingFolder={handleOpenContainingFolder} + onToggleSideBySide={() => setSideBySide((prev) => !prev)} + onEditorToggleChange={handleEditorToggleChange} + onToggleMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible( + markdownDocumentStateFileId, + !isMarkdownTableOfContentsVisible + ) + } + onToggleMarkdownFrontmatter={() => + setMarkdownFrontmatterVisible(markdownDocumentStateFileId, !isMarkdownFrontmatterVisible) + } + onExportMarkdownToPdf={() => + void exportActiveMarkdownToPdf({ fileId: activeFile.id, root: panelRef.current }) + } + onContentChange={handleContentChange} + onContentChangeForFile={handleContentChangeForFile} + onDirtyStateHint={handleDirtyStateHint} + onSave={handleSave} + onSaveForFile={handleSaveForFile} + onReloadContent={reloadContent} + onCloseMarkdownTableOfContents={() => + setMarkdownTableOfContentsVisible(markdownDocumentStateFileId, false) + } + onCloseRenameDialog={closeRenameDialog} + onRenameConfirm={handleRenameConfirm} + markdownAnnotationsEnabled={markdownAnnotationsEnabled} + /> + ) } diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index 6cbb2b6f69b..44816a40ed0 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' +import { ArrowDown, ArrowUp, Columns2, Eye, FileText, ListTree, Rows2 } from 'lucide-react' import { useAppStore } from '@/store' import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' import type { OpenFile } from '@/store/slices/editor' @@ -14,6 +14,7 @@ import { DiffNotesSendMenu } from './DiffNotesSendMenu' import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu' import { translate } from '@/i18n/i18n' import { EditorPanelHeaderPath } from './EditorPanelHeaderPath' +import { useDiffNavigation } from './diff-navigation-context' type EditorPanelHeaderProps = { activeFile: OpenFile @@ -92,6 +93,7 @@ export function EditorPanelHeader({ () => diffComments.filter((comment) => comment.filePath === activeFile.relativePath), [activeFile.relativePath, diffComments] ) + const { changeCount, goToPreviousDiff, goToNextDiff } = useDiffNavigation() return (
@@ -203,6 +205,52 @@ export function EditorPanelHeader({ )} + {isDiffSurface && ( + + + + + + + {translate('auto.components.editor.EditorPanelHeader.2076ecfc9c', 'Previous change')} + + + + )} + {isDiffSurface && ( + + + + + + + {translate('auto.components.editor.EditorPanelHeader.631dab0df3', 'Next change')} + + + + )} {hasEditorToggle && ( void + fireUpdate: () => void + goToDiff: ReturnType + disposeUpdate: ReturnType +} + +function createFakeEditor(initialCount: number): FakeDiffEditor { + let count = initialCount + let updateCallback: (() => void) | null = null + const disposeUpdate = vi.fn(() => { + updateCallback = null + }) + const editor = { + getLineChanges: () => (count > 0 ? Array.from({ length: count }, () => ({})) : []), + goToDiff: vi.fn(), + onDidUpdateDiff: (cb: () => void) => { + updateCallback = cb + return { + dispose: disposeUpdate + } + }, + setLineChanges: (next: number) => { + count = next + }, + fireUpdate: () => updateCallback?.(), + disposeUpdate + } as unknown as FakeDiffEditor + return editor +} + +let captured: DiffNavigationContextValue | null = null +let registration: DiffEditorRegistrationContextValue | null = null +let registrationRenderCount = 0 + +function Probe(): null { + captured = useDiffNavigation() + return null +} + +function RegistrationProbe(): null { + registration = useDiffEditorRegistration() + registrationRenderCount += 1 + return null +} + +describe('DiffNavigationProvider', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + function mount(): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + + + ) + }) + } + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + container = null + root = null + captured = null + registration = null + registrationRenderCount = 0 + }) + + it('exposes the change count and routes nav actions to the registered editor', () => { + mount() + const editor = createFakeEditor(3) + act(() => registration?.registerDiffEditor(editor)) + + expect(captured?.changeCount).toBe(3) + + act(() => captured?.goToNextDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('next') + + act(() => captured?.goToPreviousDiff()) + expect(editor.goToDiff).toHaveBeenCalledWith('previous') + }) + + it('re-renders when onDidUpdateDiff flips the count 0 -> N (count is state)', () => { + mount() + const editor = createFakeEditor(0) + act(() => registration?.registerDiffEditor(editor)) + expect(captured?.changeCount).toBe(0) + + act(() => { + editor.setLineChanges(2) + editor.fireUpdate() + }) + expect(captured?.changeCount).toBe(2) + expect(registrationRenderCount).toBe(1) + }) + + it('ignores a stale unregister for an editor that is no longer current (identity guard)', () => { + mount() + const oldEditor = createFakeEditor(1) + const newEditor = createFakeEditor(4) + + // Fast-swap: new editor registers before the old one's dispose fires. + act(() => registration?.registerDiffEditor(oldEditor)) + act(() => registration?.registerDiffEditor(newEditor)) + expect(captured?.changeCount).toBe(4) + expect(oldEditor.disposeUpdate).toHaveBeenCalledOnce() + + // A stale update from the old editor must not flip the count back: registering + // the new editor disposed the old subscription, so its callback no longer fires. + act(() => { + oldEditor.setLineChanges(9) + oldEditor.fireUpdate() + }) + expect(captured?.changeCount).toBe(4) + + act(() => registration?.unregisterDiffEditor(oldEditor)) + + // New editor's count is intact and nav still routes to it. + expect(captured?.changeCount).toBe(4) + act(() => captured?.goToNextDiff()) + expect(newEditor.goToDiff).toHaveBeenCalledWith('next') + expect(oldEditor.goToDiff).not.toHaveBeenCalled() + }) + + it('disposes the active diff update subscription when the provider unmounts', () => { + mount() + const editor = createFakeEditor(1) + act(() => registration?.registerDiffEditor(editor)) + + act(() => root?.unmount()) + + expect(editor.disposeUpdate).toHaveBeenCalledOnce() + root = null + }) +}) diff --git a/src/renderer/src/components/editor/diff-navigation-context.tsx b/src/renderer/src/components/editor/diff-navigation-context.tsx new file mode 100644 index 00000000000..da1f37c9032 --- /dev/null +++ b/src/renderer/src/components/editor/diff-navigation-context.tsx @@ -0,0 +1,115 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { editor } from 'monaco-editor' + +export type DiffEditorRegistrationContextValue = { + registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void + unregisterDiffEditor: (editor: editor.IStandaloneDiffEditor) => void +} + +export type DiffNavigationContextValue = { + goToPreviousDiff: () => void + goToNextDiff: () => void + changeCount: number +} + +const noop = (): void => {} + +// Why: registration stays separate from changeCount so diff recomputation only +// rerenders the header controls, not the heavy Monaco DiffViewer consumer. +const DiffEditorRegistrationContext = createContext({ + registerDiffEditor: noop, + unregisterDiffEditor: noop +}) + +const DiffNavigationContext = createContext({ + goToPreviousDiff: noop, + goToNextDiff: noop, + changeCount: 0 +}) + +function countChanges(diffEditor: editor.IStandaloneDiffEditor): number { + return diffEditor.getLineChanges()?.length ?? 0 +} + +export function DiffNavigationProvider({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + const editorRef = useRef(null) + const updateSubRef = useRef<{ dispose: () => 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. + const [changeCount, setChangeCount] = useState(0) + + const registerDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + editorRef.current = diffEditor + // Hold at most one update subscription; replace any prior editor's. + updateSubRef.current?.dispose() + updateSubRef.current = diffEditor.onDidUpdateDiff(() => { + // Why: ignore updates from an editor that is no longer current so a stale + // subscription in the fast-swap case can't write a wrong count. + if (editorRef.current === diffEditor) { + setChangeCount(countChanges(diffEditor)) + } + }) + setChangeCount(countChanges(diffEditor)) + }, []) + + const unregisterDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => { + // Why: identity guard for the fast-swap race — a stale dispose carrying the + // old editor must not wipe a freshly-registered new one. + if (editorRef.current !== diffEditor) { + return + } + updateSubRef.current?.dispose() + updateSubRef.current = null + editorRef.current = null + setChangeCount(0) + }, []) + + const goToPreviousDiff = useCallback(() => { + editorRef.current?.goToDiff('previous') + }, []) + + const goToNextDiff = useCallback(() => { + editorRef.current?.goToDiff('next') + }, []) + + useEffect(() => { + return () => { + updateSubRef.current?.dispose() + updateSubRef.current = null + } + }, []) + + const registrationValue = useMemo( + () => ({ registerDiffEditor, unregisterDiffEditor }), + [registerDiffEditor, unregisterDiffEditor] + ) + const navigationValue = useMemo( + () => ({ + goToPreviousDiff, + goToNextDiff, + changeCount + }), + [goToPreviousDiff, goToNextDiff, changeCount] + ) + + return ( + + + {children} + + + ) +} + +export function useDiffEditorRegistration(): DiffEditorRegistrationContextValue { + return useContext(DiffEditorRegistrationContext) +} + +export function useDiffNavigation(): DiffNavigationContextValue { + return useContext(DiffNavigationContext) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 007096cb832..91d1c051fbf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11436,7 +11436,9 @@ "c98ce191da": "This diff has no modified-side file to open", "9b80bbe1de": "Open file tab", "f0fd4174b5": "Open file tab to use rich markdown editing", - "a10d9b8337": "Open file" + "a10d9b8337": "Open file", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2053adb00dc..202deadeef2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11436,7 +11436,9 @@ "c98ce191da": "Este diff no tiene ningún archivo del lado modificado para abrir", "9b80bbe1de": "Abrir pestaña de archivo", "f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown", - "a10d9b8337": "Abrir archivo" + "a10d9b8337": "Abrir archivo", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exportar como PDF", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1c811412623..19e8945071f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11436,7 +11436,9 @@ "c98ce191da": "この差分には開くための変更側ファイルがありません", "9b80bbe1de": "ファイルタブを開く", "f0fd4174b5": "ファイル タブを開いてリッチ markdown 編集を使用する", - "a10d9b8337": "ファイルを開く" + "a10d9b8337": "ファイルを開く", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDFとしてエクスポート", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1851bc4059f..48782e1f3c0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11436,7 +11436,9 @@ "c98ce191da": "이 차이점에는 열 수 있는 수정된 측면 파일이 없습니다.", "9b80bbe1de": "파일 탭 열기", "f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.", - "a10d9b8337": "파일 열기" + "a10d9b8337": "파일 열기", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDF로 내보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 60632816ac6..f967af9d20a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11436,7 +11436,9 @@ "c98ce191da": "此差异没有可打开的修改端文件", "9b80bbe1de": "打开文件选项卡", "f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑", - "a10d9b8337": "打开文件" + "a10d9b8337": "打开文件", + "2076ecfc9c": "Previous change", + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "导出为 PDF", From 2306f821132b9f0c91b0fd0fe302f2860dfd1887 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:09:20 -0700 Subject: [PATCH 2/4] fix(terminal): gate Shift+Enter CSI-u on active protocol (#8427) --- .../terminal-shortcut-policy.test.ts | 13 ++++--- .../terminal-pane/terminal-shortcut-policy.ts | 6 ++- tests/e2e/terminal-shortcuts.spec.ts | 39 ++++++++++--------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index a9929951449..927f57effe1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -66,12 +66,12 @@ describe('resolveTerminalShortcutAction', () => { ).toEqual({ type: 'focusPane', direction: 'next' }) }) - it('keeps shift-enter and delete helpers explicit', () => { + it('keeps inactive shift-enter and delete helpers explicit', () => { expect( resolveTerminalShortcutAction(event({ key: 'Enter', code: 'Enter', shiftKey: true }), true) ).toEqual({ type: 'sendInput', - data: '\x1b[13;2u' + data: '\x1b\r' }) expect(resolveTerminalShortcutAction(event({ key: 'Backspace', ctrlKey: true }), true)).toEqual( { type: 'sendInput', data: '\x17' } @@ -164,9 +164,9 @@ describe('resolveTerminalShortcutAction', () => { expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled() }) - it('always uses CSI-u Shift+Enter off Windows regardless of Windows encoding', () => { + it('uses CSI-u Shift+Enter off Windows only while Kitty keyboard is active', () => { for (const encoding of [() => 'csi-u' as const, () => 'alt-enter' as const, undefined]) { - expect( + const resolve = (kittyActive: boolean) => resolveTerminalShortcutAction( event({ key: 'Enter', code: 'Enter', shiftKey: true }), false, @@ -175,11 +175,12 @@ describe('resolveTerminalShortcutAction', () => { false, undefined, undefined, - undefined, + () => kittyActive, undefined, encoding ) - ).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' }) + expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' }) } }) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index f0f284d167f..15a1ec07db8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -158,7 +158,11 @@ export function resolveTerminalShortcutAction( : isWindows ? 'alt-enter' : 'csi-u' - return { type: 'sendInput', data: encoding === 'csi-u' ? '\x1b[13;2u' : '\x1b\r' } + // Why: CSI-u is application input, not a universal terminal sequence. Off + // Windows, only send it while the pane's application has KKP active. + const canSendCsiU = + encoding === 'csi-u' && (useLocalWindowsCapability || isKittyKeyboardActivePane?.() === true) + return { type: 'sendInput', data: canSendCsiU ? '\x1b[13;2u' : '\x1b\r' } } if ( diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index c6c40afe6fc..17d837aa1ce 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -518,19 +518,26 @@ test.describe('Terminal Shortcuts', () => { await waitForPaneCount(orcaPage, 1, 30_000) }) - test('Shift+Enter writes the platform newline chord for terminal TUIs', async ({ - orcaPage, - electronApp - }) => { + test('Shift+Enter follows the pane Kitty keyboard state', async ({ orcaPage, electronApp }) => { await installMainProcessPtyWriteSpy(electronApp) - await waitForActivePanePtyId(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) - await pressAndExpectWrite( - orcaPage, - electronApp, - 'Shift+Enter', - process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' - ) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') + if (process.platform === 'win32') { + return + } + + // Why: exercise the production PTY-output tracker, not xterm's renderer- + // local flag state, so the test covers the bytes the shortcut policy sees. + await execInTerminal(orcaPage, ptyId, "printf '\\033[>1u'") + await expect.poll(() => getKittyKeyboardFlags(orcaPage)).toBe(1) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b[13;2u') + + // The shell is only standing in for a KKP-aware TUI and does not consume + // the CSI-u input above, so cancel that synthetic line before its reset. + await execInTerminal(orcaPage, ptyId, "\x03printf '\\033[ getKittyKeyboardFlags(orcaPage)).toBe(0) + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') }) test('Droid gets CSI-u Shift+Enter on Windows without changing Antigravity', async ({ @@ -769,14 +776,8 @@ test.describe('Terminal Shortcuts', () => { // Ctrl+Backspace → \x17 (unix-word-rubout). await pressAndExpectWrite(orcaPage, electronApp, 'Control+Backspace', '\x17') - // Shift+Enter stays distinct; Windows keeps Esc+CR unless the active agent - // explicitly requires CSI-u (currently Droid, #7620). - await pressAndExpectWrite( - orcaPage, - electronApp, - 'Shift+Enter', - process.platform === 'win32' ? '\x1b\r' : '\x1b[13;2u' - ) + // The shell has not enabled KKP, so Shift+Enter must not leak CSI-u text. + await pressAndExpectWrite(orcaPage, electronApp, 'Shift+Enter', '\x1b\r') // --- send-input chords (macOS-only) --- From 9dc1f253ad38bd641bfda2c33545fa65fbae3bf7 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:17:06 +0200 Subject: [PATCH 3/4] feat(editor): add file editor word wrap preference (#8423) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../src/components/editor/MonacoEditor.tsx | 14 +-- .../file-editor-word-wrap-options.test.ts | 13 +++ .../editor/file-editor-word-wrap-options.ts | 8 ++ .../settings/EditorWordWrapSetting.test.tsx | 85 +++++++++++++++++++ .../settings/EditorWordWrapSetting.tsx | 69 +++++++++++++++ .../settings/GeneralEditorSettingsSection.tsx | 3 + .../components/settings/GeneralPane.test.ts | 7 ++ .../settings/general-editor-search.ts | 11 +++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/types.ts | 2 + 16 files changed, 242 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts create mode 100644 src/renderer/src/components/editor/file-editor-word-wrap-options.ts create mode 100644 src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx create mode 100644 src/renderer/src/components/settings/EditorWordWrapSetting.tsx diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 6c608ceefa1..d3cfc60fc32 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -52,6 +52,7 @@ import { } from './monaco-markdown-selection-annotation' import { translate } from '@/i18n/i18n' import { handleMonacoLargeTextPaste } from './monaco-large-text-paste' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' import { clampMonacoAutoHeight, getMonacoAutoHeightForContent, @@ -146,6 +147,8 @@ export default function MonacoEditor({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) + const editorFontFamily = settings?.terminalFontFamily || 'monospace' + const editorWordWrap = settings?.editorWordWrap const estimatedAutoHeight = useMemo(() => { if (!autoHeight) { return null @@ -707,14 +710,15 @@ export default function MonacoEditor({ // Update editor options when settings change useEffect(() => { - if (!editorRef.current || !settings) { + if (!editorRef.current) { return } editorRef.current.updateOptions({ fontSize: editorFontSize, - fontFamily: settings.terminalFontFamily || 'monospace' + fontFamily: editorFontFamily, + ...buildFileEditorWordWrapOptions(editorWordWrap) }) - }, [editorFontSize, settings]) + }, [editorFontFamily, editorFontSize, editorWordWrap]) useEffect(() => { markdownDocLinkDecorationsRef.current?.refresh() @@ -833,9 +837,9 @@ export default function MonacoEditor({ // setting into DiffViewer/DiffSectionItem would have no effect. minimap: { enabled: settings?.editorMinimapEnabled ?? false }, scrollBeyondLastLine: false, - wordWrap: 'on', + ...buildFileEditorWordWrapOptions(editorWordWrap), fontSize: editorFontSize, - fontFamily: settings?.terminalFontFamily || 'monospace', + fontFamily: editorFontFamily, lineNumbers: 'on', renderLineHighlight: 'line', automaticLayout: true, diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts new file mode 100644 index 00000000000..c50d4389ab3 --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options' + +describe('buildFileEditorWordWrapOptions', () => { + it('keeps wrapping enabled for existing profiles without the preference', () => { + expect(buildFileEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'on' }) + expect(buildFileEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' }) + }) + + it('disables wrapping for horizontal file-editor scrolling', () => { + expect(buildFileEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' }) + }) +}) diff --git a/src/renderer/src/components/editor/file-editor-word-wrap-options.ts b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts new file mode 100644 index 00000000000..af9bedfb2ef --- /dev/null +++ b/src/renderer/src/components/editor/file-editor-word-wrap-options.ts @@ -0,0 +1,8 @@ +import type { editor } from 'monaco-editor' + +export function buildFileEditorWordWrapOptions( + editorWordWrap: boolean | undefined +): Pick { + // Why: profiles saved before this preference existed must retain Orca's previous wrapped default. + return { wordWrap: editorWordWrap === false ? 'off' : 'on' } +} diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx new file mode 100644 index 00000000000..63a963c215e --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { join } from 'node:path' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) => + selector({ settingsSearchQuery: '' }) +})) + +import { EditorWordWrapSetting } from './EditorWordWrapSetting' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null +}) + +function renderSetting(editorWordWrap: boolean | undefined, updateSettings = vi.fn()) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + ) + }) + return { container, updateSettings } +} + +describe('EditorWordWrapSetting', () => { + it('shows wrapping as on for profiles saved before the preference existed', () => { + const { container } = renderSetting(undefined) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + expect(on?.getAttribute('aria-checked')).toBe('true') + }) + + it('shows wrapping as off when the preference is disabled', () => { + const { container } = renderSetting(false) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + expect(off?.getAttribute('aria-checked')).toBe('true') + }) + + it('persists the off choice for horizontal scrolling', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(true, updateSettings) + const off = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'Off' + ) + + act(() => off?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: false }) + }) + + it('persists the on choice without changing the diff preference', () => { + const updateSettings = vi.fn() + const { container } = renderSetting(false, updateSettings) + const on = [...container.querySelectorAll('[role="radio"]')].find( + (button) => button.textContent === 'On' + ) + + act(() => on?.click()) + + expect(updateSettings).toHaveBeenCalledWith({ editorWordWrap: true }) + }) +}) diff --git a/src/renderer/src/components/settings/EditorWordWrapSetting.tsx b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx new file mode 100644 index 00000000000..b3dfbb853c4 --- /dev/null +++ b/src/renderer/src/components/settings/EditorWordWrapSetting.tsx @@ -0,0 +1,69 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { Label } from '../ui/label' +import { SettingsSegmentedControl } from './SettingsFormControls' + +type EditorWordWrapSettingProps = { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +} + +export function EditorWordWrapSetting({ + settings, + updateSettings +}: EditorWordWrapSettingProps): React.JSX.Element { + return ( + +
+ +

+ {translate( + 'auto.components.settings.GeneralEditorSettingsSection.9b18de6eea', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + )} +

+
+ updateSettings({ editorWordWrap: option === 'on' })} + options={[ + { + value: 'off', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2', + 'Off' + ) + }, + { + value: 'on', + label: translate( + 'auto.components.settings.GeneralEditorSettingsSection.3f6892f307', + 'On' + ) + } + ]} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index a4a33f1102a..9cccdf166ee 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -17,6 +17,7 @@ import { } from './SettingsFormControls' import { translate } from '@/i18n/i18n' import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' +import { EditorWordWrapSetting } from './EditorWordWrapSetting' export type AutoSaveDelayDraftState = { sourceDelayMs: number @@ -248,6 +249,8 @@ export function GeneralEditorSettingsSection({ /> + + { expect(matchesSettingsSearch('wsl', entries)).toBe(true) }) + it('includes file-editor word-wrap and horizontal-scroll keywords', () => { + const entries = getGeneralPaneSearchEntries() + + expect(matchesSettingsSearch('editor word wrap', entries)).toBe(true) + expect(matchesSettingsSearch('horizontal scroll', entries)).toBe(true) + }) + it('includes rich Markdown spellcheck keywords', () => { const entries = getGeneralPaneSearchEntries() diff --git a/src/renderer/src/components/settings/general-editor-search.ts b/src/renderer/src/components/settings/general-editor-search.ts index 8f93eea9544..c7ba4653d32 100644 --- a/src/renderer/src/components/settings/general-editor-search.ts +++ b/src/renderer/src/components/settings/general-editor-search.ts @@ -29,6 +29,17 @@ export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [ ) ] }, + { + title: translate('auto.components.settings.general.search.e61157e926', 'Editor Word Wrap'), + description: translate( + 'auto.components.settings.general.search.005be5c699', + 'Wrap long lines in file editors instead of requiring horizontal scrolling.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.general.search.e1ee631696', 'editor'), + ...translateSearchKeyword('auto.components.settings.general.search.3ca5ab78a5', 'code') + ] + }, { title: translate('auto.components.settings.general.search.2760c9933f', 'Default Diff View'), description: translate( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91d1c051fbf..6a98f7b81cd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -5401,7 +5401,9 @@ "bf16ef0af2": "Off", "3f6892f307": "On", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "Editor Word Wrap", + "9b18de6eea": "Wrap long lines in file editors instead of requiring horizontal scrolling." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7716,7 +7718,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "Editor Word Wrap", + "005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling." } }, "git": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 202deadeef2..5ea1e331e18 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5401,7 +5401,9 @@ "bf16ef0af2": "Desactivado", "3f6892f307": "Activado", "b82f86d7d2": "Corrector ortográfico de Rich Markdown", - "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "5195f0b9ef": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "7ddd66fede": "Ajuste de línea en el editor", + "9b18de6eea": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "localhost, 127.0.0.1, *.internal", @@ -7679,7 +7681,9 @@ "defaultProjectRuntime": "Runtime de proyecto predeterminado", "defaultProjectRuntimeDescription": "Elige el runtime que heredarán los proyectos locales de Windows.", "d2d2d929c0": "Corrector ortográfico de Rich Markdown", - "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown." + "4497e2e2bb": "Muestra subrayados y sugerencias ortográficas del navegador al editar Rich Markdown.", + "e61157e926": "Ajuste de línea en el editor", + "005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal." } }, "git": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 19e8945071f..ea6894df23e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "オフ", "3f6892f307": "オン", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "エディターのワードラップ", + "9b18de6eea": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "ローカルホスト、127.0.0.1、*.internal", @@ -7701,7 +7703,9 @@ "defaultProjectRuntime": "Default Project Runtime", "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "エディターのワードラップ", + "005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。" } }, "git": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 48782e1f3c0..1a5da97b828 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "끄다", "3f6892f307": "~에", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown." + "5195f0b9ef": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "7ddd66fede": "편집기 자동 줄 바꿈", + "9b18de6eea": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." }, "AdvancedNetworkSettingsSection": { "3e431564b5": "로컬호스트, 127.0.0.1, *.internal", @@ -7664,7 +7666,9 @@ "defaultProjectRuntime": "기본 프로젝트 런타임", "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown." + "4497e2e2bb": "Show browser spelling underlines and suggestions while editing rich Markdown.", + "e61157e926": "편집기 자동 줄 바꿈", + "005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다." } }, "git": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f967af9d20a..9fc7fc0cee3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5386,7 +5386,9 @@ "bf16ef0af2": "关闭", "3f6892f307": "开启", "b82f86d7d2": "Rich Markdown Spellcheck", - "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "7ddd66fede": "编辑器自动换行", + "9b18de6eea": "在文件编辑器中自动换行长行,而无需水平滚动。" }, "AdvancedNetworkSettingsSection": { "3e431564b5": "本地主机,127.0.0.1,*.internal", @@ -7664,7 +7666,9 @@ "defaultProjectRuntime": "默认项目运行时", "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", "d2d2d929c0": "Rich Markdown Spellcheck", - "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。" + "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", + "e61157e926": "编辑器自动换行", + "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。" } }, "git": { diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e185c4d073f..343e0ffe173 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -52,6 +52,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) + it('keeps file-editor word wrapping enabled by default', () => { + expect(getDefaultSettings('/tmp').editorWordWrap).toBe(true) + }) + it('keeps rich Markdown spellcheck enabled by default', () => { expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 26c547ad20a..950cc999477 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -208,6 +208,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, editorMinimapEnabled: false, + editorWordWrap: true, richMarkdownSpellcheckEnabled: true, markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), diff --git a/src/shared/types.ts b/src/shared/types.ts index bc6bcfc38a3..7b0c9b5ca84 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2512,6 +2512,8 @@ export type GlobalSettings = { editorAutoSave: boolean editorAutoSaveDelayMs: number editorMinimapEnabled: boolean + /** Defaults on for profiles saved before file-editor wrapping became configurable. */ + editorWordWrap?: boolean /** Persisted opt-out for browser spellcheck noise in rich Markdown editing surfaces. */ richMarkdownSpellcheckEnabled?: boolean /** Whether local markdown review note controls and the review panel are shown. */ From f8ae1a489ba31f128d8ceb497a4cc2332426889d Mon Sep 17 00:00:00 2001 From: sonhyrd <163875127+sonhyrd@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:13:21 +0700 Subject: [PATCH 4/4] Add F7/Shift+F7 keyboard navigation for diff changes (#8240) * feat(diff): add F7/Shift+F7 keyboard navigation for diff changes Stacks on the Previous/Next change buttons (#6668) to add keyboard navigation for single-file diffs, matching VS Code / JetBrains diff review. - Register editor.nextChange (F7) / editor.previousChange (Shift+F7) in the keybinding registry (Editors group) so they show in Settings and stay rebindable. - Teach the keybinding normalizer function keys (F1-F24) and make them first-class in the bare-key safety model (safe standalone or with Shift, opt-in per action) - F7 was previously unbindable. - Install a capture-phase listener from DiffNavigationProvider so keyboard and the existing header buttons share one goToDiff path; works on read-only and editable single-file diffs. - Translate the Previous/Next change strings for es/ja/ko/zh. Refs #6215 * test(diff): cover change navigation shortcuts * fix(diff): use shortcut chips in navigation tooltips --------- Co-authored-by: Brennan Benson --- .../components/editor/EditorPanelHeader.tsx | 18 ++++ .../editor/diff-navigation-context.test.tsx | 19 +++- .../editor/diff-navigation-context.tsx | 11 +++ .../editor/editor-shortcuts.test.ts | 88 ++++++++++++++++++- .../src/components/editor/editor-shortcuts.ts | 33 +++++++ src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/keybindings.test.ts | 18 ++++ src/shared/keybindings.ts | 67 +++++++++++--- 11 files changed, 246 insertions(+), 24 deletions(-) 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(