From 324dded5c18b30a9326d38eb5a2493655ad6fc8e Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:43:55 +0800 Subject: [PATCH] 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 <53564781+jaw52@users.noreply.github.com> --- docs/plugins/themes.md | 6 +- .../themes/harbor-night.theme.json | 4 + src/editor/api.ts | 8 +- src/editor/core/cm/lanes.ts | 70 +++++++--- src/editor/core/cm/theme.ts | 98 +++++++------- src/features/plugins/intellijTheme.ts | 18 ++- src/features/vcs/DiffEditor.module.css | 3 + src/features/vcs/DiffEditor.tsx | 6 +- src/features/vcs/MergeEditor.tsx | 3 + src/features/vcs/__tests__/diffStripe.test.ts | 121 ++++++++++++++++++ src/features/vcs/cm.ts | 63 ++++++++- src/features/vcs/mergeGutters.ts | 41 ++++++ src/styles/global.css | 4 + src/styles/tokens.css | 17 +++ 14 files changed, 393 insertions(+), 69 deletions(-) create mode 100644 src/features/vcs/__tests__/diffStripe.test.ts diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 06f620a9d..96b639c2a 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -324,7 +324,7 @@ a token. -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) diff --git a/plugins/official/harbor-night/themes/harbor-night.theme.json b/plugins/official/harbor-night/themes/harbor-night.theme.json index a8f90cf3a..ed7d0886a 100644 --- a/plugins/official/harbor-night/themes/harbor-night.theme.json +++ b/plugins/official/harbor-night/themes/harbor-night.theme.json @@ -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)", diff --git a/src/editor/api.ts b/src/editor/api.ts index f4581b3fc..d7bc2eec6 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -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. */ diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index 0a89e1f60..6c962b653 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -58,8 +58,14 @@ export function unfoldAround(state: EditorState, pos: number): StateEffect 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" }), ]; diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 909a80829..ab6727b9e 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -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": { diff --git a/src/features/plugins/intellijTheme.ts b/src/features/plugins/intellijTheme.ts index 33650dfb3..b24bb3f3f 100644 --- a/src/features/plugins/intellijTheme.ts +++ b/src/features/plugins/intellijTheme.ts @@ -109,7 +109,7 @@ export const ICLS_COLORS: Record = { DELETED_LINES_COLOR: ["--gutter-deleted"], }; -type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR"; +type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR" | "ERROR_STRIPE_COLOR"; /** `.icls` `` options → [part, token] pairs. */ export const ICLS_ATTRIBUTES: Record> = { @@ -117,9 +117,19 @@ export const ICLS_ATTRIBUTES: Record> = { ["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"]], diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index faa9d9de7..cb0ba6729 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -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; diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index d9c8dc580..89089b932 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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"), diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 6244d82a8..385628c85 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -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) => { diff --git a/src/features/vcs/__tests__/diffStripe.test.ts b/src/features/vcs/__tests__/diffStripe.test.ts new file mode 100644 index 000000000..2366d1d25 --- /dev/null +++ b/src/features/vcs/__tests__/diffStripe.test.ts @@ -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")]); + }); +}); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index 07ae5810f..67f06a428 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -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({ + 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(".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)", diff --git a/src/features/vcs/mergeGutters.ts b/src/features/vcs/mergeGutters.ts index 854270796..b989632bb 100644 --- a/src/features/vcs/mergeGutters.ts +++ b/src/features/vcs/mergeGutters.ts @@ -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" }, diff --git a/src/styles/global.css b/src/styles/global.css index 9169b90b5..66fef15e6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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 { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 6324353c4..241ff7540 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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);