дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(vcs): IntelliJ's diff algorithm and change colouring in the diff viewer (#242)
* feat(vcs): IDEA's diff algorithm and change colouring in the diff viewer Port IntelliJ's text comparison to TypeScript (src/features/vcs/comparison): Myers/patience LCS with unique-element discarding, ByLine (smart matching that skips unimportant lines, chunk optimizer, second-step correction), ByWord (word chunks, punctuation matching, whitespace policies, line block splitting) and ComparisonManager's compareLinesInner / processBlocks. The port runs IntelliJ's own LineComparisonUtilTest, WordComparisonUtilTest and SplitComparisonUtilTest cases (459 runs), converted to ideaCases.json. MergeView chunks now come straight from these line fragments, with the inner word fragments as chunk changes, instead of @codemirror/merge's presentable diff. Revert and >> / << replace lines as DiffUtil.applyModification does. Colour changes as IDEA does: modified (blue), inserted (green) and deleted (grey) by chunk type, a lighter line background for chunks whose words were compared, each word fragment in its own type at full line height, a thin bar for a fragment empty on one side, and the colours carried into the gutters and the divider bands. New --diff-modified / --diff-inserted / --diff-deleted theme tokens, imported from .icls DIFF_* attributes. Add a Playwright suite over a generated git repository of diff cases, including 10,000-line files, in every viewer mode. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(vcs): preserve unified revert boundaries and bound large diffs --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: l0ng-ai <[email protected]>
Цей коміт міститься в:
25 files changed
+5729
-315
No files matched your search
@@ -1,23 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Chunk, MergeView } from "@codemirror/merge";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { ChangeSet, Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { applyChunk, arrowControls } from "../diffControls";
|
||||
import {
|
||||
DEFAULT_DIFF_OPTIONS,
|
||||
parseDiffOptions,
|
||||
ideaChunks,
|
||||
ideaDiffConfig,
|
||||
transferChunk,
|
||||
whitespaceDiff,
|
||||
type IgnorePolicy,
|
||||
} from "../diffOptions";
|
||||
import { clipboardDiffData, compareFilesData, parseTextsArg } from "../compare";
|
||||
import { diffInput, diffTitle, sideFile, sideKey } from "../diffInput";
|
||||
import { changedLineGutters, viewerBase } from "../cm";
|
||||
import { changedLineGutters, chunkKind, diffWords, viewerBase } from "../cm";
|
||||
|
||||
const doc = (s: string) => Text.of(s.split("\n"));
|
||||
|
||||
function chunks(a: string, b: string, ignore: IgnorePolicy) {
|
||||
const override = whitespaceDiff(ignore);
|
||||
return Chunk.build(doc(a), doc(b), { override });
|
||||
return ideaChunks(doc(a), doc(b), ignore, "words");
|
||||
}
|
||||
|
||||
describe("diff options", () => {
|
||||
@@ -124,47 +125,121 @@ describe("diff options", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("decorates consecutive inserted and deleted lines as whole lines", () => {
|
||||
it("colours lines by their chunk's type, as IDEA does", () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), diffWords];
|
||||
const mv = new MergeView({
|
||||
a: { doc: "before\nremoved one\nremoved two\nafter\n", extensions: viewerBase({ readOnly: true }) },
|
||||
b: { doc: "before\nafter\n", extensions: viewerBase({ readOnly: true }) },
|
||||
a: { doc: "keep\nremoved one\nremoved two\nmid\nvalue = old\nend\n", extensions },
|
||||
b: { doc: "keep\nmid\nvalue = new\nadded\nend\n", extensions },
|
||||
parent,
|
||||
diffConfig: { override: whitespaceDiff("none") },
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
expect(mv.a.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(2);
|
||||
expect(mv.b.dom.querySelectorAll(".cm-diffWholeLine")).toHaveLength(0);
|
||||
const lines = (v: EditorView, kind: string) =>
|
||||
[...v.dom.querySelectorAll(`.cm-diffLine-${kind}`)].map((e) => e.textContent);
|
||||
expect(lines(mv.a, "deleted")).toEqual(["removed one", "removed two"]);
|
||||
// A block with lines on both sides is modified as a whole, on both sides.
|
||||
expect(lines(mv.a, "modified")).toEqual(["value = old"]);
|
||||
expect(lines(mv.b, "modified")).toEqual(["value = new", "added"]);
|
||||
expect(lines(mv.b, "inserted")).toEqual([]);
|
||||
expect(chunkKind(mv.chunks[1]!)).toBe("modified");
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("keeps word highlighting on paired lines of a multi-line replacement", () => {
|
||||
it("highlights the words of a modified block by their own type", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), diffWords];
|
||||
const mv = new MergeView({
|
||||
a: {
|
||||
doc: "before\nfrom old.first import X\nfrom old.second import Y\nafter\n",
|
||||
extensions: viewerBase({ readOnly: true }),
|
||||
},
|
||||
b: {
|
||||
doc: "before\nfrom new.first import A\nfrom new.second import B\nadded line\nafter\n",
|
||||
extensions: viewerBase({ readOnly: true }),
|
||||
},
|
||||
a: { doc: "x\n syncScroll: boolean;\ny\n", extensions },
|
||||
b: { doc: "x\n /** Docs. */\n syncScrolling: boolean;\ny\n", extensions },
|
||||
parent,
|
||||
diffConfig: { override: whitespaceDiff("none") },
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
expect(mv.a.dom.querySelectorAll(".cm-changedLine")).toHaveLength(2);
|
||||
expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull();
|
||||
expect([...mv.a.dom.querySelectorAll(".cm-changedText")].map((e) => e.textContent)).toEqual([
|
||||
"old",
|
||||
"X",
|
||||
"old",
|
||||
"Y",
|
||||
// The left pane highlights its words once the right one exists.
|
||||
await Promise.resolve();
|
||||
const words = (v: EditorView, kind: string) =>
|
||||
[...v.dom.querySelectorAll(`.cm-diffWord.cm-diffWord-${kind}`)].map((e) => e.textContent);
|
||||
// The edited identifier is highlighted whole, joined by the comment added before it.
|
||||
expect(words(mv.a, "modified")).toEqual(["syncScroll"]);
|
||||
expect(words(mv.b, "modified").join("")).toBe("/** Docs. */ syncScrolling");
|
||||
expect(mv.a.dom.classList.contains("cm-diffWords")).toBe(true);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("marks inserted and deleted words inside a modified line", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), diffWords];
|
||||
const mv = new MergeView({
|
||||
a: { doc: "call(first, extra, last)\n", extensions },
|
||||
b: { doc: "call(first, last, more)\n", extensions },
|
||||
parent,
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
// The left pane highlights its words once the right one exists.
|
||||
await Promise.resolve();
|
||||
const words = (v: EditorView, kind: string) =>
|
||||
[...v.dom.querySelectorAll(`.cm-diffWord.cm-diffWord-${kind}`)].map((e) => e.textContent);
|
||||
expect(words(mv.a, "deleted").join("")).toContain("extra");
|
||||
expect(words(mv.b, "inserted").join("")).toContain("more");
|
||||
expect(words(mv.a, "inserted")).toEqual([]);
|
||||
expect(words(mv.b, "deleted")).toEqual([]);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("diffs changed blocks by words (ByWord)", () => {
|
||||
const pairs = (a: string, b: string) =>
|
||||
chunks(a, b, "none").flatMap((ch) =>
|
||||
ch.changes.map((c) => [
|
||||
a.slice(ch.fromA + c.fromA, ch.fromA + c.toA),
|
||||
b.slice(ch.fromB + c.fromB, ch.fromB + c.toB),
|
||||
]),
|
||||
);
|
||||
// An edited identifier is changed whole.
|
||||
expect(pairs(" syncScroll: boolean;", " syncScrolling: boolean;")).toEqual([["syncScroll", "syncScrolling"]]);
|
||||
// Punctuation is matched between matched words only: two changed words around ":" are one fragment.
|
||||
expect(pairs(" syncScroll: true,", " syncScrolling: false,")).toEqual([
|
||||
["syncScroll: true", "syncScrolling: false"],
|
||||
]);
|
||||
expect(mv.b.dom.querySelectorAll(".cm-changedLine")).toHaveLength(3);
|
||||
expect([...mv.b.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["added line"]);
|
||||
expect(mv.b.dom.querySelectorAll(".cm-changedLine:not(.cm-diffWholeLine) .cm-changedText")).toHaveLength(4);
|
||||
expect(pairs("f(a, b)", "f(a, x, b)")).toEqual([["", " x,"]]);
|
||||
// Each character of a script written without spaces is a word.
|
||||
expect(pairs("你好世界", "你们世界")).toEqual([["好", "们"]]);
|
||||
expect(pairs("a b", "a b")).toEqual([[" ", ""]]);
|
||||
// A 10,000-line block edited on every line still pairs up its words.
|
||||
const big = (kw: string) =>
|
||||
Array.from({ length: 10_000 }, (_, i) => `export ${kw} value${i} = compute(${i});\n`).join("");
|
||||
const changes = pairs(big("const"), big("let"));
|
||||
expect(changes).toHaveLength(10_000);
|
||||
expect(changes.every(([a, b]) => a === "const" && b === "let")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a line with only words added modified on both sides", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), diffWords];
|
||||
const mv = new MergeView({
|
||||
a: { doc: "f() {\n return a;\n}\n", extensions },
|
||||
b: { doc: "f() {\n return a + b;\n}\n", extensions },
|
||||
parent,
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect([...mv.a.dom.querySelectorAll(".cm-diffLine-modified")].map((e) => e.textContent)).toEqual([" return a;"]);
|
||||
expect([...mv.b.dom.querySelectorAll(".cm-diffLine-modified")].map((e) => e.textContent)).toEqual([
|
||||
" return a + b;",
|
||||
]);
|
||||
// IDEA keeps the space before an inserted word in the fragment.
|
||||
expect([...mv.b.dom.querySelectorAll(".cm-diffWord-inserted")].map((e) => e.textContent).join("")).toBe(" + b");
|
||||
// and marks where it would be on the other side
|
||||
expect(mv.a.dom.querySelectorAll(".cm-diffWordEmpty.cm-diffWord-inserted")).toHaveLength(1);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
@@ -175,47 +250,76 @@ describe("diff options", () => {
|
||||
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 },
|
||||
b: { doc: "keep\nlet x = 2;\nadded\nend\nnew\n", extensions },
|
||||
parent,
|
||||
gutter: false,
|
||||
diffConfig: { override: whitespaceDiff("none") },
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
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"]);
|
||||
expect(numbers(mv.a, "cm-diffGutter-modified")).toEqual(["2"]);
|
||||
expect(numbers(mv.b, "cm-diffGutter-modified")).toEqual(["2", "3"]);
|
||||
expect(numbers(mv.b, "cm-diffGutter-inserted")).toEqual(["5"]);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("marks a deleted last line as a whole line", () => {
|
||||
it("shows a final line break added as an inserted empty line, gutter included", () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), changedLineGutters];
|
||||
const mv = new MergeView({
|
||||
a: { doc: "keep\ngone", extensions: viewerBase({ readOnly: true }) },
|
||||
b: { doc: "keep", extensions: viewerBase({ readOnly: true }) },
|
||||
a: { doc: "alpha\nbeta", extensions },
|
||||
b: { doc: "alpha\nbeta\n", extensions },
|
||||
parent,
|
||||
diffConfig: { override: whitespaceDiff("none") },
|
||||
gutter: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
expect([...mv.a.dom.querySelectorAll(".cm-diffWholeLine")].map((e) => e.textContent)).toEqual(["gone"]);
|
||||
const lines = [...mv.b.contentDOM.querySelectorAll(".cm-line")].map((l) => l.classList.contains("cm-diffLine"));
|
||||
const gutters = [...mv.b.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")]
|
||||
.filter((e) => e.textContent)
|
||||
.map((e) => e.classList.contains("cm-diffChangedGutter"));
|
||||
// IDEA compares lines without their breaks: "beta" is unchanged, the empty last line inserted.
|
||||
expect(lines).toEqual([false, false, true]);
|
||||
expect(gutters).toEqual(lines);
|
||||
expect(mv.b.contentDOM.querySelectorAll(".cm-diffLine-inserted")).toHaveLength(1);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("keeps word highlighting for a single-line replacement", () => {
|
||||
it("shows a last line removed as a deleted line only", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const extensions = [viewerBase({ readOnly: true }), diffWords];
|
||||
const mv = new MergeView({
|
||||
a: { doc: "keep\ngone", extensions },
|
||||
b: { doc: "keep", extensions },
|
||||
parent,
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
await Promise.resolve();
|
||||
// "keep" loses its line break, which IDEA does not count as a change.
|
||||
expect([...mv.a.dom.querySelectorAll(".cm-diffLine")].map((e) => e.textContent)).toEqual(["gone"]);
|
||||
expect(mv.a.dom.querySelectorAll(".cm-diffLine-deleted")).toHaveLength(1);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
|
||||
it("keeps word highlighting for a single-line replacement", async () => {
|
||||
const parent = document.createElement("div");
|
||||
document.body.appendChild(parent);
|
||||
const mv = new MergeView({
|
||||
a: { doc: "before\nvalue = old\nafter\n", extensions: viewerBase({ readOnly: true }) },
|
||||
b: { doc: "before\nvalue = new\nafter\n", extensions: viewerBase({ readOnly: true }) },
|
||||
a: { doc: "before\nvalue = old\nafter\n", extensions: [viewerBase({ readOnly: true }), diffWords] },
|
||||
b: { doc: "before\nvalue = new\nafter\n", extensions: [viewerBase({ readOnly: true }), diffWords] },
|
||||
parent,
|
||||
diffConfig: { override: whitespaceDiff("none") },
|
||||
highlightChanges: false,
|
||||
diffConfig: ideaDiffConfig("none", "words"),
|
||||
});
|
||||
expect(mv.a.dom.querySelector(".cm-diffWholeLine")).toBeNull();
|
||||
expect(mv.b.dom.querySelector(".cm-diffWholeLine")).toBeNull();
|
||||
expect(mv.a.dom.querySelector(".cm-changedText")).not.toBeNull();
|
||||
expect(mv.b.dom.querySelector(".cm-changedText")).not.toBeNull();
|
||||
// The left pane highlights its words once the right one exists.
|
||||
await Promise.resolve();
|
||||
expect([...mv.a.dom.querySelectorAll(".cm-diffWord-modified")].map((e) => e.textContent)).toEqual(["old"]);
|
||||
expect([...mv.b.dom.querySelectorAll(".cm-diffWord-modified")].map((e) => e.textContent)).toEqual(["new"]);
|
||||
mv.destroy();
|
||||
parent.remove();
|
||||
});
|
||||
@@ -305,3 +409,31 @@ describe("compare entry points", () => {
|
||||
expect(diffInput(compareFilesData("/p/a", "/p/b")).key).toContain("file:/p/a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("oversized line comparison", () => {
|
||||
it("falls back to transferable chunks on initial comparison and either-side edits", () => {
|
||||
const a = doc("alpha\n".repeat(12000) + "beta\n".repeat(12000));
|
||||
const b = doc("beta\n".repeat(12000) + "alpha\n".repeat(12000));
|
||||
const config = ideaDiffConfig("none", "words");
|
||||
const built = Chunk.build(a, b, config);
|
||||
const editB = ChangeSet.of({ from: 0, insert: "header\n" }, b.length);
|
||||
const nextB = editB.apply(b);
|
||||
const updatedB = Chunk.updateB(built, a, nextB, editB, config);
|
||||
const editA = ChangeSet.of({ from: 0, insert: "other\n" }, a.length);
|
||||
const nextA = editA.apply(a);
|
||||
const updatedA = Chunk.updateA(built, nextA, b, editA, config);
|
||||
for (const [chunks, source, target] of [
|
||||
[built, a, b],
|
||||
[updatedB, a, nextB],
|
||||
[updatedA, nextA, b],
|
||||
] as const) {
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
let restored = target;
|
||||
for (const chunk of [...chunks].reverse()) {
|
||||
const edit = transferChunk(chunk, "a", source, restored);
|
||||
restored = restored.replace(edit.from, edit.to, doc(edit.insert));
|
||||
}
|
||||
expect(restored.toString()).toBe(source.toString());
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача