Files
openidea/e2e/diffRepo.ts
T
ad09a76212 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 <noreply@anthropic.com>

* fix(vcs): preserve unified revert boundaries and bound large diffs

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-10-29 19:59:57 +08:00

190 lines
7.1 KiB
TypeScript

/**
* A git repository of diff viewer cases: each file is committed, then changed
* in the working tree (or added / deleted), covering word edits, pure inserts
* and deletes, whitespace, CJK, missing final newlines, moves, a minified line
* and 10,000-line files. The e2e specs open HEAD against the working copy; run
* `node e2e/diffRepo.ts <dir>` to build it for manual QA in the app.
*/
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
export interface DiffCase {
path: string;
/** Committed content; null for a file added in the working tree. */
before: string | null;
/** Working-tree content; null for a deleted file. */
after: string | null;
}
const lines = (n: number, line: (i: number) => string) => Array.from({ length: n }, (_, i) => line(i) + "\n").join("");
/** A 10,000-line source file; `edit(i)` may replace line `i` (or return null to drop it). */
function bigFile(edit: (i: number, line: string) => string | string[] | null = (_, l) => l) {
const out: string[] = [];
for (let i = 0; i < 10_000; i++) {
const line = i % 50 === 0 ? `// section ${i / 50}` : `export const value${i} = compute(${i}, "item-${i}");`;
const e = edit(i, line);
if (e !== null) out.push(...(Array.isArray(e) ? e : [e]));
}
return out.map((l) => l + "\n").join("");
}
const OPTIONS_BEFORE = `export interface DiffViewOptions {
mode: DiffMode;
ignore: IgnorePolicy;
highlight: HighlightPolicy;
collapse: boolean;
syncScroll: boolean;
}
`;
const OPTIONS_AFTER = `export interface DiffViewOptions {
mode: DiffMode;
ignore: IgnorePolicy;
highlight: HighlightPolicy;
collapse: boolean;
/**
* IDEA's synchronized scrolling of the side-by-side panes: unpadded panes
* that carry each other along, with a curved divider. Off, the panes are
* padded so unchanged lines line up and scroll together.
*/
syncScrolling: boolean;
}
`;
const FUNCS = ["parse", "format", "validate", "render", "save"].map(
(name) => `function ${name}(input) {\n const result = ${name}Impl(input);\n return result;\n}\n`,
);
const MINIFIED = Array.from({ length: 2_000 }, (_, i) => `var a${i}=f(${i},"k${i}");`).join("");
export const CASES: readonly DiffCase[] = [
// A word edited and a comment added: one modified block (the reported case).
{ path: "src/options.ts", before: OPTIONS_BEFORE, after: OPTIONS_AFTER },
{
path: "src/words.ts",
before: "const flag = true;\ncall(a, b);\nconst keep = 1;\n",
after: "const flag = false;\ncall(a, x, b);\nconst keep = 1;\n",
},
{
path: "src/insert.ts",
before: lines(10, (i) => `line ${i}`),
after: lines(10, (i) => `line ${i}`).replace("line 5\n", "line 5\n" + lines(5, (i) => `added ${i}`)),
},
{
path: "src/delete.ts",
before: lines(12, (i) => `line ${i}`),
after: lines(12, (i) => `line ${i}`).replace(
lines(4, (i) => `line ${i + 4}`),
"",
),
},
{
path: "src/indent.py",
before: "def f():\n if x:\n return 1\n return 0\n",
after: "def f():\n if x:\n return 1\n return 0\n",
},
{ path: "docs/readme-zh.md", before: "# 说明\n\n这是一个测试文件。\n", after: "# 说明\n\n这是一个示例文件。\n" },
{ path: "docs/emoji.md", before: "Hello 👋 world\n", after: "Hello 🌍 world\n" },
{ path: "data/no-eol.txt", before: "alpha\nkeep\nbeta", after: "alpha\nkeep\ngamma" },
{ path: "data/add-eol.txt", before: "alpha\nbeta", after: "alpha\nbeta\n" },
{ path: "src/moved.ts", before: FUNCS.join("\n"), after: [...FUNCS.slice(1), FUNCS[0]!].join("\n") },
{ path: "src/new-file.ts", before: null, after: lines(6, (i) => `export const n${i} = ${i};`) },
{ path: "src/deleted-file.ts", before: lines(6, (i) => `export const d${i} = ${i};`), after: null },
{
path: "dist/app.min.js",
before: MINIFIED + "\n",
after: MINIFIED.replace('f(1000,"k1000")', 'g(1000,"k1000")') + "\n",
},
// 10,000 lines: 40 scattered word edits, a 200-line insert and a 100-line delete.
{
path: "big/scattered.ts",
before: bigFile(),
after: bigFile((i, l) =>
i === 5_000
? [l, ...Array.from({ length: 200 }, (_, k) => `export const inserted${k} = ${k};`)]
: i >= 8_000 && i < 8_100
? null
: i % 250 === 125
? l.replace("compute(", "computeFast(")
: l,
),
},
// 10,000 lines, every tenth one edited: 1,000 differences.
{
path: "big/every-tenth.ts",
before: bigFile(),
after: bigFile((i, l) => (i % 10 === 5 ? l.replace('"item-', '"entry-') : l)),
},
// 10,000 lines, every line edited: one 10,000-line modified block.
{
path: "big/rewrite.ts",
before: bigFile(),
after: bigFile((_, l) => l.replace("export const", "export let").replace("// section", "// part")),
},
];
const git = (cwd: string, ...args: string[]) =>
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 << 20, stdio: ["ignore", "pipe", "pipe"] });
/** Builds the repository in `dir` (created, or emptied first). */
export function buildDiffRepo(dir: string): string {
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
const write = (path: string, text: string) => {
mkdirSync(dirname(join(dir, path)), { recursive: true });
writeFileSync(join(dir, path), text);
};
git(dir, "init", "-q", "-b", "main");
git(dir, "config", "user.email", "e2e@openidea.dev");
git(dir, "config", "user.name", "e2e");
git(dir, "config", "core.autocrlf", "false");
for (const c of CASES) if (c.before !== null) write(c.path, c.before);
git(dir, "add", "-A");
git(dir, "commit", "-qm", "initial");
for (const c of CASES) {
if (c.after === null) rmSync(join(dir, c.path));
else write(c.path, c.after);
}
// Intent-to-add, so `git diff HEAD` lists the new files too.
const added = CASES.filter((c) => c.before === null).map((c) => c.path);
if (added.length) git(dir, "add", "-N", "--", ...added);
return dir;
}
export function tempDiffRepo(): string {
return buildDiffRepo(join(mkdtempSync(join(tmpdir(), "openidea-diff-")), "repo"));
}
/** HEAD and working-tree texts of `path` ("" when missing on that side). */
export function readSides(dir: string, path: string): { left: string; right: string } {
let left = "";
try {
left = git(dir, "show", `HEAD:${path}`);
} catch {
// added in the working tree
}
let right = "";
try {
right = readFileSync(join(dir, path), "utf8");
} catch {
// deleted in the working tree
}
return { left, right };
}
/** Hunks of `git diff -U0` for `path`, the reference count of line differences. */
export function gitHunks(dir: string, path: string): number {
const out = git(dir, "diff", "-U0", "--no-color", "--diff-algorithm=patience", "HEAD", "--", path);
return out.split("\n").filter((l) => l.startsWith("@@")).length;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const dir = resolve(process.argv[2] ?? "diff-repo");
buildDiffRepo(dir);
console.log(`Built ${CASES.length} diff cases in ${dir}`);
console.log(git(dir, "status", "--short"));
}