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>
This commit is contained in:
Jaw
2026-10-29 19:59:57 +08:00
committed by GitHub
co-authored by Claude Opus 5.5 l0ng-ai
parent 8568a70a05
commit ad09a76212
25 changed files with 5729 additions and 315 deletions
+13
View File
@@ -28,6 +28,19 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
## JetBrains IntelliJ Platform diff algorithm
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
[intellij-community](https://github.com/JetBrains/intellij-community) (Apache-2.0):
`platform/util/diff` (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
PatienceIntLCS, UniqueLCS; `com.intellij.diff.comparison`: ByLineRt, ByWordRt,
ByCharRt, ChunkOptimizer, ChangeCorrector, LineFragmentSplitter, TrimUtil,
ComparisonUtil, iterables) and `platform/diff-impl` (ComparisonManagerImpl's
`compareLinesInner` and `processBlocks`). `__tests__/ideaCases.json` is converted
from its tests (`LineComparisonUtilTest`, `WordComparisonUtilTest`,
`SplitComparisonUtilTest`). Modified from the originals. Copyright 2000-2026
JetBrains s.r.o. and contributors. Apache License 2.0 (below).
`src/ui/openidea/**` holds OpenIDEA's own file-type icons drawn in the expui style for
languages whose JetBrains icons are not open source (Go, Rust, Ruby, Dart, Lua, Zig,
Svelte, …). Those using the page-with-corner-badge layout (`goMod`, `goWork`,
+4 -1
View File
@@ -295,7 +295,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
219 themeable tokens: 113 in the UI theme file, 106 in the editor colour scheme file.
222 themeable tokens: 116 in the UI theme file, 106 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -374,6 +374,9 @@ a token.
| `--gutter-modified` | `#375fad` | `#88adf7` |
| `--gutter-added` | `#549159` | `#7fc784` |
| `--gutter-deleted` | `#868a91` | `#767a8a` |
| `--diff-modified` | `#385570` | `#c2d8f2` |
| `--diff-inserted` | `#294436` | `#bee6be` |
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
### Syntax (editor scheme)
+336
View File
@@ -0,0 +1,336 @@
/**
* The diff viewer over a real git repository (`diffRepo.ts`): each case opens
* HEAD against the working copy and checks the IDEA-style change types of
* lines and words, the difference count against `git diff`, and that the
* 10,000-line files open, scroll and navigate in every viewer mode.
*/
import { rmSync } from "node:fs";
import { dirname } from "node:path";
import type { Page } from "@playwright/test";
import { gitHunks, readSides, tempDiffRepo } from "./diffRepo";
import { expect, test } from "./fixtures";
type Kind = "modified" | "inserted" | "deleted";
interface Opts {
mode?: "split" | "unified";
/** Padded panes (true), or panes joined by a curved divider (false, the default). */
alignChanges?: boolean;
syncScroll?: boolean;
collapse?: boolean;
ignore?: "none" | "trim" | "whitespace" | "whitespaceAndLines";
}
let repo = "";
test.beforeAll(() => {
repo = tempDiffRepo();
});
test.afterAll(() => rmSync(dirname(repo), { recursive: true, force: true }));
const toolbar = (page: Page) => page.getByRole("toolbar", { name: "Diff" });
const counter = (page: Page) =>
toolbar(page)
.locator("span")
.filter({ hasText: /differen|identical/ });
/** Opens HEAD ↔ working tree of `path` and waits until the difference count shows; returns the time it took. */
async function openCase(page: Page, path: string, opts: Opts = {}): Promise<number> {
const { left, right } = readSides(repo, path);
const options = {
mode: "split",
ignore: "none",
highlight: "words",
collapse: false,
alignChanges: true,
syncScroll: true,
...opts,
};
const started = Date.now();
await page.evaluate(
async ({ path, left, right, options }) => {
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify(options));
const actions = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actions);
await executeAction("diff.compareTexts", {
path,
title: path,
left: { text: left, label: "HEAD" },
right: { text: right, label: "Working tree" },
});
},
{ path, left, right, options },
);
await expect(counter(page)).toBeVisible({ timeout: 20_000 });
const elapsed = Date.now() - started;
// Word highlights land a microtask after both panes exist.
await page.evaluate(() => new Promise(requestAnimationFrame));
return elapsed;
}
interface Side {
lines: Kind[];
words: [string, Kind][];
}
/** Change types of the rendered lines and words of each pane (`a` / `b`; unified: `b` only). */
async function rendered(page: Page): Promise<{ a: Side; b: Side; deletedChunks: number }> {
return page
.locator(".cm-mergeView, .cm-editor.cm-merge-b")
.first()
.evaluate((el) => {
const root = el.closest("[role=region]") ?? el;
const kind = (e: Element, prefix: string) =>
(e.className.match(new RegExp(`${prefix}-(modified|inserted|deleted)`))?.[1] ?? null) as Kind | null;
const side = (cls: string) => {
const ed = root.querySelector(`.cm-editor.cm-merge-${cls}`);
const lines = [...(ed?.querySelectorAll(".cm-content > .cm-line") ?? [])]
.map((l) => kind(l, "cm-diffLine"))
.filter((k): k is Kind => !!k);
const words = [...(ed?.querySelectorAll(".cm-diffWord") ?? [])].map(
(w) => [w.textContent ?? "", kind(w, "cm-diffWord")!] as [string, Kind],
);
return { lines, words };
};
return { a: side("a"), b: side("b"), deletedChunks: root.querySelectorAll(".cm-deletedChunk").length };
});
}
const differences = (n: number) => (n === 1 ? "1 difference" : `${n} differences`);
async function expectCount(page: Page, path: string, n = gitHunks(repo, path)) {
await expect(counter(page)).toHaveText(new RegExp(`^(${differences(n)}|\\d+ of ${n} differences)$`));
}
const all = (n: number, k: Kind) => Array<Kind>(n).fill(k);
async function shot(page: Page, name: string) {
const path = test.info().outputPath(`${name}.png`);
await page.getByRole("region").last().screenshot({ path, animations: "disabled" });
await test.info().attach(name, { path, contentType: "image/png" });
}
test.describe("small cases (side-by-side, Highlight words)", () => {
const cases: {
path: string;
a: Kind[];
b: Kind[];
wordsA?: [string, Kind][];
wordsB?: [string, Kind][];
}[] = [
{
path: "src/words.ts",
a: all(2, "modified"),
b: all(2, "modified"),
wordsA: [["true", "modified"]],
wordsB: [
["false", "modified"],
[" x,", "inserted"],
],
},
{ path: "src/insert.ts", a: [], b: all(5, "inserted"), wordsA: [], wordsB: [] },
{ path: "src/delete.ts", a: all(4, "deleted"), b: [], wordsA: [], wordsB: [] },
{
path: "docs/readme-zh.md",
a: ["modified"],
b: ["modified"],
wordsA: [["测试", "modified"]],
wordsB: [["示例", "modified"]],
},
{
path: "docs/emoji.md",
a: ["modified"],
b: ["modified"],
wordsA: [["👋", "modified"]],
wordsB: [["🌍", "modified"]],
},
{
path: "data/no-eol.txt",
a: ["modified"],
b: ["modified"],
wordsA: [["beta", "modified"]],
wordsB: [["gamma", "modified"]],
},
{ path: "src/indent.py", a: all(3, "modified"), b: all(3, "modified") },
{ path: "src/moved.ts", a: all(5, "deleted"), b: all(5, "inserted"), wordsA: [], wordsB: [] },
{ path: "src/new-file.ts", a: [], b: all(6, "inserted"), wordsA: [], wordsB: [] },
{ path: "src/deleted-file.ts", a: all(6, "deleted"), b: [], wordsA: [], wordsB: [] },
];
for (const c of cases)
test(c.path, async ({ workbench: page }) => {
await openCase(page, c.path);
await expectCount(page, c.path);
const { a, b } = await rendered(page);
expect(a.lines, "left lines").toEqual(c.a);
expect(b.lines, "right lines").toEqual(c.b);
if (c.wordsA) expect(a.words, "left words").toEqual(c.wordsA);
if (c.wordsB) expect(b.words, "right words").toEqual(c.wordsB);
});
test("a renamed field with a comment added reads as one modified block", async ({ workbench: page }) => {
await openCase(page, "src/options.ts");
await expectCount(page, "src/options.ts", 1);
const { a, b } = await rendered(page);
expect(a.lines).toEqual(["modified"]);
expect(b.lines).toEqual(all(6, "modified"));
expect(a.words).toEqual([["syncScroll", "modified"]]);
expect(b.words.every(([, k]) => k === "modified")).toBe(true);
const text = b.words.map(([t]) => t).join("\n");
expect(text).toMatch(/^\/\*\*\n/);
expect(text).toMatch(/syncScrolling$/);
await shot(page, "options");
});
test("an edit in the middle of a 44,000-character minified line", async ({ workbench: page }) => {
const path = "dist/app.min.js";
await openCase(page, path);
await expectCount(page, path);
// Long lines render only around the horizontal viewport: scroll to the edit.
const { left } = readSides(repo, path);
const at = left.indexOf("f(1000,") / left.length;
for (const side of ["a", "b"])
await page
.locator(`.cm-editor.cm-merge-${side} .cm-scroller`)
.evaluate((s, at) => (s.scrollLeft = at * s.scrollWidth - s.clientWidth / 2), at);
await expect.poll(async () => (await rendered(page)).a.words).toEqual([["f", "modified"]]);
const { a, b } = await rendered(page);
expect(a.lines).toEqual(["modified"]);
expect(b.lines).toEqual(["modified"]);
expect(b.words).toEqual([["g", "modified"]]);
await shot(page, "minified");
});
test("a final newline added", async ({ workbench: page }) => {
await openCase(page, "data/add-eol.txt");
await expectCount(page, "data/add-eol.txt");
const { a, b } = await rendered(page);
// IDEA compares lines without their breaks: an empty line inserted, "beta" unchanged.
expect(a.lines).toEqual([]);
expect(b.lines).toEqual(["inserted"]);
await shot(page, "add-eol");
});
test("ignoring whitespace hides an indentation-only change", async ({ workbench: page }) => {
await openCase(page, "src/indent.py", { ignore: "whitespace" });
await expect(counter(page)).toHaveText("No differences");
expect((await rendered(page)).b.lines).toEqual([]);
});
test("unified viewer shows old lines deleted and new lines inserted", async ({ workbench: page }) => {
await openCase(page, "src/options.ts", { mode: "unified" });
await expectCount(page, "src/options.ts", 1);
const { b, deletedChunks } = await rendered(page);
expect(deletedChunks).toBe(1);
expect(b.lines).toEqual(all(6, "inserted"));
await shot(page, "options-unified");
});
});
test.describe("10,000-line files", () => {
test.describe.configure({ timeout: 60_000 });
const modes: [string, Opts][] = [
["padded", {}],
["sync scrolling", { alignChanges: false }],
["unified", { mode: "unified" }],
["collapsed", { collapse: true }],
];
/** Scrolls the diff to the end: the padded panes scroll together, the others from the right (or unified) pane. */
async function scrollToEnd(page: Page) {
await page
.locator(".cm-editor.cm-merge-b")
.first()
.evaluate((ed) => {
const merge = ed.closest<HTMLElement>(".cm-mergeView");
const s =
merge && merge.scrollHeight > merge.clientHeight ? merge : ed.querySelector<HTMLElement>(".cm-scroller")!;
s.scrollTop = s.scrollHeight;
});
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
}
/** Text of the first line fully shown in each pane. */
async function topLines(page: Page) {
return page
.locator(".cm-mergeView")
.first()
.evaluate((mv) => {
const top = (side: string) => {
const ed = mv.querySelector(`.cm-editor.cm-merge-${side}`)!;
const r = mv.getBoundingClientRect();
const s = ed.querySelector(".cm-scroller")!.getBoundingClientRect();
const y = Math.max(r.top, s.top);
return [...ed.querySelectorAll(".cm-content > .cm-line")].find((l) => l.getBoundingClientRect().top >= y)
?.textContent;
};
return [top("a"), top("b")];
});
}
for (const [mode, opts] of modes) {
test(`scattered edits, inserts and deletes (${mode})`, async ({ workbench: page }) => {
const path = "big/scattered.ts";
const ms = await openCase(page, path, opts);
test.info().annotations.push({ type: "open", description: `${ms} ms` });
expect(ms, "time to open").toBeLessThan(8_000);
await expectCount(page, path, 42);
// The first change is a single edited identifier.
await page.locator(".cm-editor.cm-merge-b .cm-content").first().click();
await page.keyboard.press("Meta+ArrowUp");
await page.keyboard.press("F7");
await expect(counter(page)).toHaveText("1 of 42 differences");
const first = await rendered(page);
if (opts.mode !== "unified") {
expect(first.a.words).toContainEqual(["compute", "modified"]);
expect(first.b.words).toContainEqual(["computeFast", "modified"]);
}
// The last difference is reachable and still highlighted.
await page.keyboard.press("Meta+ArrowDown");
await page.keyboard.press("Shift+F7");
await expect(counter(page)).toHaveText("42 of 42 differences");
const last = await rendered(page);
if (opts.mode === "unified") expect(last.b.lines).toContain("inserted");
else expect(last.b.words).toContainEqual(["computeFast", "modified"]);
await scrollToEnd(page);
if (opts.mode !== "unified") {
const [a, b] = await topLines(page);
expect(a, "panes line up at the end").toBe(b);
// Collapsed, the unchanged tail folds away.
if (!opts.collapse) expect(b).toMatch(/value99\d\d/);
}
await shot(page, `scattered-${mode.replace(" ", "-")}`);
});
}
test("every tenth line edited: 1,000 differences", async ({ workbench: page }) => {
const path = "big/every-tenth.ts";
const ms = await openCase(page, path, { alignChanges: false });
test.info().annotations.push({ type: "open", description: `${ms} ms` });
expect(ms, "time to open").toBeLessThan(8_000);
await expectCount(page, path, 1_000);
await scrollToEnd(page);
const { a, b } = await rendered(page);
expect(b.words.length).toBeGreaterThan(0);
expect(b.words.every(([t, k]) => t === "entry" && k === "modified")).toBe(true);
expect(a.words.every(([t, k]) => t === "item" && k === "modified")).toBe(true);
await shot(page, "every-tenth");
});
for (const [mode, opts] of modes.slice(0, 3))
test(`every line edited: one 10,000-line block (${mode})`, async ({ workbench: page }) => {
const path = "big/rewrite.ts";
const ms = await openCase(page, path, opts);
test.info().annotations.push({ type: "open", description: `${ms} ms` });
expect(ms, "time to open").toBeLessThan(8_000);
await expectCount(page, path, 1);
await scrollToEnd(page);
const { b } = await rendered(page);
if (opts.mode === "unified") expect(b.lines.every((k) => k === "inserted")).toBe(true);
else {
expect(b.lines.length).toBeGreaterThan(10);
expect(b.lines.every((k) => k === "modified")).toBe(true);
expect(b.words).toContainEqual(["let", "modified"]);
}
await shot(page, `rewrite-${mode.replace(" ", "-")}`);
});
});
+189
View File
@@ -0,0 +1,189 @@
/**
* 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"));
}
+3
View File
@@ -111,6 +111,9 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
["FOREGROUND", "--fg-editor"],
["BACKGROUND", "--bg-editor"],
],
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
+12 -16
View File
@@ -179,7 +179,9 @@
}
.noHighlight :global(.cm-changedLine),
.noHighlight :global(.cm-insertedLine),
.noHighlight :global(.cm-changedText) {
.noHighlight :global(.cm-changedText),
.noHighlight :global(.cm-diffLine),
.noHighlight :global(.cm-diffChangedGutter) {
background: none !important;
}
/* Side-by-side without alignment (IDEA's default): each pane scrolls itself. */
@@ -216,17 +218,17 @@
position: absolute;
inset: 0;
}
/* Opaque mixes of the line backgrounds (cm.ts), so bands and their midlines read as one shape. */
/* The full colours of the change types (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));
--band: var(--diff-inserted);
}
.host :global(.cm-diffBand-deleted) {
--band: color-mix(in srgb, var(--danger) 26%, var(--bg-app));
--band: var(--diff-deleted);
}
.host :global(.cm-diffBand-modified) {
--band: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app));
--band: var(--diff-modified);
}
.host :global(.cm-diffBand:not(.cm-diffBand-modified)) {
.host :global(.cm-diffBand) {
fill: var(--band);
}
.host :global(.cm-diffBandLine) {
@@ -234,12 +236,6 @@
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;
@@ -266,16 +262,16 @@
}
/* Where the other pane has lines this one lacks. */
.host :global(.cm-diffGap-inserted) {
box-shadow: inset 0 1px 0 var(--success);
box-shadow: inset 0 1px 0 var(--gutter-added);
}
.host :global(.cm-diffGap-deleted) {
box-shadow: inset 0 1px 0 var(--danger);
box-shadow: inset 0 1px 0 var(--gutter-deleted);
}
.host :global(.cm-diffGap-inserted.cm-diffGapEnd) {
box-shadow: inset 0 -1px 0 var(--success);
box-shadow: inset 0 -1px 0 var(--gutter-added);
}
.host :global(.cm-diffGap-deleted.cm-diffGapEnd) {
box-shadow: inset 0 -1px 0 var(--danger);
box-shadow: inset 0 -1px 0 var(--gutter-deleted);
}
/* Review comments under diff lines (pull requests). */
+19 -20
View File
@@ -5,15 +5,7 @@ import clsx from "clsx";
import { EditorView, keymap } from "@codemirror/view";
import { Prec, Text, type Extension } from "@codemirror/state";
import { history, historyKeymap } from "@codemirror/commands";
import {
MergeView,
getChunks,
goToNextChunk,
goToPreviousChunk,
rejectChunk,
unifiedMergeView,
Chunk,
} from "@codemirror/merge";
import { MergeView, getChunks, goToNextChunk, goToPreviousChunk, unifiedMergeView, Chunk } from "@codemirror/merge";
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setFindOpen } from "@/editor/core/cm/find";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
@@ -23,14 +15,14 @@ import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platfo
import { tx } from "@/platform/i18n";
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { languageFor, viewerBase } from "./cm";
import { applyChunk, arrowControls, chunkAt, hoverRevert } from "./diffControls";
import { changedLineGutters, diffWords, languageFor, viewerBase } from "./cm";
import { applyChunk, arrowControls, chunkAt, hoverRevert, revertUnifiedChunk } from "./diffControls";
import {
HIGHLIGHT_OPTIONS,
IGNORE_OPTIONS,
ideaDiffConfig,
loadDiffOptions,
saveDiffOptions,
whitespaceDiff,
type DiffViewOptions,
type HighlightPolicy,
type IgnorePolicy,
@@ -259,8 +251,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
},
});
const collapseUnchanged = opts.collapse ? { margin: 3, minSize: 6 } : undefined;
const override = whitespaceDiff(opts.ignore);
const diffConfig = { override };
const diffConfig = ideaDiffConfig(opts.ignore, opts.highlight);
const highlightChanges = opts.highlight === "words";
const inclusion = includable ? inclusionGutter(path, textA) : [];
const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [
@@ -289,11 +280,14 @@ export function DiffEditor({ input }: { input: EditorInput }) {
? { toA: editableA, toB: editableB, apply: (i, source) => merge && applyChunk(merge, i, source) }
: undefined,
});
// IDEA's colouring: lines by chunk type, carried into the gutters, and typed word highlights.
const colours = [changedLineGutters, highlightChanges ? diffWords : []];
const mv = new MergeView({
a: {
doc: textA,
extensions: [
side(attached.a, editableA, "left"),
colours,
panes?.extension ?? [],
focusTracker("a"),
findWatch("a"),
@@ -304,6 +298,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
doc: textB,
extensions: [
side(attached.b, editableB, "right"),
colours,
panes?.extension ?? [],
listener,
focusTracker("b"),
@@ -319,9 +314,9 @@ export function DiffEditor({ input }: { input: EditorInput }) {
!panes && (editableA || editableB)
? arrowControls(() => merge, { toA: editableA, toB: editableB })
: undefined,
highlightChanges,
// Unpadded panes tint the gutters of changed lines instead of a change bar.
gutter: !panes,
// Words are highlighted by type (`diffWords`), and gutters tinted instead of a change bar.
highlightChanges: false,
gutter: false,
collapseUnchanged,
diffConfig,
});
@@ -353,13 +348,14 @@ export function DiffEditor({ input }: { input: EditorInput }) {
focusTracker("b"),
findWatch("b"),
inclusion,
highlightChanges ? diffWords : [],
unifiedMergeView({
original: textA,
highlightChanges,
gutter: true,
syntaxHighlightDeletions: true,
mergeControls: editableB
? (type, action) => {
? (type) => {
const btn = document.createElement("button");
if (type === "accept") {
btn.hidden = true;
@@ -367,7 +363,10 @@ export function DiffEditor({ input }: { input: EditorInput }) {
}
btn.textContent = "Revert";
btn.title = "Revert this change";
btn.onmousedown = action;
btn.onmousedown = (event) => {
event.preventDefault();
revertUnifiedChunk(view, view.posAtDOM(btn));
};
return btn;
}
: false,
@@ -536,7 +535,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
const cur = currentChunk();
if (!views || !cur) return;
if (views.merge) applyChunk(views.merge, cur.index, "a");
else rejectChunk(views.b, cur.chunk.fromB);
else revertUnifiedChunk(views.b, cur.chunk.fromB);
};
const stageChunk = async () => {
+181 -49
View File
@@ -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);
});
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { EditorInput } from "@/workbench/contributions";
import { Chunk } from "@codemirror/merge";
import { Text } from "@codemirror/state";
@@ -29,6 +29,19 @@ vi.mock("@/workbench/contributions", async (importOriginal) => ({
editorService: () => ({ openFile }),
}));
vi.mock("@/editor/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/api")>()),
attachDocument: (path: string) =>
Promise.resolve({
path,
text: () => working,
readOnly: false,
extension: [],
save: () => Promise.resolve(),
release: () => {},
}),
}));
import { DiffEditor, mapLine } from "../DiffEditor";
/** Git Log opens a commit's file: both sides are revisions. */
@@ -94,7 +107,7 @@ describe("Diff toolbar", () => {
// 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");
expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffGutter-inserted")?.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();
@@ -104,9 +117,9 @@ describe("Diff toolbar", () => {
const item = await screen.findByRole("menuitemcheckbox", { name: "Align Changes" });
expect(item.getAttribute("aria-checked")).toBe("false");
item.click();
// Padded panes with a change bar.
// Padded panes, the changed lines' gutters still tinted as in IDEA.
await waitFor(() => expect(document.querySelector(".cm-diffDivider")).toBeNull());
expect(document.querySelector(".cm-changeGutter")).not.toBeNull();
expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffGutter-inserted")?.textContent).toBe("2");
expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!)).toMatchObject({
alignChanges: true,
syncScroll: false,
@@ -178,3 +191,48 @@ describe("Jump to Source of a revision diff", () => {
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
});
});
describe("Unified Revert", () => {
for (const control of ["toolbar", "chunk"] as const) {
it.each([
["a\nb", "a"],
["same", "same\n"],
["same\n", "same"],
["", "added"],
["deleted", ""],
])(`${control} restores %j from %j`, async (original, changed) => {
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode: "unified", collapse: false }));
texts.HEAD = original;
working = changed;
const mounted = render(
<DiffEditor
input={
{
...input,
data: {
path: "/p/a.ts",
left: { type: "rev", rev: "HEAD" },
right: { type: "worktree" },
},
} as EditorInput
}
/>,
);
try {
await waitFor(() => expect(document.querySelectorAll(".cm-editor")).toHaveLength(1));
const view = EditorView.findFromDOM(document.querySelector<HTMLElement>(".cm-editor")!)!;
if (control === "toolbar") {
const button = await screen.findByRole("button", { name: "Revert Change" });
await waitFor(() => expect(button).not.toBeDisabled());
fireEvent.click(button);
} else {
fireEvent.mouseDown(await screen.findByRole("button", { name: /^Revert$/ }));
}
await waitFor(() => expect(view.state.doc.toString()).toBe(original));
} finally {
mounted.unmount();
localStorage.clear();
}
});
}
});
+158 -76
View File
@@ -22,10 +22,11 @@ import {
highlightActiveLine,
keymap,
lineNumbers,
WidgetType,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { getChunks } from "@codemirror/merge";
import { getChunks, mergeViewSiblings, type Chunk } from "@codemirror/merge";
import { defaultKeymap } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { tags as t } from "@lezer/highlight";
@@ -35,16 +36,58 @@ import { fontExtension } from "@/editor/core/cm/setup";
import { caretStyles, findLocals, findStyles } from "@/editor/core/cm/theme";
import { editorPrefs } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
import { hasInnerFragments } from "./diffOptions";
const wholeLine = Decoration.line({ class: "cm-diffWholeLine" });
/** IDEA's change types (TextDiffType): both sides have text, only the new side, only the old side. */
export type DiffKind = "modified" | "inserted" | "deleted";
const kindOf = (hasA: boolean, hasB: boolean): DiffKind => (hasA && hasB ? "modified" : hasA ? "deleted" : "inserted");
/** A chunk's type (DiffUtil.getLineDiffType), by the sides it has lines on. */
export const chunkKind = (chunk: Chunk): DiffKind => kindOf(chunk.fromA !== chunk.toA, chunk.fromB !== chunk.toB);
const KINDS: readonly DiffKind[] = ["modified", "inserted", "deleted"];
const byKind = <T>(make: (kind: DiffKind) => T) =>
Object.fromEntries(KINDS.map((k) => [k, make(k)])) as Record<DiffKind, T>;
/** Line backgrounds by type; `fine` ones (words compared) are lighter. */
const lineDecos = byKind((kind) => ({
coarse: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind}` }),
fine: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind} cm-diffLine-fine` }),
}));
const wordDecos = byKind((kind) => Decoration.mark({ class: `cm-diffWord cm-diffWord-${kind}` }));
/** A word on the other side only: a thin bar where it would be (DiffEmptyHighlighterRenderer). */
class EmptyWordMarker extends WidgetType {
constructor(readonly kind: DiffKind) {
super();
}
eq(other: EmptyWordMarker) {
return other.kind === this.kind;
}
toDOM() {
const span = document.createElement("span");
span.className = `cm-diffWordEmpty cm-diffWord-${this.kind}`;
span.setAttribute("aria-hidden", "true");
return span;
}
}
const emptyWordDecos = byKind((kind) => Decoration.widget({ widget: new EmptyWordMarker(kind), side: 1 }));
/**
* 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.
* `vpTo`, with the type of their chunk (DiffUtil.getLineDiffType): a chunk
* with lines on both sides is modified as a whole, however its words changed.
*/
function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (line: Line, whole: boolean) => void) {
function forChangedLines(
state: EditorState,
vpFrom: number,
vpTo: number,
f: (line: Line, kind: DiffKind, fine: boolean) => void,
) {
const info = getChunks(state);
if (!info || !info.side) return;
const isA = info.side === "a";
@@ -52,38 +95,70 @@ function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (l
for (const chunk of info.chunks) {
const from = isA ? chunk.fromA : chunk.fromB;
const to = isA ? chunk.toA : chunk.toB;
if (from >= vpTo) break;
if (from > vpTo) break;
if (from === to || to < vpFrom) continue;
const otherEmpty = isA ? chunk.fromB === chunk.toB : chunk.fromA === chunk.toA;
const ranges = chunk.changes.map((c) => (isA ? [c.fromA, c.toA] : [c.fromB, c.toB]).map((p) => p + from));
// Covered including the line break after it, or the one before it (a deleted last line).
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);) {
f(line, otherEmpty || covered(line));
const kind = chunkKind(chunk);
const fine = hasInnerFragments(chunk);
// A line starting at `vpTo` is still in view (the empty last line).
for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < to && line.from <= vpTo;) {
f(line, kind, fine);
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 {
/**
* Line backgrounds in the colour of their chunk's type
* (DiffDrawUtil.LineHighlighterBuilder). The unified view shows every new
* line as inserted, below the old ones as deleted (createUnifiedChunkHighlighters).
*/
function lineDeco(view: EditorView): DecorationSet {
const unified = !mergeViewSiblings(view);
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);
forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, kind, fine) => {
const decos = lineDecos[unified ? "inserted" : kind];
builder.add(line.from, line.from, fine ? decos.fine : decos.coarse);
});
return builder.finish();
}
/**
* The inner fragments of chunks whose words were compared, each in the colour
* of its own type (SimpleDiffChangeUi.createInlineHighlighter); a fragment
* empty on this side is a thin bar.
*/
function wordDeco(view: EditorView): DecorationSet {
const info = getChunks(view.state);
// The unified view highlights words itself (`highlightChanges`).
if (!info || !info.side || !mergeViewSiblings(view)) return Decoration.none;
const isA = info.side === "a";
const { from: vpFrom, to: vpTo } = view.viewport;
const builder = new RangeSetBuilder<Decoration>();
for (const chunk of info.chunks) {
const base = isA ? chunk.fromA : chunk.fromB;
if (base > vpTo) break;
if ((isA ? chunk.toA : chunk.toB) < vpFrom || !hasInnerFragments(chunk)) continue;
for (const c of chunk.changes) {
const [from, to] = isA ? [c.fromA, c.toA] : [c.fromB, c.toB];
const kind = kindOf(c.fromA !== c.toA, c.fromB !== c.toB);
if (from < to) builder.add(base + from, base + to, wordDecos[kind]);
else builder.add(base + from, base + from, emptyWordDecos[kind]);
}
}
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");
const gutterMarkers = byKind((kind) => ({
coarse: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind}`),
fine: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind} cm-diffGutter-fine`),
}));
/** The line background of changed lines carried into the gutters (line numbers, inclusion boxes), as in IDEA. */
export const changedLineGutters = StateField.define<RangeSet<GutterMarker>>({
@@ -97,29 +172,42 @@ export const changedLineGutters = StateField.define<RangeSet<GutterMarker>>({
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),
forChangedLines(state, 0, state.doc.length, (line, kind, fine) =>
builder.add(line.from, line.from, fine ? gutterMarkers[kind].fine : gutterMarkers[kind].coarse),
);
return builder.finish();
}
const wholeChangedLines = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = wholeLineDeco(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks
)
this.decorations = wholeLineDeco(update.view);
}
},
{ decorations: (value) => value.decorations },
);
/** Recomputes `build` when the document, viewport or chunks change. */
const chunkDecorations = (build: (view: EditorView) => DecorationSet) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks
)
this.decorations = build(update.view);
}
},
{ decorations: (value) => value.decorations },
);
const changedLines = chunkDecorations(lineDeco);
/**
* IDEA's "Highlight words" for a side-by-side pane: the inner fragments of
* chunks whose words were compared, over their lighter line background.
*/
export const diffWords: Extension = [
chunkDecorations(wordDeco),
EditorView.editorAttributes.of({ class: "cm-diffWords" }),
];
export const viewerHighlight = HighlightStyle.define([
{
@@ -170,44 +258,38 @@ export const viewerTheme = EditorView.theme(
},
".cm-lineNumbers .cm-gutterElement": { padding: "0 8px 0 12px", minWidth: "32px" },
".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--fg-secondary)" },
// @codemirror/merge
"&.cm-merge-a .cm-changedLine, .cm-deletedChunk": {
backgroundColor: "color-mix(in srgb, var(--gutter-deleted) 12%, transparent)",
// Side-by-side panes colour lines by their chunk's type (changedLines) instead of the library's tint.
"&.cm-merge-a .cm-changedLine, &.cm-merge-b .cm-changedLine": { backgroundColor: "transparent" },
".cm-line.cm-diffLine-modified, .cm-diffGutter-modified": { backgroundColor: "var(--diff-modified)" },
".cm-line.cm-diffLine-inserted, .cm-diffGutter-inserted": { backgroundColor: "var(--diff-inserted)" },
".cm-line.cm-diffLine-deleted, .cm-diffGutter-deleted": { backgroundColor: "var(--diff-deleted)" },
// Chunks whose words were compared take the lighter colour (TextDiffType.getIgnoredColor), their fragments the full one.
...Object.fromEntries(
KINDS.map((k) => [
`.cm-line.cm-diffLine-fine.cm-diffLine-${k}, .cm-diffGutter-fine.cm-diffGutter-${k}`,
{ backgroundColor: `color-mix(in srgb, var(--diff-${k}) 40%, var(--bg-app))` },
]),
),
// Full line height, as IDEA paints them: an inline background only covers the font's height,
// leaving stripes between the lines of a multi-line fragment.
".cm-diffWord": { display: "inline-block" },
".cm-diffWord-modified": { backgroundColor: "var(--diff-modified)" },
".cm-diffWord-inserted": { backgroundColor: "var(--diff-inserted)" },
".cm-diffWord-deleted": { backgroundColor: "var(--diff-deleted)" },
".cm-diffWordEmpty": {
display: "inline-block",
width: "2px",
height: "1lh",
margin: "0 -1px",
verticalAlign: "top",
},
"&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": {
backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)",
},
"&.cm-merge-a .cm-changedLine.cm-diffWholeLine": {
backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)",
},
"&.cm-merge-b .cm-changedLine.cm-diffWholeLine": {
backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)",
},
"&.cm-merge-a .cm-diffWholeLine .cm-changedText, &.cm-merge-b .cm-diffWholeLine .cm-changedText": {
background: "transparent",
},
"&.cm-merge-a .cm-changedText, .cm-deletedChunk .cm-deletedText": {
background: "color-mix(in srgb, var(--danger) 26%, transparent)",
},
"&.cm-merge-b .cm-changedText": {
background: "color-mix(in srgb, var(--success) 26%, transparent)",
},
"&.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)",
// The unified view: old lines deleted, new lines inserted.
".cm-deletedChunk": { backgroundColor: "var(--diff-deleted)" },
"&.cm-diffWords .cm-deletedChunk": {
backgroundColor: "color-mix(in srgb, var(--diff-deleted) 40%, var(--bg-app))",
},
".cm-deletedChunk .cm-deletedText": { background: "var(--diff-deleted)" },
"&.cm-merge-b .cm-changedText": { background: "var(--diff-inserted)" },
".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)" },
@@ -286,7 +368,7 @@ export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean; fin
: [search({ top: true }), keymap.of([...searchKeymap, ...defaultKeymap])],
syntaxHighlighting(viewerHighlight),
viewerTheme,
wholeChangedLines,
changedLines,
EditorState.readOnly.of(opts.readOnly),
EditorView.editable.of(!opts.readOnly),
// Non-editable revision panes still need keyboard selection and copy.
@@ -0,0 +1,133 @@
/**
* IntelliJ's own comparison tests (LineComparisonUtilTest,
* WordComparisonUtilTest, SplitComparisonUtilTest; Apache-2.0, Copyright
* JetBrains s.r.o. and contributors), converted from their Kotlin DSL into
* `ideaCases.json`, run against the port.
*/
import { describe, expect, it } from "vitest";
import {
compareLineFragments,
compareLinesInner,
compareWords,
processBlocks,
type ComparisonPolicy,
type LineFragment,
type Range,
} from "..";
import cases from "./ideaCases.json";
type Policy = "default" | "trim" | "ignore";
type Change = [number, number, number, number];
interface Case {
source: string;
test: string;
type: "lines" | "lines_inner" | "words" | "splitter";
squash: boolean;
trim: boolean;
before: string;
after: string;
changes: Partial<Record<Policy, Change[]>>;
matchings: Partial<Record<Policy, [string, string]>>;
policies: Policy[];
}
/** PolicyData.get: "ignore" falls back to "trim", then "default". */
const forPolicy = <T>(data: Partial<Record<Policy, T>>, policy: Policy): T | undefined =>
policy === "ignore"
? (data.ignore ?? data.trim ?? data.default)
: policy === "trim"
? (data.trim ?? data.default)
: data.default;
const POLICY: Record<Policy, ComparisonPolicy> = { default: "default", trim: "trim", ignore: "ignore" };
/** Changed character offsets marked by non-space characters, '.' skipped (DiffTestCase.parseMatching). */
function parseMatching(matching: string): number[] {
return [...matching.replace(/\./g, "")].flatMap((c, i) => (c !== " " ? [i] : []));
}
/** Changed lines: lines (split at '_' / '*') with a character other than ' ' and '_' (parseLineMatching). */
function parseLineMatching(matching: string): number[] {
const out: number[] = [];
let line = 0;
let index = 0;
while (index < matching.length) {
let end = matching.slice(index).search(/[_*]/) + 1;
end = end === 0 ? matching.length : index + end;
if (/[^ _]/.test(matching.slice(index, end))) out.push(line);
line++;
index = end;
}
return out;
}
const charSets = (fragments: readonly Range[]) => {
const s1 = new Set<number>();
const s2 = new Set<number>();
for (const f of fragments) {
for (let i = f.start1; i < f.end1; i++) s1.add(i);
for (let i = f.start2; i < f.end2; i++) s2.add(i);
}
return [[...s1].sort((a, b) => a - b), [...s2].sort((a, b) => a - b)];
};
const lineSets = (fragments: readonly LineFragment[]) =>
charSets(fragments.map((f) => ({ start1: f.startLine1, end1: f.endLine1, start2: f.startLine2, end2: f.endLine2 })));
const lineChanges = (fragments: readonly LineFragment[]): Change[] =>
fragments.map((f) => [f.startLine1, f.endLine1, f.startLine2, f.endLine2]);
const rangeChanges = (fragments: readonly Range[]): Change[] =>
fragments.map((f) => [f.start1, f.end1, f.start2, f.end2]);
function checkWords(fragments: readonly Range[], changes?: Change[], matching?: [string, string]) {
if (changes) expect(rangeChanges(fragments)).toEqual(changes);
if (matching) expect(charSets(fragments)).toEqual([parseMatching(matching[0]), parseMatching(matching[1])]);
}
function run(c: Case, policy: Policy) {
const p = POLICY[policy];
const changes = forPolicy(c.changes, policy);
const matching = forPolicy(c.matchings, policy);
expect(changes ?? matching, "the case has expectations").toBeDefined();
switch (c.type) {
case "lines": {
const fragments = compareLineFragments(c.before, c.after, p);
if (changes) expect(lineChanges(fragments)).toEqual(changes);
if (matching)
expect(lineSets(fragments)).toEqual([parseLineMatching(matching[0]), parseLineMatching(matching[1])]);
break;
}
case "lines_inner": {
const fragments = processBlocks(compareLinesInner(c.before, c.after, p), c.before, c.after, p, true, false);
expect(fragments).toHaveLength(1);
const [f] = fragments;
expect([f!.startOffset1, f!.endOffset1, f!.startOffset2, f!.endOffset2]).toEqual([
0,
c.before.length,
0,
c.after.length,
]);
checkWords(f!.inner!, changes, matching);
checkWords(compareWords(c.before, c.after, p), changes, matching);
break;
}
case "words":
checkWords(compareWords(c.before, c.after, p), changes, matching);
break;
case "splitter": {
const inner = compareLinesInner(c.before, c.after, p);
expect(lineChanges(processBlocks(inner, c.before, c.after, p, c.squash, c.trim))).toEqual(changes);
break;
}
}
}
describe("IntelliJ comparison tests", () => {
(cases as Case[]).forEach((c, i) => {
for (const policy of new Set(c.policies))
it(`${c.source}.${c.test} #${i} ${c.type} ${JSON.stringify(c.before)} - ${JSON.stringify(c.after)} (${policy})`, () =>
run(c, policy));
});
});
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
/**
* Character-level comparison of punctuation (IntelliJ's ByCharRt
* .comparePunctuation, ported; Apache-2.0, Copyright JetBrains s.r.o. and
* contributors): only punctuation is matched, everything else stays changed.
*/
import { ChangeBuilder, type DiffIterable, unchanged, diffSequences } from "./iterables";
import { isPunctuation } from "./text";
export function comparePunctuation(text1: string, text2: string): DiffIterable {
const chars = (text: string) => {
const codes: number[] = [];
const offsets: number[] = [];
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (isPunctuation(c)) {
codes.push(c);
offsets.push(i);
}
}
return { codes, offsets };
};
const chars1 = chars(text1);
const chars2 = chars(text2);
const changes = diffSequences(chars1.codes, chars2.codes);
const builder = new ChangeBuilder(text1.length, text2.length);
for (const r of unchanged(changes))
for (let i = 0; i < r.end1 - r.start1; i++)
builder.markEqual(chars1.offsets[r.start1 + i]!, chars2.offsets[r.start2 + i]!);
return builder.finish();
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Line-level comparison (IntelliJ's ByLineRt with SmartLineChangeCorrector,
* ported; Apache-2.0, Copyright JetBrains s.r.o. and contributors): lines are
* first matched ignoring whitespace and "unimportant" lines (blank, `}`), the
* gaps are filled, boundaries optimized, then whitespace-only matches that
* the policy doesn't allow are corrected.
*/
import { optimizeLineChunks, UNIMPORTANT_LINE_CHAR_COUNT, type LineInfo } from "./chunkOptimizer";
import {
ChangeBuilder,
create,
type DiffIterable,
diffSequences,
expandEqual,
ExpandChangeBuilder,
unchanged,
} from "./iterables";
import { isEmptyRange, type Range } from "./lcs";
import { type ComparisonPolicy, isSpace, lineKey } from "./text";
interface Line extends LineInfo {
readonly content: string;
/** The key ignoring whitespace. */
readonly iwKey: string;
}
function toLine(content: string, policy: ComparisonPolicy): Line {
let nonSpaceChars = 0;
for (let i = 0; i < content.length; i++) if (!isSpace(content[i])) nonSpaceChars++;
const iwKey = lineKey(content, "ignore");
return { content, key: policy === "ignore" ? iwKey : lineKey(content, policy), iwKey, nonSpaceChars };
}
/** Line changes between two lists of line texts (without line breaks). */
export function compareLines(
lines1: readonly string[],
lines2: readonly string[],
policy: ComparisonPolicy,
): DiffIterable {
const l1 = lines1.map((l) => toLine(l, policy));
const l2 = lines2.map((l) => toLine(l, policy));
const iw1 = l1.map((l) => l.iwKey);
const iw2 = l2.map((l) => l.iwKey);
if (policy === "ignore") {
const changes = optimizeLineChunks(l1, l2, compareSmart(l1, l2, iw1, iw2));
const keys1 = l1.map((l) => l.key);
const keys2 = l2.map((l) => l.key);
const expanded: Range[] = [];
for (const c of changes.changes) {
const r = expandEqual(keys1, keys2, c.start1, c.start2, c.end1, c.end2);
if (!isEmptyRange(r)) expanded.push(r);
}
return create(expanded, l1.length, l2.length);
}
const iwChanges = optimizeLineChunks(l1, l2, compareSmart(l1, l2, iw1, iw2));
return correctChangesSecondStep(l1, l2, iwChanges);
}
/**
* Matches lines ignoring the unimportant ones, then compares the gaps between
* the matches with all lines (compareSmart + SmartLineChangeCorrector).
*/
function compareSmart(
lines1: readonly Line[],
lines2: readonly Line[],
keys1: string[],
keys2: string[],
): DiffIterable {
const big = (lines: readonly Line[]) => {
const indexes: number[] = [];
lines.forEach((l, i) => l.nonSpaceChars > UNIMPORTANT_LINE_CHAR_COUNT && indexes.push(i));
return indexes;
};
const indexes1 = big(lines1);
const indexes2 = big(lines2);
const changes = diffSequences(
indexes1.map((i) => keys1[i]!),
indexes2.map((i) => keys2[i]!),
);
const builder = new ChangeBuilder(lines1.length, lines2.length);
const matchGap = (start1: number, end1: number, start2: number, end2: number) => {
const expand = expandEqual(keys1, keys2, start1, start2, end1, end2);
const inner = diffSequences(keys1.slice(expand.start1, expand.end1), keys2.slice(expand.start2, expand.end2));
builder.markEqual(start1, start2, expand.start1, expand.start2);
for (const r of unchanged(inner))
builder.markEqualCount(expand.start1 + r.start1, expand.start2 + r.start2, r.end1 - r.start1);
builder.markEqual(expand.end1, expand.end2, end1, end2);
};
let last1 = 0;
let last2 = 0;
for (const r of unchanged(changes)) {
for (let i = 0; i < r.end1 - r.start1; i++) {
const start1 = indexes1[r.start1 + i]!;
const start2 = indexes2[r.start2 + i]!;
matchGap(last1, start1, last2, start2);
builder.markEqual(start1, start2, start1 + 1, start2 + 1);
last1 = start1 + 1;
last2 = start2 + 1;
}
}
matchGap(last1, lines1.length, last2, lines2.length);
return builder.finish();
}
/**
* Fixes lines matched ignoring whitespace but not equal under the policy
* (ByLineRt.correctChangesSecondStep). Equal lines stay matched, unmatched
* stay unmatched; for whitespace-equal runs the alignment with the most equal
* pairs wins, so ".{ ..{ ...{" vs "..{ ...{" matches the last two.
*/
function correctChangesSecondStep(
lines1: readonly Line[],
lines2: readonly Line[],
changes: DiffIterable,
): DiffIterable {
const builder = new ExpandChangeBuilder(
lines1.map((l) => l.key),
lines2.map((l) => l.key),
);
const equal = (i1: number, i2: number) => lines1[i1]!.key === lines2[i2]!.key;
let sample: string | null = null;
let last1 = 0;
let last2 = 0;
const alignExactMatching = (sub1: number[], sub2: number[]) => {
const n = Math.max(sub1.length, sub2.length);
// brute force over the combinations: limit the search space
if (n > 10 || sub1.length === sub2.length) {
for (let i = 0; i < Math.min(sub1.length, sub2.length); i++)
if (equal(sub1[i]!, sub2[i]!)) builder.markEqual(sub1[i]!, sub2[i]!);
return;
}
if (sub1.length < sub2.length) {
const matching = bestMatchingAlignment(sub1, sub2, equal);
for (let i = 0; i < sub1.length; i++) {
const index2 = sub2[matching[i]!]!;
if (equal(sub1[i]!, index2)) builder.markEqual(sub1[i]!, index2);
}
} else {
const matching = bestMatchingAlignment(sub2, sub1, (i2, i1) => equal(i1, i2));
for (let i = 0; i < sub2.length; i++) {
const index1 = sub1[matching[i]!]!;
if (equal(index1, sub2[i]!)) builder.markEqual(index1, sub2[i]!);
}
}
};
const flush = (line1: number, line2: number) => {
if (sample === null) return;
const start1 = Math.max(last1, builder.index1);
const start2 = Math.max(last2, builder.index2);
const sub1: number[] = [];
const sub2: number[] = [];
for (let i = start1; i < line1; i++)
if (lines1[i]!.iwKey === sample) {
sub1.push(i);
last1 = i + 1;
}
for (let i = start2; i < line2; i++)
if (lines2[i]!.iwKey === sample) {
sub2.push(i);
last2 = i + 1;
}
alignExactMatching(sub1, sub2);
sample = null;
};
for (const r of unchanged(changes)) {
for (let i = 0; i < r.end1 - r.start1; i++) {
const index1 = r.start1 + i;
const index2 = r.start2 + i;
if (sample === lines1[index1]!.iwKey) continue;
flush(index1, index2);
if (equal(index1, index2)) builder.markEqual(index1, index2);
else sample = lines1[index1]!.iwKey;
}
}
flush(changes.length1, changes.length2);
return builder.finish();
}
/** The combination of `sub2` positions for `sub1` with the most equal pairs (getBestMatchingAlignment). */
function bestMatchingAlignment(sub1: number[], sub2: number[], equal: (i1: number, i2: number) => boolean): number[] {
const size = sub1.length;
const comb = new Array<number>(size).fill(0);
let best = Array.from({ length: size }, (_, i) => i);
let bestWeight = 0;
const combinations = (start: number, n: number, k: number) => {
if (k === size) {
let weight = 0;
for (let i = 0; i < size; i++) if (equal(sub1[i]!, sub2[comb[i]!]!)) weight++;
if (weight > bestWeight) {
bestWeight = weight;
best = [...comb];
}
return;
}
for (let i = start; i <= n; i++) {
comb[k] = i;
combinations(i + 1, n, k + 1);
}
};
combinations(0, sub2.length - 1, 0);
return best;
}
+428
View File
@@ -0,0 +1,428 @@
/**
* Word-level comparison of a changed block (IntelliJ's ByWordRt
* .compareAndSplit with LineFragmentSplitter, ported; Apache-2.0, Copyright
* JetBrains s.r.o. and contributors): words and line breaks are diffed, the
* block is split into "logically different" line blocks, punctuation between
* matched words is matched by character, and whitespace by the policy.
*/
import { comparePunctuation } from "./byChar";
import { optimizeWordChunks, type WordChunk } from "./chunkOptimizer";
import { ChangeBuilder, create, type DiffIterable, diffSequences, subIterable, unchanged } from "./iterables";
import { isEmptyRange, range, type Range } from "./lcs";
import {
type ComparisonPolicy,
charCount,
expandWhitespaces,
expandWhitespacesBackward,
expandWhitespacesForward,
isAlpha,
isContinuousScript,
isEqualsIgnoreWhitespacesRange,
isEqualsRange,
isSpace,
trimEnd,
trimRange,
trimStart,
} from "./text";
export interface InlineChunk extends WordChunk {
/** Equal keys are equal chunks: the word itself, or "\n". */
readonly key: string;
}
/**
* The words of a text (ByWordRt.getInlineChunks): runs of letters and digits
* (anything but ASCII whitespace and punctuation), single characters of
* scripts written without spaces, and line breaks. Punctuation is matched
* separately.
*/
export function getInlineChunks(text: string): InlineChunk[] {
const chunks: InlineChunk[] = [];
const word = (from: number, to: number): InlineChunk => ({
offset1: from,
offset2: to,
newline: false,
key: text.slice(from, to),
});
let wordStart = -1;
for (let offset = 0; offset < text.length;) {
const ch = text.codePointAt(offset)!;
const n = charCount(ch);
const alpha = isAlpha(ch);
if (alpha && !isContinuousScript(ch)) {
if (wordStart === -1) wordStart = offset;
} else {
if (wordStart !== -1) {
chunks.push(word(wordStart, offset));
wordStart = -1;
}
if (alpha) chunks.push(word(offset, offset + n));
else if (ch === 10) chunks.push({ offset1: offset, offset2: offset + 1, newline: true, key: "\n" });
}
offset += n;
}
if (wordStart !== -1) chunks.push(word(wordStart, text.length));
return chunks;
}
/** Word changes of two texts (ByWordRt.compare). */
export function compareWords(text1: string, text2: string, policy: ComparisonPolicy): readonly Range[] {
const words1 = getInlineChunks(text1);
const words2 = getInlineChunks(text2);
const keys1 = words1.map((w) => w.key);
const keys2 = words2.map((w) => w.key);
let wordChanges = diffSequences(keys1, keys2);
wordChanges = optimizeWordChunks(keys1, keys2, words1, words2, text1, text2, wordChanges);
const delimiters = matchPunctuation(text1, text2, words1, words2, 0, 0, wordChanges);
return matchWhitespaces(text1, text2, delimiters, policy).changes;
}
export interface LineBlock {
/** Changed ranges of the block's texts, relative to `offsets`. */
readonly fragments: readonly Range[];
readonly offsets: Range;
readonly newlines1: number;
readonly newlines2: number;
}
/** Word changes of a changed line block, split into line blocks (ByWordRt.compareAndSplit). */
export function compareAndSplit(text1: string, text2: string, policy: ComparisonPolicy): LineBlock[] {
const words1 = getInlineChunks(text1);
const words2 = getInlineChunks(text2);
const keys1 = words1.map((w) => w.key);
const keys2 = words2.map((w) => w.key);
let wordChanges = diffSequences(keys1, keys2);
wordChanges = optimizeWordChunks(keys1, keys2, words1, words2, text1, text2, wordChanges);
const wordBlocks = splitLineFragments(text1, text2, words1, words2, wordChanges);
const changed = wordChanges.changes;
let index = 0;
return wordBlocks.map((block) => {
const { offsets, words } = block;
while (index < changed.length) {
const r = changed[index]!;
if (r.end1 < words.start1 || r.end2 < words.start2) index++;
else break;
}
const sub = subIterable(changed, words.start1, words.end1, words.start2, words.end2, index);
const subtext1 = text1.slice(offsets.start1, offsets.end1);
const subtext2 = text2.slice(offsets.start2, offsets.end2);
const subwords1 = words1.slice(words.start1, words.end1);
const subwords2 = words2.slice(words.start2, words.end2);
const delimiters = matchPunctuation(subtext1, subtext2, subwords1, subwords2, offsets.start1, offsets.start2, sub);
const iterable = matchWhitespaces(subtext1, subtext2, delimiters, policy);
return {
fragments: iterable.changes,
offsets,
newlines1: subwords1.filter((w) => w.newline).length,
newlines2: subwords2.filter((w) => w.newline).length,
};
});
}
interface WordBlock {
readonly words: Range;
readonly offsets: Range;
}
interface PendingChunk {
readonly block: WordBlock;
readonly hasEqualWords: boolean;
readonly hasWordsInside: boolean;
readonly isEqualIgnoreWhitespaces: boolean;
}
/**
* Splits a changed block at matched line breaks and at matched first words of
* lines (LineFragmentSplitter). Word indexes: -1 is the start of the text,
* `words.length` its end.
*/
function splitLineFragments(
text1: string,
text2: string,
words1: readonly InlineChunk[],
words2: readonly InlineChunk[],
iterable: DiffIterable,
): WordBlock[] {
const result: WordBlock[] = [];
let last1 = -1;
let last2 = -1;
let pending: PendingChunk | null = null;
const offset = (words: readonly InlineChunk[], text: string, index: number) =>
index === -1 ? 0 : index === words.length ? text.length : words[index]!.offset2;
const createChunk = (start1: number, start2: number, end1: number, end2: number, hasEqualWords: boolean) => {
const offsets = range(
offset(words1, text1, start1),
offset(words1, text1, end1),
offset(words2, text2, start2),
offset(words2, text2, end2),
);
const words = range(
Math.max(0, start1 + 1),
Math.min(end1 + 1, words1.length),
Math.max(0, start2 + 1),
Math.min(end2 + 1, words2.length),
);
const block = { words, offsets };
let hasWordsInside = false;
for (let i = words.start1; i < words.end1 && !hasWordsInside; i++) hasWordsInside = !words1[i]!.newline;
for (let i = words.start2; i < words.end2 && !hasWordsInside; i++) hasWordsInside = !words2[i]!.newline;
return {
block,
hasEqualWords,
hasWordsInside,
isEqualIgnoreWhitespaces: isEqualsIgnoreWhitespacesRange(text1, text2, offsets),
};
};
const shouldMerge = (c1: PendingChunk, c2: PendingChunk) =>
// lines matched only by '\n'
(!c1.hasEqualWords && !c2.hasEqualWords) ||
// whitespace-only changed lines
(c1.isEqualIgnoreWhitespaces && c2.isEqualIgnoreWhitespaces) ||
// a block without words in it
!c1.hasWordsInside ||
!c2.hasWordsInside;
const merge = (c1: PendingChunk, c2: PendingChunk): PendingChunk => ({
block: {
words: range(c1.block.words.start1, c2.block.words.end1, c1.block.words.start2, c2.block.words.end2),
offsets: range(c1.block.offsets.start1, c2.block.offsets.end1, c1.block.offsets.start2, c2.block.offsets.end2),
},
hasEqualWords: c1.hasEqualWords || c2.hasEqualWords,
hasWordsInside: c1.hasWordsInside || c2.hasWordsInside,
isEqualIgnoreWhitespaces: c1.isEqualIgnoreWhitespaces && c2.isEqualIgnoreWhitespaces,
});
const addLineChunk = (end1: number, end2: number, hasEqualWords: boolean) => {
if (last1 > end1 || last2 > end2) return;
const chunk = createChunk(last1, last2, end1, end2, hasEqualWords);
if (isEmptyRange(chunk.block.offsets)) return;
if (pending && shouldMerge(pending, chunk)) pending = merge(pending, chunk);
else {
if (pending) result.push(pending.block);
pending = chunk;
}
last1 = end1;
last2 = end2;
};
const firstInLine = (words: readonly InlineChunk[], index: number) => index === 0 || words[index - 1]!.newline;
let hasEqualWords = false;
for (const r of unchanged(iterable)) {
for (let i = 0; i < r.end1 - r.start1; i++) {
const index1 = r.start1 + i;
const index2 = r.start2 + i;
if (words1[index1]!.newline && words2[index2]!.newline) {
// split by matched newlines
addLineChunk(index1, index2, hasEqualWords);
hasEqualWords = false;
} else {
if (firstInLine(words1, index1) && firstInLine(words2, index2)) {
// split by matched first word in line
addLineChunk(index1 - 1, index2 - 1, hasEqualWords);
hasEqualWords = false;
}
hasEqualWords = true;
}
}
}
addLineChunk(words1.length, words2.length, hasEqualWords);
if (pending) result.push((pending as PendingChunk).block);
return result;
}
/**
* Matches punctuation between words (AdjustmentPunctuationMatcher): the
* separators around each pair of matched words are compared by character, so
* "[ X { A ! B } Y ]" vs "( X ... Y )" compares "[" with "(", "{" + "}" with
* "..." and "]" with ")".
*/
function matchPunctuation(
text1: string,
text2: string,
words1: readonly InlineChunk[],
words2: readonly InlineChunk[],
startShift1: number,
startShift2: number,
changes: DiffIterable,
): DiffIterable {
const len1 = text1.length;
const len2 = text2.length;
const builder = new ChangeBuilder(len1, len2);
const startOffset1 = (i: number) => words1[i]!.offset1 - startShift1;
const startOffset2 = (i: number) => words2[i]!.offset1 - startShift2;
const endOffset1 = (i: number) => words1[i]!.offset2 - startShift1;
const endOffset2 = (i: number) => words2[i]!.offset2 - startShift2;
let lastStart1 = -1;
let lastStart2 = -1;
let lastEnd1 = -1;
let lastEnd2 = -1;
const matchRange = (start1: number, start2: number, end1: number, end2: number) => {
if (start1 === end1 && start2 === end2) return;
const inner = comparePunctuation(text1.slice(start1, end1), text2.slice(start2, end2));
for (const r of unchanged(inner))
builder.markEqual(start1 + r.start1, start2 + r.start2, start1 + r.end1, start2 + r.end2);
};
// One sequence compared with two others as if they were one (comparePunctuation2Side).
const compare2Side = (text: string, text21: string, text22: string): [Range[], Range[]] => {
const changes = comparePunctuation(text, text21 + text22);
const ranges1: Range[] = [];
const ranges2: Range[] = [];
const split = text21.length;
for (const r of unchanged(changes)) {
if (r.end2 <= split) ranges1.push(r);
else if (r.start2 >= split) ranges2.push(range(r.start1, r.end1, r.start2 - split, r.end2 - split));
else {
const len = split - r.start2;
ranges1.push(range(r.start1, r.start1 + len, r.start2, split));
ranges2.push(range(r.start1 + len, r.end1, 0, r.end2 - split));
}
}
return [ranges1, ranges2];
};
const matchComplexRange = (
start11: number,
start12: number,
end11: number,
end12: number,
start21: number,
start22: number,
end21: number,
end22: number,
) => {
if (start11 === start21 && end11 === end21) {
const [first, second] = compare2Side(
text1.slice(start11, end11),
text2.slice(start12, end12),
text2.slice(start22, end22),
);
for (const r of first)
builder.markEqual(start11 + r.start1, start12 + r.start2, start11 + r.end1, start12 + r.end2);
for (const r of second)
builder.markEqual(start11 + r.start1, start22 + r.start2, start11 + r.end1, start22 + r.end2);
} else if (start12 === start22 && end12 === end22) {
// mirrored: two left sides against one right side
const [first, second] = compare2Side(
text2.slice(start12, end12),
text1.slice(start11, end11),
text1.slice(start21, end21),
);
for (const r of first)
builder.markEqual(start11 + r.start2, start12 + r.start1, start11 + r.end2, start12 + r.end1);
for (const r of second)
builder.markEqual(start21 + r.start2, start12 + r.start1, start21 + r.end2, start12 + r.end1);
} else {
throw new Error("unexpected punctuation ranges");
}
};
const matchForward = (index1: number, index2: number) => {
lastStart1 = index1 === -1 ? 0 : endOffset1(index1);
lastStart2 = index2 === -1 ? 0 : endOffset2(index2);
lastEnd1 = index1 + 1 === words1.length ? len1 : startOffset1(index1 + 1);
lastEnd2 = index2 + 1 === words2.length ? len2 : startOffset2(index2 + 1);
};
const matchBackward = (index1: number, index2: number) => {
const start1 = index1 === 0 ? 0 : endOffset1(index1 - 1);
const start2 = index2 === 0 ? 0 : endOffset2(index2 - 1);
const end1 = index1 === words1.length ? len1 : startOffset1(index1);
const end2 = index2 === words2.length ? len2 : startOffset2(index2);
if (lastStart1 === start1 && lastStart2 === start2) {
// adjacent matched words, match the gap between ("A B" - "A B")
matchRange(start1, start2, end1, end2);
} else if (lastStart1 < start1 && lastStart2 < start2) {
// matched words with unmatched ones between ("A X B" - "A Y B")
matchRange(lastStart1, lastStart2, lastEnd1, lastEnd2);
matchRange(start1, start2, end1, end2);
} else {
// one side adjacent, the other with unmatched words between ("A B" - "A Y B")
matchComplexRange(lastStart1, lastStart2, lastEnd1, lastEnd2, start1, start2, end1, end2);
}
lastStart1 = lastStart2 = lastEnd1 = lastEnd2 = -1;
};
matchForward(-1, -1);
for (const r of unchanged(changes)) {
for (let i = 0; i < r.end1 - r.start1; i++) {
const index1 = r.start1 + i;
const index2 = r.start2 + i;
matchBackward(index1, index2);
builder.markEqual(startOffset1(index1), startOffset2(index2), endOffset1(index1), endOffset2(index2));
matchForward(index1, index2);
}
}
matchBackward(words1.length, words2.length);
return builder.finish();
}
/** Whitespace handling of the changes by policy (ByWordRt.matchAdjustmentWhitespaces). */
export function matchWhitespaces(text1: string, text2: string, iterable: DiffIterable, policy: ComparisonPolicy) {
if (policy === "ignore") return ignoreSpaces(iterable, text1, text2);
const corrected = defaultSpaces(iterable, text1, text2);
return policy === "trim" ? trimSpaces(corrected, text1, text2) : corrected;
}
/** Equal whitespace at the edges of a change is unchanged (DefaultCorrector). */
function defaultSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable {
const changes: Range[] = [];
for (const r of iterable.changes) {
const endCut = expandWhitespacesBackward(text1, text2, r.start1, r.start2, r.end1, r.end2);
const startCut = expandWhitespacesForward(text1, text2, r.start1, r.start2, r.end1 - endCut, r.end2 - endCut);
const expanded = range(r.start1 + startCut, r.end1 - endCut, r.start2 + startCut, r.end2 - endCut);
if (!isEmptyRange(expanded)) changes.push(expanded);
}
return create(changes, text1.length, text2.length);
}
/** Whitespace is matched where possible and ignored where not (IgnoreSpacesCorrector). */
function ignoreSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable {
const changes: Range[] = [];
for (const r of iterable.changes) {
const trimmed = trimRange(text1, text2, expandWhitespaces(text1, text2, r));
if (!isEmptyRange(trimmed) && !isEqualsIgnoreWhitespacesRange(text1, text2, trimmed)) changes.push(trimmed);
}
return create(changes, text1.length, text2.length);
}
function isLeadingSpace(text: string, start: number): boolean {
if (start < 0 || start === text.length || !isSpace(text[start])) return false;
for (let i = start - 1; i >= 0; i--) {
if (text[i] === "\n") return true;
if (!isSpace(text[i])) return false;
}
return true;
}
function isTrailingSpace(text: string, end: number): boolean {
if (end < 0 || end === text.length || !isSpace(text[end])) return false;
for (let i = end; i < text.length; i++) {
if (text[i] === "\n") return true;
if (!isSpace(text[i])) return false;
}
return true;
}
const isLeadingTrailingSpace = (text: string, i: number) => isLeadingSpace(text, i) || isTrailingSpace(text, i);
/** Leading and trailing whitespace of lines is not a change (TrimSpacesCorrector). */
function trimSpaces(iterable: DiffIterable, text1: string, text2: string): DiffIterable {
const changes: Range[] = [];
for (const r of iterable.changes) {
let { start1, start2, end1, end2 } = r;
if (isLeadingTrailingSpace(text1, start1)) start1 = trimStart(text1, start1, end1);
if (isLeadingTrailingSpace(text1, end1 - 1)) end1 = trimEnd(text1, start1, end1);
if (isLeadingTrailingSpace(text2, start2)) start2 = trimStart(text2, start2, end2);
if (isLeadingTrailingSpace(text2, end2 - 1)) end2 = trimEnd(text2, start2, end2);
const trimmed = range(start1, end1, start2, end2);
if (!isEmptyRange(trimmed) && !isEqualsRange(text1, text2, trimmed)) changes.push(trimmed);
}
return create(changes, text1.length, text2.length);
}
@@ -0,0 +1,183 @@
/**
* Shifts ambiguous changes to where people expect them (IntelliJ's
* ChunkOptimizer, ported; Apache-2.0, Copyright JetBrains s.r.o. and
* contributors): touching changes are merged, and an insertion or deletion
* that could sit at several places moves to a word boundary (words) or next to
* an empty line (lines).
*/
import { createUnchanged, expandBackward, expandForward, type DiffIterable, unchanged } from "./iterables";
import { range, type Range } from "./lcs";
import { isSpace } from "./text";
/** `touchLeft`: the changes touch on the left side; returns the shift (> 0 forward, < 0 backward). */
type GetShift = (
touchLeft: boolean,
equalForward: number,
equalBackward: number,
range1: Range,
range2: Range,
) => number;
function optimize<T>(data1: readonly T[], data2: readonly T[], iterable: DiffIterable, getShift: GetShift) {
const ranges: Range[] = [];
const processLastRanges = (): void => {
if (ranges.length < 2) return;
const range1 = ranges[ranges.length - 2]!;
const range2 = ranges[ranges.length - 1]!;
// Changes that don't touch can't be optimized (the input would not be an LCS).
if (range1.end1 !== range2.start1 && range1.end2 !== range2.start2) return;
const count1 = range1.end1 - range1.start1;
const count2 = range2.end1 - range2.start1;
const equalForward = expandForward(
data1,
data2,
range1.end1,
range1.end2,
Math.min(range1.end1 + count2, data1.length),
Math.min(range1.end2 + count2, data2.length),
);
const equalBackward = expandBackward(
data1,
data2,
Math.max(range2.start1 - count1, 0),
Math.max(range2.start2 - count1, 0),
range2.start1,
range2.start2,
);
if (equalForward === 0 && equalBackward === 0) return;
// merge chunks left [A]B[B] -> [AB]B
if (equalForward === count2) {
ranges.splice(-2, 2, range(range1.start1, range1.end1 + count2, range1.start2, range1.end2 + count2));
return processLastRanges();
}
// merge chunks right [A]A[B] -> A[AB]
if (equalBackward === count1) {
ranges.splice(-2, 2, range(range2.start1 - count1, range2.end1, range2.start2 - count1, range2.end2));
return processLastRanges();
}
const shift = getShift(range1.end1 === range2.start1, equalForward, equalBackward, range1, range2);
if (shift !== 0)
ranges.splice(
-2,
2,
range(range1.start1, range1.end1 + shift, range1.start2, range1.end2 + shift),
range(range2.start1 + shift, range2.end1, range2.start2 + shift, range2.end2),
);
};
for (const r of unchanged(iterable)) {
ranges.push(r);
processLastRanges();
}
return createUnchanged(ranges, data1.length, data2.length);
}
export interface WordChunk {
readonly offset1: number;
readonly offset2: number;
readonly newline: boolean;
}
/**
* Words: fewer chunks, and fewer changed "sentences" (runs of words not
* separated by whitespace): "[AX] [AZ]" - "[AX] AY [AZ]", not "[AX A][Z]".
*/
export function optimizeWordChunks(
keys1: readonly string[],
keys2: readonly string[],
words1: readonly WordChunk[],
words2: readonly WordChunk[],
text1: string,
text2: string,
iterable: DiffIterable,
): DiffIterable {
const separated = (text: string, word1: WordChunk, word2: WordChunk) => {
if (word1.newline || word2.newline) return true;
for (let i = word1.offset2; i < word2.offset1; i++) if (isSpace(text[i])) return true;
return false;
};
const edgeShift = (text: string, words: readonly WordChunk[], offset: number, count: number, ltr: boolean) => {
for (let i = 0; i < count; i++) {
const [w1, w2] = ltr
? [words[offset + i]!, words[offset + i + 1]!]
: [words[offset - i - 1]!, words[offset - i]!];
if (separated(text, w1, w2)) return i + 1;
}
return -1;
};
return optimize(keys1, keys2, iterable, (touchLeft, equalForward, equalBackward, _range1, range2) => {
const words = touchLeft ? words1 : words2;
const text = touchLeft ? text1 : text2;
const touchStart = touchLeft ? range2.start1 : range2.start2;
// already separated by whitespace
if (separated(text, words[touchStart - 1]!, words[touchStart]!)) return 0;
// shift chunks left [X]A Y[A ZA] -> [XA] YA [ZA]
const left = edgeShift(text, words, touchStart, equalForward, true);
if (left > 0) return left;
// shift chunks right [AX A]Y A[Z] -> [AX] AY [AZ]
const right = edgeShift(text, words, touchStart - 1, equalBackward, false);
if (right > 0) return -right;
return 0;
});
}
/** Lines with at most this many non-space characters are "unimportant" (`}`, blank). */
export const UNIMPORTANT_LINE_CHAR_COUNT = 3;
export interface LineInfo {
readonly key: string;
readonly nonSpaceChars: number;
}
/**
* Lines: fewer chunks, and insertions / deletions bounded by an empty (or
* unimportant) line: "ABooYZ [ABuuYZ ]ABzzYZ", not "ABooYZ AB[uuYZ AB]zzYZ".
*/
export function optimizeLineChunks(
lines1: readonly LineInfo[],
lines2: readonly LineInfo[],
iterable: DiffIterable,
): DiffIterable {
const nextUnimportant = (lines: readonly LineInfo[], offset: number, count: number, threshold: number) => {
for (let i = 0; i < count; i++) if (lines[offset + i]!.nonSpaceChars <= threshold) return i;
return -1;
};
const prevUnimportant = (lines: readonly LineInfo[], offset: number, count: number, threshold: number) => {
for (let i = 0; i < count; i++) if (lines[offset - i]!.nonSpaceChars <= threshold) return i;
return -1;
};
const toShift = (forward: number, backward: number): number | null => {
if (forward === -1 && backward === -1) return null;
if (forward === 0 || backward === 0) return 0;
return forward !== -1 ? forward : -backward;
};
const keys1 = lines1.map((l) => l.key);
const keys2 = lines2.map((l) => l.key);
return optimize(keys1, keys2, iterable, (touchLeft, equalForward, equalBackward, range1, range2) => {
// an empty line boundary in unchanged lines: the insertion / deletion goes right before / after it
const unchangedBoundary = (threshold: number) => {
const lines = touchLeft ? lines1 : lines2;
const touchStart = touchLeft ? range2.start1 : range2.start2;
return toShift(
nextUnimportant(lines, touchStart, equalForward + 1, threshold),
prevUnimportant(lines, touchStart - 1, equalBackward + 1, threshold),
);
};
// an empty line boundary in changed lines: the insertion / deletion starts / ends with it
const changedBoundary = (threshold: number) => {
const lines = touchLeft ? lines2 : lines1;
const changeStart = touchLeft ? range1.end2 : range1.end1;
const changeEnd = touchLeft ? range2.start2 : range2.start1;
return toShift(
nextUnimportant(lines, changeStart, equalForward + 1, threshold),
prevUnimportant(lines, changeEnd - 1, equalBackward + 1, threshold),
);
};
return (
unchangedBoundary(0) ??
changedBoundary(0) ??
unchangedBoundary(UNIMPORTANT_LINE_CHAR_COUNT) ??
changedBoundary(UNIMPORTANT_LINE_CHAR_COUNT) ??
0
);
});
}
+255
View File
@@ -0,0 +1,255 @@
/**
* IntelliJ's text comparison (ComparisonManagerImpl.compareLinesInner and
* processBlocks, ported; Apache-2.0, Copyright JetBrains s.r.o. and
* contributors): lines are compared, each changed block is compared by words
* and split into line blocks, then adjoining blocks are squashed back and,
* when ignoring whitespace and empty lines, whitespace-only blocks trimmed.
*/
import { compareLines } from "./byLine";
import { compareAndSplit } from "./byWord";
import { DiffTooBigError, type Range } from "./lcs";
import { type ComparisonPolicy, isEqualTexts, stripSpaces } from "./text";
export type { ComparisonPolicy } from "./text";
export type { Range } from "./lcs";
export { compareWords } from "./byWord";
export interface LineFragment {
readonly startLine1: number;
readonly endLine1: number;
readonly startLine2: number;
readonly endLine2: number;
/** Text offsets; the end includes the last line's line break, when it has one. */
readonly startOffset1: number;
readonly endOffset1: number;
readonly startOffset2: number;
readonly endOffset2: number;
/**
* Changed ranges inside the fragment, relative to its start offsets; null
* when not computed (by-line highlighting, or a block too big to compare).
*/
readonly inner: readonly Range[] | null;
}
export interface CompareOptions {
policy: ComparisonPolicy;
/** Compare changed blocks by words ("Highlight words"). */
words: boolean;
/** Drop leading / trailing whitespace-only blocks ("Ignore whitespaces and empty lines"). */
trimChunks?: boolean;
/** Keep the line blocks words split a change into ("Highlight split changes"). */
split?: boolean;
}
/** Do not compare by words after that many blocks were too big (DiffConfig.MAX_BAD_LINES). */
const MAX_BAD_LINES = 3;
class LineOffsets {
private readonly starts: number[] = [0];
constructor(readonly text: string) {
for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) this.starts.push(i + 1);
}
get lineCount() {
return this.starts.length;
}
lineStart(line: number) {
return this.starts[line]!;
}
lineEnd(line: number, includeNewline = false) {
if (line === this.starts.length - 1) return this.text.length;
return this.starts[line + 1]! - (includeNewline ? 0 : 1);
}
lines(): string[] {
return this.starts.map((_, i) => this.text.slice(this.lineStart(i), this.lineEnd(i)));
}
/** Offsets of lines `[start, end)` (ComparisonManagerImpl.getOffsets). */
offsets(start: number, end: number): [number, number] {
if (start === end) {
const offset = start < this.lineCount ? this.lineStart(start) : this.lineEnd(this.lineCount - 1, true);
return [offset, offset];
}
return [this.lineStart(start), this.lineEnd(end - 1, true)];
}
}
/** Line fragments of two texts with their inner word changes, as IDEA's diff viewer computes them. */
export function compareTexts(text1: string, text2: string, options: CompareOptions): LineFragment[] {
const { policy } = options;
const fragments = options.words
? compareLinesInner(text1, text2, policy)
: compareLineFragments(text1, text2, policy);
return processBlocks(fragments, text1, text2, policy, !options.split, !!options.trimChunks);
}
/** Changed lines (ComparisonManagerImpl.compareLines). */
export function compareLineFragments(text1: string, text2: string, policy: ComparisonPolicy): LineFragment[] {
const offsets1 = new LineOffsets(text1);
const offsets2 = new LineOffsets(text2);
return compareLines(offsets1.lines(), offsets2.lines(), policy).changes.map((r) => {
const [startOffset1, endOffset1] = offsets1.offsets(r.start1, r.end1);
const [startOffset2, endOffset2] = offsets2.offsets(r.start2, r.end2);
return {
startLine1: r.start1,
endLine1: r.end1,
startLine2: r.start2,
endLine2: r.end2,
startOffset1,
endOffset1,
startOffset2,
endOffset2,
inner: null,
};
});
}
/** Changed lines split into line blocks with their word changes (ComparisonManagerImpl.compareLinesInner). */
export function compareLinesInner(text1: string, text2: string, policy: ComparisonPolicy): LineFragment[] {
return innerFragments(compareLineFragments(text1, text2, policy), text1, text2, policy);
}
function innerFragments(
fragments: readonly LineFragment[],
text1: string,
text2: string,
policy: ComparisonPolicy,
): LineFragment[] {
const out: LineFragment[] = [];
let tooBig = 0;
for (const f of fragments) {
const sub1 = text1.slice(f.startOffset1, f.endOffset1);
const sub2 = text2.slice(f.startOffset2, f.endOffset2);
if (f.startLine1 === f.endLine1 || f.startLine2 === f.endLine2) {
// insertion / deletion
out.push(isEqualTexts(sub1, sub2, policy) ? { ...f, inner: [] } : f);
continue;
}
if (tooBig >= MAX_BAD_LINES) {
out.push(f);
continue;
}
try {
const blocks = compareAndSplit(sub1, sub2, policy);
let line1 = f.startLine1;
let line2 = f.startLine2;
blocks.forEach((block, i) => {
// the last block ends with the fragment, whatever its last line
const last = i === blocks.length - 1;
const endLine1 = last ? f.endLine1 : line1 + block.newlines1;
const endLine2 = last ? f.endLine2 : line2 + block.newlines2;
out.push({
startLine1: line1,
endLine1,
startLine2: line2,
endLine2,
startOffset1: f.startOffset1 + block.offsets.start1,
endOffset1: f.startOffset1 + block.offsets.end1,
startOffset2: f.startOffset2 + block.offsets.start2,
endOffset2: f.startOffset2 + block.offsets.end2,
inner: block.fragments,
});
line1 = endLine1;
line2 = endLine2;
});
} catch (e) {
if (!(e instanceof DiffTooBigError)) throw e;
out.push(f);
tooBig++;
}
}
return out;
}
const adjoining = (before: LineFragment, after: LineFragment) =>
before.endLine1 === after.startLine1 &&
before.endLine2 === after.startLine2 &&
before.endOffset1 === after.startOffset1 &&
before.endOffset2 === after.startOffset2;
/** Squashes adjoining fragments and trims whitespace-only ones at their edges (processBlocks). */
export function processBlocks(
fragments: LineFragment[],
text1: string,
text2: string,
policy: ComparisonPolicy,
squash: boolean,
trim: boolean,
): LineFragment[] {
if ((!squash && !trim) || !fragments.length) return fragments;
const out: LineFragment[] = [];
let start = 0;
for (let i = 1; i <= fragments.length; i++) {
if (i < fragments.length && adjoining(fragments[i - 1]!, fragments[i]!)) continue;
out.push(...processAdjoining(fragments.slice(start, i), text1, text2, policy, squash, trim));
start = i;
}
return out;
}
function processAdjoining(
fragments: LineFragment[],
text1: string,
text2: string,
policy: ComparisonPolicy,
squash: boolean,
trim: boolean,
): LineFragment[] {
let start = 0;
let end = fragments.length;
if (trim && policy === "ignore") {
const ignorable = (f: LineFragment) =>
(f.inner !== null && f.inner.length === 0) ||
stripSpaces(text1.slice(f.startOffset1, f.endOffset1)) === stripSpaces(text2.slice(f.startOffset2, f.endOffset2));
while (start < end && ignorable(fragments[start]!)) start++;
while (start < end && ignorable(fragments[end - 1]!)) end--;
}
if (start === end) return [];
return squash ? [doSquash(fragments.slice(start, end))] : fragments.slice(start, end);
}
function doSquash(fragments: LineFragment[]): LineFragment {
if (fragments.length === 1) return fragments[0]!;
const first = fragments[0]!;
const last = fragments[fragments.length - 1]!;
const inner: Range[] = [];
for (const f of fragments) {
const shift1 = f.startOffset1 - first.startOffset1;
const shift2 = f.startOffset2 - first.startOffset2;
const own = f.inner ?? [
{ start1: 0, end1: f.endOffset1 - f.startOffset1, start2: 0, end2: f.endOffset2 - f.startOffset2 },
];
for (const r of own) {
const prev = inner[inner.length - 1];
if (prev && prev.end1 === r.start1 + shift1 && prev.end2 === r.start2 + shift2)
inner[inner.length - 1] = {
start1: prev.start1,
end1: r.end1 + shift1,
start2: prev.start2,
end2: r.end2 + shift2,
};
else
inner.push({
start1: r.start1 + shift1,
end1: r.end1 + shift1,
start2: r.start2 + shift2,
end2: r.end2 + shift2,
});
}
}
return {
startLine1: first.startLine1,
endLine1: last.endLine1,
startLine2: first.startLine2,
endLine2: last.endLine2,
startOffset1: first.startOffset1,
endOffset1: last.endOffset1,
startOffset2: first.startOffset2,
endOffset2: last.endOffset2,
inner,
};
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Changed / unchanged ranges between two sequences (IntelliJ's DiffIterable
* and DiffIterableUtil, ported; Apache-2.0, Copyright JetBrains s.r.o. and
* contributors). Changes are kept as a sorted list; "fair" iterables, whose
* unchanged ranges have equal lengths on both sides, use the same shape.
*/
import { diffKeys, isEmptyRange, range, type Range } from "./lcs";
export interface DiffIterable {
readonly length1: number;
readonly length2: number;
readonly changes: readonly Range[];
}
export const create = (changes: readonly Range[], length1: number, length2: number): DiffIterable => ({
length1,
length2,
changes,
});
/** The ranges between the changes (ChangeDiffIterableBase.UnchangedIterator). */
export function unchanged(it: DiffIterable): Range[] {
const out: Range[] = [];
let last1 = 0;
let last2 = 0;
let i = 0;
const { changes } = it;
if (changes.length && changes[0]!.start1 === 0 && changes[0]!.start2 === 0) {
last1 = changes[0]!.end1;
last2 = changes[0]!.end2;
i = 1;
}
for (; i < changes.length; i++) {
const c = changes[i]!;
out.push(range(last1, c.start1, last2, c.start2));
last1 = c.end1;
last2 = c.end2;
}
if (last1 !== it.length1 || last2 !== it.length2) out.push(range(last1, it.length1, last2, it.length2));
return out;
}
export const invert = (it: DiffIterable): DiffIterable => create(unchanged(it), it.length1, it.length2);
export const createUnchanged = (ranges: readonly Range[], length1: number, length2: number) =>
invert(create(ranges, length1, length2));
/** Both changed and unchanged ranges in order, without empty ones (DiffIterableUtil.iterateAll). */
export function iterateAll(it: DiffIterable): [Range, boolean][] {
const out: [Range, boolean][] = [];
let last1 = 0;
let last2 = 0;
for (const c of it.changes) {
const gap = range(last1, c.start1, last2, c.start2);
if (!isEmptyRange(gap)) out.push([gap, true]);
out.push([c, false]);
last1 = c.end1;
last2 = c.end2;
}
const tail = range(last1, it.length1, last2, it.length2);
if (!isEmptyRange(tail)) out.push([tail, true]);
return out;
}
/** Changes of sequences of keys compared by equality (DiffIterableUtil.diff). */
export const diffSequences = <T>(a: readonly T[], b: readonly T[]): DiffIterable =>
create(diffKeys(a, b), a.length, b.length);
/** The part of `changed` inside the given ranges, relative to their starts (SubiterableDiffIterable). */
export function subIterable(
changed: readonly Range[],
start1: number,
end1: number,
start2: number,
end2: number,
firstIndex: number,
): DiffIterable {
const out: Range[] = [];
for (let i = firstIndex; i < changed.length; i++) {
const r = changed[i]!;
if (r.end1 < start1 || r.end2 < start2) continue;
if (r.start1 > end1 || r.start2 > end2) break;
const sub = range(
Math.max(start1, r.start1) - start1,
Math.min(end1, r.end1) - start1,
Math.max(start2, r.start2) - start2,
Math.min(end2, r.end2) - start2,
);
if (!isEmptyRange(sub)) out.push(sub);
}
return create(out, end1 - start1, end2 - start2);
}
/** Collects changes from the equal ranges marked in order (DiffIterableUtil.ChangeBuilder). */
export class ChangeBuilder {
index1 = 0;
index2 = 0;
protected readonly changes: Range[] = [];
constructor(
readonly length1: number,
readonly length2: number,
) {}
markEqual(index1: number, index2: number, end1 = index1 + 1, end2 = index2 + 1) {
if (index1 === end1 && index2 === end2) return;
if (this.index1 !== index1 || this.index2 !== index2) this.addChange(this.index1, this.index2, index1, index2);
this.index1 = end1;
this.index2 = end2;
}
markEqualCount(index1: number, index2: number, count: number) {
this.markEqual(index1, index2, index1 + count, index2 + count);
}
finish(): DiffIterable {
if (this.length1 !== this.index1 || this.length2 !== this.index2) {
this.addChange(this.index1, this.index2, this.length1, this.length2);
this.index1 = this.length1;
this.index2 = this.length2;
}
return create(this.changes, this.length1, this.length2);
}
protected addChange(start1: number, start2: number, end1: number, end2: number) {
this.changes.push(range(start1, end1, start2, end2));
}
}
/** A ChangeBuilder that shrinks each change by its equal ends (ExpandChangeBuilder). */
export class ExpandChangeBuilder<T> extends ChangeBuilder {
constructor(
private readonly objects1: readonly T[],
private readonly objects2: readonly T[],
) {
super(objects1.length, objects2.length);
}
protected override addChange(start1: number, start2: number, end1: number, end2: number) {
const r = expandEqual(this.objects1, this.objects2, start1, start2, end1, end2);
if (!isEmptyRange(r)) super.addChange(r.start1, r.start2, r.end1, r.end2);
}
}
/** Shrinks `[start, end)` by the equal elements at both ends (TrimUtil.expand). */
export function expandEqual<T>(
a: ArrayLike<T>,
b: ArrayLike<T>,
start1: number,
start2: number,
end1: number,
end2: number,
): Range {
const forward = expandForward(a, b, start1, start2, end1, end2);
start1 += forward;
start2 += forward;
const backward = expandBackward(a, b, start1, start2, end1, end2);
return range(start1, end1 - backward, start2, end2 - backward);
}
export function expandForward<T>(
a: ArrayLike<T>,
b: ArrayLike<T>,
start1: number,
start2: number,
end1: number,
end2: number,
): number {
const old = start1;
while (start1 < end1 && start2 < end2 && a[start1] === b[start2]) {
start1++;
start2++;
}
return start1 - old;
}
export function expandBackward<T>(
a: ArrayLike<T>,
b: ArrayLike<T>,
start1: number,
start2: number,
end1: number,
end2: number,
): number {
const old = end1;
while (start1 < end1 && start2 < end2 && a[end1 - 1] === b[end2 - 1]) {
end1--;
end2--;
}
return old - end1;
}
+512
View File
@@ -0,0 +1,512 @@
/**
* Longest common subsequence of two sequences, ported from the IntelliJ
* Platform (`com.intellij.util.diff`: Diff, Reindexer, MyersLCS,
* PatienceIntLCS, UniqueLCS; Apache-2.0, Copyright JetBrains s.r.o. and
* contributors): common prefix / suffix trimmed, elements unique to one side
* discarded, then Myers' O(ND) diff bounded by a threshold, falling back to
* patience diff when the sequences differ too much.
*/
/** Half-open index ranges `[start1, end1)` and `[start2, end2)`. */
export interface Range {
readonly start1: number;
readonly end1: number;
readonly start2: number;
readonly end2: number;
}
export const range = (start1: number, end1: number, start2: number, end2: number): Range => ({
start1,
end1,
start2,
end2,
});
export const isEmptyRange = (r: Range) => r.start1 === r.end1 && r.start2 === r.end2;
/** The sequences differ too much to be compared (DiffTooBigException). */
export class DiffTooBigError extends Error {
constructor() {
super("Diff is too big");
}
}
/** Thrown inside the LCS when a bounded search gives up (FilesTooBigForDiffException). */
class FilesTooBig extends Error {}
/** Changed ranges between two int sequences (Diff.buildChanges). */
export function diffInts(a: ArrayLike<number>, b: ArrayLike<number>): Range[] {
const n1 = a.length;
const n2 = b.length;
let startShift = 0;
while (startShift < Math.min(n1, n2) && a[startShift] === b[startShift]) startShift++;
let endCut = 0;
while (endCut < Math.min(n1, n2) - startShift && a[n1 - endCut - 1] === b[n2 - endCut - 1]) endCut++;
const len1 = n1 - startShift - endCut;
const len2 = n2 - startShift - endCut;
if (len1 === 0 || len2 === 0)
return len1 || len2 ? [range(startShift, startShift + len1, startShift, startShift + len2)] : [];
const ints1 = Int32Array.from({ length: len1 }, (_, i) => a[startShift + i]!);
const ints2 = Int32Array.from({ length: len2 }, (_, i) => b[startShift + i]!);
try {
return buildChanges(ints1, ints2, startShift);
} catch (e) {
if (e instanceof FilesTooBig) throw new DiffTooBigError();
throw e;
}
}
/** Changed ranges between two sequences of keys, equal keys being equal elements. */
export function diffKeys<T>(a: readonly T[], b: readonly T[]): Range[] {
const numbers = new Map<T, number>();
const enumerate = (xs: readonly T[]) =>
Int32Array.from(xs, (x) => {
let n = numbers.get(x);
if (n === undefined) numbers.set(x, (n = numbers.size + 1));
return n;
});
return diffInts(enumerate(a), enumerate(b));
}
function buildChanges(ints1: Int32Array, ints2: Int32Array, startShift: number): Range[] {
const out: Range[] = [];
let index1 = startShift;
let index2 = startShift;
const builder: LCSBuilder = {
addEqual(length) {
index1 += length;
index2 += length;
},
addChange(first, second) {
out.push(range(index1, index1 + first, index2, index2 + second));
index1 += first;
index2 += second;
},
};
const reindexer = new Reindexer();
const [discarded1, discarded2] = reindexer.discardUnique(ints1, ints2);
if (discarded1.length === 0 && discarded2.length === 0) {
builder.addChange(ints1.length, ints2.length);
return out;
}
let changes: [Uint8Array, Uint8Array];
try {
const lcs = new MyersLCS(discarded1, discarded2);
lcs.executeWithThreshold();
changes = lcs.changes;
} catch (e) {
if (!(e instanceof FilesTooBig)) throw e;
const lcs = new PatienceLCS(discarded1, discarded2);
lcs.execute(true);
changes = lcs.changes;
}
reindexer.reindex(changes, builder);
return out;
}
interface LCSBuilder {
addEqual(length: number): void;
addChange(first: number, second: number): void;
}
const fill = (bits: Uint8Array, from: number, to: number, value = 1) => {
if (to > from) bits.fill(value, from, to);
};
/** Myers' O(ND) diff with linear space (MyersLCS); `changes` marks the unmatched elements. */
class MyersLCS {
private readonly vForward: Int32Array;
private readonly vBackward: Int32Array;
constructor(
private readonly first: Int32Array,
private readonly second: Int32Array,
private readonly start1 = 0,
private readonly count1 = first.length,
private readonly start2 = 0,
private readonly count2 = second.length,
private readonly changes1: Uint8Array = new Uint8Array(first.length),
private readonly changes2: Uint8Array = new Uint8Array(second.length),
) {
fill(changes1, start1, start1 + count1);
fill(changes2, start2, start2 + count2);
this.vForward = new Int32Array(count1 + count2 + 1);
this.vBackward = new Int32Array(count1 + count2 + 1);
}
get changes(): [Uint8Array, Uint8Array] {
return [this.changes1, this.changes2];
}
/** Bounds D by A + B·√N: expected O(N) under common assumptions. */
executeLinear() {
this.run(20000 + 10 * Math.floor(Math.sqrt(this.count1 + this.count2)), false);
}
executeWithThreshold() {
this.run(Math.max(20000 + 10 * Math.floor(Math.sqrt(this.count1 + this.count2)), DELTA_THRESHOLD_SIZE), true);
}
private run(threshold: number, throwException: boolean) {
if (this.count1 === 0 || this.count2 === 0) return;
this.execute(0, this.count1, 0, this.count2, Math.min(threshold, this.count1 + this.count2), throwException);
}
// LCS(old[oldStart, oldEnd), new[newStart, newEnd))
private execute(
oldStart: number,
oldEnd: number,
newStart: number,
newEnd: number,
differenceEstimate: number,
throwException: boolean,
) {
if (!(oldStart < oldEnd && newStart < newEnd)) return;
const { vForward: VF, vBackward: VB } = this;
const oldLength = oldEnd - oldStart;
const newLength = newEnd - newStart;
VF[newLength + 1] = 0;
VB[newLength + 1] = 0;
const halfD = (differenceEstimate + 1) >> 1;
let xx = -1;
let kk = -1;
let td = -1;
loop: for (let d = 0; d <= halfD; d++) {
const L = newLength + Math.max(-d, -newLength + ((d ^ newLength) & 1));
const R = newLength + Math.min(d, oldLength - ((d ^ oldLength) & 1));
for (let k = L; k <= R; k += 2) {
let x = k === L || (k !== R && VF[k - 1]! < VF[k + 1]!) ? VF[k + 1]! : VF[k - 1]! + 1;
const y = x - k + newLength;
x += this.commonForward(oldStart + x, newStart + y, Math.min(oldEnd - oldStart - x, newEnd - newStart - y));
VF[k] = x;
}
if ((oldLength - newLength) % 2 !== 0) {
for (let k = L; k <= R; k += 2) {
if (oldLength - (d - 1) <= k && k <= oldLength + (d - 1)) {
if (VF[k]! + VB[newLength + oldLength - k]! >= oldLength) {
xx = VF[k]!;
kk = k;
td = 2 * d - 1;
break loop;
}
}
}
}
for (let k = L; k <= R; k += 2) {
let x = k === L || (k !== R && VB[k - 1]! < VB[k + 1]!) ? VB[k + 1]! : VB[k - 1]! + 1;
const y = x - k + newLength;
x += this.commonBackward(
oldEnd - 1 - x,
newEnd - 1 - y,
Math.min(oldEnd - oldStart - x, newEnd - newStart - y),
);
VB[k] = x;
}
if ((oldLength - newLength) % 2 === 0) {
for (let k = L; k <= R; k += 2) {
if (oldLength - d <= k && k <= oldLength + d) {
if (VF[oldLength + newLength - k]! + VB[k]! >= oldLength) {
xx = oldLength - VB[k]!;
kk = oldLength + newLength - k;
td = 2 * d;
break loop;
}
}
}
}
}
if (td > 1) {
const yy = xx - kk + newLength;
const oldDiff = (td + 1) >> 1;
if (0 < xx && 0 < yy) this.execute(oldStart, oldStart + xx, newStart, newStart + yy, oldDiff, throwException);
if (oldStart + xx < oldEnd && newStart + yy < newEnd)
this.execute(oldStart + xx, oldEnd, newStart + yy, newEnd, td - oldDiff, throwException);
} else if (td >= 0) {
let x = oldStart;
let y = newStart;
while (x < oldEnd && y < newEnd) {
const common = this.commonForward(x, y, Math.min(oldEnd - x, newEnd - y));
if (common > 0) {
fill(this.changes1, this.start1 + x, this.start1 + x + common, 0);
fill(this.changes2, this.start2 + y, this.start2 + y + common, 0);
x += common;
y += common;
} else if (oldEnd - oldStart > newEnd - newStart) x++;
else y++;
}
} else if (throwException) {
// The difference is more than the given estimate.
throw new FilesTooBig();
}
}
private commonForward(oldIndex: number, newIndex: number, maxLength: number): number {
let x = oldIndex;
let y = newIndex;
maxLength = Math.min(maxLength, this.count1 - oldIndex, this.count2 - newIndex);
while (x - oldIndex < maxLength && this.first[this.start1 + x] === this.second[this.start2 + y]) {
x++;
y++;
}
return x - oldIndex;
}
private commonBackward(oldIndex: number, newIndex: number, maxLength: number): number {
let x = oldIndex;
let y = newIndex;
maxLength = Math.min(maxLength, Math.min(oldIndex, newIndex) + 1);
while (oldIndex - x < maxLength && this.first[this.start1 + x] === this.second[this.start2 + y]) {
x--;
y--;
}
return oldIndex - x;
}
}
const DELTA_THRESHOLD_SIZE = 20000;
/** Patience diff (PatienceIntLCS): unique common elements anchor the match, Myers fills the gaps. */
class PatienceLCS {
private readonly changes1: Uint8Array;
private readonly changes2: Uint8Array;
constructor(
private readonly first: Int32Array,
private readonly second: Int32Array,
) {
this.changes1 = new Uint8Array(first.length);
this.changes2 = new Uint8Array(second.length);
}
get changes(): [Uint8Array, Uint8Array] {
return [this.changes1, this.changes2];
}
execute(failOnSmallReduction = false) {
this.run(0, this.first.length, 0, this.second.length, failOnSmallReduction ? 2 : -1);
}
private run(start1: number, count1: number, start2: number, count2: number, thresholdCheckCounter: number) {
if (count1 === 0 && count2 === 0) return;
if (count1 === 0 || count2 === 0) return this.addChange(start1, count1, start2, count2);
let startOffset = 0;
while (
startOffset < Math.min(count1, count2) &&
this.first[start1 + startOffset] === this.second[start2 + startOffset]
)
startOffset++;
start1 += startOffset;
start2 += startOffset;
count1 -= startOffset;
count2 -= startOffset;
let endOffset = 0;
while (
endOffset < Math.min(count1, count2) &&
this.first[start1 + count1 - endOffset - 1] === this.second[start2 + count2 - endOffset - 1]
)
endOffset++;
count1 -= endOffset;
count2 -= endOffset;
if (count1 === 0 || count2 === 0) return this.addChange(start1, count1, start2, count2);
if (thresholdCheckCounter === 0) this.checkReduction(count1, count2);
thresholdCheckCounter = Math.max(-1, thresholdCheckCounter - 1);
const matching = uniqueLCS(this.first, this.second, start1, count1, start2, count2);
if (!matching) {
if (thresholdCheckCounter >= 0) this.checkReduction(count1, count2);
new MyersLCS(
this.first,
this.second,
start1,
count1,
start2,
count2,
this.changes1,
this.changes2,
).executeLinear();
return;
}
const [m1, m2] = matching;
const matched = m1.length;
this.run(start1, m1[0]!, start2, m2[0]!, thresholdCheckCounter);
for (let i = 1; i < matched; i++) {
const s1 = m1[i - 1]! + 1;
const s2 = m2[i - 1]! + 1;
const c1 = m1[i]! - s1;
const c2 = m2[i]! - s2;
if (c1 > 0 || c2 > 0) this.run(start1 + s1, c1, start2 + s2, c2, thresholdCheckCounter);
}
let s1: number, c1: number, s2: number, c2: number;
if (m1[matched - 1] === count1 - 1) [s1, c1] = [count1 - 1, 0];
else [s1, c1] = [m1[matched - 1]! + 1, count1 - m1[matched - 1]! - 1];
if (m2[matched - 1] === count2 - 1) [s2, c2] = [count2 - 1, 0];
else [s2, c2] = [m2[matched - 1]! + 1, count2 - m2[matched - 1]! - 1];
this.run(start1 + s1, c1, start2 + s2, c2, thresholdCheckCounter);
}
private addChange(start1: number, count1: number, start2: number, count2: number) {
fill(this.changes1, start1, start1 + count1);
fill(this.changes2, start2, start2 + count2);
}
private checkReduction(count1: number, count2: number) {
if (count1 * 2 < this.first.length) return;
if (count2 * 2 < this.second.length) return;
throw new FilesTooBig();
}
}
/** Longest increasing run of the elements unique on both sides (UniqueLCS), as relative indexes; null if none. */
function uniqueLCS(
first: Int32Array,
second: Int32Array,
start1: number,
count1: number,
start2: number,
count2: number,
): [Int32Array, Int32Array] | null {
// key -> (offset1 + 1), or -1 when repeated; match: offset1 -> (offset2 + 1)
const map = new Map<number, number>();
const match = new Int32Array(count1);
for (let i = 0; i < count1; i++) {
const key = first[start1 + i]!;
const value = map.get(key) ?? 0;
if (value === -1) continue;
map.set(key, value === 0 ? i + 1 : -1);
}
let count = 0;
for (let i = 0; i < count2; i++) {
const key = second[start2 + i]!;
const value = map.get(key) ?? 0;
if (value === 0 || value === -1) continue;
if (match[value - 1] === 0) {
match[value - 1] = i + 1;
count++;
} else {
match[value - 1] = 0;
map.set(key, -1);
count--;
}
}
if (count === 0) return null;
const sequence = new Int32Array(count);
const lastElement = new Int32Array(count);
const predecessor = new Int32Array(count1);
let length = 0;
for (let i = 0; i < count1; i++) {
const m = match[i]!;
if (m === 0) continue;
// first j with sequence[j] > m (elements are distinct)
let lo = 0;
let hi = length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sequence[mid]! < m) lo = mid + 1;
else hi = mid;
}
sequence[lo] = m;
lastElement[lo] = i;
predecessor[i] = lo > 0 ? lastElement[lo - 1]! : -1;
if (lo === length) length++;
}
const ret: [Int32Array, Int32Array] = [new Int32Array(length), new Int32Array(length)];
for (let i = length - 1, curr = lastElement[length - 1]!; curr !== -1; i--, curr = predecessor[curr]!) {
ret[0][i] = curr;
ret[1][i] = match[curr]! - 1;
}
return ret;
}
/** Drops elements that can't be matched and maps the result back (Reindexer). */
class Reindexer {
private readonly oldIndices: [Int32Array, Int32Array] = [new Int32Array(0), new Int32Array(0)];
private readonly originalLengths = [-1, -1];
private readonly discardedLengths = [-1, -1];
discardUnique(ints1: Int32Array, ints2: Int32Array): [Int32Array, Int32Array] {
const discarded1 = this.discard(ints2, ints1, 0);
return [discarded1, this.discard(discarded1, ints2, 1)];
}
private discard(needed: Int32Array, toDiscard: Int32Array, arrayIndex: 0 | 1): Int32Array {
this.originalLengths[arrayIndex] = toDiscard.length;
const present = new Set(needed);
const discarded: number[] = [];
const oldIndices: number[] = [];
for (let i = 0; i < toDiscard.length; i++) {
if (present.has(toDiscard[i]!)) {
discarded.push(toDiscard[i]!);
oldIndices.push(i);
}
}
this.oldIndices[arrayIndex] = Int32Array.from(oldIndices);
this.discardedLengths[arrayIndex] = discarded.length;
return Int32Array.from(discarded);
}
reindex(discardedChanges: [Uint8Array, Uint8Array], builder: LCSBuilder) {
const [orig1, orig2] = this.originalLengths as [number, number];
const [disc1, disc2] = this.discardedLengths as [number, number];
const [old1, old2] = this.oldIndices;
let changes1: Uint8Array;
let changes2: Uint8Array;
if (disc1 === orig1 && disc2 === orig2) {
[changes1, changes2] = discardedChanges;
} else {
changes1 = new Uint8Array(orig1);
changes2 = new Uint8Array(orig2);
const increment = (indexes: Int32Array, i: number, set: Uint8Array, length: number) => {
fill(set, indexes[i]! + 1, i + 1 < indexes.length ? indexes[i + 1]! : length);
return i + 1;
};
let x = 0;
let y = 0;
while (x < disc1 || y < disc2) {
if (x < disc1 && y < disc2 && !discardedChanges[0][x] && !discardedChanges[1][y]) {
x = increment(old1, x, changes1, orig1);
y = increment(old2, y, changes2, orig2);
} else if (discardedChanges[0][x]) {
changes1[old1[x]!] = 1;
x = increment(old1, x, changes1, orig1);
} else if (discardedChanges[1][y]) {
changes2[old2[y]!] = 1;
y = increment(old2, y, changes2, orig2);
}
}
fill(changes1, 0, disc1 === 0 ? orig1 : old1[0]!);
fill(changes2, 0, disc2 === 0 ? orig2 : old2[0]!);
}
let x = 0;
let y = 0;
while (x < orig1 && y < orig2) {
const startX = x;
while (x < orig1 && y < orig2 && !changes1[x] && !changes2[y]) {
x++;
y++;
}
if (x > startX) builder.addEqual(x - startX);
let dx = 0;
let dy = 0;
while (x < orig1 && changes1[x]) {
dx++;
x++;
}
while (y < orig2 && changes2[y]) {
dy++;
y++;
}
if (dx !== 0 || dy !== 0) builder.addChange(dx, dy);
}
if (x !== orig1 || y !== orig2) builder.addChange(orig1 - x, orig2 - y);
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Character classes, whitespace trimming and text equality under a
* comparison policy (IntelliJ's TrimUtil, ComparisonUtil and CharacterUtils,
* ported; Apache-2.0, Copyright JetBrains s.r.o. and contributors).
*/
import { range, type Range } from "./lcs";
export type ComparisonPolicy = "default" | "trim" | "ignore";
/** Whitespace for comparison purposes: space, tab and line break only (Strings.isWhiteSpace). */
export const isSpace = (c: string | undefined) => c === " " || c === "\t" || c === "\n";
const isSpaceCode = (c: number) => c === 32 || c === 9 || c === 10;
/** ASCII punctuation, except `_`. */
export function isPunctuation(c: number): boolean {
if (c === 95) return false;
return (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126);
}
export const isWhiteSpaceCodePoint = (c: number) => c < 128 && isSpaceCode(c);
export const isAlpha = (c: number) => !isWhiteSpaceCodePoint(c) && !isPunctuation(c);
const DECIMAL_DIGIT = /\p{Nd}/u;
const IDEOGRAPHIC = /\p{Ideographic}/u;
const ALPHABETIC = /\p{Alphabetic}/u;
const SPACELESS_SCRIPT = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Javanese}]/u;
/** Characters that are words on their own: CJK, Thai, symbols, anything outside the BMP. */
export function isContinuousScript(c: number): boolean {
if (c < 128) return false;
const s = String.fromCodePoint(c);
if (DECIMAL_DIGIT.test(s)) return false;
if (c > 0xffff) return true;
if (IDEOGRAPHIC.test(s)) return true;
if (!ALPHABETIC.test(s)) return true;
return SPACELESS_SCRIPT.test(s);
}
export const charCount = (codePoint: number) => (codePoint >= 0x10000 ? 2 : 1);
export function trimStart(text: string, start: number, end: number): number {
while (start < end && isSpace(text[start])) start++;
return start;
}
export function trimEnd(text: string, start: number, end: number): number {
while (start < end && isSpace(text[end - 1])) end--;
return end;
}
/** Both sides of a range without their surrounding whitespace. */
export function trimRange(text1: string, text2: string, r: Range): Range {
const start1 = trimStart(text1, r.start1, r.end1);
const start2 = trimStart(text2, r.start2, r.end2);
return range(start1, trimEnd(text1, start1, r.end1), start2, trimEnd(text2, start2, r.end2));
}
/** Shrinks a range by the equal whitespace at both ends (TrimUtil.expandWhitespaces). */
export function expandWhitespaces(text1: string, text2: string, r: Range): Range {
const forward = expandWhitespacesForward(text1, text2, r.start1, r.start2, r.end1, r.end2);
const start1 = r.start1 + forward;
const start2 = r.start2 + forward;
const backward = expandWhitespacesBackward(text1, text2, start1, start2, r.end1, r.end2);
return range(start1, r.end1 - backward, start2, r.end2 - backward);
}
export function expandWhitespacesForward(
text1: string,
text2: string,
start1: number,
start2: number,
end1: number,
end2: number,
): number {
const old = start1;
while (start1 < end1 && start2 < end2 && text1[start1] === text2[start2] && isSpace(text1[start1])) {
start1++;
start2++;
}
return start1 - old;
}
export function expandWhitespacesBackward(
text1: string,
text2: string,
start1: number,
start2: number,
end1: number,
end2: number,
): number {
const old = end1;
while (start1 < end1 && start2 < end2 && text1[end1 - 1] === text2[end2 - 1] && isSpace(text1[end1 - 1])) {
end1--;
end2--;
}
return old - end1;
}
/** The text without whitespace (StringsKmp.equalsIgnoreWhitespaces compares these). */
export const stripSpaces = (s: string) => s.replace(/[ \t\n]+/g, "");
/** The text without surrounding whitespace. */
export function trimSpaces(s: string): string {
const start = trimStart(s, 0, s.length);
return s.slice(start, trimEnd(s, start, s.length));
}
/** A line's identity under a policy: equal keys are equal lines (ComparisonUtil.isEquals). */
export function lineKey(s: string, policy: ComparisonPolicy): string {
return policy === "default" ? s : policy === "trim" ? trimSpaces(s) : stripSpaces(s);
}
/** Texts equal under a policy, trimming every line for "trim" (ComparisonUtil.isEqualTexts). */
export function isEqualTexts(text1: string, text2: string, policy: ComparisonPolicy): boolean {
if (policy === "default") return text1 === text2;
if (policy === "ignore") return stripSpaces(text1) === stripSpaces(text2);
const lines1 = text1.split("\n");
const lines2 = text2.split("\n");
return lines1.length === lines2.length && lines1.every((l, i) => trimSpaces(l) === trimSpaces(lines2[i]!));
}
export const isEqualsRange = (text1: string, text2: string, r: Range) =>
text1.slice(r.start1, r.end1) === text2.slice(r.start2, r.end2);
export const isEqualsIgnoreWhitespacesRange = (text1: string, text2: string, r: Range) =>
stripSpaces(text1.slice(r.start1, r.end1)) === stripSpaces(text2.slice(r.start2, r.end2));
+11 -1
View File
@@ -5,7 +5,7 @@
*/
import { StateEffect, StateField, type Extension, type Range } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet } from "@codemirror/view";
import { getChunks, type Chunk, type MergeView } from "@codemirror/merge";
import { getChunks, getOriginalDoc, type Chunk, type MergeView } from "@codemirror/merge";
import { iconMarkup } from "@/ui";
import { transferChunk } from "./diffOptions";
@@ -17,6 +17,16 @@ export function applyChunk(mv: MergeView, index: number, source: "a" | "b") {
dst.dispatch({ changes: transferChunk(chunk, source, src.state.doc, dst.state.doc), userEvent: "revert" });
}
/** Reverts a unified change with the same line-boundary handling as the split viewer. */
export function revertUnifiedChunk(view: EditorView, pos: number) {
const chunk = getChunks(view.state)?.chunks.find((c) => c.fromB <= pos && c.endB >= pos);
if (!chunk) return;
view.dispatch({
changes: transferChunk(chunk, "a", getOriginalDoc(view.state), view.state.doc),
userEvent: "revert",
});
}
/** 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");
+109 -133
View File
@@ -1,10 +1,12 @@
/**
* Diff viewer settings (IDEA's diff toolbar): viewer kind, whitespace policy,
* highlighting, collapsed unchanged fragments and synchronized scrolling, plus
* the whitespace-ignoring diff algorithm and per-chunk transfer helpers.
* chunks computed by IDEA's comparison and per-chunk transfer helpers.
*/
import { Change, diff, type Chunk } from "@codemirror/merge";
import type { Text } from "@codemirror/state";
import { Change, Chunk, type DiffConfig } from "@codemirror/merge";
import type { ChangeDesc, Text } from "@codemirror/state";
import { compareTexts, type ComparisonPolicy, type LineFragment } from "./comparison";
import { DiffTooBigError } from "./comparison/lcs";
export type DiffMode = "split" | "unified";
export type IgnorePolicy = "none" | "trim" | "whitespace" | "whitespaceAndLines";
@@ -82,151 +84,125 @@ export function saveDiffOptions(o: DiffViewOptions) {
}
}
interface Line {
/** Start offset. */
from: number;
/** End offset including the line break. */
to: number;
key: string;
const IDEA = Symbol("ideaDiff");
interface IdeaDiff {
policy: ComparisonPolicy;
words: boolean;
trimChunks: boolean;
}
function lineKey(text: string, policy: IgnorePolicy): string {
if (policy === "trim") return text.trim();
if (policy === "none") return text;
return text.replace(/\s+/g, "");
}
function splitLines(s: string, policy: IgnorePolicy): Line[] {
const out: Line[] = [];
let from = 0;
for (;;) {
const nl = s.indexOf("\n", from);
const end = nl < 0 ? s.length : nl;
const key = lineKey(s.slice(from, end), policy) + (policy === "none" && nl >= 0 ? "\n" : "");
if (!(policy === "whitespaceAndLines" && key === "")) out.push({ from, to: nl < 0 ? s.length : nl + 1, key });
if (nl < 0) return out;
from = nl + 1;
}
}
/** First char code used to encode lines (skips control chars), and the surrogate gap. */
const BASE = 0x21;
function encoder() {
const codes = new Map<string, string>();
let next = BASE;
return (key: string): string | null => {
let c = codes.get(key);
if (c === undefined) {
if (next === 0xd800) next = 0xe000;
if (next > 0xffff) return null;
c = String.fromCharCode(next++);
codes.set(key, c);
}
return c;
};
}
/** Bounds the expensive diff search; larger inputs fall back to a cheaper, coarser match. */
const DIFF_CONFIG = { scanLimit: 500 };
const COMPARISON_POLICY: Record<IgnorePolicy, ComparisonPolicy> = {
none: "default",
trim: "trim",
whitespace: "ignore",
whitespaceAndLines: "ignore",
};
/**
* Diff of encoded lines (one char per line). Lines that occur exactly once on
* each side anchor the match (patience diff), and only the gaps between the
* anchors get a regular diff, so distant edits in long files stay separate
* without a quadratic search.
* A MergeView `diffConfig` whose chunks come from IDEA's comparison
* (`./comparison`) instead of `@codemirror/merge`'s own diff: lines matched as
* IDEA does, word fragments by IDEA's rules, no merging of nearby changes.
*/
function diffLines(a: string, b: string): Change[] {
const seen = new Map<string, { inA: number; inB: number; at: number }>();
for (let i = 0; i < a.length; i++) {
const e = seen.get(a[i]!);
if (e) e.inA++;
else seen.set(a[i]!, { inA: 1, inB: 0, at: i });
}
for (const c of b) {
const e = seen.get(c);
if (e) e.inB++;
}
// Unique common lines as [indexA, indexB] in A order, then their longest increasing run in B.
const pairs: [number, number][] = [];
for (let j = 0; j < b.length; j++) {
const e = seen.get(b[j]!);
if (e && e.inA === 1 && e.inB === 1) pairs.push([e.at, j]);
}
pairs.sort((x, y) => x[0] - y[0]);
const tails: number[] = [];
const prev = new Array<number>(pairs.length);
for (let i = 0; i < pairs.length; i++) {
let lo = 0;
let hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (pairs[tails[mid]!]![1] < pairs[i]![1]) lo = mid + 1;
else hi = mid;
}
prev[i] = lo > 0 ? tails[lo - 1]! : -1;
tails[lo] = i;
}
const anchors: [number, number][] = [];
for (let i = tails.length ? tails[tails.length - 1]! : -1; i >= 0; i = prev[i]!) anchors.push(pairs[i]!);
anchors.reverse().push([a.length, b.length]);
const out: Change[] = [];
let fromA = 0;
let fromB = 0;
for (const [toA, toB] of anchors) {
if (toA > fromA || toB > fromB)
for (const c of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG))
out.push(new Change(c.fromA + fromA, c.toA + fromA, c.fromB + fromB, c.toB + fromB));
fromA = toA + 1;
fromB = toB + 1;
}
return out;
export function ideaDiffConfig(ignore: IgnorePolicy, highlight: HighlightPolicy): DiffConfig {
const idea: IdeaDiff = {
policy: COMPARISON_POLICY[ignore],
words: highlight === "words",
trimChunks: ignore === "whitespaceAndLines",
};
const config: DiffConfig = {};
(config as Record<symbol, IdeaDiff>)[IDEA] = idea;
return config;
}
/** The line fragment a chunk was built from. */
const fragments = new WeakMap<Chunk, LineFragment>();
/**
* Match lines first, then compare the contents of changed blocks for word
* highlighting. The line keys also implement the whitespace policy.
* Whether the chunk has IDEA's inner fragments (its words were compared):
* such a chunk gets the lighter line background (TextDiffType "ignored"
* colour) with its `changes` in full colour.
*/
export function whitespaceDiff(policy: IgnorePolicy): (a: string, b: string) => readonly Change[] {
return (a, b) => {
const la = splitLines(a, policy);
const lb = splitLines(b, policy);
const enc = encoder();
const ea: string[] = [];
const eb: string[] = [];
for (const l of la) {
const c = enc(l.key);
if (c === null) return diff(a, b, DIFF_CONFIG);
ea.push(c);
}
for (const l of lb) {
const c = enc(l.key);
if (c === null) return diff(a, b, DIFF_CONFIG);
eb.push(c);
}
const out: Change[] = [];
const at = (lines: Line[], i: number) => (i > 0 ? lines[i - 1]!.to : 0);
for (const c of diffLines(ea.join(""), eb.join(""))) {
const fromA = c.toA > c.fromA ? la[c.fromA]!.from : at(la, c.fromA);
const toA = c.toA > c.fromA ? la[c.toA - 1]!.to : fromA;
const fromB = c.toB > c.fromB ? lb[c.fromB]!.from : at(lb, c.fromB);
const toB = c.toB > c.fromB ? lb[c.toB - 1]!.to : fromB;
if (fromA === toA || fromB === toB) {
out.push(new Change(fromA, toA, fromB, toB));
continue;
}
for (const inner of diff(a.slice(fromA, toA), b.slice(fromB, toB), DIFF_CONFIG))
out.push(new Change(inner.fromA + fromA, inner.toA + fromA, inner.fromB + fromB, inner.toB + fromB));
}
return out;
};
export const hasInnerFragments = (chunk: Chunk) => !!fragments.get(chunk)?.inner;
/** IDEA's line fragments as chunks; `changes` hold the inner fragments, or the whole block. */
export function ideaChunks(a: Text, b: Text, ignore: IgnorePolicy, highlight: HighlightPolicy): readonly Chunk[] {
return toChunks(a, b, ideaOf(ideaDiffConfig(ignore, highlight))!);
}
function toChunks(a: Text, b: Text, idea: IdeaDiff): readonly Chunk[] {
let compared: LineFragment[];
try {
compared = compareTexts(a.toString(), b.toString(), idea);
} catch (error) {
if (!(error instanceof DiffTooBigError)) throw error;
// Repeated lines can exhaust IDEA's search. Keep the viewer usable with a bounded, coarser diff.
return build.call(Chunk, a, b, { scanLimit: 500 });
}
return compared.map((f) => {
// `to` is one past the last line's end, past the document for its last line.
const toA = f.startLine1 === f.endLine1 ? f.startOffset1 : f.endLine1 === a.lines ? a.length + 1 : f.endOffset1;
const toB = f.startLine2 === f.endLine2 ? f.startOffset2 : f.endLine2 === b.lines ? b.length + 1 : f.endOffset2;
const changes = f.inner
? f.inner.map((r) => new Change(r.start1, r.end1, r.start2, r.end2))
: [new Change(0, f.endOffset1 - f.startOffset1, 0, f.endOffset2 - f.startOffset2)];
const chunk = new Chunk(changes, f.startOffset1, toA, f.startOffset2, toB);
fragments.set(chunk, f);
return chunk;
});
}
// Every chunk computation of MergeView and unifiedMergeView goes through these.
const ideaOf = (conf?: DiffConfig) => (conf as Record<symbol, IdeaDiff | undefined> | undefined)?.[IDEA];
const { build, updateA, updateB } = Chunk;
Chunk.build = (a: Text, b: Text, conf?: DiffConfig) => {
const idea = ideaOf(conf);
return idea ? toChunks(a, b, idea) : build.call(Chunk, a, b, conf);
};
Chunk.updateA = (chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) => {
const idea = ideaOf(conf);
return idea ? toChunks(a, b, idea) : updateA.call(Chunk, chunks, a, b, changes, conf);
};
Chunk.updateB = (chunks: readonly Chunk[], a: Text, b: Text, changes: ChangeDesc, conf?: DiffConfig) => {
const idea = ideaOf(conf);
return idea ? toChunks(a, b, idea) : updateB.call(Chunk, chunks, a, b, changes, conf);
};
/** Text of lines `[from, to)` (0-based), without the last line break. */
const linesContent = (doc: Text, from: number, to: number) => doc.sliceString(doc.line(from + 1).from, doc.line(to).to);
/**
* The change that copies a chunk's content from one side to the other
* (IDEA's ">>" / "<<" and Revert), in the destination's coordinates.
* (IDEA's ">>" / "<<" and Revert), in the destination's coordinates. Lines are
* replaced as DiffUtil.applyModification does, so a missing final line break
* stays missing.
*/
export function transferChunk(chunk: Chunk, source: "a" | "b", src: Text, dst: Text) {
const f = fragments.get(chunk);
if (!f) return transferByOffsets(chunk, source, src, dst);
const [srcFrom, srcTo, dstFrom, dstTo] =
source === "a"
? [f.startLine1, f.endLine1, f.startLine2, f.endLine2]
: [f.startLine2, f.endLine2, f.startLine1, f.endLine1];
if (dstFrom === dstTo) {
const text = linesContent(src, srcFrom, srcTo);
return dstFrom === dst.lines
? { from: dst.length, to: dst.length, insert: "\n" + text }
: { from: dst.line(dstFrom + 1).from, to: dst.line(dstFrom + 1).from, insert: text + "\n" };
}
let from = dst.line(dstFrom + 1).from;
let to = dst.line(dstTo).to;
if (srcFrom === srcTo) {
// Lines removed with a line break: the one before them, or else the one after.
if (from > 0) from--;
else if (to < dst.length) to++;
return { from, to, insert: "" };
}
return { from, to, insert: linesContent(src, srcFrom, srcTo) };
}
/** `transferChunk` for chunks not built from line fragments. */
function transferByOffsets(chunk: Chunk, source: "a" | "b", src: Text, dst: Text) {
const [srcFrom, srcTo, dstFrom, dstTo] =
source === "a"
? [chunk.fromA, chunk.toA, chunk.fromB, chunk.toB]
+5 -15
View File
@@ -8,7 +8,7 @@
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 { changedLineGutters, chunkKind, type DiffKind } from "./cm";
import { arrowButton } from "./diffControls";
type Side = "a" | "b";
@@ -91,9 +91,6 @@ export function transferY(bands: readonly Band[], y: number, from: Side): number
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.
@@ -104,7 +101,7 @@ export function bandPath(w: number, a0: number, a1: number, b0: number, b1: numb
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) =>
const gapMark = (kind: DiffKind, 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. */
@@ -122,7 +119,7 @@ function gapDeco(view: EditorView): DecorationSet {
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));
builder.add(line.from, line.from, gapMark(chunkKind(c), from > doc.length));
}
return builder.finish();
}
@@ -143,7 +140,6 @@ const gapMarks = ViewPlugin.fromClass(
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. */
@@ -162,7 +158,6 @@ export class UnalignedPanes {
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 }> = {
@@ -179,10 +174,6 @@ export class UnalignedPanes {
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);
}
@@ -321,12 +312,11 @@ export class UnalignedPanes {
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 kind = chunkKind(c);
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-diffBand cm-diffBand-${kind}" 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);
+13
View File
@@ -73,6 +73,10 @@ html {
--gutter-modified: #375fad;
--gutter-added: #549159;
--gutter-deleted: #868a91;
/* diff viewer change types (IDEA DIFF_MODIFIED / INSERTED / DELETED) */
--diff-modified: #385570;
--diff-inserted: #294436;
--diff-deleted: #484a4a;
/* coverage (IDEA LINE_FULL / PARTIAL / NONE_COVERAGE), following the theme's status colours */
--coverage-covered: var(--gutter-added);
@@ -448,6 +452,9 @@ html {
--gutter-modified: #88adf7;
--gutter-added: #7fc784;
--gutter-deleted: #767a8a;
--diff-modified: #c2d8f2;
--diff-inserted: #bee6be;
--diff-deleted: #d6d6d6;
/* syntax (Light scheme + Default / Python defaults) */
--syn-keyword: #0033b3;
@@ -699,6 +706,9 @@ html {
--gutter-modified: #6e95e8;
--gutter-added: #6fb875;
--gutter-deleted: #a5a8ae;
--diff-modified: #000099;
--diff-inserted: #00400f;
--diff-deleted: #4d2e08;
--syn-keyword: #e8a885;
--syn-string: #8ccc94;
@@ -976,6 +986,9 @@ html {
--info: var(--palette-blue);
--vcs-modified: var(--palette-blue);
--gutter-modified: var(--palette-blue);
--diff-modified: color-mix(in srgb, var(--palette-blue) 30%, var(--palette-bg));
--diff-inserted: color-mix(in srgb, var(--palette-green) 30%, var(--palette-bg));
--diff-deleted: color-mix(in srgb, var(--palette-fg) 18%, var(--palette-bg));
--syn-link: var(--palette-blue);
--link-hover: var(--palette-cyan);
--danger: var(--palette-red);
@@ -63,6 +63,9 @@
"--gutter-modified": "#469e98",
"--gutter-added": "#549159",
"--gutter-deleted": "#75869d",
"--diff-modified": "#2f4f63",
"--diff-inserted": "#294436",
"--diff-deleted": "#43494f",
// Shadows and window controls
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",