From 0396f0d5521d632dc980bd5e3c29a0850efed8b5 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Tue, 3 Nov 2026 20:26:36 +0500 Subject: [PATCH] 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 <7658451+shohruh-genius@users.noreply.github.com> --- e2e/diff.spec.ts | 21 +- e2e/diffRepo.ts | 5 + src/features/vcs/DiffEditor.tsx | 29 ++- src/features/vcs/__tests__/movedCode.test.tsx | 242 +++++++++++++++++ src/features/vcs/cm.ts | 24 +- src/features/vcs/comparison/moves.ts | 245 ++++++++++++++++++ src/features/vcs/diffOptions.ts | 27 +- src/features/vcs/movedCode.ts | 117 +++++++++ src/platform/locales/text/zh.json | 13 + 9 files changed, 709 insertions(+), 14 deletions(-) create mode 100644 src/features/vcs/__tests__/movedCode.test.tsx create mode 100644 src/features/vcs/comparison/moves.ts create mode 100644 src/features/vcs/movedCode.ts diff --git a/e2e/diff.spec.ts b/e2e/diff.spec.ts index 9657d5a7b..724b72900 100644 --- a/e2e/diff.spec.ts +++ b/e2e/diff.spec.ts @@ -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 { @@ -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(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([]); }); diff --git a/e2e/diffRepo.ts b/e2e/diffRepo.ts index 3dc5db18e..b2a1ddaa5 100644 --- a/e2e/diffRepo.ts +++ b/e2e/diffRepo.ts @@ -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 }, { diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index f44a47d02..332d041e4 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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(null); const [opts, setOptsState] = useState(loadDiffOptions); const [lang, setLang] = useState(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(null); const viewsRef = useRef(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`)} {contribs.map((c, i) => c.Toolbar && )} ({ before: "", after: "" })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + 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(); + 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(".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(); + 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(); + 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(".cm-editor.cm-merge-a")!)!; + const b = EditorView.findFromDOM(document.querySelector(".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(); + 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(); + expect(await screen.findByText("Whitespace-only changes")).toBeInTheDocument(); + expect(screen.queryByText("No differences")).toBeNull(); + }); +}); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index 0ddba4b37..131c06af0 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -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(); + 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 { } /** 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; diff --git a/src/features/vcs/comparison/moves.ts b/src/features/vcs/comparison/moves.ts new file mode 100644 index 000000000..0531c2bdb --- /dev/null +++ b/src/features/vcs/comparison/moves.ts @@ -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(); + const unique = new Map(); + 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(); + 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): MovedBlock[] { + const pairs = new Map(); + 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(); + const after = new Map(); + 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(); + const targets = new Map(); + 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(); + const usedB = new Set(); + 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(); + 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; +} diff --git a/src/features/vcs/diffOptions.ts b/src/features/vcs/diffOptions.ts index 4e7299494..b07837983 100644 --- a/src/features/vcs/diffOptions.ts +++ b/src/features/vcs/diffOptions.ts @@ -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(); +interface DiffInsights { + readonly moves: readonly MovedBlock[]; + readonly whitespaceOnly: boolean; +} + +const insights = new WeakMap(); +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. diff --git a/src/features/vcs/movedCode.ts b/src/features/vcs/movedCode.ts new file mode 100644 index 000000000..2e1488ce8 --- /dev/null +++ b/src/features/vcs/movedCode.ts @@ -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" }, + }), +]; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d09276bd7..d24144b15 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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)以启用这些检查。",