feat(vcs): IDEA-style synchronized scrolling for the side-by-side diff (#181)

The side-by-side diff gets two independent options. Synchronize Scrolling (toolbar lock, also in the settings menu, on by default) scrolls both panes together; Align Changes (settings menu, off by default) pads the panes so changes line up. With Align Changes off, the panes keep their own heights and a divider of curved bands joins each change, with arrows to apply it; scrolling one pane moves the other through the matching change (anchored at a third of the height), including caret moves and the wheel over the divider. Scroll mapping and the divider look up changes by binary search, so large files with thousands of changes scroll smoothly.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Co-authored-by: jaw52 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-10-29 10:25:57 +08:00
1 джерело 75e7d582d4
коміт 4410da60a2
10 files changed
+766 -36

No files matched your search

+37 -3
Переглянути файл
@@ -11,7 +11,7 @@ import {
} from "../diffOptions";
import { clipboardDiffData, compareFilesData, parseTextsArg } from "../compare";
import { diffInput, diffTitle, sideFile, sideKey } from "../diffInput";
import { viewerBase } from "../cm";
import { changedLineGutters, viewerBase } from "../cm";
const doc = (s: string) => Text.of(s.split("\n"));
@@ -24,8 +24,22 @@ describe("diff options", () => {
it("parses stored options with per-field defaults", () => {
expect(parseDiffOptions(null)).toEqual(DEFAULT_DIFF_OPTIONS);
expect(
parseDiffOptions({ mode: "split", ignore: "trim", highlight: "lines", collapse: false, syncScroll: false }),
).toEqual({ mode: "split", ignore: "trim", highlight: "lines", collapse: false, syncScroll: false });
parseDiffOptions({
mode: "split",
ignore: "trim",
highlight: "lines",
collapse: false,
syncScroll: false,
alignChanges: false,
}),
).toEqual({
mode: "split",
ignore: "trim",
highlight: "lines",
collapse: false,
syncScroll: false,
alignChanges: false,
});
expect(parseDiffOptions({ ignore: "bogus", highlight: 3, collapse: "x" })).toEqual(DEFAULT_DIFF_OPTIONS);
});
@@ -155,6 +169,26 @@ describe("diff options", () => {
parent.remove();
});
it("carries the change background into the line number gutter", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);
const extensions = [viewerBase({ readOnly: true }), changedLineGutters];
const mv = new MergeView({
a: { doc: "keep\nlet x = 1;\nend\n", extensions },
b: { doc: "keep\nlet x = 2;\nadded\nend\n", extensions },
parent,
gutter: false,
diffConfig: { override: whitespaceDiff("none") },
});
const numbers = (v: typeof mv.a, cls: string) =>
[...v.dom.querySelectorAll(`.cm-lineNumbers .${cls}`)].map((e) => e.textContent);
expect(numbers(mv.a, "cm-diffChangedGutter")).toEqual(["2"]);
expect(numbers(mv.b, "cm-diffChangedGutter")).toEqual(["2", "3"]);
expect(numbers(mv.b, "cm-diffWholeGutter")).toEqual(["3"]);
mv.destroy();
parent.remove();
});
it("marks a deleted last line as a whole line", () => {
const parent = document.createElement("div");
document.body.appendChild(parent);