Files
openidea/src/features/vcs/cm.ts
T
Jaw 324dded5c1 feat(vcs): mark diff and merge changes on the scrollbar (#1519)
The diff and merge viewers had no overview lane, so changes outside the viewport were invisible. The scrollbar now marks every change, coloured by type (modified, inserted, deleted, conflict), in the side-by-side view (both panes), the aligned view (one stripe over the shared scroller), the unified view and the three-way merge editor; clicking a mark jumps to the change. Marks span the lane's width from the first to the last changed line with a 2px minimum. The colours are new --diff-*-stripe theme tokens (Darcula, Light and High-contrast values), mapped from ERROR_STRIPE_COLOR on .icls import and set in the Harbor Night theme template.

Part of #1505

Co-authored-by: jaw52 <[email protected]>
2026-11-05 21:43:55 +08:00

494 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CodeMirror setup for the VCS viewers (diff, merge, local history): token
* based theme + syntax colours and lazy language loading by file name.
*/
import { HighlightStyle, LanguageDescription, syntaxHighlighting, type LanguageSupport } from "@codemirror/language";
import {
Compartment,
EditorState,
RangeSet,
RangeSetBuilder,
StateEffect,
StateField,
type Extension,
type Line,
} from "@codemirror/state";
import {
Decoration,
EditorView,
GutterMarker,
ViewPlugin,
gutterLineClass,
highlightActiveLine,
keymap,
lineNumbers,
WidgetType,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { getChunks, mergeViewSiblings, type Chunk } from "@codemirror/merge";
import { defaultKeymap } from "@codemirror/commands";
import { search, searchKeymap } from "@codemirror/search";
import { tags as t } from "@lezer/highlight";
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { findExtension } from "@/editor/core/cm/find";
import { overviewLane, overviewLaneIn } from "@/editor/core/cm/lanes";
import { caretExtension } from "@/editor/core/cm/prefs";
import { fontExtension } from "@/editor/core/cm/setup";
import { caretStyles, findLocals, findStyles, overviewStyles } from "@/editor/core/cm/theme";
import { editorPrefs } from "@/platform/editorSettings";
import { useProject } from "@/platform/project";
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";
const kindOf = (hasA: boolean, hasB: boolean): DiffKind => (hasA && hasB ? "modified" : hasA ? "deleted" : "inserted");
/** A chunk's type (DiffUtil.getLineDiffType), by the sides it has lines on. */
export const chunkKind = (chunk: Chunk): DiffKind => kindOf(chunk.fromA !== chunk.toA, chunk.fromB !== chunk.toB);
const KINDS: readonly DiffKind[] = ["modified", "inserted", "deleted"];
const byKind = <T>(make: (kind: DiffKind) => T) =>
Object.fromEntries(KINDS.map((k) => [k, make(k)])) as Record<DiffKind, T>;
/**
* Text-area diff backgrounds stay translucent so the selection layer, painted
* below the content, shows through them; opaque line colours hide it. Gutters
* have no selection and keep the full colour.
*/
const diffTint = (kind: DiffKind, pct: number) => `color-mix(in srgb, var(--diff-${kind}) ${pct}%, transparent)`;
/** Line backgrounds by type; `fine` ones (words compared) are lighter. */
const lineDecos = byKind((kind) => ({
coarse: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind}` }),
fine: Decoration.line({ class: `cm-diffLine cm-diffLine-${kind} cm-diffLine-fine` }),
}));
const wordDecos = byKind((kind) => Decoration.mark({ class: `cm-diffWord cm-diffWord-${kind}` }));
/** A word on the other side only: a thin bar where it would be (DiffEmptyHighlighterRenderer). */
class EmptyWordMarker extends WidgetType {
constructor(readonly kind: DiffKind) {
super();
}
eq(other: EmptyWordMarker) {
return other.kind === this.kind;
}
toDOM() {
const span = document.createElement("span");
span.className = `cm-diffWordEmpty cm-diffWord-${this.kind}`;
span.setAttribute("aria-hidden", "true");
return span;
}
}
const emptyWordDecos = byKind((kind) => Decoration.widget({ widget: new EmptyWordMarker(kind), side: 1 }));
/**
* Calls `f` for the changed lines of a side-by-side pane between `vpFrom` and
* `vpTo`, with the type of their chunk (DiffUtil.getLineDiffType): a chunk
* with lines on both sides is modified as a whole, however its words changed.
*/
function forChangedLines(
state: EditorState,
vpFrom: number,
vpTo: number,
f: (line: Line, kind: DiffKind, fine: boolean) => void,
) {
const info = getChunks(state);
if (!info || !info.side) return;
const isA = info.side === "a";
const { doc } = state;
for (const chunk of info.chunks) {
const from = isA ? chunk.fromA : chunk.fromB;
const to = isA ? chunk.toA : chunk.toB;
if (from > vpTo) break;
if (from === to || to < vpFrom) continue;
const kind = chunkKind(chunk);
const fine = hasInnerFragments(chunk);
// A line starting at `vpTo` is still in view (the empty last line).
for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < to && line.from <= vpTo;) {
f(line, kind, fine);
if (line.number === doc.lines) break;
line = doc.line(line.number + 1);
}
}
}
/**
* Line backgrounds in the colour of their chunk's type
* (DiffDrawUtil.LineHighlighterBuilder). The unified view shows every new
* line as inserted, below the old ones as deleted (createUnifiedChunkHighlighters).
*/
function lineDeco(view: EditorView): DecorationSet {
const unified = !mergeViewSiblings(view);
const builder = new RangeSetBuilder<Decoration>();
forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, kind, fine) => {
const decos = lineDecos[unified ? "inserted" : kind];
builder.add(line.from, line.from, fine ? decos.fine : decos.coarse);
});
return builder.finish();
}
/**
* The inner fragments of chunks whose words were compared, each in the colour
* of its own type (SimpleDiffChangeUi.createInlineHighlighter); a fragment
* empty on this side is a thin bar.
*/
function wordDeco(view: EditorView): DecorationSet {
const info = getChunks(view.state);
// The unified view highlights words itself (`highlightChanges`).
if (!info || !info.side || !mergeViewSiblings(view)) return Decoration.none;
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;
if ((isA ? chunk.toA : chunk.toB) < vpFrom || !hasInnerFragments(chunk)) continue;
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);
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();
}
class LineClassMarker extends GutterMarker {
constructor(readonly elementClass: string) {
super();
}
}
const gutterMarkers = byKind((kind) => ({
coarse: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind}`),
fine: new LineClassMarker(`cm-diffChangedGutter cm-diffGutter-${kind} cm-diffGutter-fine`),
}));
/** The line background of changed lines carried into the gutters (line numbers, inclusion boxes), as in IDEA. */
export const changedLineGutters = StateField.define<RangeSet<GutterMarker>>({
create: (state) => changedGutterMarkers(state),
update: (value, tr) =>
tr.docChanged || getChunks(tr.startState)?.chunks !== getChunks(tr.state)?.chunks
? changedGutterMarkers(tr.state)
: value,
provide: (f) => gutterLineClass.from(f),
});
function changedGutterMarkers(state: EditorState): RangeSet<GutterMarker> {
const builder = new RangeSetBuilder<GutterMarker>();
forChangedLines(state, 0, state.doc.length, (line, kind, fine) =>
builder.add(line.from, line.from, fine ? gutterMarkers[kind].fine : gutterMarkers[kind].coarse),
);
return builder.finish();
}
/** Recomputes `build` when the document, viewport or chunks change. */
export const chunkDecorations = (build: (view: EditorView) => DecorationSet) =>
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = build(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
getChunks(update.startState)?.chunks !== getChunks(update.state)?.chunks
)
this.decorations = build(update.view);
}
},
{ decorations: (value) => value.decorations },
);
const changedLines = chunkDecorations(lineDeco);
/** Stripe colours by type (TextDiffType.getMarkerColor: the keys' ERROR_STRIPE_COLOR). */
const stripeColor = byKind((kind) => `var(--diff-${kind}-stripe)`);
/** The diff viewer's layouts, as the overview lane sees them. */
export type DiffLayout = "split" | "aligned" | "unified";
/**
* The changes on the overview lane as marks across its width, after IDEA's diff error stripe
* (DiffDrawUtil.LineHighlighterBuilder): a side-by-side pane marks each chunk's lines in the
* chunk's type, an empty side a point where the lines would be. The unified view has no modified type: the old lines are marked deleted and the
* new ones inserted (createUnifiedChunkHighlighters); the old lines sit above the new ones in a
* widget, so their mark is a point on top of the inserted lines.
*/
export function diffScrollbarMarks(state: EditorState, unified: boolean): ScrollbarMark[] {
const info = getChunks(state);
if (!info) return [];
if (unified)
return info.chunks.flatMap((c) => {
const marks: ScrollbarMark[] = [];
if (c.fromB !== c.toB) marks.push({ from: c.fromB, to: c.endB, color: stripeColor.inserted, kind: "full" });
if (c.fromA !== c.toA) marks.push({ from: c.fromB, color: stripeColor.deleted, kind: "full", priority: 1 });
return marks;
});
const isA = info.side === "a";
return info.chunks.map((c) => ({
from: isA ? c.fromA : c.fromB,
to: isA ? c.endA : c.endB,
color: stripeColor[chunkKind(c)],
kind: "full",
}));
}
type ChunkMarks = { chunks: readonly Chunk[] | undefined; marks: readonly ScrollbarMark[] };
/** Recomputed when the chunks change, which can happen without a document change (MergeView, `updateOriginalDoc`). */
const chunkMarks = (unified: boolean) =>
StateField.define<ChunkMarks>({
create: (state) => ({ chunks: getChunks(state)?.chunks, marks: diffScrollbarMarks(state, unified) }),
update: (value, tr) => {
const chunks = getChunks(tr.state)?.chunks;
return chunks === value.chunks ? value : { chunks, marks: diffScrollbarMarks(tr.state, unified) };
},
provide: (f) => scrollbarMarks.from(f, (v) => v.marks),
});
/**
* The overview lane with the diff's changes, over the pane's scrollbar. The panes of an aligned
* side-by-side diff scroll together in the MergeView, whose scrollbar the right pane's lane takes.
*/
export function diffOverview(layout: DiffLayout): Extension {
return [
chunkMarks(layout === "unified"),
layout === "aligned" ? overviewLaneIn((view) => view.dom.closest<HTMLElement>(".cm-mergeView")) : overviewLane,
];
}
/**
* IDEA's "Highlight words" for a side-by-side pane: the inner fragments of
* chunks whose words were compared, over their lighter line background.
*/
export const diffWords: Extension = [
chunkDecorations(wordDeco),
EditorView.editorAttributes.of({ class: "cm-diffWords" }),
];
export const viewerHighlight = HighlightStyle.define([
{
tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword, t.definitionKeyword],
color: "var(--syn-keyword)",
},
{ tag: [t.string, t.special(t.string), t.regexp, t.character], color: "var(--syn-string)" },
{ tag: [t.typeName, t.className, t.namespace, t.tagName], color: "var(--syn-type)" },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "var(--syn-function)" },
{ tag: [t.comment, t.lineComment, t.blockComment, t.docComment], color: "var(--syn-comment)", fontStyle: "italic" },
{ tag: [t.annotation, t.meta, t.attributeName], color: "var(--syn-annotation)" },
{ tag: [t.number, t.bool, t.null, t.atom], color: "var(--syn-number)" },
{ tag: [t.constant(t.variableName), t.standard(t.variableName)], color: "var(--syn-constant)" },
{ tag: [t.punctuation, t.bracket, t.operator], color: "var(--syn-punct)" },
{ tag: [t.propertyName], color: "var(--syn-property)" },
{ tag: [t.variableName], color: "var(--syn-variable)" },
{ tag: t.invalid, color: "var(--syn-invalid)" },
{ tag: t.heading, fontWeight: "var(--fw-code-bold, 700)", color: "var(--fg-strong)" },
{ tag: t.link, color: "var(--link)", textDecoration: "underline" },
]);
export const viewerTheme = EditorView.theme(
{
...overviewStyles,
"&": {
height: "100%",
backgroundColor: "var(--bg-app)",
color: "var(--fg)",
fontSize: "var(--openidea-editor-font-size, var(--fs-code))",
...findLocals,
},
".cm-scroller": {
fontFamily: "var(--openidea-editor-font, var(--font-mono))",
fontWeight: "var(--fw-code, 400)",
lineHeight: "var(--openidea-editor-line-height, var(--lh-code))",
fontVariantLigatures: "none",
},
"&.cm-openidea-ligatures .cm-scroller": { fontVariantLigatures: "normal" },
".cm-content": { caretColor: "var(--caret, var(--fg-strong))", WebkitUserSelect: "text", userSelect: "text" },
...caretStyles,
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection, .cm-openidea-selected, &.cm-focused .cm-openidea-selected":
{
backgroundColor: "var(--bg-selected-strong)",
},
".cm-activeLine": { backgroundColor: "var(--bg-current-line)" },
".cm-gutters": {
backgroundColor: "var(--bg-app)",
color: "var(--fg-gutter)",
border: "none",
},
".cm-lineNumbers .cm-gutterElement": { padding: "0 8px 0 12px", minWidth: "32px" },
".cm-activeLineGutter": { backgroundColor: "transparent", color: "var(--fg-secondary)" },
// Side-by-side panes colour lines by their chunk's type (changedLines) instead of the library's tint.
"&.cm-merge-a .cm-changedLine, &.cm-merge-b .cm-changedLine": { backgroundColor: "transparent" },
// The unified view's own line tint (cm-inlineChangedLine), keyed to the inserted colour.
".cm-inlineChangedLine": { backgroundColor: diffTint("inserted", 70) },
".cm-line.cm-diffLine-modified": { backgroundColor: diffTint("modified", 70) },
".cm-line.cm-diffLine-inserted": { backgroundColor: diffTint("inserted", 70) },
".cm-line.cm-diffLine-deleted": { backgroundColor: diffTint("deleted", 70) },
".cm-diffGutter-modified": { backgroundColor: "var(--diff-modified)" },
".cm-diffGutter-inserted": { backgroundColor: "var(--diff-inserted)" },
".cm-diffGutter-deleted": { backgroundColor: "var(--diff-deleted)" },
// Chunks whose words were compared take the lighter colour (TextDiffType.getIgnoredColor), their
// fragments the full one; lines translucent so the selection shows through, gutters opaque.
...Object.assign(
{},
...KINDS.flatMap((k) => [
{
[`.cm-line.cm-diffLine-fine.cm-diffLine-${k}`]: {
backgroundColor: `color-mix(in srgb, var(--diff-${k}) 28%, transparent)`,
},
},
{
[`.cm-diffGutter-fine.cm-diffGutter-${k}`]: {
backgroundColor: `color-mix(in srgb, var(--diff-${k}) 40%, var(--bg-app))`,
},
},
]),
),
// Full line height, as IDEA paints them: an inline background only covers the font's height,
// leaving stripes between the lines of a multi-line fragment.
".cm-diffWord": { display: "inline-block" },
".cm-diffWord-modified": { backgroundColor: diffTint("modified", 85) },
".cm-diffWord-inserted": { backgroundColor: diffTint("inserted", 85) },
".cm-diffWord-deleted": { backgroundColor: diffTint("deleted", 85) },
".cm-diffWordEmpty": {
display: "inline-block",
width: "2px",
height: "1lh",
margin: "0 -1px",
verticalAlign: "top",
},
// The unified view: old lines deleted, new lines inserted.
".cm-deletedChunk": { backgroundColor: diffTint("deleted", 70) },
"&.cm-diffWords .cm-deletedChunk": { backgroundColor: diffTint("deleted", 28) },
".cm-deletedChunk .cm-deletedText": { background: diffTint("deleted", 85) },
"&.cm-merge-a .cm-changedText": { background: diffTint("deleted", 85) },
"&.cm-merge-b .cm-changedText": { background: diffTint("inserted", 85) },
".cm-changeGutter": { width: "3px", paddingLeft: "0" },
"&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter": { background: "var(--gutter-deleted)" },
"&.cm-merge-b .cm-changedLineGutter": { background: "var(--gutter-modified)" },
".cm-collapsedLines": {
padding: "2px 10px",
background: "var(--bg-elev)",
color: "var(--fg-subtle)",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-small)",
cursor: "pointer",
},
".cm-collapsedLines:before": { content: "none" },
".cm-collapsedLines:after": { content: "none" },
// Clear of the overview lane over the scrollbar (12px wide).
"&.cm-openidea-withOverview .cm-deletedChunk .cm-chunkButtons": { insetInlineEnd: "17px" },
".cm-chunkButtons button": {
font: "inherit",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-tiny)",
padding: "0 6px",
border: "1px solid var(--border-strong)",
borderRadius: "var(--radius-small)",
background: "var(--bg-elev)",
color: "var(--fg-secondary)",
},
".cm-panels": { backgroundColor: "var(--bg-elev)", color: "var(--fg)" },
".cm-searchMatch": { backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)" },
...findStyles,
},
{ dark: true },
);
const caretSlot = new Compartment();
const fontSlot = new Compartment();
const editorSettings = () => useProject.getState().settings?.editor;
/**
* The editor's caret (Settings › Editor › General › Appearance) and font
* (Settings › Editor › Font), kept in step with the settings.
*/
function viewerEditorLook(): Extension {
return [
caretSlot.of(caretExtension(editorPrefs())),
fontSlot.of(fontExtension(editorSettings())),
ViewPlugin.define((view) => {
let appearance = editorPrefs().appearance;
let editor = editorSettings();
const unsubscribe = useProject.subscribe(() => {
const effects: StateEffect<unknown>[] = [];
const next = editorPrefs();
if (JSON.stringify(next.appearance) !== JSON.stringify(appearance)) {
appearance = next.appearance;
effects.push(caretSlot.reconfigure(caretExtension(next)));
}
if (editorSettings() !== editor) {
editor = editorSettings();
effects.push(fontSlot.reconfigure(fontExtension(editor)));
}
if (effects.length) view.dispatch({ effects });
});
return { destroy: unsubscribe };
}),
];
}
/**
* Base extensions for a read-only (or editable) viewer pane. `findBar`: the
* viewer shows the editor's find bar (FindPanel) instead of CodeMirror's panel.
*/
export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean; findBar?: boolean }): Extension[] {
return [
opts.lineNumbers === false ? [] : lineNumbers(),
viewerEditorLook(),
highlightActiveLine(),
opts.findBar
? [findExtension(), keymap.of(defaultKeymap)]
: [search({ top: true }), keymap.of([...searchKeymap, ...defaultKeymap])],
syntaxHighlighting(viewerHighlight),
viewerTheme,
changedLines,
EditorState.readOnly.of(opts.readOnly),
EditorView.editable.of(!opts.readOnly),
// Non-editable revision panes still need keyboard selection and copy.
opts.readOnly ? EditorView.contentAttributes.of({ tabindex: "0" }) : [],
];
}
let languagesPromise: Promise<readonly LanguageDescription[]> | null = null;
const cache = new Map<string, Promise<LanguageSupport | null>>();
/** Loads the language for `fileName` (by extension / name), or null. */
export function languageFor(fileName: string): Promise<LanguageSupport | null> {
const name = fileName.slice(fileName.lastIndexOf("/") + 1);
const hit = cache.get(name);
if (hit) return hit;
languagesPromise ??= import("@codemirror/language-data").then((m) => m.languages);
const p = languagesPromise
.then((langs) => LanguageDescription.matchFilename(langs, name))
.then((desc) => (desc ? desc.load() : null))
.catch(() => null);
cache.set(name, p);
return p;
}