Files
openidea/src/features/vcs/movedCode.ts
T
Shohruh Ismoilovandshohruh-genius 0396f0d552 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]>
2026-11-03 23:26:36 +08:00

118 lines
4.3 KiB
TypeScript

/** 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" },
}),
];