feat(vcs): recognize moved and edited code in the diff viewer (#868)

Blocks that were moved unchanged, re-indented or moved and then edited are paired across the split diff: each side gets a link to the other (keyboard accessible), moved lines carry a left accent bar, edits inside a moved block get word-level highlights, and the toolbar counts moved blocks and whitespace-only changes. Staging and reverting still work on the original hunks.

Co-authored-by: shohruh-genius <[email protected]>
Цей коміт міститься в:
Shohruh Ismoilovтаshohruh-genius authored and GitHub committed 2026-11-03 23:26:36 +08:00
1 джерело 09402e3e95
коміт 0396f0d552
9 files changed
+709 -14

No files matched your search

+18 -3
Переглянути файл
@@ -31,7 +31,7 @@ const toolbar = (page: Page) => page.getByRole("toolbar", { name: "Diff" });
const counter = (page: Page) =>
toolbar(page)
.locator("span")
.filter({ hasText: /differen|identical/ });
.filter({ hasText: /differen|identical|Whitespace-only/ });
/** 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> {
@@ -98,7 +98,9 @@ async function rendered(page: Page): Promise<{ a: Side; b: Side; deletedChunks:
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)$`));
await expect(counter(page)).toHaveText(
new RegExp(`^(${differences(n)}|\\d+ of ${n} differences)(?: · (?:Whitespace-only changes|\\d+ moved blocks?))?$`),
);
}
const all = (n: number, k: Kind) => Array<Kind>(n).fill(k);
@@ -164,8 +166,21 @@ test.describe("small cases (side-by-side, Highlight words)", () => {
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);
if (c.path === "src/moved.ts") await expect(page.locator(".cm-diffMoveLink")).toHaveCount(2);
});
test("edited moves link the counterpart and highlight their actual word changes", async ({ workbench: page }) => {
await openCase(page, "src/moved-edited.ts");
await expectCount(page, "src/moved-edited.ts");
await expect(page.locator(".cm-diffMoveLink")).toHaveCount(2);
await expect(page.locator(".cm-diffMoveEdit-deleted")).toHaveText("parseImpl");
await expect(page.locator(".cm-diffMoveEdit-inserted")).toHaveText("parseUpdated");
const link = page.getByRole("button", { name: /Go to moved code at line/ });
await expect(link).toContainText("(edited)");
await link.click();
await expect(page.locator(".cm-merge-b .cm-activeLine")).toContainText("function parse");
});
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);
@@ -211,7 +226,7 @@ test.describe("small cases (side-by-side, Highlight words)", () => {
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");
await expect(counter(page)).toHaveText("Whitespace-only changes");
expect((await rendered(page)).b.lines).toEqual([]);
});
+5
Переглянути файл
@@ -91,6 +91,11 @@ export const CASES: readonly DiffCase[] = [
{ 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/moved-edited.ts",
before: FUNCS.join("\n"),
after: [...FUNCS.slice(1), FUNCS[0]!.replace("parseImpl", "parseUpdated")].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 },
{
+25 -4
Переглянути файл
@@ -32,6 +32,7 @@ import {
ideaDiffConfig,
loadDiffOptions,
saveDiffOptions,
getDiffInsights,
type DiffMode,
type DiffViewOptions,
type HighlightPolicy,
@@ -51,6 +52,7 @@ import {
type DiffSide,
} from "./diffInput";
import { diffAnnotations } from "./diffAnnotations";
import { movedCodeHighlights } from "./movedCode";
import {
createDiffViewerContributions,
type DiffPaneSide,
@@ -206,7 +208,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
const [error, setError] = useState<string | null>(null);
const [opts, setOptsState] = useState<DiffViewOptions>(loadDiffOptions);
const [lang, setLang] = useState<Extension | null>(null);
const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1 });
const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1, moves: 0, whitespaceOnly: false });
const [busy, setBusy] = useState(false);
const hostRef = useRef<HTMLDivElement>(null);
const viewsRef = useRef<Views | null>(null);
@@ -302,7 +304,15 @@ export function DiffEditor({ input }: { input: EditorInput }) {
const chunks = getChunks(v.state)?.chunks ?? [];
const head = v.state.selection.main.head;
const current = chunks.findIndex((c) => c.fromB <= head && head <= Math.max(c.fromB, c.endB));
setChunkInfo((p) => (p.count === chunks.length && p.current === current ? p : { count: chunks.length, current }));
const { moves, whitespaceOnly } = getDiffInsights(chunks);
setChunkInfo((p) =>
p.count === chunks.length &&
p.current === current &&
p.moves === moves.length &&
p.whitespaceOnly === whitespaceOnly
? p
: { count: chunks.length, current, moves: moves.length, whitespaceOnly },
);
}, []);
// A HEAD ↔ working file diff of a tracked change: blocks can be left out of the commit.
@@ -379,7 +389,11 @@ export function DiffEditor({ input }: { input: EditorInput }) {
: undefined,
});
// IDEA's colouring: lines by chunk type, carried into the gutters, and typed word highlights.
const colours = [changedLineGutters, highlightChanges ? diffWords : []];
const colours = [
changedLineGutters,
highlightChanges ? diffWords : [],
opts.highlight !== "none" ? movedCodeHighlights(highlightChanges) : [],
];
const mv = new MergeView({
a: {
doc: textA,
@@ -993,12 +1007,19 @@ export function DiffEditor({ input }: { input: EditorInput }) {
: chunkInfo.count === 0
? identical
? tx("Contents are identical")
: tx("No differences")
: chunkInfo.whitespaceOnly
? tx("Whitespace-only changes")
: tx("No differences")
: chunkInfo.current >= 0
? tx`${chunkInfo.current + 1} of ${chunkInfo.count} differences`
: chunkInfo.count === 1
? tx`${chunkInfo.count} difference`
: tx`${chunkInfo.count} differences`}
{ready && !binary && chunkInfo.count > 0 && chunkInfo.whitespaceOnly && tx(" · Whitespace-only changes")}
{ready &&
!binary &&
chunkInfo.moves > 0 &&
(chunkInfo.moves === 1 ? tx(" · 1 moved block") : tx` · ${chunkInfo.moves} moved blocks`)}
</span>
{contribs.map((c, i) => c.Toolbar && <c.Toolbar key={i} host={viewer} />)}
<IconButton
+242
Переглянути файл
@@ -0,0 +1,242 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { EditorInput } from "@/workbench/contributions";
const texts = vi.hoisted(() => ({ before: "", after: "" }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
fsProbe: () => Promise.resolve({ status: "error", error: "missing" }),
vcsFileAt: (_path: string, rev: string) =>
Promise.resolve({ status: "ok", data: rev === "A" ? texts.before : texts.after }),
},
};
});
import { DiffEditor } from "../DiffEditor";
import { getDiffInsights, ideaChunks, transferChunk, type IgnorePolicy } from "../diffOptions";
const first = "function first() {\n return 'first';\n}\n";
const second = "function second() {\n return 'second';\n}\n";
const third = "function third() {\n return 'third';\n}\n";
const doc = (text: string) => Text.of(text.split("\n"));
const compare = (a: string, b: string, ignore: IgnorePolicy = "none") => {
const chunks = ideaChunks(doc(a), doc(b), ignore, "words");
return { chunks, ...getDiffInsights(chunks) };
};
const input = {
kind: "diff",
key: "diff:moved",
data: {
path: "/p/example.ts",
left: { type: "rev", rev: "A" },
right: { type: "rev", rev: "B" },
},
} as unknown as EditorInput;
beforeEach(() => {
localStorage.clear();
texts.before = first + second + third;
texts.after = second + third + first;
});
describe("built-in moved block detection", () => {
it("pairs a moved function while preserving all transferable line hunks", () => {
const { chunks, moves } = compare(texts.before, texts.after);
expect(moves).toHaveLength(1);
const move = moves[0]!;
expect(texts.before.slice(move.fromA, move.toA)).toBe(first.trimEnd());
expect(texts.after.slice(move.fromB, move.toB)).toBe(first.trimEnd());
let restored = doc(texts.after);
for (const chunk of [...chunks].reverse()) {
const edit = transferChunk(chunk, "a", doc(texts.before), restored);
restored = restored.replace(edit.from, edit.to, doc(edit.insert));
}
expect(restored.toString()).toBe(texts.before);
});
it("recognizes reindented moved code and records token changes separately", () => {
const reindented = first
.split("\n")
.map((l) => (l ? ` ${l}` : l))
.join("\n");
expect(compare(texts.before, second + third + reindented).moves).toHaveLength(1);
expect(compare(texts.before, second + third + reindented).moves[0]!.changes).toEqual([]);
const changed = first.replace("'first'", "'changed'");
const { moves } = compare(texts.before, second + third + changed);
expect(moves).toHaveLength(1);
const move = moves[0]!;
expect(move.changes.map((c) => [first.slice(c.start1, c.end1), changed.slice(c.start2, c.end2)])).toEqual([
["first", "changed"],
]);
});
it("does not label copied code, repeated blocks, braces or indentation-only edits as moves", () => {
expect(compare(texts.before, texts.before + first).moves).toHaveLength(0);
expect(compare(first + first + second + third, second + third + first + first).moves).toHaveLength(0);
expect(compare("{\n}\nkeep\n", "keep\n{\n}\n").moves).toHaveLength(0);
expect(compare(first, first.replace(" return", " return")).moves).toHaveLength(0);
});
it("handles Unicode, no final newline and multiple independently moved blocks", () => {
const unicode = "function привет() {\n return '你好 😀';\n}\n";
const a = unicode + second + third;
const b = (second + third + unicode).trimEnd();
const { moves } = compare(a, b);
expect(moves).toHaveLength(1);
expect(a.slice(moves[0]!.fromA, moves[0]!.toA)).toBe(unicode.trimEnd());
expect(b.slice(moves[0]!.fromB, moves[0]!.toB)).toBe(unicode.trimEnd());
const fourth = "function fourth() {\n return 'fourth';\n}\n";
const fifth = "function fifth() {\n return 'fifth';\n}\n";
expect(
compare(first + second + third + fourth + fifth, second + third + first + fifth + fourth).moves,
).toHaveLength(2);
expect(
compare(first + second + third + fourth + fifth, second + third + first + fifth + fourth).moves.every(
(m) => m.changes.length === 0,
),
).toBe(true);
});
it("classifies whitespace-only differences under every ignore policy", () => {
for (const ignore of ["none", "trim", "whitespace", "whitespaceAndLines"] as const) {
expect(compare("let a = 1;\n", " let a = 1;\n\n", ignore).whitespaceOnly).toBe(true);
expect(compare("let a = 1;\n", "let a = 2;\n", ignore).whitespaceOnly).toBe(false);
expect(compare("same", "same", ignore).whitespaceOnly).toBe(false);
}
});
});
describe("edited moves", () => {
const original =
"function compute() {\n const value = calculate(input);\n record(value);\n return value + 1;\n}\n";
const inspect = (changed: string) => compare(original + second + third, second + third + changed);
it.each([
["body edit", original.replace("+ 1", "+ 2")],
["added line", original.replace(" return", " validate(value);\n return")],
["deleted line", original.replace(" record(value);\n", "")],
["renamed function", original.replace("compute", "renamed")],
[
"reindent and edit",
original
.replace("+ 1", "+ 2")
.split("\n")
.map((l) => (l ? ` ${l}` : l))
.join("\n"),
],
])("pairs a moved block with a %s and preserves revert", (_name, changed) => {
const a = original + second + third;
const b = second + third + changed;
const { moves, chunks } = inspect(changed);
expect(moves).toHaveLength(1);
expect(a.slice(moves[0]!.fromA, moves[0]!.toA)).toBe(original.trimEnd());
expect(b.slice(moves[0]!.fromB, moves[0]!.toB)).toBe(changed.trimEnd());
expect(moves[0]!.changes.length).toBeGreaterThan(0);
let restored = doc(b);
for (const chunk of [...chunks].reverse()) {
const edit = transferChunk(chunk, "a", doc(a), restored);
restored = restored.replace(edit.from, edit.to, doc(edit.insert));
}
expect(restored.toString()).toBe(a);
});
it("does not classify an ordinary edit shifted by nearby insertions as a move", () => {
const a = original + second + third;
const b = "// new header\n" + original.replace("+ 1", "+ 2") + second + third;
expect(compare(a, b).moves).toHaveLength(0);
});
it("rejects major rewrites, repeated alternatives and weak matches between unrelated functions", () => {
expect(inspect("function compute() {\n throw new Error('not implemented');\n}\n").moves).toHaveLength(0);
const renamedTiny = first.replace("function first", "function unrelated");
expect(compare(first + second + third, second + third + renamedTiny).moves).toHaveLength(0);
const changed = original.replace("+ 1", "+ 2");
expect(
compare(original + original + second + third, second + third + changed + changed).moves.every(
(m) => m.changes.length === 0,
),
).toBe(true);
});
it("keeps UTF-16 edit offsets correct for Unicode and a missing final newline", () => {
const aBlock = original.replace("calculate(input)", "calculate('你好 😀')");
const bBlock = aBlock.replace("+ 1", "+ 2").trimEnd();
const { moves } = compare(aBlock + second + third, second + third + bBlock);
expect(moves).toHaveLength(1);
expect(moves[0]!.changes.map((c) => [aBlock.slice(c.start1, c.end1), bBlock.slice(c.start2, c.end2)])).toEqual([
["1", "2"],
]);
});
it("leaves large approximate comparisons usable as ordinary line hunks", () => {
const large = "function large() {\n" + Array.from({ length: 300 }, (_, i) => ` call${i}();\n`).join("") + "}\n";
const a = large + second + third;
const b = second + third + large.replace("call150()", "changed150()");
const result = compare(a, b);
expect(result.moves.every((m) => m.changes.length === 0)).toBe(true);
expect(result.chunks.length).toBeGreaterThan(0);
});
});
describe("moved code in the diff viewer", () => {
it("labels edited moves and highlights only their internal word edits", async () => {
texts.after = second + third + first.replace("'first'", "'changed'");
render(<DiffEditor input={input} />);
const link = await screen.findByRole("button", { name: "Go to moved code at line 7" });
expect(link).toHaveTextContent("(edited)");
expect(document.querySelector(".cm-diffMoveEdit-deleted")?.textContent).toBe("first");
expect(document.querySelector(".cm-diffMoveEdit-inserted")?.textContent).toBe("changed");
fireEvent.click(link);
const b = EditorView.findFromDOM(document.querySelector<HTMLElement>(".cm-editor.cm-merge-b")!)!;
expect(b.state.doc.lineAt(b.state.selection.main.head).number).toBe(7);
});
it("omits inner edits in Highlight lines mode", async () => {
texts.after = second + third + first.replace("'first'", "'changed'");
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ highlight: "lines" }));
render(<DiffEditor input={input} />);
expect(await screen.findByRole("button", { name: "Go to moved code at line 7" })).toHaveTextContent("(edited)");
expect(document.querySelector(".cm-diffMoveEdit")).toBeNull();
});
it("shows native move links, follows them in both directions and updates after editing", async () => {
render(<DiffEditor input={input} />);
const to = await screen.findByRole("button", { name: "Go to moved code at line 7" });
await screen.findByText(/1 moved block/);
const a = EditorView.findFromDOM(document.querySelector<HTMLElement>(".cm-editor.cm-merge-a")!)!;
const b = EditorView.findFromDOM(document.querySelector<HTMLElement>(".cm-editor.cm-merge-b")!)!;
expect(document.querySelectorAll(".cm-diffMoved").length).toBeGreaterThan(0);
fireEvent.click(to);
expect(b.state.doc.lineAt(b.state.selection.main.head).number).toBe(7);
fireEvent.click(screen.getByRole("button", { name: "Go to original code at line 1" }));
expect(a.state.selection.main.head).toBe(0);
await act(async () => b.dispatch({ changes: { from: 0, to: b.state.doc.length, insert: texts.before } }));
await waitFor(() => expect(screen.queryByText(/1 moved block/)).toBeNull());
expect(document.querySelector(".cm-diffMoved")).toBeNull();
expect(screen.queryByRole("button", { name: /Go to moved code/ })).toBeNull();
});
it("respects Do not highlight", async () => {
texts.after = second + third + first.replace("'first'", "'changed'");
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ highlight: "none" }));
render(<DiffEditor input={input} />);
await screen.findByText(/1 moved block/);
expect(document.querySelector(".cm-diffMoved")).toBeNull();
expect(document.querySelector(".cm-diffMoveEdit")).toBeNull();
expect(screen.queryByRole("button", { name: /Go to moved code/ })).toBeNull();
});
it("explains ignored whitespace rather than displaying No differences", async () => {
texts.before = "let a = 1;\n";
texts.after = " let a = 1;\n";
localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ ignore: "whitespace" }));
render(<DiffEditor input={input} />);
expect(await screen.findByText("Whitespace-only changes")).toBeInTheDocument();
expect(screen.queryByText("No differences")).toBeNull();
});
});
+20 -4
Переглянути файл
@@ -36,7 +36,7 @@ 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";
import { getDiffInsights, hasInnerFragments } from "./diffOptions";
/** IDEA's change types (TextDiffType): both sides have text, only the new side, only the old side. */
export type DiffKind = "modified" | "inserted" | "deleted";
@@ -142,6 +142,10 @@ function wordDeco(view: EditorView): DecorationSet {
const isA = info.side === "a";
const { from: vpFrom, to: vpTo } = view.viewport;
const builder = new RangeSetBuilder<Decoration>();
const moved = getDiffInsights(info.chunks)
.moves.map((m) => (isA ? { from: m.fromA, to: m.toA } : { from: m.fromB, to: m.toB }))
.sort((a, b) => a.from - b.from);
let moveIndex = 0;
for (const chunk of info.chunks) {
const base = isA ? chunk.fromA : chunk.fromB;
if (base > vpTo) break;
@@ -149,8 +153,20 @@ function wordDeco(view: EditorView): DecorationSet {
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]);
let cursor = base + from;
const end = base + to;
while (moveIndex < moved.length && moved[moveIndex]!.to <= cursor) moveIndex++;
if (from === to) {
if (!moved[moveIndex] || moved[moveIndex]!.from > cursor) builder.add(cursor, cursor, emptyWordDecos[kind]);
continue;
}
// Moved ranges have their own comparison against the actual counterpart.
for (let i = moveIndex; i < moved.length && moved[i]!.from < end; i++) {
const move = moved[i]!;
if (move.from > cursor) builder.add(cursor, Math.min(move.from, end), wordDecos[kind]);
cursor = Math.max(cursor, move.to);
}
if (cursor < end) builder.add(cursor, end, wordDecos[kind]);
}
}
return builder.finish();
@@ -186,7 +202,7 @@ function changedGutterMarkers(state: EditorState): RangeSet<GutterMarker> {
}
/** Recomputes `build` when the document, viewport or chunks change. */
const chunkDecorations = (build: (view: EditorView) => DecorationSet) =>
export const chunkDecorations = (build: (view: EditorView) => DecorationSet) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
+245
Переглянути файл
@@ -0,0 +1,245 @@
/** Conservative moved-block matching over changed line ranges. */
import type { Text } from "@codemirror/state";
import type { Chunk } from "@codemirror/merge";
import { compareWords, getInlineChunks } from "./byWord";
import { DiffTooBigError, type Range } from "./lcs";
export interface MovedBlock {
readonly fromA: number;
readonly toA: number;
readonly fromB: number;
readonly toB: number;
readonly lineA: number;
readonly lineB: number;
/** Word edits relative to each moved block, ignoring indentation and whitespace. */
readonly changes: readonly Range[];
}
interface Candidate {
start: number;
editEnd: number;
row: number;
end: number;
text: string;
pure: boolean;
}
interface Anchor {
source: Candidate;
target: Candidate;
}
interface EditedRun {
start: number;
from: number;
to: number;
text: string;
tokens: readonly string[];
}
const meaningful = /[\p{L}\p{N}_]/u;
const declaration =
/^(?:(?:export|default|async|public|private|protected|static)\s+)*(?:function|class|def|fn|fun|struct|interface)\b/;
function candidates(doc: Text, other: Text, chunks: readonly Chunk[], side: "a" | "b") {
const rows = new Map<number, Candidate>();
const unique = new Map<string, Candidate | null>();
for (const c of chunks) {
const [otherFrom, otherTo] = side === "a" ? [c.fromB, c.toB] : [c.fromA, c.toA];
// A chunk may replace the empty final row when the moved block loses its final newline.
const pure = otherFrom === otherTo || otherFrom >= other.length;
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
if (from === to || from > doc.length) continue;
const end = doc.lineAt(Math.min(to - 1, doc.length)).number;
const first = doc.lineAt(from).number;
let start = first;
let indent = Infinity;
let block: Candidate[] = [];
for (let row = first; row <= end; row++) {
const raw = doc.line(row).text;
const text = raw.trim();
const leading = raw.length - raw.trimStart().length;
// Keep edits from neighbouring declarations out of an approximate move.
// Nested declarations stay with their enclosing changed block.
if (declaration.test(text) && leading <= indent) {
for (const line of block) line.editEnd = row - 1;
block = [];
start = row;
indent = leading;
}
const candidate = { start, editEnd: end, row, end, text, pure };
block.push(candidate);
rows.set(row, candidate);
if (meaningful.test(text)) unique.set(text, unique.has(text) ? null : candidate);
}
}
return { rows, unique };
}
function editedRun(doc: Text, candidate: Candidate): EditedRun | null {
let start = candidate.start;
let end = candidate.editEnd;
// Approximate matching is optional; keep its work bounded on generated or very large changes.
if (end - start >= 256 || doc.line(end).to - doc.line(start).from > 16_384) return null;
while (start <= end && !doc.line(start).text.trim()) start++;
while (end >= start && !doc.line(end).text.trim()) end--;
if (start > end) return null;
// A shared closing brace can sit just outside a replacement hunk. Include it on both
// sides rather than reporting the hunk boundary itself as an edit to the moved code.
if (
start < end &&
declaration.test(doc.line(start).text.trim()) &&
end < doc.lines &&
!/^};?$/.test(doc.line(end).text.trim()) &&
/^};?$/.test(doc.line(end + 1).text.trim())
)
end++;
const from = doc.line(start).from;
const to = doc.line(end).to;
const text = doc.sliceString(from, to);
const tokens = getInlineChunks(text)
.filter((t) => !t.newline)
.map((t) => t.key);
return tokens.length >= 4 && tokens.length <= 2048 ? { start, from, to, text, tokens } : null;
}
function tokenSimilarity(a: readonly string[], b: readonly string[]): number {
const counts = new Map<string, number>();
for (const token of a) counts.set(token, (counts.get(token) ?? 0) + 1);
let common = 0;
for (const token of b) {
const count = counts.get(token) ?? 0;
if (count) {
common++;
counts.set(token, count - 1);
}
}
return (2 * common) / (a.length + b.length);
}
/** Only score anchored, relocated runs; never compare every deletion with every insertion. */
function editedMoves(a: Text, b: Text, anchors: readonly Anchor[], crossed: ReadonlySet<number>): MovedBlock[] {
const pairs = new Map<string, Anchor[]>();
for (const anchor of anchors) {
const { source, target } = anchor;
if (source.start === target.start || (!(source.pure || target.pure) && !crossed.has(source.row))) continue;
const key = `${source.start}:${target.start}`;
const pair = pairs.get(key);
if (pair) pair.push(anchor);
else pairs.set(key, [anchor]);
// Exhausting a budget must not turn an unexamined competitor into a confident match.
if (pairs.size > 128) return [];
}
const before = new Map<number, EditedRun | null>();
const after = new Map<number, EditedRun | null>();
const proposals: MovedBlock[] = [];
let budget = 65_536;
for (const pair of pairs.values()) {
const { source, target } = pair[0]!;
if (!before.has(source.start)) before.set(source.start, editedRun(a, source));
if (!after.has(target.start)) after.set(target.start, editedRun(b, target));
const old = before.get(source.start);
const next = after.get(target.start);
if (!old || !next) continue;
const sameDeclaration = pair.some(
({ source: s, target: t }) => s.row === old.start && t.row === next.start && declaration.test(s.text),
);
// One shared body line is too weak to infer that an unrelated function was renamed.
if (!sameDeclaration && pair.length < 2) continue;
budget -= old.tokens.length + next.tokens.length;
if (budget < 0) return [];
if (tokenSimilarity(old.tokens, next.tokens) < 0.75) continue;
let changes: readonly Range[];
try {
changes = compareWords(old.text, next.text, "ignore");
} catch (error) {
if (!(error instanceof DiffTooBigError)) throw error;
continue;
}
proposals.push({
fromA: old.from,
toA: old.to,
fromB: next.from,
toB: next.to,
lineA: old.start,
lineB: next.start,
changes,
});
}
// Both sides must identify a single counterpart; similar alternatives stay ordinary edits.
const sources = new Map<number, number>();
const targets = new Map<number, number>();
for (const p of proposals) {
sources.set(p.fromA, (sources.get(p.fromA) ?? 0) + 1);
targets.set(p.fromB, (targets.get(p.fromB) ?? 0) + 1);
}
return proposals.filter((p) => p.changes.length > 0 && sources.get(p.fromA) === 1 && targets.get(p.fromB) === 1);
}
/**
* Match unique line anchors, then extend through the surrounding changed run.
* Ignore leading/trailing whitespace; repeated anchors and small boilerplate stay ordinary changes.
* Each accepted line is consumed once, so large moved blocks need no pairwise search.
*/
export function findMovedBlocks(a: Text, b: Text, chunks: readonly Chunk[]): readonly MovedBlock[] {
const before = candidates(a, b, chunks, "a");
const after = candidates(b, a, chunks, "b");
const usedA = new Set<number>();
const usedB = new Set<number>();
const anchors = [...before.unique.values()].flatMap((source) => {
if (!source) return [];
const target = after.unique.get(source.text);
return target ? [{ source, target }] : [];
});
// In replacement chunks require crossed matches: a monotonic shift is an ordinary edit.
const crossed = new Set<number>();
let max = -1;
for (const { source, target } of anchors) {
if (target.row < max) crossed.add(source.row);
max = Math.max(max, target.row);
}
let min = Infinity;
for (let i = anchors.length - 1; i >= 0; i--) {
const { source, target } = anchors[i]!;
if (target.row > min) crossed.add(source.row);
min = Math.min(min, target.row);
}
const moves = editedMoves(a, b, anchors, crossed);
for (const move of moves) {
for (let row = move.lineA; row <= a.lineAt(move.toA).number; row++) usedA.add(row);
for (let row = move.lineB; row <= b.lineAt(move.toB).number; row++) usedB.add(row);
}
for (const start of before.rows.values()) {
if (usedA.has(start.row) || before.unique.get(start.text) !== start) continue;
const target = after.unique.get(start.text);
if (!target || usedB.has(target.row)) continue;
if (start.row === target.row || (!(start.pure || target.pure) && !crossed.has(start.row))) continue;
let lines = 0;
let words = 0;
let size = 0;
while (start.row + lines <= start.end && target.row + lines <= target.end) {
const old = before.rows.get(start.row + lines)!;
const next = after.rows.get(target.row + lines)!;
if (old.text !== next.text || usedA.has(old.row) || usedB.has(next.row)) break;
if (meaningful.test(old.text)) words++;
size += old.text.length;
lines++;
}
while (lines && !before.rows.get(start.row + lines - 1)!.text) lines--;
if (!lines || size < 16 || (words < 2 && !(declaration.test(start.text) && /\{.*\}/.test(start.text)))) continue;
for (let i = 0; i < lines; i++) {
usedA.add(start.row + i);
usedB.add(target.row + i);
}
moves.push({
fromA: a.line(start.row).from,
toA: a.line(start.row + lines - 1).to,
fromB: b.line(target.row).from,
toB: b.line(target.row + lines - 1).to,
lineA: start.row,
lineB: target.row,
changes: [],
});
}
return moves;
}
+24 -3
Переглянути файл
@@ -7,6 +7,8 @@ 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";
import { findMovedBlocks, type MovedBlock } from "./comparison/moves";
import { stripSpaces } from "./comparison/text";
export type DiffMode = "split" | "unified";
export type IgnorePolicy = "none" | "trim" | "whitespace" | "whitespaceAndLines";
@@ -118,6 +120,15 @@ export function ideaDiffConfig(ignore: IgnorePolicy, highlight: HighlightPolicy)
/** The line fragment a chunk was built from. */
const fragments = new WeakMap<Chunk, LineFragment>();
interface DiffInsights {
readonly moves: readonly MovedBlock[];
readonly whitespaceOnly: boolean;
}
const insights = new WeakMap<readonly Chunk[], DiffInsights>();
const NO_INSIGHTS: DiffInsights = { moves: [], whitespaceOnly: false };
export const getDiffInsights = (chunks: readonly Chunk[]): DiffInsights => insights.get(chunks) ?? NO_INSIGHTS;
/**
* Whether the chunk has IDEA's inner fragments (its words were compared):
* such a chunk gets the lighter line background (TextDiffType "ignored"
@@ -131,15 +142,24 @@ export function ideaChunks(a: Text, b: Text, ignore: IgnorePolicy, highlight: Hi
}
function toChunks(a: Text, b: Text, idea: IdeaDiff): readonly Chunk[] {
const textA = a.toString();
const textB = b.toString();
const remember = (chunks: readonly Chunk[]) => {
insights.set(chunks, {
moves: findMovedBlocks(a, b, chunks),
whitespaceOnly: textA !== textB && stripSpaces(textA) === stripSpaces(textB),
});
return chunks;
};
let compared: LineFragment[];
try {
compared = compareTexts(a.toString(), b.toString(), idea);
compared = compareTexts(textA, textB, 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 remember(build.call(Chunk, a, b, { scanLimit: 500 }));
}
return compared.map((f) => {
const chunks = 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;
@@ -150,6 +170,7 @@ function toChunks(a: Text, b: Text, idea: IdeaDiff): readonly Chunk[] {
fragments.set(chunk, f);
return chunk;
});
return remember(chunks);
}
// Every chunk computation of MergeView and unifiedMergeView goes through these.
+117
Переглянути файл
@@ -0,0 +1,117 @@
/** Moved-code highlights and keyboard-accessible links between split diff panes. */
import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view";
import { getChunks, mergeViewSiblings, uncollapseUnchanged } from "@codemirror/merge";
import { tx } from "@/platform/i18n";
import { chunkDecorations } from "./cm";
import { getDiffInsights } from "./diffOptions";
const movedLine = Decoration.line({ class: "cm-diffMoved" });
class MovedCodeLink extends WidgetType {
constructor(
readonly target: number,
readonly line: number,
readonly edited: boolean,
) {
super();
}
eq(other: MovedCodeLink) {
return other.target === this.target && other.line === this.line && other.edited === this.edited;
}
toDOM(view: EditorView) {
const button = document.createElement("button");
const isA = getChunks(view.state)?.side === "a";
button.type = "button";
button.className = "cm-diffMoveLink";
if (this.edited)
button.title = isA
? tx`Moved to line ${this.line} with internal edits`
: tx`Moved from line ${this.line} with internal edits`;
button.textContent = this.edited
? isA
? tx`To L${this.line} (edited)`
: tx`From L${this.line} (edited)`
: isA
? tx`Moved to line ${this.line}`
: tx`Moved from line ${this.line}`;
button.setAttribute(
"aria-label",
isA ? tx`Go to moved code at line ${this.line}` : tx`Go to original code at line ${this.line}`,
);
button.onclick = () => {
const panes = mergeViewSiblings(view);
if (!panes) return;
const other = isA ? panes.b : panes.a;
if (!other) return;
other.dispatch({
selection: { anchor: this.target },
effects: [uncollapseUnchanged.of(this.target), EditorView.scrollIntoView(this.target, { y: "center" })],
});
other.focus();
};
return button;
}
ignoreEvent() {
return true;
}
}
function decorations(view: EditorView, words: boolean): DecorationSet {
const info = getChunks(view.state);
if (!info?.side) return Decoration.none;
const isA = info.side === "a";
const ranges = getDiffInsights(info.chunks).moves.flatMap((m) => {
const [from, to, target] = isA ? [m.fromA, m.toA, m.fromB] : [m.fromB, m.toB, m.fromA];
const line = isA ? m.lineB : m.lineA;
const edits = words
? m.changes.flatMap((c) => {
const start = isA ? c.start1 : c.start2;
const end = isA ? c.end1 : c.end2;
return start === end
? []
: [
Decoration.mark({
class: `cm-diffMoveEdit cm-diffMoveEdit-${isA ? "deleted" : "inserted"}`,
attributes: { title: tx("Edited within moved code") },
}).range(from + start, from + end),
];
})
: [];
// Whole lines, not the text: the chunk's own background already shows the change.
const lines = [];
for (let line = view.state.doc.lineAt(from); ; line = view.state.doc.line(line.number + 1)) {
lines.push(movedLine.range(line.from));
if (line.to >= to - 1 || line.number === view.state.doc.lines) break;
}
return [
...lines,
...edits,
Decoration.widget({ widget: new MovedCodeLink(target, line, m.changes.length > 0), side: 1 }).range(
view.state.doc.lineAt(from).to,
),
];
});
return Decoration.set(ranges, true);
}
export const movedCodeHighlights = (words: boolean) => [
chunkDecorations((view) => decorations(view, words)),
EditorView.baseTheme({
".cm-diffMoved": { boxShadow: "inset 3px 0 0 color-mix(in srgb, var(--accent) 70%, transparent)" },
".cm-diffMoveEdit-deleted": { backgroundColor: "color-mix(in srgb, var(--diff-deleted) 85%, transparent)" },
".cm-diffMoveEdit-inserted": { backgroundColor: "color-mix(in srgb, var(--diff-inserted) 85%, transparent)" },
".cm-diffMoveLink": {
marginLeft: "12px",
padding: "0 4px",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-small)",
color: "var(--link)",
background: "var(--bg-app)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-small)",
cursor: "pointer",
},
".cm-diffMoveLink:hover": { background: "var(--bg-hover)" },
".cm-diffMoveLink:focus-visible": { outline: "2px solid var(--accent)", outlineOffset: "2px" },
}),
];
+13
Переглянути файл
@@ -1,5 +1,18 @@
{
"Edit {name}": "编辑 {name}",
"Whitespace-only changes": "仅空白字符更改",
" · Whitespace-only changes": " · 仅空白字符更改",
" · 1 moved block": " · 已移动 1 个代码块",
" · {moves} moved blocks": " · 已移动 {moves} 个代码块",
"Moved to line {line} with internal edits": "已移至第 {line} 行,且内容已修改",
"Moved from line {line} with internal edits": "从第 {line} 行移动而来,且内容已修改",
"To L{line} (edited)": "至第 {line} 行(已修改)",
"From L{line} (edited)": "来自第 {line} 行(已修改)",
"Moved to line {line}": "已移至第 {line} 行",
"Moved from line {line}": "从第 {line} 行移动而来",
"Go to moved code at line {line}": "跳转到第 {line} 行的移动后代码",
"Go to original code at line {line}": "跳转到第 {line} 行的原始代码",
"Edited within moved code": "移动代码中的修改",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。",
"ACP Registry": "ACP 注册表",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。",