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);