дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
];
|
||||
@@ -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)以启用这些检查。",
|
||||
|
||||
Reference in new issue
Заблокувати користувача