mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
* feat(vcs): IDEA's diff algorithm and change colouring in the diff viewer Port IntelliJ's text comparison to TypeScript (src/features/vcs/comparison): Myers/patience LCS with unique-element discarding, ByLine (smart matching that skips unimportant lines, chunk optimizer, second-step correction), ByWord (word chunks, punctuation matching, whitespace policies, line block splitting) and ComparisonManager's compareLinesInner / processBlocks. The port runs IntelliJ's own LineComparisonUtilTest, WordComparisonUtilTest and SplitComparisonUtilTest cases (459 runs), converted to ideaCases.json. MergeView chunks now come straight from these line fragments, with the inner word fragments as chunk changes, instead of @codemirror/merge's presentable diff. Revert and >> / << replace lines as DiffUtil.applyModification does. Colour changes as IDEA does: modified (blue), inserted (green) and deleted (grey) by chunk type, a lighter line background for chunks whose words were compared, each word fragment in its own type at full line height, a thin bar for a fragment empty on one side, and the colours carried into the gutters and the divider bands. New --diff-modified / --diff-inserted / --diff-deleted theme tokens, imported from .icls DIFF_* attributes. Add a Playwright suite over a generated git repository of diff cases, including 10,000-line files, in every viewer mode. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(vcs): preserve unified revert boundaries and bound large diffs --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: l0ng-ai <[email protected]>
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
/**
|
|
* Per-change controls of the diff viewer, as in IDEA: ">>" / "<<" apply arrows
|
|
* in the divider (toward each editable side) and a Revert button shown while
|
|
* hovering a change in an editable pane.
|
|
*/
|
|
import { StateEffect, StateField, type Extension, type Range } from "@codemirror/state";
|
|
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet } from "@codemirror/view";
|
|
import { getChunks, getOriginalDoc, type Chunk, type MergeView } from "@codemirror/merge";
|
|
import { iconMarkup } from "@/ui";
|
|
import { transferChunk } from "./diffOptions";
|
|
|
|
/** Copies chunk `index` of `mv` from `source` to the other side (an undoable user edit). */
|
|
export function applyChunk(mv: MergeView, index: number, source: "a" | "b") {
|
|
const chunk = mv.chunks[index];
|
|
if (!chunk) return;
|
|
const [src, dst] = source === "a" ? [mv.a, mv.b] : [mv.b, mv.a];
|
|
dst.dispatch({ changes: transferChunk(chunk, source, src.state.doc, dst.state.doc), userEvent: "revert" });
|
|
}
|
|
|
|
/** Reverts a unified change with the same line-boundary handling as the split viewer. */
|
|
export function revertUnifiedChunk(view: EditorView, pos: number) {
|
|
const chunk = getChunks(view.state)?.chunks.find((c) => c.fromB <= pos && c.endB >= pos);
|
|
if (!chunk) return;
|
|
view.dispatch({
|
|
changes: transferChunk(chunk, "a", getOriginalDoc(view.state), view.state.doc),
|
|
userEvent: "revert",
|
|
});
|
|
}
|
|
|
|
/** A ">>" / "<<" button; the chunk index comes from the nearest `data-chunk` (itself included). */
|
|
export function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = `cm-diffArrow cm-diffArrow-${dir}`;
|
|
const label = dir === "a-to-b" ? "Apply change to the right (Replace ›››)" : "Apply change to the left (‹‹‹ Replace)";
|
|
btn.setAttribute("aria-label", label);
|
|
btn.title = label;
|
|
btn.innerHTML = iconMarkup(dir === "a-to-b" ? "chevron-right" : "chevron-left", 12) ?? "";
|
|
btn.addEventListener("mousedown", (e) => {
|
|
// Handled here rather than by MergeView's own (one-directional) revert handler.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const holder = (e.currentTarget as HTMLElement).closest<HTMLElement>("[data-chunk]");
|
|
const index = Number(holder?.dataset.chunk);
|
|
if (Number.isInteger(index)) run(index);
|
|
});
|
|
return btn;
|
|
}
|
|
|
|
/**
|
|
* `renderRevertControl` for MergeView: one or two arrows per chunk. `toB`:
|
|
* the right side is editable (">>"); `toA`: the left side is (">>" / "<<").
|
|
*/
|
|
export function arrowControls(
|
|
getView: () => MergeView | null,
|
|
sides: { toA: boolean; toB: boolean },
|
|
): () => HTMLElement {
|
|
return () => {
|
|
const box = document.createElement("div");
|
|
box.className = "cm-diffArrows";
|
|
if (sides.toB) box.appendChild(arrowButton("a-to-b", (i) => getView() && applyChunk(getView()!, i, "a")));
|
|
if (sides.toA) box.appendChild(arrowButton("b-to-a", (i) => getView() && applyChunk(getView()!, i, "b")));
|
|
return box;
|
|
};
|
|
}
|
|
|
|
const setHover = StateEffect.define<number | null>();
|
|
|
|
class RevertWidget extends WidgetType {
|
|
constructor(
|
|
readonly index: number,
|
|
readonly onRevert: (view: EditorView, index: number) => void,
|
|
) {
|
|
super();
|
|
}
|
|
eq(other: RevertWidget) {
|
|
return other.index === this.index;
|
|
}
|
|
toDOM(view: EditorView) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "cm-diffHoverRevert";
|
|
btn.title = "Revert this change";
|
|
btn.setAttribute("aria-label", "Revert this change");
|
|
btn.innerHTML = `${iconMarkup("revert", 12) ?? ""}<span>Revert</span>`;
|
|
btn.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.onRevert(view, this.index);
|
|
});
|
|
return btn;
|
|
}
|
|
ignoreEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function chunkAt(chunks: readonly Chunk[], side: "a" | "b", pos: number, docLength: number): number {
|
|
return chunks.findIndex((c) => {
|
|
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
|
return from === to ? pos === from : pos >= from && pos < Math.max(to, from + 1) && pos <= docLength;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A "Revert" button at the first line of the change under the mouse, for an
|
|
* editable pane of a split diff. `onRevert` copies the other side's content.
|
|
*/
|
|
export function hoverRevert(onRevert: (view: EditorView, index: number) => void): Extension {
|
|
const field = StateField.define<{ index: number | null; deco: DecorationSet }>({
|
|
create: () => ({ index: null, deco: Decoration.none }),
|
|
update(value, tr) {
|
|
let index = value.index;
|
|
for (const e of tr.effects) if (e.is(setHover)) index = e.value;
|
|
const info = getChunks(tr.state);
|
|
if (index !== null && (!info || index >= info.chunks.length)) index = null;
|
|
if (index === value.index && !tr.docChanged && getChunks(tr.startState) === info) return value;
|
|
const decos: Range<Decoration>[] = [];
|
|
const chunk = index !== null ? info?.chunks[index] : undefined;
|
|
if (chunk && info?.side) {
|
|
const from = info.side === "a" ? chunk.fromA : chunk.fromB;
|
|
const line = tr.state.doc.lineAt(Math.min(from, tr.state.doc.length));
|
|
decos.push(Decoration.line({ class: "cm-diffHoverLine" }).range(line.from));
|
|
decos.push(Decoration.widget({ widget: new RevertWidget(index!, onRevert), side: 1 }).range(line.to));
|
|
}
|
|
return { index, deco: Decoration.set(decos, true) };
|
|
},
|
|
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
|
});
|
|
const tracker = ViewPlugin.fromClass(
|
|
class {
|
|
constructor(readonly view: EditorView) {}
|
|
set(index: number | null) {
|
|
if (this.view.state.field(field).index !== index) this.view.dispatch({ effects: setHover.of(index) });
|
|
}
|
|
},
|
|
{
|
|
eventHandlers: {
|
|
mousemove(e) {
|
|
const info = getChunks(this.view.state);
|
|
if (!info?.side) return;
|
|
const pos = this.view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
|
const line = this.view.state.doc.lineAt(pos);
|
|
const i = chunkAt(info.chunks, info.side, line.from, this.view.state.doc.length);
|
|
this.set(i >= 0 ? i : null);
|
|
},
|
|
mouseleave() {
|
|
this.set(null);
|
|
},
|
|
},
|
|
},
|
|
);
|
|
return [field, tracker];
|
|
}
|