diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index d01b4c87..24b7b3a6 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -182,19 +182,100 @@ .noHighlight :global(.cm-changedText) { background: none !important; } -.unsynced :global(.cm-mergeView) { +/* Side-by-side without alignment (IDEA's default): each pane scrolls itself. */ +.unaligned :global(.cm-mergeView) { overflow: hidden; } -.unsynced :global(.cm-mergeViewEditors) { +.unaligned :global(.cm-mergeViewEditors) { height: 100%; min-height: 0; } -.unsynced :global(.cm-mergeViewEditor) { +.unaligned :global(.cm-mergeViewEditor) { height: 100%; + min-height: 0; overflow: hidden; } -.unsynced :global(.cm-mergeViewEditor > .cm-editor) { - height: 100%; +/* Overrides MergeView's base theme, which leaves scrolling to the outer view. */ +.unaligned :global(.cm-mergeViewEditor > .cm-editor), +.unaligned :global(.cm-mergeViewEditor > .cm-editor > .cm-scroller) { + height: 100% !important; +} +.unaligned :global(.cm-mergeViewEditor > .cm-editor > .cm-scroller) { + overflow: auto !important; +} +.host :global(.cm-diffDivider) { + position: relative; + flex: none; + width: 30px; + overflow: hidden; + background: var(--bg-app); + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); +} +.host :global(.cm-diffDivider svg) { + position: absolute; + inset: 0; +} +/* Opaque mixes of the line backgrounds (cm.ts), so bands and their midlines read as one shape. */ +.host :global(.cm-diffBand-inserted) { + --band: color-mix(in srgb, var(--success) 26%, var(--bg-app)); +} +.host :global(.cm-diffBand-deleted) { + --band: color-mix(in srgb, var(--danger) 26%, var(--bg-app)); +} +.host :global(.cm-diffBand-modified) { + --band: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app)); +} +.host :global(.cm-diffBand:not(.cm-diffBand-modified)) { + fill: var(--band); +} +.host :global(.cm-diffBandLine) { + fill: none; + stroke: var(--band); + stroke-width: 1px; +} +.host :global(.cm-diffBandStopA) { + stop-color: color-mix(in srgb, var(--gutter-deleted) 20%, var(--bg-app)); +} +.host :global(.cm-diffBandStopB) { + stop-color: color-mix(in srgb, var(--gutter-added) 20%, var(--bg-app)); +} +.host :global(.cm-diffDivider button.cm-diffArrow) { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 16px; + padding: 0; + border: 0; + border-radius: var(--radius-small); + background: none; + color: var(--fg-subtle); + cursor: pointer; +} +.host :global(.cm-diffDivider button.cm-diffArrow-a-to-b) { + left: 1px; +} +.host :global(.cm-diffDivider button.cm-diffArrow-b-to-a) { + right: 1px; +} +.host :global(.cm-diffDivider button.cm-diffArrow:hover) { + color: var(--fg-strong); + background: var(--bg-hover); +} +/* Where the other pane has lines this one lacks. */ +.host :global(.cm-diffGap-inserted) { + box-shadow: inset 0 1px 0 var(--success); +} +.host :global(.cm-diffGap-deleted) { + box-shadow: inset 0 1px 0 var(--danger); +} +.host :global(.cm-diffGap-inserted.cm-diffGapEnd) { + box-shadow: inset 0 -1px 0 var(--success); +} +.host :global(.cm-diffGap-deleted.cm-diffGapEnd) { + box-shadow: inset 0 -1px 0 var(--danger); } /* Review comments under diff lines (pull requests). */ diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 8f4bb9be..a6eeefa2 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -49,6 +49,7 @@ import { type DiffSide, } from "./diffInput"; import { diffAnnotations } from "./diffAnnotations"; +import { UnalignedPanes } from "./diffScroll"; import { groupOf } from "./changes"; import { inclusionGutter } from "./hunkInclusion"; import { hunksOverlapping } from "./hunks"; @@ -65,6 +66,8 @@ interface Views { b: EditorView; a: EditorView | null; merge: MergeView | null; + /** Unpadded side-by-side panes with synchronized scrolling (IDEA). */ + panes: UnalignedPanes | null; destroy(): void; } @@ -227,18 +230,23 @@ export function DiffEditor({ input }: { input: EditorInput }) { const editableB = !!attached?.b && !attached.b.readOnly; const ready = !!attached && !!statics && lang !== null; + // Read when the views are built; toggling it later goes through `setSync`. + const syncScroll = useRef(opts.syncScroll); + syncScroll.current = opts.syncScroll; + // (Re)build the CodeMirror views. useEffect(() => { const host = hostRef.current; if (!host || !attached || !statics || lang === null) return; const textA = attached.a?.text() ?? statics.a ?? ""; const textB = attached.b?.text() ?? statics.b ?? ""; - const scrollTop = viewsRef.current - ? viewsRef.current.merge - ? (viewsRef.current.merge.dom as HTMLElement).scrollTop - : viewsRef.current.b.scrollDOM.scrollTop + const prev = viewsRef.current; + const scrollTop = prev + ? prev.merge && !prev.panes + ? (prev.merge.dom as HTMLElement).scrollTop + : prev.b.scrollDOM.scrollTop : null; - viewsRef.current?.destroy(); + prev?.destroy(); host.textContent = ""; const listener = EditorView.updateListener.of((u) => { if (u.selectionSet || u.docChanged) updateChunkInfo(); @@ -272,11 +280,21 @@ export function DiffEditor({ input }: { input: EditorInput }) { let views: Views; if (opts.mode === "split") { let merge: MergeView | null = null; + const panes = opts.alignChanges + ? null + : new UnalignedPanes({ + sync: syncScroll.current, + arrows: + editableA || editableB + ? { toA: editableA, toB: editableB, apply: (i, source) => merge && applyChunk(merge, i, source) } + : undefined, + }); const mv = new MergeView({ a: { doc: textA, extensions: [ side(attached.a, editableA, "left"), + panes?.extension ?? [], focusTracker("a"), findWatch("a"), editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [], @@ -286,6 +304,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { doc: textB, extensions: [ side(attached.b, editableB, "right"), + panes?.extension ?? [], listener, focusTracker("b"), findWatch("b"), @@ -294,17 +313,36 @@ export function DiffEditor({ input }: { input: EditorInput }) { ], }, parent: host, - revertControls: editableB ? "a-to-b" : editableA ? "b-to-a" : undefined, + // Unaligned panes draw their arrows on their own divider. + revertControls: panes ? undefined : editableB ? "a-to-b" : editableA ? "b-to-a" : undefined, renderRevertControl: - editableA || editableB ? arrowControls(() => merge, { toA: editableA, toB: editableB }) : undefined, + !panes && (editableA || editableB) + ? arrowControls(() => merge, { toA: editableA, toB: editableB }) + : undefined, highlightChanges, - gutter: true, + // Unpadded panes tint the gutters of changed lines instead of a change bar. + gutter: !panes, collapseUnchanged, diffConfig, }); merge = mv; - views = { a: mv.a, b: mv.b, merge: mv, destroy: () => mv.destroy() }; - if (scrollTop != null) requestAnimationFrame(() => ((mv.dom as HTMLElement).scrollTop = scrollTop)); + panes?.attach(mv); + views = { + a: mv.a, + b: mv.b, + merge: mv, + panes, + destroy: () => { + panes?.destroy(); + mv.destroy(); + }, + }; + if (scrollTop != null) + requestAnimationFrame(() => { + // The left pane of unpadded panes follows by itself. + if (!panes) (mv.dom as HTMLElement).scrollTop = scrollTop; + else mv.b.scrollDOM.scrollTop = scrollTop; + }); } else { const view = new EditorView({ doc: textB, @@ -338,7 +376,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { }), ], }); - views = { a: null, b: view, merge: null, destroy: () => view.destroy() }; + views = { a: null, b: view, merge: null, panes: null, destroy: () => view.destroy() }; if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop)); } viewsRef.current = views; @@ -349,6 +387,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { statics, lang, opts.mode, + opts.alignChanges, opts.collapse, opts.ignore, opts.highlight, @@ -360,6 +399,8 @@ export function DiffEditor({ input }: { input: EditorInput }) { path, ]); + useEffect(() => viewsRef.current?.panes?.setSync(opts.syncScroll), [opts.syncScroll]); + useEffect( () => () => { viewsRef.current?.destroy(); @@ -603,9 +644,10 @@ export function DiffEditor({ input }: { input: EditorInput }) { if (neighbour) void openDiff(neighbour, { preview: true }); }; const toolbarRef = useRef(null); - const showSettings = () => { - const r = toolbarRef.current?.getBoundingClientRect(); - showContextMenu({ x: r ? r.left + 8 : 120, y: r ? r.bottom : 120 }, [ + /** The viewer settings menu, under the toolbar's gear (`from`) or its start. */ + const showSettings = (from?: HTMLElement) => { + const r = (from ?? toolbarRef.current)?.getBoundingClientRect(); + showContextMenu({ x: r ? r.left + (from ? 0 : 8) : 120, y: r ? r.bottom : 120 }, [ { kind: "header", label: "Viewer" }, { id: "split", @@ -654,6 +696,13 @@ export function DiffEditor({ input }: { input: EditorInput }) { disabled: opts.mode !== "split", run: () => setOpts({ syncScroll: !opts.syncScroll }), }, + { + id: "align", + label: "Align Changes", + checked: opts.alignChanges, + disabled: opts.mode !== "split", + run: () => setOpts({ alignChanges: !opts.alignChanges }), + }, ]); }; const nav = useRef({ @@ -805,6 +854,14 @@ export function DiffEditor({ input }: { input: EditorInput }) { ? tx`${chunkInfo.count} difference` : tx`${chunkInfo.count} differences`} + showSettings(e.currentTarget)} + /> {opts.mode === "split" ? (
@@ -860,7 +917,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { className={clsx( styles.host, opts.highlight === "none" && styles.noHighlight, - opts.mode === "split" && !opts.syncScroll && styles.unsynced, + opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned), )} hidden={!!error || !ready} aria-label={diffTitle(data)} diff --git a/src/features/vcs/__tests__/diffOptions.test.ts b/src/features/vcs/__tests__/diffOptions.test.ts index f3066b6e..dc1b0cf2 100644 --- a/src/features/vcs/__tests__/diffOptions.test.ts +++ b/src/features/vcs/__tests__/diffOptions.test.ts @@ -11,7 +11,7 @@ import { } from "../diffOptions"; import { clipboardDiffData, compareFilesData, parseTextsArg } from "../compare"; import { diffInput, diffTitle, sideFile, sideKey } from "../diffInput"; -import { viewerBase } from "../cm"; +import { changedLineGutters, viewerBase } from "../cm"; const doc = (s: string) => Text.of(s.split("\n")); @@ -24,8 +24,22 @@ describe("diff options", () => { it("parses stored options with per-field defaults", () => { expect(parseDiffOptions(null)).toEqual(DEFAULT_DIFF_OPTIONS); expect( - parseDiffOptions({ mode: "split", ignore: "trim", highlight: "lines", collapse: false, syncScroll: false }), - ).toEqual({ mode: "split", ignore: "trim", highlight: "lines", collapse: false, syncScroll: false }); + parseDiffOptions({ + mode: "split", + ignore: "trim", + highlight: "lines", + collapse: false, + syncScroll: false, + alignChanges: false, + }), + ).toEqual({ + mode: "split", + ignore: "trim", + highlight: "lines", + collapse: false, + syncScroll: false, + alignChanges: false, + }); expect(parseDiffOptions({ ignore: "bogus", highlight: 3, collapse: "x" })).toEqual(DEFAULT_DIFF_OPTIONS); }); @@ -155,6 +169,26 @@ describe("diff options", () => { parent.remove(); }); + it("carries the change background into the line number gutter", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const extensions = [viewerBase({ readOnly: true }), changedLineGutters]; + const mv = new MergeView({ + a: { doc: "keep\nlet x = 1;\nend\n", extensions }, + b: { doc: "keep\nlet x = 2;\nadded\nend\n", extensions }, + parent, + gutter: false, + diffConfig: { override: whitespaceDiff("none") }, + }); + const numbers = (v: typeof mv.a, cls: string) => + [...v.dom.querySelectorAll(`.cm-lineNumbers .${cls}`)].map((e) => e.textContent); + expect(numbers(mv.a, "cm-diffChangedGutter")).toEqual(["2"]); + expect(numbers(mv.b, "cm-diffChangedGutter")).toEqual(["2", "3"]); + expect(numbers(mv.b, "cm-diffWholeGutter")).toEqual(["3"]); + mv.destroy(); + parent.remove(); + }); + it("marks a deleted last line as a whole line", () => { const parent = document.createElement("div"); document.body.appendChild(parent); diff --git a/src/features/vcs/__tests__/diffScroll.test.ts b/src/features/vcs/__tests__/diffScroll.test.ts new file mode 100644 index 00000000..1668509f --- /dev/null +++ b/src/features/vcs/__tests__/diffScroll.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { Chunk, MergeView } from "@codemirror/merge"; +import { Text } from "@codemirror/state"; +import { bandAt, bandPath, transferY, type Band } from "../diffScroll"; + +const doc = (s: string) => Text.of(s.split("\n")); +const LINE = 20; + +/** The band at `y` of side `from` in two texts with every line LINE px tall. */ +function bands(a: string, b: string) { + const da = doc(a); + const db = doc(b); + const ext = (d: Text, from: number, to: number): [number, number] => { + if (from > d.length) return [d.lines * LINE, d.lines * LINE]; + const top = (d.lineAt(from).number - 1) * LINE; + return from >= to ? [top, top] : [top, d.lineAt(Math.min(to - 1, d.length)).number * LINE]; + }; + const chunks = Chunk.build(da, db); + return (y: number, from: "a" | "b"): Band => + bandAt( + chunks, + (c) => ext(da, c.fromA, c.toA), + (c) => ext(db, c.fromB, c.toB), + da.lines * LINE, + db.lines * LINE, + y, + from, + ); +} + +describe("unaligned side-by-side scrolling", () => { + // Right side inserts three lines after line 2 and changes line 5 into two lines. + const a = "1\n2\n3\n4\n5\n6\n7"; + const b = "1\n2\nx\ny\nz\n3\n4\nfive\nfive'\n6\n7"; + const bandOf = bands(a, b); + const at = (y: number, from: "a" | "b") => transferY([bandOf(y, from)], y, from); + + it("splits both documents into unchanged and changed bands", () => { + const row = (x: Band) => [x.a0, x.a1, x.b0, x.b1, x.changed]; + expect([0, 39, 40, 79, 80, 99, 100, 139].map((y) => row(bandOf(y, "a")))).toEqual([ + [0, 40, 0, 40, false], + [0, 40, 0, 40, false], + [40, 80, 100, 140, false], + [40, 80, 100, 140, false], + [80, 100, 140, 180, true], + [80, 100, 140, 180, true], + [100, 140, 180, 220, false], + [100, 140, 180, 220, false], + ]); + expect([0, 40, 99, 100, 140, 179, 180].map((y) => row(bandOf(y, "b")))).toEqual([ + [0, 40, 0, 40, false], + [40, 40, 40, 100, true], + [40, 40, 40, 100, true], + [40, 80, 100, 140, false], + [80, 100, 140, 180, true], + [80, 100, 140, 180, true], + [100, 140, 180, 220, false], + ]); + }); + + it("maps unchanged text straight through", () => { + expect(at(10, "a")).toBe(10); + expect(at(50, "a")).toBe(110); + expect(at(130, "a")).toBe(210); + expect(at(210, "b")).toBe(130); + }); + + it("jumps over an insertion when scrolling the side without it", () => { + expect(at(39, "a")).toBe(39); + expect(at(40, "a")).toBe(100); + }); + + it("waits at the boundary while the inserted lines scroll by", () => { + for (const y of [40, 60, 99]) expect(at(y, "b")).toBe(40); + }); + + it("follows line by line inside a change, then waits at the shorter end", () => { + expect(at(150, "b")).toBe(90); + expect(at(170, "b")).toBe(100); + expect(at(90, "a")).toBe(150); + }); + + it("scales unchanged text of different heights (wrapped lines)", () => { + const stretched = [{ a0: 0, a1: 100, b0: 0, b1: 200, changed: false }]; + expect(transferY(stretched, 50, "a")).toBe(100); + expect(transferY(stretched, 50, "b")).toBe(25); + }); + + it("draws an empty side as a sliver on its boundary", () => { + expect(bandPath(30, 40, 40, 40, 100)).toBe("M0 39C9 39 21 40 30 40L30 100C21 100 9 41 0 41Z"); + }); + + it("can keep MergeView from padding the panes", () => { + // UnalignedPanes replaces this (private) method; a rename upstream would bring the padding back. + expect(typeof (MergeView.prototype as unknown as { measure: unknown }).measure).toBe("function"); + }); +}); diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index 45f9626a..bc057bcf 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -4,7 +4,7 @@ import type { EditorInput } from "@/workbench/contributions"; import { Chunk } from "@codemirror/merge"; import { Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { iconMarkup } from "@/ui"; +import { ContextMenuHost, iconMarkup } from "@/ui"; const probe = vi.fn(); const openFile = vi.fn(); @@ -80,6 +80,40 @@ describe("Diff toolbar", () => { expect(probe).toHaveBeenCalledWith("/p/a.ts"); }); + it("turns on aligned changes from the settings menu (off by default)", async () => { + localStorage.clear(); + texts.HEAD = "a\nb\n"; + texts.abc123 = "a\nnew\nb\n"; + render( + <> + + + , + ); + await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor")).toHaveLength(2)); + // Unpadded panes joined by the divider. + await waitFor(() => expect(document.querySelector(".cm-diffDivider")).not.toBeNull()); + expect(document.querySelector(".cm-changeGutter")).toBeNull(); + expect(document.querySelector(".cm-merge-b .cm-lineNumbers .cm-diffWholeGutter")?.textContent).toBe("2"); + // The lock turns scrolling together off without rebuilding the panes. + const divider = document.querySelector(".cm-diffDivider"); + (await screen.findByRole("button", { name: "Synchronize Scrolling" })).click(); + await waitFor(() => expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).syncScroll).toBe(false)); + expect(document.querySelector(".cm-diffDivider")).toBe(divider); + (await screen.findByRole("button", { name: "Settings" })).click(); + const item = await screen.findByRole("menuitemcheckbox", { name: "Align Changes" }); + expect(item.getAttribute("aria-checked")).toBe("false"); + item.click(); + // Padded panes with a change bar. + await waitFor(() => expect(document.querySelector(".cm-diffDivider")).toBeNull()); + expect(document.querySelector(".cm-changeGutter")).not.toBeNull(); + expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!)).toMatchObject({ + alignChanges: true, + syncScroll: false, + }); + localStorage.clear(); + }); + it("has no Jump to Source when the file is gone from the working tree", async () => { probe.mockRejectedValue(new Error("no such file")); render(); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index 47e94347..25185bf1 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -3,11 +3,21 @@ * based theme + syntax colours and lazy language loading by file name. */ import { HighlightStyle, LanguageDescription, syntaxHighlighting, type LanguageSupport } from "@codemirror/language"; -import { Compartment, EditorState, RangeSetBuilder, type Extension, type Line } from "@codemirror/state"; +import { + Compartment, + EditorState, + RangeSet, + RangeSetBuilder, + StateField, + type Extension, + type Line, +} from "@codemirror/state"; import { Decoration, EditorView, + GutterMarker, ViewPlugin, + gutterLineClass, highlightActiveLine, keymap, lineNumbers, @@ -27,17 +37,16 @@ import { useProject } from "@/platform/project"; const wholeLine = Decoration.line({ class: "cm-diffWholeLine" }); /** - * Marks the changed lines that have no counterpart on the other side (inserted - * or deleted as a whole) so they get a solid background. Lines paired with a - * line on the other side keep the line tint plus word-level highlights. + * Calls `f` for the changed lines of a side-by-side pane between `vpFrom` and + * `vpTo`, with whether the line has no counterpart on the other side (inserted + * or deleted as a whole). Lines paired with a line on the other side keep the + * line tint plus word-level highlights. */ -function wholeLineDeco(view: EditorView): DecorationSet { - const info = getChunks(view.state); - if (!info || !info.side) return Decoration.none; +function forChangedLines(state: EditorState, vpFrom: number, vpTo: number, f: (line: Line, whole: boolean) => void) { + const info = getChunks(state); + if (!info || !info.side) return; const isA = info.side === "a"; - const { doc } = view.state; - const { from: vpFrom, to: vpTo } = view.viewport; - const builder = new RangeSetBuilder(); + const { doc } = state; for (const chunk of info.chunks) { const from = isA ? chunk.fromA : chunk.fromB; const to = isA ? chunk.toA : chunk.toB; @@ -49,11 +58,46 @@ function wholeLineDeco(view: EditorView): DecorationSet { const covered = (line: Line) => ranges.some(([f = 0, t = 0]) => (f <= line.from && t > line.to) || (f < line.from && t >= line.to)); for (let line = doc.lineAt(Math.max(from, vpFrom)); line.from < Math.min(to, vpTo);) { - if (otherEmpty || covered(line)) builder.add(line.from, line.from, wholeLine); + f(line, otherEmpty || covered(line)); if (line.number === doc.lines) break; line = doc.line(line.number + 1); } } +} + +/** Solid background for the lines changed as a whole. */ +function wholeLineDeco(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + forChangedLines(view.state, view.viewport.from, view.viewport.to, (line, whole) => { + if (whole) builder.add(line.from, line.from, wholeLine); + }); + return builder.finish(); +} + +class LineClassMarker extends GutterMarker { + constructor(readonly elementClass: string) { + super(); + } +} + +const changedGutter = new LineClassMarker("cm-diffChangedGutter"); +const wholeGutter = new LineClassMarker("cm-diffChangedGutter cm-diffWholeGutter"); + +/** The line background of changed lines carried into the gutters (line numbers, inclusion boxes), as in IDEA. */ +export const changedLineGutters = StateField.define>({ + create: (state) => changedGutterMarkers(state), + update: (value, tr) => + tr.docChanged || getChunks(tr.startState)?.chunks !== getChunks(tr.state)?.chunks + ? changedGutterMarkers(tr.state) + : value, + provide: (f) => gutterLineClass.from(f), +}); + +function changedGutterMarkers(state: EditorState): RangeSet { + const builder = new RangeSetBuilder(); + forChangedLines(state, 0, state.doc.length + 1, (line, whole) => + builder.add(line.from, line.from, whole ? wholeGutter : changedGutter), + ); return builder.finish(); } @@ -143,6 +187,19 @@ export const viewerTheme = EditorView.theme( "&.cm-merge-b .cm-changedText, .cm-insertedLine .cm-changedText": { background: "color-mix(in srgb, var(--success) 26%, transparent)", }, + // Gutters of changed lines take the line background (changedLineGutters). + "&.cm-merge-a .cm-diffChangedGutter": { + backgroundColor: "color-mix(in srgb, var(--gutter-deleted) 12%, transparent)", + }, + "&.cm-merge-b .cm-diffChangedGutter": { + backgroundColor: "color-mix(in srgb, var(--gutter-added) 12%, transparent)", + }, + "&.cm-merge-a .cm-diffWholeGutter": { + backgroundColor: "color-mix(in srgb, var(--danger) 26%, transparent)", + }, + "&.cm-merge-b .cm-diffWholeGutter": { + backgroundColor: "color-mix(in srgb, var(--success) 26%, transparent)", + }, ".cm-changeGutter": { width: "3px", paddingLeft: "0" }, "&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter": { background: "var(--gutter-deleted)" }, "&.cm-merge-b .cm-changedLineGutter": { background: "var(--gutter-modified)" }, diff --git a/src/features/vcs/diffControls.ts b/src/features/vcs/diffControls.ts index 7b8af568..3b707e07 100644 --- a/src/features/vcs/diffControls.ts +++ b/src/features/vcs/diffControls.ts @@ -17,7 +17,8 @@ export function applyChunk(mv: MergeView, index: number, source: "a" | "b") { dst.dispatch({ changes: transferChunk(chunk, source, src.state.doc, dst.state.doc), userEvent: "revert" }); } -function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement { +/** A ">>" / "<<" button; the chunk index comes from the nearest `data-chunk` (itself included). */ +export function arrowButton(dir: "a-to-b" | "b-to-a", run: (chunkIndex: number) => void): HTMLButtonElement { const btn = document.createElement("button"); btn.type = "button"; btn.className = `cm-diffArrow cm-diffArrow-${dir}`; diff --git a/src/features/vcs/diffOptions.ts b/src/features/vcs/diffOptions.ts index 35860fe4..72e67f4a 100644 --- a/src/features/vcs/diffOptions.ts +++ b/src/features/vcs/diffOptions.ts @@ -15,7 +15,13 @@ export interface DiffViewOptions { ignore: IgnorePolicy; highlight: HighlightPolicy; collapse: boolean; + /** Side-by-side panes scroll together. */ syncScroll: boolean; + /** + * Side-by-side panes are padded so that unchanged lines line up. Off, each pane + * keeps its own height and a curved divider joins the changes (IDEA's default). + */ + alignChanges: boolean; } export const IGNORE_OPTIONS: ReadonlyArray<{ value: IgnorePolicy; label: string }> = [ @@ -37,6 +43,7 @@ export const DEFAULT_DIFF_OPTIONS: DiffViewOptions = { highlight: "words", collapse: true, syncScroll: true, + alignChanges: false, }; const KEY = "openidea.vcs.diffOptions"; @@ -54,6 +61,7 @@ export function parseDiffOptions(raw: unknown): DiffViewOptions { highlight: oneOf(o.highlight, HIGHLIGHT_OPTIONS, d.highlight), collapse: typeof o.collapse === "boolean" ? o.collapse : d.collapse, syncScroll: typeof o.syncScroll === "boolean" ? o.syncScroll : d.syncScroll, + alignChanges: typeof o.alignChanges === "boolean" ? o.alignChanges : d.alignChanges, }; } diff --git a/src/features/vcs/diffScroll.ts b/src/features/vcs/diffScroll.ts new file mode 100644 index 00000000..779c4e5f --- /dev/null +++ b/src/features/vcs/diffScroll.ts @@ -0,0 +1,360 @@ +/** + * IDEA's side-by-side diff without alignment padding: each pane scrolls on its + * own, scrolling one carries the other along through the changes + * (SyncScrollSupport), and the divider between them draws a curved band per + * change (DiffDividerDrawUtil) with the ">>" / "<<" arrows on it. Changed lines + * tint their gutters instead of showing a change bar. + */ +import { RangeSetBuilder, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { getChunks, type Chunk, type MergeView } from "@codemirror/merge"; +import { changedLineGutters } from "./cm"; +import { arrowButton } from "./diffControls"; + +type Side = "a" | "b"; + +/** A stretch of both documents in document pixels: unchanged text, or a change. */ +export interface Band { + a0: number; + a1: number; + b0: number; + b1: number; + changed: boolean; +} + +/** Pixel [top, bottom] of a chunk's range in `view`; an empty range is the boundary it sits on. */ +function extent(view: EditorView, from: number, to: number): [number, number] { + const len = view.state.doc.length; + if (from > len) { + const end = view.lineBlockAt(len).bottom; + return [end, end]; + } + const top = view.lineBlockAt(from).top; + return from >= to ? [top, top] : [top, view.lineBlockAt(Math.min(to - 1, len)).bottom]; +} + +/** First index in `[0, n)` for which `past` holds, given that it holds for a suffix. */ +function firstIndex(n: number, past: (i: number) => boolean): number { + let lo = 0; + let hi = n; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (past(mid)) hi = mid; + else lo = mid + 1; + } + return lo; +} + +/** + * The stretch of both documents around `y` of side `from`, from the top to + * `endA` / `endB`: unchanged text, or a change (BaseSyncScrollable.processHelper). + * Only the chunks next to `y` are measured. + */ +export function bandAt( + chunks: readonly Chunk[], + extentA: (c: Chunk) => [number, number], + extentB: (c: Chunk) => [number, number], + endA: number, + endB: number, + y: number, + from: Side, +): Band { + const master = from === "a" ? extentA : extentB; + const next = firstIndex(chunks.length, (i) => master(chunks[i]!)[0] > y); + let a = 0; + let b = 0; + const prev = chunks[next - 1]; + if (prev) { + const [a0, a1] = extentA(prev); + const [b0, b1] = extentB(prev); + if (y < (from === "a" ? a1 : b1)) return { a0, a1, b0, b1, changed: true }; + a = a1; + b = b1; + } + const c = chunks[next]; + return c + ? { a0: a, a1: extentA(c)[0], b0: b, b1: extentB(c)[0], changed: false } + : { a0: a, a1: endA, b0: b, b1: endB, changed: false }; +} + +/** + * The position on the other side matching `y` of side `from` + * (BaseSyncScrollable.transferLine, in pixels): unchanged text maps through, + * and inside a change the other side follows line by line, then waits at the + * end of its shorter part. + */ +export function transferY(bands: readonly Band[], y: number, from: Side): number { + const fromA = from === "a"; + const band = bands.find((b) => y < (fromA ? b.a1 : b.b1)) ?? bands[bands.length - 1]!; + const [m0, m1, s0, s1] = fromA ? [band.a0, band.a1, band.b0, band.b1] : [band.b0, band.b1, band.a0, band.a1]; + if (band.changed) return Math.min(s0 + (y - m0), s1); + return m1 > m0 ? s0 + ((y - m0) * (s1 - s0)) / (m1 - m0) : s0; +} + +type ChangeKind = "inserted" | "deleted" | "modified"; +const kindOf = (c: Chunk): ChangeKind => (c.fromA === c.toA ? "inserted" : c.fromB === c.toB ? "deleted" : "modified"); + +/** The divider band between [a0, a1] on the left and [b0, b1] on the right (DiffDrawUtil.makeCurve). */ +export function bandPath(w: number, a0: number, a1: number, b0: number, b1: number): string { + // An empty side is a 2px sliver on its boundary. + if (a1 - a0 < 2) [a0, a1] = [a0 - 1, a0 + 1]; + if (b1 - b0 < 2) [b0, b1] = [b0 - 1, b0 + 1]; + const c1 = w * 0.3; + const c2 = w * 0.7; + return `M0 ${a0}C${c1} ${a0} ${c2} ${b0} ${w} ${b0}L${w} ${b1}C${c2} ${b1} ${c1} ${a1} 0 ${a1}Z`; +} + +const gapMark = (kind: ChangeKind, end: boolean) => + Decoration.line({ class: `cm-diffGap cm-diffGap-${kind}${end ? " cm-diffGapEnd" : ""}` }); + +/** A line where the other side has lines this one lacks, as in IDEA's empty-side marker. */ +function gapDeco(view: EditorView): DecorationSet { + const info = getChunks(view.state); + if (!info?.side) return Decoration.none; + const isA = info.side === "a"; + const { doc } = view.state; + const { from: vpFrom, to: vpTo } = view.viewport; + const builder = new RangeSetBuilder(); + for (const c of info.chunks) { + const [from, to] = isA ? [c.fromA, c.toA] : [c.fromB, c.toB]; + if (from !== to) continue; + if (from > vpTo) break; + if (from < vpFrom) continue; + // Past the end of a document without a final line break: below its last line. + const line = doc.lineAt(Math.min(from, doc.length)); + builder.add(line.from, line.from, gapMark(kindOf(c), from > doc.length)); + } + return builder.finish(); +} + +const gapMarks = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = gapDeco(view); + } + update(u: ViewUpdate) { + if (u.docChanged || u.viewportChanged || getChunks(u.startState)?.chunks !== getChunks(u.state)?.chunks) + this.decorations = gapDeco(u.view); + } + }, + { decorations: (v) => v.decorations }, +); + +const SVG = "http://www.w3.org/2000/svg"; +const ARROW_HEIGHT = 16; +let gradients = 0; + +export interface UnalignedPanesOptions { + /** Scrolling one pane carries the other along. */ + sync: boolean; + /** Divider arrows toward the editable sides; `apply` copies chunk `index` from `source`. */ + arrows?: { toA: boolean; toB: boolean; apply(index: number, source: Side): void }; +} + +/** + * Scroll synchronization and the divider for a MergeView whose panes are not + * padded into alignment. Add `extension` to both editors, then `attach` the view. + */ +export class UnalignedPanes { + readonly extension: Extension; + private mv: MergeView | null = null; + private readonly divider = document.createElement("div"); + private readonly svg = document.createElementNS(SVG, "svg"); + private readonly bandLayer = document.createElementNS(SVG, "g"); + private readonly gradient = `cm-diffBandGradient-${++gradients}`; + private readonly arrowEls = new Map(); + /** Scroll position each pane was last moved to by the other one, to tell its echo from the user. */ + private readonly moved: Record = { + a: { top: null, left: null }, + b: { top: null, left: null }, + }; + private sync: boolean; + private frame = -1; + private resize: ResizeObserver | null = null; + private readonly cleanup: (() => void)[] = []; + + constructor(private readonly opts: UnalignedPanesOptions) { + this.sync = opts.sync; + this.extension = [gapMarks, changedLineGutters, EditorView.updateListener.of(() => this.schedule())]; + this.divider.className = "cm-diffDivider"; + this.svg.setAttribute("aria-hidden", "true"); + this.svg.innerHTML = + `` + + `` + + ``; + this.svg.appendChild(this.bandLayer); + this.divider.appendChild(this.svg); + } + + attach(mv: MergeView) { + this.mv = mv; + // MergeView pads the panes into alignment on each measure; these panes stay unpadded. + (mv as unknown as { measure(): void }).measure = () => {}; + mv.a.dom.parentElement!.after(this.divider); + for (const side of ["a", "b"] as const) { + const el = this.view(side).scrollDOM; + const onScroll = () => this.scrolled(side); + el.addEventListener("scroll", onScroll, { passive: true }); + this.cleanup.push(() => el.removeEventListener("scroll", onScroll)); + } + // The divider scrolls the right pane (and, when synchronized, through it the left one). + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + mv.b.scrollDOM.scrollBy(e.deltaX, e.deltaY); + }; + this.divider.addEventListener("wheel", onWheel, { passive: false }); + this.cleanup.push(() => this.divider.removeEventListener("wheel", onWheel)); + if (typeof ResizeObserver !== "undefined") { + this.resize = new ResizeObserver(() => this.schedule()); + this.resize.observe(this.divider); + } + requestAnimationFrame(() => this.resync()); + } + + /** Brings the left pane in line with the right one. */ + resync() { + if (this.mv && this.sync) this.follow("b"); + this.schedule(); + } + + /** Turns synchronized scrolling on (bringing the left pane in line) or off. */ + setSync(on: boolean) { + if (on === this.sync) return; + this.sync = on; + this.resync(); + } + + destroy() { + if (this.frame >= 0) cancelAnimationFrame(this.frame); + this.resize?.disconnect(); + for (const f of this.cleanup) f(); + this.divider.remove(); + this.mv = null; + } + + private view(side: Side): EditorView { + return side === "a" ? this.mv!.a : this.mv!.b; + } + + private scrolled(side: Side) { + const el = this.view(side).scrollDOM; + const moved = this.moved[side]; + const echo = + (moved.top !== null || moved.left !== null) && + (moved.top === null || Math.abs(el.scrollTop - moved.top) <= 1) && + (moved.left === null || Math.abs(el.scrollLeft - moved.left) <= 1); + moved.top = moved.left = null; + if (!echo && this.sync) this.follow(side); + this.schedule(); + } + + private bandAt(y: number, from: Side): Band { + const { a, b, chunks } = this.mv!; + return bandAt( + chunks, + (c) => extent(a, c.fromA, c.toA), + (c) => extent(b, c.fromB, c.toB), + a.lineBlockAt(a.state.doc.length).bottom, + b.lineBlockAt(b.state.doc.length).bottom, + y, + from, + ); + } + + /** Scrolls the other pane to match `from`, anchored a third down the pane (SyncScrollSupport). */ + private follow(from: Side) { + const to: Side = from === "a" ? "b" : "a"; + const master = this.view(from); + const slave = this.view(to); + const anchor = master.scrollDOM.clientHeight / 3; + const at = master.scrollDOM.scrollTop + anchor - docOffset(master); + const y = transferY([this.bandAt(at, from)], at, from); + const el = slave.scrollDOM; + const top = Math.max(0, Math.min(y + docOffset(slave) - anchor, el.scrollHeight - el.clientHeight)); + const moved = this.moved[to]; + if (Math.abs(el.scrollTop - top) >= 1) { + const before = el.scrollTop; + el.scrollTop = top; + if (el.scrollTop !== before) moved.top = el.scrollTop; + } + const left = master.scrollDOM.scrollLeft; + if (el.scrollLeft !== left) { + const before = el.scrollLeft; + el.scrollLeft = left; + if (el.scrollLeft !== before) moved.left = el.scrollLeft; + } + } + + private schedule() { + if (this.frame < 0 && this.mv) this.frame = requestAnimationFrame(() => this.draw()); + } + + private draw() { + this.frame = -1; + const mv = this.mv; + if (!mv) return; + const rect = this.divider.getBoundingClientRect(); + const w = rect.width; + const h = rect.height; + if (!w || !h) return; + this.svg.setAttribute("width", String(w)); + this.svg.setAttribute("height", String(h)); + this.svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + const offA = mv.a.documentTop - rect.top; + const offB = mv.b.documentTop - rect.top; + const lineHeight = mv.a.defaultLineHeight; + const arrowTop = (y0: number, y1: number) => + y1 - y0 < 2 ? y0 - ARROW_HEIGHT / 2 : y0 + (Math.min(lineHeight, y1 - y0) - ARROW_HEIGHT) / 2; + const arrows = this.opts.arrows; + const shown = new Set(); + let paths = ""; + const { chunks } = mv; + // Chunks run down both panes in order: start at the first one reaching into either. + const first = Math.min( + firstIndex(chunks.length, (i) => extent(mv.a, chunks[i]!.fromA, chunks[i]!.toA)[1] + offA >= -1), + firstIndex(chunks.length, (i) => extent(mv.b, chunks[i]!.fromB, chunks[i]!.toB)[1] + offB >= -1), + ); + for (let i = first; i < chunks.length; i++) { + const c = chunks[i]!; + const [a0, a1] = extent(mv.a, c.fromA, c.toA).map((y) => y + offA) as [number, number]; + const [b0, b1] = extent(mv.b, c.fromB, c.toB).map((y) => y + offB) as [number, number]; + if (a0 > h + 1 && b0 > h + 1) break; + if (a1 < -1 && b1 < -1) continue; + const kind = kindOf(c); + const fill = kind === "modified" ? ` fill="url(#${this.gradient})"` : ""; + const mid = (a0 + a1) / 2; + const midB = (b0 + b1) / 2; + paths += + `` + + ``; + if (arrows?.toB) this.placeArrow(shown, i, "a-to-b", arrowTop(a0, a1), h); + if (arrows?.toA) this.placeArrow(shown, i, "b-to-a", arrowTop(b0, b1), h); + } + this.bandLayer.innerHTML = paths; + for (const [key, el] of this.arrowEls) + if (!shown.has(key)) { + el.remove(); + this.arrowEls.delete(key); + } + } + + private placeArrow(shown: Set, index: number, dir: "a-to-b" | "b-to-a", top: number, h: number) { + if (top < -ARROW_HEIGHT || top > h) return; + const key = `${index}${dir}`; + let el = this.arrowEls.get(key); + if (!el) { + el = arrowButton(dir, (i) => this.opts.arrows?.apply(i, dir === "a-to-b" ? "a" : "b")); + this.arrowEls.set(key, el); + this.divider.appendChild(el); + } + el.dataset.chunk = String(index); + el.style.top = `${top}px`; + shown.add(key); + } +} + +/** Offset of the document's top within the pane's scrolled content. */ +function docOffset(view: EditorView): number { + return view.documentTop - view.scrollDOM.getBoundingClientRect().top + view.scrollDOM.scrollTop; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 7e9d6faf..ab9debc0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -58,6 +58,7 @@ "Adjust colors for red-green vision deficiency": "针对红绿色觉缺陷调整颜色", "After Replace": "替换后", "After Stop, the process gets this long to exit after the interrupt before it is killed.": "停止后,进程在收到中断信号后有这么长时间退出,超时将被强制终止。", + "Align Changes": "对齐更改", "All": "全部", "All Places": "所有位置", "All breakpoints": "所有断点",