/** * @file The file editor's side-by-side layout, JetBrains-shaped: the original * (read-only, left) and the working file (editable, right) are two * CodeMirror views in their OWN scrollers, with no padding to line them up — * each shows its real lines — and the gutter between them draws, for every * changed block, a curved band joining its lines on the left to its lines on * the right (`lib/diffConnectors.ts`), with a » that reverts the block. * Scrolling either side scrolls the other to the matching line (`mapLine`: * 1:1 outside a block, proportional inside one). * * What @codemirror/merge's MergeView gave the old layout is kept, by its own * public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on * every edit of the right side; the changed-line, changed-text and gutter * classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded * unchanged stretches (opened on both sides at once) and the revert's text * rule are ported from it, so DiffEditor's theme and colouring apply as before. * @author Nguyễn Ngọc Trí Vĩ */ import { EditorState, RangeSet, StateEffect, StateField, Text, type Extension, } from "@codemirror/state"; import { Decoration, EditorView, GutterMarker, WidgetType, gutterLineClass, type DecorationSet, } from "@codemirror/view"; import { Chunk, getChunks } from "@codemirror/merge"; import { bandPath, mapLine, type Span } from "../../lib/diffConnectors"; /** The chunks a side carries, the side it is, and (right side) the original. */ type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null }; const setChunks = StateEffect.define(); const uncollapse = StateEffect.define(); /** Each side's chunks. The right side recomputes them as it is edited; the * left, read-only, is handed the new list (`setChunks`). */ const splitField = StateField.define({ create: () => ({ chunks: [], side: "b", orig: null }), update(v, tr) { for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value }; if (v.orig && tr.docChanged) { return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) }; } return v; }, }); /** The chunks and side of an editor in either merge layout — unified (the * merge package's own state) or this split. */ export function chunksOf( state: EditorState ): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null { return getChunks(state) ?? state.field(splitField, false) ?? null; } const changedLine = Decoration.line({ class: "cm-changedLine" }); const changedText = Decoration.mark({ class: "cm-changedText" }); const changedGutter = new (class extends GutterMarker { override elementClass = "cm-changedLineGutter"; })(); /** The lines (and, inside them, the changed spans) of this side's chunks. */ function changedRanges(state: EditorState) { const { chunks, side } = state.field(splitField); const doc = state.doc; const lines: number[] = []; const spans: [number, number][] = []; for (const c of chunks) { const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB]; if (from >= to) continue; const last = doc.lineAt(Math.min(to - 1, doc.length)).number; for (let n = doc.lineAt(from).number; n <= last; n += 1) { const line = doc.line(n); lines.push(line.from); for (const ch of c.changes) { const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB)); const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB)); if (s < e) spans.push([s, e]); } } } return { lines, spans }; } const chunkDecorations = EditorView.decorations.compute([splitField], (state) => { const { lines, spans } = changedRanges(state); return Decoration.set( [...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))], true ); }); const chunkGutter = gutterLineClass.compute([splitField], (state) => RangeSet.of( changedRanges(state).lines.map((p) => changedGutter.range(p)), true ) ); /** The other side's editor, for a fold opened on one side to open on both. */ const siblings = new WeakMap(); /** `pos` on one side, moved past the chunks before it to the other (ported). */ function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) { let ours = 0; let theirs = 0; for (const c of chunks) { if ((isA ? c.fromA : c.fromB) >= pos) break; [ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA]; } return theirs + (pos - ours); } class CollapseWidget extends WidgetType { constructor(readonly lines: number) { super(); } override eq(o: CollapseWidget) { return this.lines === o.lines; } toDOM(view: EditorView) { const el = document.createElement("div"); el.className = "cm-collapsedLines"; el.textContent = view.state.phrase("$ unchanged lines", this.lines); el.addEventListener("click", (e) => { const pos = view.posAtDOM(e.target as Node); view.dispatch({ effects: uncollapse.of(pos) }); const { chunks, side } = view.state.field(splitField); siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) }); }); return el; } override ignoreEvent(e: Event) { return e instanceof MouseEvent; } override get estimatedHeight() { return 27; } } /** The folded unchanged stretches: built once at mount, then only mapped, * opened by a click, and dropped where an edit grows a chunk into them. */ const collapsedField = StateField.define({ create: () => Decoration.none, update(deco, tr) { deco = deco.map(tr.changes); for (const e of tr.effects) { if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value }); } const { chunks, side } = tr.state.field(splitField); if (deco.size && chunks !== tr.startState.field(splitField).chunks) { const touching: number[] = []; for (const c of chunks) { deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => { touching.push(from); }); } if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) }); } return deco; }, provide: (f) => EditorView.decorations.from(f), }); function buildCollapsed(state: EditorState, margin: number, minLines: number) { const { chunks, side } = state.field(splitField); const doc = state.doc; const out = []; let prev = 1; for (let i = 0; ; i += 1) { const c = i < chunks.length ? chunks[i]! : null; const from = i ? prev + margin : 1; const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines; const lines = to - from + 1; if (lines >= minLines) { out.push( Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range( doc.line(from).from, doc.line(to).to ) ); } if (!c) break; prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number; } return Decoration.set(out); } /** A chunk side as 0-based half-open lines; an empty side is the point * before the line it sits above (past the end: after the last line). */ function sideLines(doc: Text, from: number, to: number): [number, number] { const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1; if (from >= to) return [first, first]; return [first, doc.lineAt(Math.min(to - 1, doc.length)).number]; } function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] { return chunks.map((c) => { const [a0, a1] = sideLines(a, c.fromA, c.toA); const [b0, b1] = sideLines(b, c.fromB, c.toB); return { a0, a1, b0, b1 }; }); } /** Where the document starts inside the view's scroll content. */ const docOffset = (v: EditorView) => v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop; /** The fractional line at the top of the view (a folded stretch spreads its * height over the lines it hides). */ function topLine(v: EditorView) { const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v)); const blk = v.lineBlockAtHeight(h); const first = v.state.doc.lineAt(blk.from).number - 1; const n = v.state.doc.lineAt(blk.to).number - first; const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0; return first + frac * n; } /** Document y of a fractional line (0-based). */ function yOf(v: EditorView, line: number) { const doc = v.state.doc; if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom; const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from); const first = doc.lineAt(blk.from).number - 1; const n = doc.lineAt(blk.to).number - first; return blk.top + ((line - first) / n) * blk.height; } /** The gutter's width: room for an S-curve and the » under it. */ const GUTTER_PX = 40; const BAND: Record<"add" | "del" | "mod", string> = { add: "fill-status-success/20 stroke-status-success/60", del: "fill-status-danger/20 stroke-status-danger/60", mod: "fill-diff-mod/20 stroke-diff-mod/60", }; const SVG = "http://www.w3.org/2000/svg"; export function splitDiff({ parent, original, value, extA, extB, collapse, revertLabel, }: { parent: HTMLElement; original: string; value: string; /** Each side's own extensions (DiffEditor's colours, keymaps, …). */ extA: Extension[]; extB: Extension[]; collapse?: { margin: number; minSize: number }; revertLabel: string; }): { a: EditorView; b: EditorView; destroy: () => void } { const origDoc = Text.of(original.split(/\r?\n/)); const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/))); const root = parent.appendChild(document.createElement("div")); root.className = "flex h-full min-h-0"; const pane = () => { const el = document.createElement("div"); el.className = "h-full min-w-0 flex-1"; return el; }; const paneA = root.appendChild(pane()); const gutter = root.appendChild(document.createElement("div")); gutter.className = "cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1"; gutter.style.width = `${GUTTER_PX}px`; gutter.dataset.testid = "lane-editor-connectors"; const svg = gutter.appendChild(document.createElementNS(SVG, "svg")); svg.setAttribute("class", "absolute inset-0 h-full w-full"); const buttons = gutter.appendChild(document.createElement("div")); const paneB = root.appendChild(pane()); let frame = 0; const draw = () => { if (frame) return; const run = () => { frame = 0; paint(); }; frame = typeof requestAnimationFrame === "function" ? requestAnimationFrame(run) : window.setTimeout(run); }; const side = (s: "a" | "b"): Extension[] => [ splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })), chunkDecorations, chunkGutter, EditorView.editorAttributes.of({ class: `cm-merge-${s}` }), ...(collapse ? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))] : []), EditorView.updateListener.of((u) => { if (u.docChanged || u.geometryChanged || u.viewportChanged) draw(); }), ]; const a = new EditorView({ parent: paneA, state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }), }); const b = new EditorView({ parent: paneB, state: EditorState.create({ doc: value, extensions: [ ...side("b"), ...extB, // The left side is told the chunks the right side's edit produced. EditorView.updateListener.of((u) => { const now = u.state.field(splitField).chunks; if (now !== u.startState.field(splitField).chunks) a.dispatch({ effects: setChunks.of(now) }); }), ], }), }); siblings.set(a, b); siblings.set(b, a); const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc); // Scrolling one side scrolls the other to the line beside it. A scroll we // caused comes back as the other side's own event: `echo` holds where we // put it, so that event is recognised and not mirrored back. const echo = { a: -1, b: -1 }; const follow = (from: "a" | "b") => () => { const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const]; if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) { echo[from] = -1; draw(); return; } const line = mapLine(topLine(src), spans(), from === "a"); const target = Math.round(yOf(dst, line) + docOffset(dst)); const before = dst.scrollDOM.scrollTop; dst.scrollDOM.scrollTop = target; if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop; draw(); }; const onA = follow("a"); const onB = follow("b"); a.scrollDOM.addEventListener("scroll", onA, { passive: true }); b.scrollDOM.addEventListener("scroll", onB, { passive: true }); const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null; resize?.observe(root); // » puts the original block back on the right (the merge package's rule: // a non-empty source brings its line break unless it ends the document). buttons.addEventListener("mousedown", (e) => { const i = Number((e.target as HTMLElement).closest("[data-chunk]")?.dataset.chunk); const c = b.state.field(splitField).chunks[i]; if (!c) return; let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1)); if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak; b.dispatch({ changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert }, userEvent: "revert", }); e.preventDefault(); }); function paint() { const g = gutter.getBoundingClientRect(); const height = gutter.clientHeight; const ya = (l: number) => a.documentTop - g.top + yOf(a, l); const yb = (l: number) => b.documentTop - g.top + yOf(b, l); const paths: SVGPathElement[] = []; const btns: HTMLButtonElement[] = []; spans().forEach((s, i) => { const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)]; // Off screen on both sides: nothing to draw (no layout = draw all). if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return; const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod"; const p = document.createElementNS(SVG, "path"); p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1)); p.setAttribute("class", BAND[kind]); p.setAttribute("vector-effect", "non-scaling-stroke"); p.dataset.kind = kind; paths.push(p); const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "»"; btn.title = revertLabel; btn.setAttribute("aria-label", revertLabel); btn.dataset.chunk = String(i); btn.className = "absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"; btn.style.top = `${Math.max(0, y0) + 1}px`; btns.push(btn); }); svg.replaceChildren(...paths); buttons.replaceChildren(...btns); } draw(); return { a, b, destroy: () => { if (frame) (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame); resize?.disconnect(); a.scrollDOM.removeEventListener("scroll", onA); b.scrollDOM.removeEventListener("scroll", onB); a.destroy(); b.destroy(); root.remove(); }, }; }