mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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 <53564781+jaw52@users.noreply.github.com>
This commit is contained in:
@@ -182,19 +182,100 @@
|
||||
.noHighlight :global(.cm-changedText) {
|
||||
background: none !important;
|
||||
}
|
||||
.unsynced :global(.cm-mergeView) {
|
||||
/* Side-by-side without alignment (IDEA's default): each pane scrolls itself. */
|
||||
.unaligned :global(.cm-mergeView) {
|
||||
overflow: hidden;
|
||||
}
|
||||
.unsynced :global(.cm-mergeViewEditors) {
|
||||
.unaligned :global(.cm-mergeViewEditors) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.unsynced :global(.cm-mergeViewEditor) {
|
||||
.unaligned :global(.cm-mergeViewEditor) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.unsynced :global(.cm-mergeViewEditor > .cm-editor) {
|
||||
height: 100%;
|
||||
/* Overrides MergeView's base theme, which leaves scrolling to the outer view. */
|
||||
.unaligned :global(.cm-mergeViewEditor > .cm-editor),
|
||||
.unaligned :global(.cm-mergeViewEditor > .cm-editor > .cm-scroller) {
|
||||
height: 100% !important;
|
||||
}
|
||||
.unaligned :global(.cm-mergeViewEditor > .cm-editor > .cm-scroller) {
|
||||
overflow: auto !important;
|
||||
}
|
||||
.host :global(.cm-diffDivider) {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 30px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.host :global(.cm-diffDivider svg) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
/* Opaque mixes of the line backgrounds (cm.ts), so bands and their midlines read as one shape. */
|
||||
.host :global(.cm-diffBand-inserted) {
|
||||
--band: color-mix(in srgb, var(--success) 26%, var(--bg-app));
|
||||
}
|
||||
.host :global(.cm-diffBand-deleted) {
|
||||
--band: color-mix(in srgb, var(--danger) 26%, var(--bg-app));
|
||||
}
|
||||
.host :global(.cm-diffBand-modified) {
|
||||
--band: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app));
|
||||
}
|
||||
.host :global(.cm-diffBand:not(.cm-diffBand-modified)) {
|
||||
fill: var(--band);
|
||||
}
|
||||
.host :global(.cm-diffBandLine) {
|
||||
fill: none;
|
||||
stroke: var(--band);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
.host :global(.cm-diffBandStopA) {
|
||||
stop-color: color-mix(in srgb, var(--gutter-deleted) 20%, var(--bg-app));
|
||||
}
|
||||
.host :global(.cm-diffBandStopB) {
|
||||
stop-color: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app));
|
||||
}
|
||||
.host :global(.cm-diffDivider button.cm-diffArrow) {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: none;
|
||||
color: var(--fg-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
.host :global(.cm-diffDivider button.cm-diffArrow-a-to-b) {
|
||||
left: 1px;
|
||||
}
|
||||
.host :global(.cm-diffDivider button.cm-diffArrow-b-to-a) {
|
||||
right: 1px;
|
||||
}
|
||||
.host :global(.cm-diffDivider button.cm-diffArrow:hover) {
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Where the other pane has lines this one lacks. */
|
||||
.host :global(.cm-diffGap-inserted) {
|
||||
box-shadow: inset 0 1px 0 var(--success);
|
||||
}
|
||||
.host :global(.cm-diffGap-deleted) {
|
||||
box-shadow: inset 0 1px 0 var(--danger);
|
||||
}
|
||||
.host :global(.cm-diffGap-inserted.cm-diffGapEnd) {
|
||||
box-shadow: inset 0 -1px 0 var(--success);
|
||||
}
|
||||
.host :global(.cm-diffGap-deleted.cm-diffGapEnd) {
|
||||
box-shadow: inset 0 -1px 0 var(--danger);
|
||||
}
|
||||
|
||||
/* Review comments under diff lines (pull requests). */
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type DiffSide,
|
||||
} from "./diffInput";
|
||||
import { diffAnnotations } from "./diffAnnotations";
|
||||
import { UnalignedPanes } from "./diffScroll";
|
||||
import { groupOf } from "./changes";
|
||||
import { inclusionGutter } from "./hunkInclusion";
|
||||
import { hunksOverlapping } from "./hunks";
|
||||
@@ -65,6 +66,8 @@ interface Views {
|
||||
b: EditorView;
|
||||
a: EditorView | null;
|
||||
merge: MergeView | null;
|
||||
/** Unpadded side-by-side panes with synchronized scrolling (IDEA). */
|
||||
panes: UnalignedPanes | null;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -227,18 +230,23 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
const editableB = !!attached?.b && !attached.b.readOnly;
|
||||
const ready = !!attached && !!statics && lang !== null;
|
||||
|
||||
// Read when the views are built; toggling it later goes through `setSync`.
|
||||
const syncScroll = useRef(opts.syncScroll);
|
||||
syncScroll.current = opts.syncScroll;
|
||||
|
||||
// (Re)build the CodeMirror views.
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !attached || !statics || lang === null) return;
|
||||
const textA = attached.a?.text() ?? statics.a ?? "";
|
||||
const textB = attached.b?.text() ?? statics.b ?? "";
|
||||
const scrollTop = viewsRef.current
|
||||
? viewsRef.current.merge
|
||||
? (viewsRef.current.merge.dom as HTMLElement).scrollTop
|
||||
: viewsRef.current.b.scrollDOM.scrollTop
|
||||
const prev = viewsRef.current;
|
||||
const scrollTop = prev
|
||||
? prev.merge && !prev.panes
|
||||
? (prev.merge.dom as HTMLElement).scrollTop
|
||||
: prev.b.scrollDOM.scrollTop
|
||||
: null;
|
||||
viewsRef.current?.destroy();
|
||||
prev?.destroy();
|
||||
host.textContent = "";
|
||||
const listener = EditorView.updateListener.of((u) => {
|
||||
if (u.selectionSet || u.docChanged) updateChunkInfo();
|
||||
@@ -272,11 +280,21 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
let views: Views;
|
||||
if (opts.mode === "split") {
|
||||
let merge: MergeView | null = null;
|
||||
const panes = opts.alignChanges
|
||||
? null
|
||||
: new UnalignedPanes({
|
||||
sync: syncScroll.current,
|
||||
arrows:
|
||||
editableA || editableB
|
||||
? { toA: editableA, toB: editableB, apply: (i, source) => merge && applyChunk(merge, i, source) }
|
||||
: undefined,
|
||||
});
|
||||
const mv = new MergeView({
|
||||
a: {
|
||||
doc: textA,
|
||||
extensions: [
|
||||
side(attached.a, editableA, "left"),
|
||||
panes?.extension ?? [],
|
||||
focusTracker("a"),
|
||||
findWatch("a"),
|
||||
editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [],
|
||||
@@ -286,6 +304,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
doc: textB,
|
||||
extensions: [
|
||||
side(attached.b, editableB, "right"),
|
||||
panes?.extension ?? [],
|
||||
listener,
|
||||
focusTracker("b"),
|
||||
findWatch("b"),
|
||||
@@ -294,17 +313,36 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
],
|
||||
},
|
||||
parent: host,
|
||||
revertControls: editableB ? "a-to-b" : editableA ? "b-to-a" : undefined,
|
||||
// Unaligned panes draw their arrows on their own divider.
|
||||
revertControls: panes ? undefined : editableB ? "a-to-b" : editableA ? "b-to-a" : undefined,
|
||||
renderRevertControl:
|
||||
editableA || editableB ? arrowControls(() => merge, { toA: editableA, toB: editableB }) : undefined,
|
||||
!panes && (editableA || editableB)
|
||||
? arrowControls(() => merge, { toA: editableA, toB: editableB })
|
||||
: undefined,
|
||||
highlightChanges,
|
||||
gutter: true,
|
||||
// Unpadded panes tint the gutters of changed lines instead of a change bar.
|
||||
gutter: !panes,
|
||||
collapseUnchanged,
|
||||
diffConfig,
|
||||
});
|
||||
merge = mv;
|
||||
views = { a: mv.a, b: mv.b, merge: mv, destroy: () => mv.destroy() };
|
||||
if (scrollTop != null) requestAnimationFrame(() => ((mv.dom as HTMLElement).scrollTop = scrollTop));
|
||||
panes?.attach(mv);
|
||||
views = {
|
||||
a: mv.a,
|
||||
b: mv.b,
|
||||
merge: mv,
|
||||
panes,
|
||||
destroy: () => {
|
||||
panes?.destroy();
|
||||
mv.destroy();
|
||||
},
|
||||
};
|
||||
if (scrollTop != null)
|
||||
requestAnimationFrame(() => {
|
||||
// The left pane of unpadded panes follows by itself.
|
||||
if (!panes) (mv.dom as HTMLElement).scrollTop = scrollTop;
|
||||
else mv.b.scrollDOM.scrollTop = scrollTop;
|
||||
});
|
||||
} else {
|
||||
const view = new EditorView({
|
||||
doc: textB,
|
||||
@@ -338,7 +376,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
}),
|
||||
],
|
||||
});
|
||||
views = { a: null, b: view, merge: null, destroy: () => view.destroy() };
|
||||
views = { a: null, b: view, merge: null, panes: null, destroy: () => view.destroy() };
|
||||
if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop));
|
||||
}
|
||||
viewsRef.current = views;
|
||||
@@ -349,6 +387,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
statics,
|
||||
lang,
|
||||
opts.mode,
|
||||
opts.alignChanges,
|
||||
opts.collapse,
|
||||
opts.ignore,
|
||||
opts.highlight,
|
||||
@@ -360,6 +399,8 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
path,
|
||||
]);
|
||||
|
||||
useEffect(() => viewsRef.current?.panes?.setSync(opts.syncScroll), [opts.syncScroll]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
viewsRef.current?.destroy();
|
||||
@@ -603,9 +644,10 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
if (neighbour) void openDiff(neighbour, { preview: true });
|
||||
};
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const showSettings = () => {
|
||||
const r = toolbarRef.current?.getBoundingClientRect();
|
||||
showContextMenu({ x: r ? r.left + 8 : 120, y: r ? r.bottom : 120 }, [
|
||||
/** The viewer settings menu, under the toolbar's gear (`from`) or its start. */
|
||||
const showSettings = (from?: HTMLElement) => {
|
||||
const r = (from ?? toolbarRef.current)?.getBoundingClientRect();
|
||||
showContextMenu({ x: r ? r.left + (from ? 0 : 8) : 120, y: r ? r.bottom : 120 }, [
|
||||
{ kind: "header", label: "Viewer" },
|
||||
{
|
||||
id: "split",
|
||||
@@ -654,6 +696,13 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
disabled: opts.mode !== "split",
|
||||
run: () => setOpts({ syncScroll: !opts.syncScroll }),
|
||||
},
|
||||
{
|
||||
id: "align",
|
||||
label: "Align Changes",
|
||||
checked: opts.alignChanges,
|
||||
disabled: opts.mode !== "split",
|
||||
run: () => setOpts({ alignChanges: !opts.alignChanges }),
|
||||
},
|
||||
]);
|
||||
};
|
||||
const nav = useRef<DiffNavigator>({
|
||||
@@ -805,6 +854,14 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
? tx`${chunkInfo.count} difference`
|
||||
: tx`${chunkInfo.count} differences`}
|
||||
</span>
|
||||
<IconButton
|
||||
icon="settings"
|
||||
title="Settings"
|
||||
shortcut={shortcutLabel("diff.showSettings")}
|
||||
size="small"
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => showSettings(e.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
{opts.mode === "split" ? (
|
||||
<div className={styles.labels}>
|
||||
@@ -860,7 +917,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
className={clsx(
|
||||
styles.host,
|
||||
opts.highlight === "none" && styles.noHighlight,
|
||||
opts.mode === "split" && !opts.syncScroll && styles.unsynced,
|
||||
opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned),
|
||||
)}
|
||||
hidden={!!error || !ready}
|
||||
aria-label={diffTitle(data)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Chunk, MergeView } from "@codemirror/merge";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { bandAt, bandPath, transferY, type Band } from "../diffScroll";
|
||||
|
||||
const doc = (s: string) => Text.of(s.split("\n"));
|
||||
const LINE = 20;
|
||||
|
||||
/** The band at `y` of side `from` in two texts with every line LINE px tall. */
|
||||
function bands(a: string, b: string) {
|
||||
const da = doc(a);
|
||||
const db = doc(b);
|
||||
const ext = (d: Text, from: number, to: number): [number, number] => {
|
||||
if (from > d.length) return [d.lines * LINE, d.lines * LINE];
|
||||
const top = (d.lineAt(from).number - 1) * LINE;
|
||||
return from >= to ? [top, top] : [top, d.lineAt(Math.min(to - 1, d.length)).number * LINE];
|
||||
};
|
||||
const chunks = Chunk.build(da, db);
|
||||
return (y: number, from: "a" | "b"): Band =>
|
||||
bandAt(
|
||||
chunks,
|
||||
(c) => ext(da, c.fromA, c.toA),
|
||||
(c) => ext(db, c.fromB, c.toB),
|
||||
da.lines * LINE,
|
||||
db.lines * LINE,
|
||||
y,
|
||||
from,
|
||||
);
|
||||
}
|
||||
|
||||
describe("unaligned side-by-side scrolling", () => {
|
||||
// Right side inserts three lines after line 2 and changes line 5 into two lines.
|
||||
const a = "1\n2\n3\n4\n5\n6\n7";
|
||||
const b = "1\n2\nx\ny\nz\n3\n4\nfive\nfive'\n6\n7";
|
||||
const bandOf = bands(a, b);
|
||||
const at = (y: number, from: "a" | "b") => transferY([bandOf(y, from)], y, from);
|
||||
|
||||
it("splits both documents into unchanged and changed bands", () => {
|
||||
const row = (x: Band) => [x.a0, x.a1, x.b0, x.b1, x.changed];
|
||||
expect([0, 39, 40, 79, 80, 99, 100, 139].map((y) => row(bandOf(y, "a")))).toEqual([
|
||||
[0, 40, 0, 40, false],
|
||||
[0, 40, 0, 40, false],
|
||||
[40, 80, 100, 140, false],
|
||||
[40, 80, 100, 140, false],
|
||||
[80, 100, 140, 180, true],
|
||||
[80, 100, 140, 180, true],
|
||||
[100, 140, 180, 220, false],
|
||||
[100, 140, 180, 220, false],
|
||||
]);
|
||||
expect([0, 40, 99, 100, 140, 179, 180].map((y) => row(bandOf(y, "b")))).toEqual([
|
||||
[0, 40, 0, 40, false],
|
||||
[40, 40, 40, 100, true],
|
||||
[40, 40, 40, 100, true],
|
||||
[40, 80, 100, 140, false],
|
||||
[80, 100, 140, 180, true],
|
||||
[80, 100, 140, 180, true],
|
||||
[100, 140, 180, 220, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps unchanged text straight through", () => {
|
||||
expect(at(10, "a")).toBe(10);
|
||||
expect(at(50, "a")).toBe(110);
|
||||
expect(at(130, "a")).toBe(210);
|
||||
expect(at(210, "b")).toBe(130);
|
||||
});
|
||||
|
||||
it("jumps over an insertion when scrolling the side without it", () => {
|
||||
expect(at(39, "a")).toBe(39);
|
||||
expect(at(40, "a")).toBe(100);
|
||||
});
|
||||
|
||||
it("waits at the boundary while the inserted lines scroll by", () => {
|
||||
for (const y of [40, 60, 99]) expect(at(y, "b")).toBe(40);
|
||||
});
|
||||
|
||||
it("follows line by line inside a change, then waits at the shorter end", () => {
|
||||
expect(at(150, "b")).toBe(90);
|
||||
expect(at(170, "b")).toBe(100);
|
||||
expect(at(90, "a")).toBe(150);
|
||||
});
|
||||
|
||||
it("scales unchanged text of different heights (wrapped lines)", () => {
|
||||
const stretched = [{ a0: 0, a1: 100, b0: 0, b1: 200, changed: false }];
|
||||
expect(transferY(stretched, 50, "a")).toBe(100);
|
||||
expect(transferY(stretched, 50, "b")).toBe(25);
|
||||
});
|
||||
|
||||
it("draws an empty side as a sliver on its boundary", () => {
|
||||
expect(bandPath(30, 40, 40, 40, 100)).toBe("M0 39C9 39 21 40 30 40L30 100C21 100 9 41 0 41Z");
|
||||
});
|
||||
|
||||
it("can keep MergeView from padding the panes", () => {
|
||||
// UnalignedPanes replaces this (private) method; a rename upstream would bring the padding back.
|
||||
expect(typeof (MergeView.prototype as unknown as { measure: unknown }).measure).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type { EditorInput } from "@/workbench/contributions";
|
||||
import { Chunk } from "@codemirror/merge";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { iconMarkup } from "@/ui";
|
||||
import { ContextMenuHost, iconMarkup } from "@/ui";
|
||||
|
||||
const probe = vi.fn();
|
||||
const openFile = vi.fn();
|
||||
@@ -80,6 +80,40 @@ describe("Diff toolbar", () => {
|
||||
expect(probe).toHaveBeenCalledWith("/p/a.ts");
|
||||
});
|
||||
|
||||
it("turns on aligned changes from the settings menu (off by default)", async () => {
|
||||
localStorage.clear();
|
||||
texts.HEAD = "a\nb\n";
|
||||
texts.abc123 = "a\nnew\nb\n";
|
||||
render(
|
||||
<>
|
||||
<DiffEditor input={input} />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2));
|
||||
// Unpadded panes joined by the divider.
|
||||
await waitFor(() => expect(document.querySelector(".cm-diffDivider")).not.toBeNull());
|
||||
expect(document.querySelector(".cm-changeGutter")).toBeNull();
|
||||
expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffWholeGutter")?.textContent).toBe("2");
|
||||
// The lock turns scrolling together off without rebuilding the panes.
|
||||
const divider = document.querySelector(".cm-diffDivider");
|
||||
(await screen.findByRole("button", { name: "Synchronize Scrolling" })).click();
|
||||
await waitFor(() => expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).syncScroll).toBe(false));
|
||||
expect(document.querySelector(".cm-diffDivider")).toBe(divider);
|
||||
(await screen.findByRole("button", { name: "Settings" })).click();
|
||||
const item = await screen.findByRole("menuitemcheckbox", { name: "Align Changes" });
|
||||
expect(item.getAttribute("aria-checked")).toBe("false");
|
||||
item.click();
|
||||
// Padded panes with a change bar.
|
||||
await waitFor(() => expect(document.querySelector(".cm-diffDivider")).toBeNull());
|
||||
expect(document.querySelector(".cm-changeGutter")).not.toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!)).toMatchObject({
|
||||
alignChanges: true,
|
||||
syncScroll: false,
|
||||
});
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("has no Jump to Source when the file is gone from the working tree", async () => {
|
||||
probe.mockRejectedValue(new Error("no such file"));
|
||||
render(<DiffEditor input={input} />);
|
||||
|
||||
+68
-11
@@ -3,11 +3,21 @@
|
||||
* based theme + syntax colours and lazy language loading by file name.
|
||||
*/
|
||||
import { HighlightStyle, LanguageDescription, syntaxHighlighting, type LanguageSupport } from "@codemirror/language";
|
||||
import { Compartment, EditorState, RangeSetBuilder, type Extension, type Line } from "@codemirror/state";
|
||||
import {
|
||||
Compartment,
|
||||
EditorState,
|
||||
RangeSet,
|
||||
RangeSetBuilder,
|
||||
StateField,
|
||||
type Extension,
|
||||
type Line,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
ViewPlugin,
|
||||
gutterLineClass,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
@@ -27,17 +37,16 @@ import { useProject } from "@/platform/project";
|
||||
const wholeLine = Decoration.line({ class: "cm-diffWholeLine" });
|
||||
|
||||
/**
|
||||
* Marks the changed lines that have no counterpart on the other side (inserted
|
||||
* or deleted as a whole) so they get a solid background. Lines paired with a
|
||||
* line on the other side keep the line tint plus word-level highlights.
|
||||
* Calls `f` for the changed lines of a side-by-side pane between `vpFrom` and
|
||||
* `vpTo`, with whether the line has no counterpart on the other side (inserted
|
||||
* or deleted as a whole). Lines paired with a line on the other side keep the
|
||||
* line tint plus word-level highlights.
|
||||
*/
|
||||
function wholeLineDeco(view: EditorView): DecorationSet {
|
||||
const info = getChunks(view.state);
|
||||
if (!info || !info.side) return Decoration.none;
|
||||
function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (line: Line, whole: boolean) => void) {
|
||||
const info = getChunks(state);
|
||||
if (!info || !info.side) return;
|
||||
const isA = info.side === "a";
|
||||
const { doc } = view.state;
|
||||
const { from: vpFrom, to: vpTo } = view.viewport;
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
const { doc } = state;
|
||||
for (const chunk of info.chunks) {
|
||||
const from = isA ? chunk.fromA : chunk.fromB;
|
||||
const to = isA ? chunk.toA : chunk.toB;
|
||||
@@ -49,11 +58,46 @@ function wholeLineDeco(view: EditorView): DecorationSet {
|
||||
const covered = (line: Line) =>
|
||||
ranges.some(([f = 0, t = 0]) => (f <= line.from && t > line.to) || (f < line.from && t >= line.to));
|
||||
for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < Math.min(to, vpTo);) {
|
||||
if (otherEmpty || covered(line)) builder.add(line.from, line.from, wholeLine);
|
||||
f(line, otherEmpty || covered(line));
|
||||
if (line.number === doc.lines) break;
|
||||
line = doc.line(line.number + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Solid background for the lines changed as a whole. */
|
||||
function wholeLineDeco(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, whole) => {
|
||||
if (whole) builder.add(line.from, line.from, wholeLine);
|
||||
});
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
class LineClassMarker extends GutterMarker {
|
||||
constructor(readonly elementClass: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
const changedGutter = new LineClassMarker("cm-diffChangedGutter");
|
||||
const wholeGutter = new LineClassMarker("cm-diffChangedGutter cm-diffWholeGutter");
|
||||
|
||||
/** The line background of changed lines carried into the gutters (line numbers, inclusion boxes), as in IDEA. */
|
||||
export const changedLineGutters = StateField.define<RangeSet<GutterMarker>>({
|
||||
create: (state) => changedGutterMarkers(state),
|
||||
update: (value, tr) =>
|
||||
tr.docChanged || getChunks(tr.startState)?.chunks !== getChunks(tr.state)?.chunks
|
||||
? changedGutterMarkers(tr.state)
|
||||
: value,
|
||||
provide: (f) => gutterLineClass.from(f),
|
||||
});
|
||||
|
||||
function changedGutterMarkers(state: EditorState): RangeSet<GutterMarker> {
|
||||
const builder = new RangeSetBuilder<GutterMarker>();
|
||||
forChangedLines(state, 0, state.doc.length + 1, (line, whole) =>
|
||||
builder.add(line.from, line.from, whole ? wholeGutter : changedGutter),
|
||||
);
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
@@ -143,6 +187,19 @@ export const viewerTheme = EditorView.theme(
|
||||
"&.cm-merge-b .cm-changedText, .cm-insertedLine .cm-changedText": {
|
||||
background: "color-mix(in srgb, var(--success) 26%, transparent)",
|
||||
},
|
||||
// Gutters of changed lines take the line background (changedLineGutters).
|
||||
"&.cm-merge-a .cm-diffChangedGutter": {
|
||||
backgroundColor: "color-mix(in srgb, var(--gutter-deleted) 12%, transparent)",
|
||||
},
|
||||
"&.cm-merge-b .cm-diffChangedGutter": {
|
||||
backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)",
|
||||
},
|
||||
"&.cm-merge-a .cm-diffWholeGutter": {
|
||||
backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)",
|
||||
},
|
||||
"&.cm-merge-b .cm-diffWholeGutter": {
|
||||
backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)",
|
||||
},
|
||||
".cm-changeGutter": { width: "3px", paddingLeft: "0" },
|
||||
"&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter": { background: "var(--gutter-deleted)" },
|
||||
"&.cm-merge-b .cm-changedLineGutter": { background: "var(--gutter-modified)" },
|
||||
|
||||
@@ -17,7 +17,8 @@ export function applyChunk(mv: MergeView, index: number, source: "a" | "b") {
|
||||
dst.dispatch({ changes: transferChunk(chunk, source, src.state.doc, dst.state.doc), userEvent: "revert" });
|
||||
}
|
||||
|
||||
function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement {
|
||||
/** A ">>" / "<<" button; the chunk index comes from the nearest `data-chunk` (itself included). */
|
||||
export function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `cm-diffArrow cm-diffArrow-${dir}`;
|
||||
|
||||
@@ -15,7 +15,13 @@ export interface DiffViewOptions {
|
||||
ignore: IgnorePolicy;
|
||||
highlight: HighlightPolicy;
|
||||
collapse: boolean;
|
||||
/** Side-by-side panes scroll together. */
|
||||
syncScroll: boolean;
|
||||
/**
|
||||
* Side-by-side panes are padded so that unchanged lines line up. Off, each pane
|
||||
* keeps its own height and a curved divider joins the changes (IDEA's default).
|
||||
*/
|
||||
alignChanges: boolean;
|
||||
}
|
||||
|
||||
export const IGNORE_OPTIONS: ReadonlyArray<{ value: IgnorePolicy; label: string }> = [
|
||||
@@ -37,6 +43,7 @@ export const DEFAULT_DIFF_OPTIONS: DiffViewOptions = {
|
||||
highlight: "words",
|
||||
collapse: true,
|
||||
syncScroll: true,
|
||||
alignChanges: false,
|
||||
};
|
||||
|
||||
const KEY = "openidea.vcs.diffOptions";
|
||||
@@ -54,6 +61,7 @@ export function parseDiffOptions(raw: unknown): DiffViewOptions {
|
||||
highlight: oneOf(o.highlight, HIGHLIGHT_OPTIONS, d.highlight),
|
||||
collapse: typeof o.collapse === "boolean" ? o.collapse : d.collapse,
|
||||
syncScroll: typeof o.syncScroll === "boolean" ? o.syncScroll : d.syncScroll,
|
||||
alignChanges: typeof o.alignChanges === "boolean" ? o.alignChanges : d.alignChanges,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* IDEA's side-by-side diff without alignment padding: each pane scrolls on its
|
||||
* own, scrolling one carries the other along through the changes
|
||||
* (SyncScrollSupport), and the divider between them draws a curved band per
|
||||
* change (DiffDividerDrawUtil) with the ">>" / "<<" arrows on it. Changed lines
|
||||
* tint their gutters instead of showing a change bar.
|
||||
*/
|
||||
import { RangeSetBuilder, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
import { getChunks, type Chunk, type MergeView } from "@codemirror/merge";
|
||||
import { changedLineGutters } from "./cm";
|
||||
import { arrowButton } from "./diffControls";
|
||||
|
||||
type Side = "a" | "b";
|
||||
|
||||
/** A stretch of both documents in document pixels: unchanged text, or a change. */
|
||||
export interface Band {
|
||||
a0: number;
|
||||
a1: number;
|
||||
b0: number;
|
||||
b1: number;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** Pixel [top, bottom] of a chunk's range in `view`; an empty range is the boundary it sits on. */
|
||||
function extent(view: EditorView, from: number, to: number): [number, number] {
|
||||
const len = view.state.doc.length;
|
||||
if (from > len) {
|
||||
const end = view.lineBlockAt(len).bottom;
|
||||
return [end, end];
|
||||
}
|
||||
const top = view.lineBlockAt(from).top;
|
||||
return from >= to ? [top, top] : [top, view.lineBlockAt(Math.min(to - 1, len)).bottom];
|
||||
}
|
||||
|
||||
/** First index in `[0, n)` for which `past` holds, given that it holds for a suffix. */
|
||||
function firstIndex(n: number, past: (i: number) => boolean): number {
|
||||
let lo = 0;
|
||||
let hi = n;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (past(mid)) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretch of both documents around `y` of side `from`, from the top to
|
||||
* `endA` / `endB`: unchanged text, or a change (BaseSyncScrollable.processHelper).
|
||||
* Only the chunks next to `y` are measured.
|
||||
*/
|
||||
export function bandAt(
|
||||
chunks: readonly Chunk[],
|
||||
extentA: (c: Chunk) => [number, number],
|
||||
extentB: (c: Chunk) => [number, number],
|
||||
endA: number,
|
||||
endB: number,
|
||||
y: number,
|
||||
from: Side,
|
||||
): Band {
|
||||
const master = from === "a" ? extentA : extentB;
|
||||
const next = firstIndex(chunks.length, (i) => master(chunks[i]!)[0] > y);
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
const prev = chunks[next - 1];
|
||||
if (prev) {
|
||||
const [a0, a1] = extentA(prev);
|
||||
const [b0, b1] = extentB(prev);
|
||||
if (y < (from === "a" ? a1 : b1)) return { a0, a1, b0, b1, changed: true };
|
||||
a = a1;
|
||||
b = b1;
|
||||
}
|
||||
const c = chunks[next];
|
||||
return c
|
||||
? { a0: a, a1: extentA(c)[0], b0: b, b1: extentB(c)[0], changed: false }
|
||||
: { a0: a, a1: endA, b0: b, b1: endB, changed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The position on the other side matching `y` of side `from`
|
||||
* (BaseSyncScrollable.transferLine, in pixels): unchanged text maps through,
|
||||
* and inside a change the other side follows line by line, then waits at the
|
||||
* end of its shorter part.
|
||||
*/
|
||||
export function transferY(bands: readonly Band[], y: number, from: Side): number {
|
||||
const fromA = from === "a";
|
||||
const band = bands.find((b) => y < (fromA ? b.a1 : b.b1)) ?? bands[bands.length - 1]!;
|
||||
const [m0, m1, s0, s1] = fromA ? [band.a0, band.a1, band.b0, band.b1] : [band.b0, band.b1, band.a0, band.a1];
|
||||
if (band.changed) return Math.min(s0 + (y - m0), s1);
|
||||
return m1 > m0 ? s0 + ((y - m0) * (s1 - s0)) / (m1 - m0) : s0;
|
||||
}
|
||||
|
||||
type ChangeKind = "inserted" | "deleted" | "modified";
|
||||
const kindOf = (c: Chunk): ChangeKind => (c.fromA === c.toA ? "inserted" : c.fromB === c.toB ? "deleted" : "modified");
|
||||
|
||||
/** The divider band between [a0, a1] on the left and [b0, b1] on the right (DiffDrawUtil.makeCurve). */
|
||||
export function bandPath(w: number, a0: number, a1: number, b0: number, b1: number): string {
|
||||
// An empty side is a 2px sliver on its boundary.
|
||||
if (a1 - a0 < 2) [a0, a1] = [a0 - 1, a0 + 1];
|
||||
if (b1 - b0 < 2) [b0, b1] = [b0 - 1, b0 + 1];
|
||||
const c1 = w * 0.3;
|
||||
const c2 = w * 0.7;
|
||||
return `M0 ${a0}C${c1} ${a0} ${c2} ${b0} ${w} ${b0}L${w} ${b1}C${c2} ${b1} ${c1} ${a1} 0 ${a1}Z`;
|
||||
}
|
||||
|
||||
const gapMark = (kind: ChangeKind, end: boolean) =>
|
||||
Decoration.line({ class: `cm-diffGap cm-diffGap-${kind}${end ? " cm-diffGapEnd" : ""}` });
|
||||
|
||||
/** A line where the other side has lines this one lacks, as in IDEA's empty-side marker. */
|
||||
function gapDeco(view: EditorView): DecorationSet {
|
||||
const info = getChunks(view.state);
|
||||
if (!info?.side) return Decoration.none;
|
||||
const isA = info.side === "a";
|
||||
const { doc } = view.state;
|
||||
const { from: vpFrom, to: vpTo } = view.viewport;
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (const c of info.chunks) {
|
||||
const [from, to] = isA ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
||||
if (from !== to) continue;
|
||||
if (from > vpTo) break;
|
||||
if (from < vpFrom) continue;
|
||||
// Past the end of a document without a final line break: below its last line.
|
||||
const line = doc.lineAt(Math.min(from, doc.length));
|
||||
builder.add(line.from, line.from, gapMark(kindOf(c), from > doc.length));
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
const gapMarks = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = gapDeco(view);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.viewportChanged || getChunks(u.startState)?.chunks !== getChunks(u.state)?.chunks)
|
||||
this.decorations = gapDeco(u.view);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
const ARROW_HEIGHT = 16;
|
||||
let gradients = 0;
|
||||
|
||||
export interface UnalignedPanesOptions {
|
||||
/** Scrolling one pane carries the other along. */
|
||||
sync: boolean;
|
||||
/** Divider arrows toward the editable sides; `apply` copies chunk `index` from `source`. */
|
||||
arrows?: { toA: boolean; toB: boolean; apply(index: number, source: Side): void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll synchronization and the divider for a MergeView whose panes are not
|
||||
* padded into alignment. Add `extension` to both editors, then `attach` the view.
|
||||
*/
|
||||
export class UnalignedPanes {
|
||||
readonly extension: Extension;
|
||||
private mv: MergeView | null = null;
|
||||
private readonly divider = document.createElement("div");
|
||||
private readonly svg = document.createElementNS(SVG, "svg");
|
||||
private readonly bandLayer = document.createElementNS(SVG, "g");
|
||||
private readonly gradient = `cm-diffBandGradient-${++gradients}`;
|
||||
private readonly arrowEls = new Map<string, HTMLButtonElement>();
|
||||
/** Scroll position each pane was last moved to by the other one, to tell its echo from the user. */
|
||||
private readonly moved: Record<Side, { top: number | null; left: number | null }> = {
|
||||
a: { top: null, left: null },
|
||||
b: { top: null, left: null },
|
||||
};
|
||||
private sync: boolean;
|
||||
private frame = -1;
|
||||
private resize: ResizeObserver | null = null;
|
||||
private readonly cleanup: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly opts: UnalignedPanesOptions) {
|
||||
this.sync = opts.sync;
|
||||
this.extension = [gapMarks, changedLineGutters, EditorView.updateListener.of(() => this.schedule())];
|
||||
this.divider.className = "cm-diffDivider";
|
||||
this.svg.setAttribute("aria-hidden", "true");
|
||||
this.svg.innerHTML =
|
||||
`<defs><linearGradient id="${this.gradient}" x1="0" x2="1">` +
|
||||
`<stop offset="0" class="cm-diffBandStopA"/><stop offset="1" class="cm-diffBandStopB"/>` +
|
||||
`</linearGradient></defs>`;
|
||||
this.svg.appendChild(this.bandLayer);
|
||||
this.divider.appendChild(this.svg);
|
||||
}
|
||||
|
||||
attach(mv: MergeView) {
|
||||
this.mv = mv;
|
||||
// MergeView pads the panes into alignment on each measure; these panes stay unpadded.
|
||||
(mv as unknown as { measure(): void }).measure = () => {};
|
||||
mv.a.dom.parentElement!.after(this.divider);
|
||||
for (const side of ["a", "b"] as const) {
|
||||
const el = this.view(side).scrollDOM;
|
||||
const onScroll = () => this.scrolled(side);
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
this.cleanup.push(() => el.removeEventListener("scroll", onScroll));
|
||||
}
|
||||
// The divider scrolls the right pane (and, when synchronized, through it the left one).
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
mv.b.scrollDOM.scrollBy(e.deltaX, e.deltaY);
|
||||
};
|
||||
this.divider.addEventListener("wheel", onWheel, { passive: false });
|
||||
this.cleanup.push(() => this.divider.removeEventListener("wheel", onWheel));
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resize = new ResizeObserver(() => this.schedule());
|
||||
this.resize.observe(this.divider);
|
||||
}
|
||||
requestAnimationFrame(() => this.resync());
|
||||
}
|
||||
|
||||
/** Brings the left pane in line with the right one. */
|
||||
resync() {
|
||||
if (this.mv && this.sync) this.follow("b");
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
/** Turns synchronized scrolling on (bringing the left pane in line) or off. */
|
||||
setSync(on: boolean) {
|
||||
if (on === this.sync) return;
|
||||
this.sync = on;
|
||||
this.resync();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.frame >= 0) cancelAnimationFrame(this.frame);
|
||||
this.resize?.disconnect();
|
||||
for (const f of this.cleanup) f();
|
||||
this.divider.remove();
|
||||
this.mv = null;
|
||||
}
|
||||
|
||||
private view(side: Side): EditorView {
|
||||
return side === "a" ? this.mv!.a : this.mv!.b;
|
||||
}
|
||||
|
||||
private scrolled(side: Side) {
|
||||
const el = this.view(side).scrollDOM;
|
||||
const moved = this.moved[side];
|
||||
const echo =
|
||||
(moved.top !== null || moved.left !== null) &&
|
||||
(moved.top === null || Math.abs(el.scrollTop - moved.top) <= 1) &&
|
||||
(moved.left === null || Math.abs(el.scrollLeft - moved.left) <= 1);
|
||||
moved.top = moved.left = null;
|
||||
if (!echo && this.sync) this.follow(side);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private bandAt(y: number, from: Side): Band {
|
||||
const { a, b, chunks } = this.mv!;
|
||||
return bandAt(
|
||||
chunks,
|
||||
(c) => extent(a, c.fromA, c.toA),
|
||||
(c) => extent(b, c.fromB, c.toB),
|
||||
a.lineBlockAt(a.state.doc.length).bottom,
|
||||
b.lineBlockAt(b.state.doc.length).bottom,
|
||||
y,
|
||||
from,
|
||||
);
|
||||
}
|
||||
|
||||
/** Scrolls the other pane to match `from`, anchored a third down the pane (SyncScrollSupport). */
|
||||
private follow(from: Side) {
|
||||
const to: Side = from === "a" ? "b" : "a";
|
||||
const master = this.view(from);
|
||||
const slave = this.view(to);
|
||||
const anchor = master.scrollDOM.clientHeight / 3;
|
||||
const at = master.scrollDOM.scrollTop + anchor - docOffset(master);
|
||||
const y = transferY([this.bandAt(at, from)], at, from);
|
||||
const el = slave.scrollDOM;
|
||||
const top = Math.max(0, Math.min(y + docOffset(slave) - anchor, el.scrollHeight - el.clientHeight));
|
||||
const moved = this.moved[to];
|
||||
if (Math.abs(el.scrollTop - top) >= 1) {
|
||||
const before = el.scrollTop;
|
||||
el.scrollTop = top;
|
||||
if (el.scrollTop !== before) moved.top = el.scrollTop;
|
||||
}
|
||||
const left = master.scrollDOM.scrollLeft;
|
||||
if (el.scrollLeft !== left) {
|
||||
const before = el.scrollLeft;
|
||||
el.scrollLeft = left;
|
||||
if (el.scrollLeft !== before) moved.left = el.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
if (this.frame < 0 && this.mv) this.frame = requestAnimationFrame(() => this.draw());
|
||||
}
|
||||
|
||||
private draw() {
|
||||
this.frame = -1;
|
||||
const mv = this.mv;
|
||||
if (!mv) return;
|
||||
const rect = this.divider.getBoundingClientRect();
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
if (!w || !h) return;
|
||||
this.svg.setAttribute("width", String(w));
|
||||
this.svg.setAttribute("height", String(h));
|
||||
this.svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
||||
const offA = mv.a.documentTop - rect.top;
|
||||
const offB = mv.b.documentTop - rect.top;
|
||||
const lineHeight = mv.a.defaultLineHeight;
|
||||
const arrowTop = (y0: number, y1: number) =>
|
||||
y1 - y0 < 2 ? y0 - ARROW_HEIGHT / 2 : y0 + (Math.min(lineHeight, y1 - y0) - ARROW_HEIGHT) / 2;
|
||||
const arrows = this.opts.arrows;
|
||||
const shown = new Set<string>();
|
||||
let paths = "";
|
||||
const { chunks } = mv;
|
||||
// Chunks run down both panes in order: start at the first one reaching into either.
|
||||
const first = Math.min(
|
||||
firstIndex(chunks.length, (i) => extent(mv.a, chunks[i]!.fromA, chunks[i]!.toA)[1] + offA >= -1),
|
||||
firstIndex(chunks.length, (i) => extent(mv.b, chunks[i]!.fromB, chunks[i]!.toB)[1] + offB >= -1),
|
||||
);
|
||||
for (let i = first; i < chunks.length; i++) {
|
||||
const c = chunks[i]!;
|
||||
const [a0, a1] = extent(mv.a, c.fromA, c.toA).map((y) => y + offA) as [number, number];
|
||||
const [b0, b1] = extent(mv.b, c.fromB, c.toB).map((y) => y + offB) as [number, number];
|
||||
if (a0 > h + 1 && b0 > h + 1) break;
|
||||
if (a1 < -1 && b1 < -1) continue;
|
||||
const kind = kindOf(c);
|
||||
const fill = kind === "modified" ? ` fill="url(#${this.gradient})"` : "";
|
||||
const mid = (a0 + a1) / 2;
|
||||
const midB = (b0 + b1) / 2;
|
||||
paths +=
|
||||
`<path class="cm-diffBand cm-diffBand-${kind}"${fill} d="${bandPath(w, a0, a1, b0, b1)}"/>` +
|
||||
`<path class="cm-diffBandLine cm-diffBand-${kind}" d="M0 ${mid}C${w * 0.3} ${mid} ${w * 0.7} ${midB} ${w} ${midB}"/>`;
|
||||
if (arrows?.toB) this.placeArrow(shown, i, "a-to-b", arrowTop(a0, a1), h);
|
||||
if (arrows?.toA) this.placeArrow(shown, i, "b-to-a", arrowTop(b0, b1), h);
|
||||
}
|
||||
this.bandLayer.innerHTML = paths;
|
||||
for (const [key, el] of this.arrowEls)
|
||||
if (!shown.has(key)) {
|
||||
el.remove();
|
||||
this.arrowEls.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private placeArrow(shown: Set<string>, index: number, dir: "a-to-b" | "b-to-a", top: number, h: number) {
|
||||
if (top < -ARROW_HEIGHT || top > h) return;
|
||||
const key = `${index}${dir}`;
|
||||
let el = this.arrowEls.get(key);
|
||||
if (!el) {
|
||||
el = arrowButton(dir, (i) => this.opts.arrows?.apply(i, dir === "a-to-b" ? "a" : "b"));
|
||||
this.arrowEls.set(key, el);
|
||||
this.divider.appendChild(el);
|
||||
}
|
||||
el.dataset.chunk = String(index);
|
||||
el.style.top = `${top}px`;
|
||||
shown.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset of the document's top within the pane's scrolled content. */
|
||||
function docOffset(view: EditorView): number {
|
||||
return view.documentTop - view.scrollDOM.getBoundingClientRect().top + view.scrollDOM.scrollTop;
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
"Adjust colors for red-green vision deficiency": "针对红绿色觉缺陷调整颜色",
|
||||
"After Replace": "替换后",
|
||||
"After Stop, the process gets this long to exit after the interrupt before it is killed.": "停止后,进程在收到中断信号后有这么长时间退出,超时将被强制终止。",
|
||||
"Align Changes": "对齐更改",
|
||||
"All": "全部",
|
||||
"All Places": "所有位置",
|
||||
"All breakpoints": "所有断点",
|
||||
|
||||
Reference in New Issue
Block a user