дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
14 files changed
+393
-69
No files matched your search
@@ -324,7 +324,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
228 themeable tokens: 121 in the UI theme file, 107 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -407,6 +407,10 @@ a token.
|
||||
| `--diff-modified` | `#385570` | `#c2d8f2` |
|
||||
| `--diff-inserted` | `#294436` | `#bee6be` |
|
||||
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
|
||||
| `--diff-modified-stripe` | `#43698d` | `#b6d2f2` |
|
||||
| `--diff-inserted-stripe` | `#447152` | `#aadeaa` |
|
||||
| `--diff-deleted-stripe` | `#656e76` | `#c8c8c8` |
|
||||
| `--diff-conflict-stripe` | `#8f5247` | `#ffc8bd` |
|
||||
|
||||
### Syntax (editor scheme)
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
"--diff-modified": "#2f4f63",
|
||||
"--diff-inserted": "#294436",
|
||||
"--diff-deleted": "#43494f",
|
||||
"--diff-modified-stripe": "#3a6380",
|
||||
"--diff-inserted-stripe": "#447152",
|
||||
"--diff-deleted-stripe": "#5f676f",
|
||||
"--diff-conflict-stripe": "#8f5247",
|
||||
|
||||
// Shadows and window controls
|
||||
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
|
||||
|
||||
@@ -238,8 +238,12 @@ export interface ScrollbarMark {
|
||||
to?: number;
|
||||
/** CSS colour, normally a token such as `var(--danger)`. */
|
||||
color: string;
|
||||
/** `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences). */
|
||||
kind?: "line" | "thin";
|
||||
/**
|
||||
* `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences);
|
||||
* `full`: the lane's whole width, from the top of the first line to the top of the last (IDEA's
|
||||
* diff and merge error stripe, so a one-line change is a min-height dash).
|
||||
*/
|
||||
kind?: "line" | "thin" | "full";
|
||||
/** Shown on hover. */
|
||||
tooltip?: string;
|
||||
/** Higher wins when marks overlap. Default 0. */
|
||||
|
||||
@@ -58,8 +58,14 @@ export function unfoldAround(state: EditorState, pos: number): StateEffect<unkno
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The class an outer scroller gets while a lane draws its scrollbar (its native one is hidden). */
|
||||
const OUTER_SCROLLER = "cm-openidea-overviewScroller";
|
||||
|
||||
class OverviewLane {
|
||||
dom: HTMLElement;
|
||||
/** What scrolls the document: the editor's own scroller, or an outer one the editor is laid out in. */
|
||||
private scroller: HTMLElement;
|
||||
private resize: ResizeObserver | null = null;
|
||||
private scheduled = false;
|
||||
private lastKey = "";
|
||||
/** The last pointer position over the editor, to re-check the thumb when it scrolls. */
|
||||
@@ -70,9 +76,13 @@ class OverviewLane {
|
||||
*/
|
||||
private thumb: HTMLElement;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
constructor(
|
||||
private view: EditorView,
|
||||
outer?: HTMLElement,
|
||||
) {
|
||||
this.scroller = outer ?? view.scrollDOM;
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "cm-openidea-overview";
|
||||
this.dom.className = outer ? "cm-openidea-overview cm-openidea-overview-sticky" : "cm-openidea-overview";
|
||||
this.dom.setAttribute("aria-hidden", "true");
|
||||
this.thumb = document.createElement("div");
|
||||
this.thumb.className = "cm-openidea-overviewThumb";
|
||||
@@ -80,8 +90,14 @@ class OverviewLane {
|
||||
this.thumb.addEventListener("mousedown", this.onThumbMouseDown);
|
||||
view.dom.addEventListener("mousemove", this.onPointerMove);
|
||||
view.dom.addEventListener("mouseleave", this.onPointerLeave);
|
||||
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
view.dom.appendChild(this.dom);
|
||||
this.scroller.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
if (outer) {
|
||||
// First in the editor, sticking to the top of the outer scroller's viewport.
|
||||
view.dom.prepend(this.dom);
|
||||
outer.classList.add(OUTER_SCROLLER);
|
||||
this.resize = new ResizeObserver(() => this.schedule());
|
||||
this.resize.observe(outer);
|
||||
} else view.dom.appendChild(this.dom);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
@@ -104,7 +120,7 @@ class OverviewLane {
|
||||
key: this,
|
||||
read: (view) => {
|
||||
this.scheduled = false;
|
||||
const laneHeight = view.scrollDOM.clientHeight;
|
||||
const laneHeight = this.scroller.clientHeight;
|
||||
const len = view.state.doc.length;
|
||||
// The document itself, without the scroll-past-end padding that follows the editor height.
|
||||
const contentHeight = Math.max(view.lineBlockAt(len).bottom, 1);
|
||||
@@ -125,7 +141,10 @@ class OverviewLane {
|
||||
// Marks landing on the same lane pixel collapse into one.
|
||||
const px = Math.floor(a.top * scale);
|
||||
const top = pos(a.top);
|
||||
const height = fit ? Math.round(b.bottom - a.top) : +(((b.bottom - a.top) / contentHeight) * 100).toFixed(3);
|
||||
// A `full` mark runs from the top of its first line to the top of its last, as IDEA's
|
||||
// error stripe (EditorMarkupModelImpl.offsetsToYPositions): one line is a min-height dash.
|
||||
const span = (m.kind === "full" ? b.top : b.bottom) - a.top;
|
||||
const height = fit ? Math.round(span) : +((span / contentHeight) * 100).toFixed(3);
|
||||
const key = `${px}:${m.color}:${m.kind ?? "line"}`;
|
||||
const prev = rows.get(key);
|
||||
if (!prev || (m.priority ?? 0) > (prev.mark.priority ?? 0) || height > prev.height)
|
||||
@@ -136,19 +155,25 @@ class OverviewLane {
|
||||
rows: [...rows.values()].sort((x, y) => (x.mark.priority ?? 0) - (y.mark.priority ?? 0)),
|
||||
caret,
|
||||
unit,
|
||||
laneHeight,
|
||||
};
|
||||
},
|
||||
write: ({ rows, caret, unit }) => {
|
||||
write: ({ rows, caret, unit, laneHeight }) => {
|
||||
if (this.scroller !== this.view.scrollDOM) {
|
||||
this.dom.style.height = `${laneHeight}px`;
|
||||
this.dom.style.marginBottom = `-${laneHeight}px`;
|
||||
}
|
||||
const key =
|
||||
rows.map((r) => `${r.top},${r.height},${r.mark.color},${r.mark.kind}`).join("|") + `#${caret}${unit}`;
|
||||
rows.map((r) => `${r.top},${r.height},${r.mark.color},${r.mark.kind}`).join("|") +
|
||||
`#${caret}${unit}${laneHeight}`;
|
||||
if (key === this.lastKey) return;
|
||||
this.lastKey = key;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const r of rows) {
|
||||
const el = document.createElement("div");
|
||||
el.className =
|
||||
r.mark.kind === "thin"
|
||||
? "cm-openidea-overviewMark cm-openidea-overviewMark-thin"
|
||||
r.mark.kind === "thin" || r.mark.kind === "full"
|
||||
? `cm-openidea-overviewMark cm-openidea-overviewMark-${r.mark.kind}`
|
||||
: "cm-openidea-overviewMark";
|
||||
el.style.top = `${r.top}${unit}`;
|
||||
el.style.height = `${r.height}${unit}`;
|
||||
@@ -171,8 +196,8 @@ class OverviewLane {
|
||||
private scheduleThumb() {
|
||||
this.view.requestMeasure({
|
||||
key: this.thumb,
|
||||
read: (view) => {
|
||||
const s = view.scrollDOM;
|
||||
read: () => {
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
// The lane starts at the top of the editor, the scrollbar at the top of the scroller.
|
||||
const offset = s.getBoundingClientRect().top - this.dom.getBoundingClientRect().top;
|
||||
@@ -197,7 +222,7 @@ class OverviewLane {
|
||||
let over = false;
|
||||
if (p) {
|
||||
const lane = this.dom.getBoundingClientRect();
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
const y = p.y - s.getBoundingClientRect().top;
|
||||
over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom;
|
||||
@@ -226,7 +251,7 @@ class OverviewLane {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
if (!thumb) return;
|
||||
const startY = e.clientY;
|
||||
@@ -255,7 +280,7 @@ class OverviewLane {
|
||||
const hit = nearestMark(marks, e.clientY);
|
||||
if (!hit) {
|
||||
// Above / below the thumb pages the editor, like the native track it replaces.
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
e.preventDefault();
|
||||
if (!thumb) return;
|
||||
@@ -280,12 +305,23 @@ class OverviewLane {
|
||||
this.thumb.removeEventListener("mousedown", this.onThumbMouseDown);
|
||||
this.view.dom.removeEventListener("mousemove", this.onPointerMove);
|
||||
this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
|
||||
this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
|
||||
this.scroller.removeEventListener("scroll", this.onScroll);
|
||||
this.resize?.disconnect();
|
||||
if (this.scroller !== this.view.scrollDOM) this.scroller.classList.remove(OUTER_SCROLLER);
|
||||
this.dom.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview lane over an outer scroller that holds the editor at its full height (the panes of
|
||||
* an aligned side-by-side diff scroll together in their container): `scroller` finds it once the
|
||||
* editor is in the DOM. The lane sticks to the right edge of this editor.
|
||||
*/
|
||||
export function overviewLaneIn(scroller: (view: EditorView) => HTMLElement | null): Extension {
|
||||
return ViewPlugin.define((view) => new OverviewLane(view, scroller(view) ?? undefined));
|
||||
}
|
||||
|
||||
export const overviewLane: Extension = [
|
||||
ViewPlugin.fromClass(OverviewLane),
|
||||
ViewPlugin.define((view) => new OverviewLane(view)),
|
||||
EditorView.editorAttributes.of({ class: "cm-openidea-withOverview" }),
|
||||
];
|
||||
@@ -64,6 +64,60 @@ export const caretStyles = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The error stripe and scrollbar lane (cm/lanes.ts), also for the VCS viewers. */
|
||||
export const overviewStyles = {
|
||||
".cm-openidea-overview": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
width: "12px",
|
||||
cursor: "default",
|
||||
zIndex: "3",
|
||||
},
|
||||
// Over an outer scroller (an aligned diff): stays at the top of its viewport, taking no room.
|
||||
".cm-openidea-overview-sticky": {
|
||||
position: "sticky",
|
||||
bottom: "auto",
|
||||
flex: "none",
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
".cm-openidea-overviewMark": {
|
||||
position: "absolute",
|
||||
left: "3px",
|
||||
width: "6px",
|
||||
minHeight: "3px",
|
||||
borderRadius: "1px",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
|
||||
".cm-openidea-overviewMark-full": { left: "0", width: "100%", minHeight: "2px", borderRadius: "0" },
|
||||
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
|
||||
// The lane owns the scrollbar and its thumb (cm/lanes.ts): the native one is hidden so a press
|
||||
// on the scrollbar can never reach the text layer and start a text selection.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar:vertical": { width: "0" },
|
||||
".cm-openidea-overviewThumb": {
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
width: "6px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--scrollbar-thumb)",
|
||||
pointerEvents: "auto",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewThumb": { background: "var(--scrollbar-thumb-hover)" },
|
||||
".cm-openidea-overviewCaret": {
|
||||
position: "absolute",
|
||||
left: "2px",
|
||||
right: "2px",
|
||||
height: "2px",
|
||||
pointerEvents: "none",
|
||||
background: "color-mix(in srgb, var(--fg) 30%, transparent)",
|
||||
},
|
||||
};
|
||||
|
||||
export const openideaTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
@@ -267,49 +321,7 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--border)" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--border)" },
|
||||
|
||||
// Error stripe and scrollbar lane (cm/lanes.ts)
|
||||
".cm-openidea-overview": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
width: "12px",
|
||||
cursor: "default",
|
||||
zIndex: "3",
|
||||
},
|
||||
".cm-openidea-overviewMark": {
|
||||
position: "absolute",
|
||||
left: "3px",
|
||||
width: "6px",
|
||||
minHeight: "3px",
|
||||
borderRadius: "1px",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
|
||||
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
|
||||
// The lane owns the scrollbar and its thumb (cm/lanes.ts): the native one is hidden so a press
|
||||
// on the scrollbar can never reach the text layer and start a text selection.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar:vertical": { width: "0" },
|
||||
".cm-openidea-overviewThumb": {
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
width: "6px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--scrollbar-thumb)",
|
||||
pointerEvents: "auto",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewThumb": { background: "var(--scrollbar-thumb-hover)" },
|
||||
".cm-openidea-overviewCaret": {
|
||||
position: "absolute",
|
||||
left: "2px",
|
||||
right: "2px",
|
||||
height: "2px",
|
||||
pointerEvents: "none",
|
||||
background: "color-mix(in srgb, var(--fg) 30%, transparent)",
|
||||
},
|
||||
...overviewStyles,
|
||||
|
||||
// Sticky lines (cm/stickyScroll.ts)
|
||||
".cm-openidea-sticky": {
|
||||
|
||||
@@ -109,7 +109,7 @@ export const ICLS_COLORS: Record<string, string[]> = {
|
||||
DELETED_LINES_COLOR: ["--gutter-deleted"],
|
||||
};
|
||||
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR";
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR" | "ERROR_STRIPE_COLOR";
|
||||
|
||||
/** `.icls` `<attributes>` options → [part, token] pairs. */
|
||||
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
@@ -117,9 +117,19 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
["FOREGROUND", "--fg-editor"],
|
||||
["BACKGROUND", "--bg-editor"],
|
||||
],
|
||||
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
|
||||
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
|
||||
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
|
||||
DIFF_MODIFIED: [
|
||||
["BACKGROUND", "--diff-modified"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-modified-stripe"],
|
||||
],
|
||||
DIFF_INSERTED: [
|
||||
["BACKGROUND", "--diff-inserted"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-inserted-stripe"],
|
||||
],
|
||||
DIFF_DELETED: [
|
||||
["BACKGROUND", "--diff-deleted"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-deleted-stripe"],
|
||||
],
|
||||
DIFF_CONFLICT: [["ERROR_STRIPE_COLOR", "--diff-conflict-stripe"]],
|
||||
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
|
||||
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
|
||||
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
|
||||
|
||||
@@ -135,6 +135,9 @@
|
||||
}
|
||||
.host :global(.cm-mergeViewEditor) {
|
||||
min-width: 0;
|
||||
/* Clips like the library's `hidden` without becoming a scroll container, so the aligned view's
|
||||
overview lane sticks to the MergeView's viewport (cm/lanes.ts `overviewLaneIn`). */
|
||||
overflow: clip;
|
||||
}
|
||||
.host :global(.cm-merge-revert) {
|
||||
width: 22px;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platfo
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { changedLineGutters, diffWords, languageFor, viewerBase } from "./cm";
|
||||
import { changedLineGutters, diffOverview, diffWords, languageFor, viewerBase } from "./cm";
|
||||
import { applyChunk, arrowControls, chunkAt, hoverRevert, revertUnifiedChunk } from "./diffControls";
|
||||
import {
|
||||
HIGHLIGHT_OPTIONS,
|
||||
@@ -417,6 +417,8 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
|
||||
extensions: [
|
||||
side(attached.a, editableA, "left"),
|
||||
colours,
|
||||
// Aligned panes scroll together: one stripe, on the right (as IDEA's shared scrollbar).
|
||||
panes ? diffOverview("split") : [],
|
||||
panes?.extension ?? [],
|
||||
focusTracker("a"),
|
||||
findWatch("a"),
|
||||
@@ -428,6 +430,7 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
|
||||
extensions: [
|
||||
side(attached.b, editableB, "right"),
|
||||
colours,
|
||||
diffOverview(panes ? "split" : "aligned"),
|
||||
panes?.extension ?? [],
|
||||
listener,
|
||||
focusTracker("b"),
|
||||
@@ -473,6 +476,7 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
|
||||
parent: host,
|
||||
extensions: [
|
||||
side(attached.b, editableB, "right"),
|
||||
diffOverview("unified"),
|
||||
listener,
|
||||
focusTracker("b"),
|
||||
findWatch("b"),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { syncMergeScroll } from "./mergeScroll";
|
||||
import { mergeTitles } from "./conflicts";
|
||||
import {
|
||||
mergeChanged,
|
||||
mergeStripe,
|
||||
mergeTheme,
|
||||
resultControls,
|
||||
resultMarks,
|
||||
@@ -176,6 +177,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
}),
|
||||
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
|
||||
sideControls(which, controls),
|
||||
mergeStripe(which),
|
||||
mergeTheme,
|
||||
];
|
||||
const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side("left") });
|
||||
@@ -192,6 +194,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
embeddedEditorExtensions(path),
|
||||
lang,
|
||||
resultMarks,
|
||||
mergeStripe("result"),
|
||||
resultControls(controls),
|
||||
mergeTheme,
|
||||
EditorView.updateListener.of((u) => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MergeView, unifiedMergeView } from "@codemirror/merge";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { scrollbarMarks } from "@/editor/api";
|
||||
import { diffOverview, diffScrollbarMarks } from "../cm";
|
||||
import { mergeStripe, sideControls, syncSide, type MergeControls } from "../mergeGutters";
|
||||
import { applyTo, mergeField, startMerge, type MergeChange } from "../mergeModel";
|
||||
|
||||
// One modified line, one inserted line, one deleted line.
|
||||
const A = "one\ntwo\nthree\nfour\nfive\nsix";
|
||||
const B = "one\nTWO\nthree\nnew\nfour\nsix";
|
||||
|
||||
const line = (text: string, n: number) =>
|
||||
text
|
||||
.split("\n")
|
||||
.slice(0, n - 1)
|
||||
.join("\n").length + (n > 1 ? 1 : 0);
|
||||
const stripe = (type: "modified" | "inserted" | "deleted" | "conflict") => `var(--diff-${type}-stripe)`;
|
||||
const marksOf = (state: EditorState) => state.facet(scrollbarMarks).map((m) => ({ from: m.from, color: m.color }));
|
||||
|
||||
let merge: MergeView | null = null;
|
||||
afterEach(() => {
|
||||
merge?.destroy();
|
||||
merge = null;
|
||||
});
|
||||
|
||||
const splitView = (extA = diffOverview("split"), extB = diffOverview("split")) =>
|
||||
(merge = new MergeView({ a: { doc: A, extensions: extA }, b: { doc: B, extensions: extB }, parent: document.body }));
|
||||
|
||||
describe("diff overview stripe (#1505)", () => {
|
||||
it.each([
|
||||
// The insertion has no lines on the left: a point where they would be; likewise the deletion on the right.
|
||||
{ side: "a", text: A, lines: [2, 4, 5] },
|
||||
{ side: "b", text: B, lines: [2, 4, 6] },
|
||||
] as const)("marks every chunk of side-by-side pane $side in the chunk's type", ({ side, text, lines }) => {
|
||||
const view = splitView()[side];
|
||||
expect(marksOf(view.state)).toEqual([
|
||||
{ from: line(text, lines[0]), color: stripe("modified") },
|
||||
{ from: line(text, lines[1]), color: stripe("inserted") },
|
||||
{ from: line(text, lines[2]), color: stripe("deleted") },
|
||||
]);
|
||||
expect(diffScrollbarMarks(view.state, false)).toEqual(view.state.facet(scrollbarMarks));
|
||||
});
|
||||
|
||||
it("marks the unified view's old lines deleted and new lines inserted, with no modified type", () => {
|
||||
const state = EditorState.create({
|
||||
doc: B,
|
||||
extensions: [unifiedMergeView({ original: A }), diffOverview("unified")],
|
||||
});
|
||||
expect(marksOf(state)).toEqual([
|
||||
{ from: line(B, 2), color: stripe("inserted") },
|
||||
{ from: line(B, 2), color: stripe("deleted") },
|
||||
{ from: line(B, 4), color: stripe("inserted") },
|
||||
{ from: line(B, 6), color: stripe("deleted") },
|
||||
]);
|
||||
// The old lines' point sits on top of the new lines that replaced them.
|
||||
const [inserted, deleted] = state.facet(scrollbarMarks);
|
||||
expect(deleted!.priority).toBeGreaterThan(inserted!.priority ?? 0);
|
||||
});
|
||||
|
||||
it("follows the chunks when the other side changes without a document change of its own", () => {
|
||||
const mv = splitView();
|
||||
mv.a.dispatch({ changes: { from: 0, to: mv.a.state.doc.length, insert: B } });
|
||||
expect(mv.b.state.facet(scrollbarMarks)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws the aligned view's lane in the right pane over the shared scroller", () => {
|
||||
const mv = splitView([], diffOverview("aligned"));
|
||||
const lane = mv.b.dom.firstElementChild as HTMLElement;
|
||||
expect(lane.classList.contains("cm-openidea-overview-sticky")).toBe(true);
|
||||
expect(mv.dom.classList.contains("cm-openidea-overviewScroller")).toBe(true);
|
||||
expect(mv.a.dom.querySelector(".cm-openidea-overview")).toBeNull();
|
||||
mv.destroy();
|
||||
expect(mv.dom.classList.contains("cm-openidea-overviewScroller")).toBe(false);
|
||||
merge = null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge overview stripe (#1505)", () => {
|
||||
// Line 2: both sides changed it differently (conflict); line 4: left only; line 6: right deletes it.
|
||||
const base = "a\nb\nc\nd\ne\nf\ng";
|
||||
const left = "a\nB-left\nc\nD\ne\nf\ng";
|
||||
const right = "a\nB-right\nc\nd\ne\ng";
|
||||
const start = startMerge(left, base, right);
|
||||
const noop: MergeControls = { accept: () => {}, ignore: () => {}, resolveAutomatically: () => {} };
|
||||
const result = () =>
|
||||
EditorState.create({ doc: start.text, extensions: [mergeField.init(() => start.changes), mergeStripe("result")] });
|
||||
const sidePane = (side: "left" | "right", changes: readonly MergeChange[] = start.changes) =>
|
||||
EditorState.create({
|
||||
doc: side === "left" ? left : right,
|
||||
extensions: [sideControls(side, noop), mergeStripe(side)],
|
||||
}).update({ effects: syncSide.of(changes) }).state;
|
||||
|
||||
it("marks every open change of the result by its type, conflicts on top", () => {
|
||||
const marks = result().facet(scrollbarMarks);
|
||||
expect(marks.map((m) => m.color)).toEqual([stripe("conflict"), stripe("modified"), stripe("deleted")]);
|
||||
expect(marks[0]!.priority).toBe(1);
|
||||
});
|
||||
|
||||
it("marks on each side only the changes that side made", () => {
|
||||
expect(marksOf(sidePane("left"))).toEqual([
|
||||
{ from: line(left, 2), color: stripe("conflict") },
|
||||
{ from: line(left, 4), color: stripe("modified") },
|
||||
]);
|
||||
expect(marksOf(sidePane("right"))).toEqual([
|
||||
{ from: line(right, 2), color: stripe("conflict") },
|
||||
{ from: line(right, 6), color: stripe("deleted") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a change once it is resolved, and a side's mark once that side is done", () => {
|
||||
let state = result();
|
||||
state = state.update(applyTo(state, [1], { type: "accept", side: "left", resolve: false })!).state;
|
||||
expect(state.facet(scrollbarMarks).map((m) => m.color)).toEqual([stripe("conflict"), stripe("deleted")]);
|
||||
expect(
|
||||
sidePane("left", state.field(mergeField))
|
||||
.facet(scrollbarMarks)
|
||||
.map((m) => m.color),
|
||||
).toEqual([stripe("conflict")]);
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,12 @@ 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 } from "@/editor/core/cm/theme";
|
||||
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";
|
||||
@@ -223,6 +225,62 @@ export const chunkDecorations = (build: (view: EditorView) => DecorationSet) =>
|
||||
|
||||
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.
|
||||
@@ -254,6 +312,7 @@ export const viewerHighlight = HighlightStyle.define([
|
||||
|
||||
export const viewerTheme = EditorView.theme(
|
||||
{
|
||||
...overviewStyles,
|
||||
"&": {
|
||||
height: "100%",
|
||||
backgroundColor: "var(--bg-app)",
|
||||
@@ -341,6 +400,8 @@ export const viewerTheme = EditorView.theme(
|
||||
},
|
||||
".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)",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
import { RangeSetBuilder, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, GutterMarker, WidgetType, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import { scrollbarMarks, type ScrollbarMark } from "@/editor/api";
|
||||
import { overviewLane } from "@/editor/core/cm/lanes";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup } from "@/ui";
|
||||
@@ -190,6 +192,45 @@ export const resultMarks = EditorView.decorations.compute([mergeField], (state)
|
||||
return out.finish();
|
||||
});
|
||||
|
||||
/** A change's type (MergeConflictType): a conflict, else how the changed side(s) differ from the base. */
|
||||
function changeType(c: MergeChange): "conflict" | "inserted" | "deleted" | "modified" {
|
||||
if (isConflict(c)) return "conflict";
|
||||
const changed = c.kind === "right" ? c.right : c.left;
|
||||
return c.base === "" ? "inserted" : changed === "" ? "deleted" : "modified";
|
||||
}
|
||||
|
||||
const stripeMark = (c: MergeChange, from: number, to: number): ScrollbarMark => {
|
||||
const type = changeType(c);
|
||||
return {
|
||||
from,
|
||||
to: to > from ? to - 1 : from,
|
||||
color: `var(--diff-${type}-stripe)`,
|
||||
kind: "full",
|
||||
priority: type === "conflict" ? 1 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The open changes on a pane's overview lane, as IDEA's merge viewer: the result marks each
|
||||
* change until it is resolved, a side pane the changes of that side until that side is accepted
|
||||
* or ignored (TextMergeChange: a resolved side paints no background, so no stripe either).
|
||||
*/
|
||||
export function mergeStripe(pane: MergeSide | "result"): Extension {
|
||||
const marks =
|
||||
pane === "result"
|
||||
? scrollbarMarks.compute([mergeField], (state): readonly ScrollbarMark[] =>
|
||||
state.field(mergeField).flatMap((c) => (isResolved(c) ? [] : [stripeMark(c, c.from, c.to)])),
|
||||
)
|
||||
: scrollbarMarks.compute([sideChanges], (state): readonly ScrollbarMark[] =>
|
||||
state.field(sideChanges).flatMap((c) => {
|
||||
if (!changesSide(c, pane) || c.done[pane]) return [];
|
||||
const [from, to] = pane === "left" ? c.leftRange : c.rightRange;
|
||||
return [stripeMark(c, from, to)];
|
||||
}),
|
||||
);
|
||||
return [marks, overviewLane];
|
||||
}
|
||||
|
||||
export const mergeTheme = EditorView.baseTheme({
|
||||
// A fixed width, so the text does not shift as controls come and go while scrolling.
|
||||
".cm-merge-gutter": { minWidth: "34px" },
|
||||
|
||||
@@ -237,6 +237,10 @@ a:hover {
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
/* An outer scroller whose scrollbar the editor's overview lane draws (cm/lanes.ts `overviewLaneIn`). */
|
||||
.cm-openidea-overviewScroller::-webkit-scrollbar:vertical {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Settings › Appearance › Antialiasing (IDE / Editor). */
|
||||
:root[data-aa-ide="subpixel"] body {
|
||||
|
||||
@@ -88,6 +88,11 @@ html {
|
||||
--diff-modified: #385570;
|
||||
--diff-inserted: #294436;
|
||||
--diff-deleted: #484a4a;
|
||||
/* their error stripe marks (ERROR_STRIPE_COLOR of the same keys and DIFF_CONFLICT) */
|
||||
--diff-modified-stripe: #43698d;
|
||||
--diff-inserted-stripe: #447152;
|
||||
--diff-deleted-stripe: #656e76;
|
||||
--diff-conflict-stripe: #8f5247;
|
||||
|
||||
/* coverage (IDEA LINE_FULL / PARTIAL / NONE_COVERAGE), following the theme's status colours */
|
||||
--coverage-covered: var(--gutter-added);
|
||||
@@ -480,6 +485,10 @@ html {
|
||||
--diff-modified: #c2d8f2;
|
||||
--diff-inserted: #bee6be;
|
||||
--diff-deleted: #d6d6d6;
|
||||
--diff-modified-stripe: #b6d2f2;
|
||||
--diff-inserted-stripe: #aadeaa;
|
||||
--diff-deleted-stripe: #c8c8c8;
|
||||
--diff-conflict-stripe: #ffc8bd;
|
||||
|
||||
/* syntax (Light scheme + Default / Python defaults) */
|
||||
--syn-keyword: #0033b3;
|
||||
@@ -737,6 +746,10 @@ html {
|
||||
--diff-modified: #000099;
|
||||
--diff-inserted: #00400f;
|
||||
--diff-deleted: #4d2e08;
|
||||
--diff-modified-stripe: #1aabff;
|
||||
--diff-inserted-stripe: #009924;
|
||||
--diff-deleted-stripe: #a66a32;
|
||||
--diff-conflict-stripe: #ff0d25;
|
||||
|
||||
--syn-keyword: #e8a885;
|
||||
--syn-string: #8ccc94;
|
||||
@@ -1019,6 +1032,10 @@ html {
|
||||
--diff-modified: color-mix(in srgb, var(--palette-blue) 30%, var(--palette-bg));
|
||||
--diff-inserted: color-mix(in srgb, var(--palette-green) 30%, var(--palette-bg));
|
||||
--diff-deleted: color-mix(in srgb, var(--palette-fg) 18%, var(--palette-bg));
|
||||
--diff-modified-stripe: color-mix(in srgb, var(--palette-blue) 50%, var(--palette-bg));
|
||||
--diff-inserted-stripe: color-mix(in srgb, var(--palette-green) 50%, var(--palette-bg));
|
||||
--diff-deleted-stripe: color-mix(in srgb, var(--palette-fg) 35%, var(--palette-bg));
|
||||
--diff-conflict-stripe: color-mix(in srgb, var(--palette-red) 50%, var(--palette-bg));
|
||||
--syn-link: var(--palette-blue);
|
||||
--link-hover: var(--palette-cyan);
|
||||
--danger: var(--palette-red);
|
||||
|
||||
Reference in new issue
Заблокувати користувача