From f1ada853395292f595678587424802598767eca4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:13:48 +0800 Subject: [PATCH] fix(vcs): keep merge editor panes scrollable while they scroll together Scrolling one pane of the merge editor moved the other two by scroll ratio, and their scroll events, which arrive a frame later, moved the first pane back. That fought every scroll, so the panes barely moved. The panes now ignore the scroll events caused by those moves and line up through their changes, using the diff viewer's mapping: each side with the result, and one side with the other through the result. Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through the keymap instead of a fixed F7 key handler, and the toolbar shows the shortcut. --- src/features/vcs/MergeEditor.tsx | 92 ++++++++++------- .../vcs/__tests__/mergeScroll.test.ts | 28 ++++++ src/features/vcs/diffScroll.ts | 4 +- src/features/vcs/mergeScroll.ts | 98 +++++++++++++++++++ 4 files changed, 186 insertions(+), 36 deletions(-) create mode 100644 src/features/vcs/__tests__/mergeScroll.test.ts create mode 100644 src/features/vcs/mergeScroll.ts diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 4c899b98f..b04e51675 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -8,13 +8,17 @@ import { keymap } from "@codemirror/view"; import { unifiedMergeView } from "@codemirror/merge"; import type { MergeSides } from "@/bindings"; import { embeddedEditorExtensions } from "@/editor/api"; +import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; +import { shortcutLabel } from "@/platform/keymap"; import { Button, IconButton, showContextMenu, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { blameSlot, toggleAnnotations } from "./blame"; import { languageFor, viewerBase } from "./cm"; import { confirmDialog } from "./dialogs"; +import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; +import { syncMergeScroll } from "./mergeScroll"; import { initialResult, mergeTitles, resolutionText, type Resolution, type ResultConflict } from "./conflicts"; import { baseName } from "./model"; import { errorMessage, readWorkingText, runOp, writeFileText } from "./ops"; @@ -220,28 +224,10 @@ export function MergeEditor({ input }: { input: EditorInput }) { setLeft(result.state.field(conflictField).conflicts.length); oursView.current = ours; theirsView.current = theirs; - // Scroll the three panes together (by scroll ratio), like IDEA's merge view. - const views = [ours, result, theirs]; - let syncing = false; - const onScroll = (from: EditorView) => () => { - if (syncing) return; - syncing = true; - const s = from.scrollDOM; - const ratio = s.scrollTop / Math.max(1, s.scrollHeight - s.clientHeight); - for (const other of views) { - if (other === from) continue; - const o = other.scrollDOM; - o.scrollTop = ratio * Math.max(0, o.scrollHeight - o.clientHeight); - } - syncing = false; - }; - const offs = views.map((v) => { - const h = onScroll(v); - v.scrollDOM.addEventListener("scroll", h, { passive: true }); - return () => v.scrollDOM.removeEventListener("scroll", h); - }); + // Scroll the three panes together, lined up through their changes. + const stopSync = syncMergeScroll({ ours, result, theirs }); return () => { - offs.forEach((off) => off()); + stopSync(); ours.destroy(); theirs.destroy(); result.destroy(); @@ -313,17 +299,31 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can - // annotate its version with Git Blame from the context menu. - // Each side pane blames on the branch it belongs to, so lines that only exist on - // the other branch show their real history instead of "Not Committed Yet". + // Next / Previous Difference (F7 / Shift+F7 by default) jump between conflicts through + // the keymap, as in the diff viewer. + // `goto` reads only refs, so the first render's one stays valid. + const [nav] = useState(() => ({ + next: () => goto(1), + prev: () => goto(-1), + jumpToSource: () => void editorService().openFile(path, { focus: true }), + applyChange: () => false, + file: () => {}, + showSettings: () => {}, + })); + useEffect( + () => () => { + if (focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }, + [nav], + ); + + // Each side pane can annotate its version with Git Blame from the context menu, on + // the branch it belongs to, so lines that only exist on the other branch show their + // real history instead of "Not Committed Yet". const otherRev = state?.sides.commit ?? "HEAD"; - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "F7") { - e.preventDefault(); - goto(e.shiftKey ? -1 : 1); - } - }; const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; @@ -358,10 +358,34 @@ export function MergeEditor({ input }: { input: EditorInput }) { ); return ( -
+
{ + setFocusedDiffNavigator(nav); + setContext("diffEditorFocus", true); + }} + onBlurCapture={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav) { + setFocusedDiffNavigator(null); + setContext("diffEditorFocus", false); + } + }} + >
- goto(-1)} /> - goto(1)} /> + goto(-1)} + /> + goto(1)} + />