/** * @file The Changes panel's file editor: CodeMirror 6 diffed against the * file's original text (HEAD, or a branch's merge base), editable while the * diff re-colours as you type. `unified` is one editor with the removed lines * inline above their replacement (`unifiedMergeView`); `split` is the * original read-only on the left beside the editable file (`MergeView`). * `collapse` folds unchanged stretches away — the panel's Changes view — * and every changed block carries a Revert that puts the original back in the * editor (nothing is written until Save). Green marks a line added whole, red * a line removed whole, blue a line edited in place (it keeps some of its * text), its edit in a stronger blue — the same scheme as the read-only diff in * LaneDiffView. Syntax colours come from the dashboard's own per-line * tokenizer (`lib/highlight.ts`), like every other code view here. * Lazy-loaded by LaneChangesPanel, which keys it by file and shape: * CodeMirror stays out of the main bundle, and one mount is one view. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef } from "react"; import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, type DecorationSet, type ViewUpdate, } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge"; import { highlight, tokenClass } from "../../lib/highlight"; /** * True when a letter or digit of a line (`text`, starting at document offset * `from`) lies outside every changed range: an edit inside the line, not a * line added or removed whole. Kept indentation or punctuation alone (a `;`, * a `}`) does not make two lines the same line. */ export function keepsText(text: string, from: number, changed: [number, number][]): boolean { for (let i = 0; i < text.length; i += 1) { if (!/\w/.test(text[i]!)) continue; const at = from + i; if (!changed.some(([a, b]) => at >= a && at < b)) return true; } return false; } const modLine = Decoration.line({ class: "cm-ah-mod" }); const marks = new Map(); const mark = (cls: string) => { let d = marks.get(cls); if (!d) marks.set(cls, (d = Decoration.mark({ class: cls }))); return d; }; /** Syntax marks and the edited-in-place class for the visible lines — on * either side of a split view (`a` is the original). */ function decorate(view: EditorView, lang: string): DecorationSet { const out = new RangeSetBuilder(); const info = getChunks(view.state); const a = info?.side === "a"; const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]); const chunks = info?.chunks ?? []; const doc = view.state.doc; let last = 0; for (const { from, to } of view.visibleRanges) { for (let pos = from; pos <= to; ) { const line = doc.lineAt(pos); pos = line.to + 1; if (line.number <= last) continue; last = line.number; const c = chunks.find((k) => { const [from, to, end] = span(k); return to! > from! && line.from >= from! && line.from <= end!; }); const at0 = c ? span(c)[0]! : 0; if ( c && keepsText( line.text, line.from, c.changes.map((ch) => a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB] ) ) ) { out.add(line.from, line.from, modLine); } let at = line.from; for (const tok of highlight(line.text, lang)) { const cls = tokenClass(tok.type); if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls)); at += tok.text.length; } } } return out.finish(); } /** The removed lines are drawn by @codemirror/merge's own widget, so they are * classed after the fact, by `keepsText`'s rule: text outside its * `.cm-deletedText` spans is text the line kept. */ function classDeleted(root: HTMLElement) { root.querySelectorAll(".cm-deletedLine:not([data-ah])").forEach((line) => { let kept = ""; (line.firstElementChild ?? line).childNodes.forEach((n) => { if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) { kept += n.textContent ?? ""; } }); line.dataset.ah = /\w/.test(kept) ? "mod" : "del"; }); } const diffColours = (lang: string) => ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = decorate(view, lang); this.classLater(view); } update(u: ViewUpdate) { if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang); this.classLater(u.view); } // After CodeMirror has drawn, and before the browser paints. classLater(view: EditorView) { view.requestMeasure({ key: this, read: () => null, write: () => classDeleted(view.contentDOM), }); } }, { decorations: (p) => p.decorations } ); const GREEN = "rgb(var(--status-success) / 0.18)"; const RED = "rgb(var(--status-danger) / 0.18)"; const BLUE = "rgb(var(--diff-mod) / 0.18)"; const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)"; // `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's // base theme, whose selectors carry an extra light/dark scope class. const M = "&.cm-merge-b.cm-merge-b"; const A = "&.cm-merge-a.cm-merge-a"; const theme = EditorView.theme({ "&": { height: "100%", fontSize: "11px", backgroundColor: "rgb(var(--surface-0))", color: "rgb(var(--fg-primary))", }, "&.cm-focused": { outline: "none" }, ".cm-scroller": { fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace', lineHeight: "1.45", }, ".cm-gutters": { backgroundColor: "rgb(var(--surface-1))", color: "rgb(var(--fg-muted))", border: "none", }, ".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" }, ".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" }, ".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { backgroundColor: "rgb(var(--accent) / 0.3)", }, [`${M} .cm-changedLine`]: { backgroundColor: GREEN }, [`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE }, [`${M} .cm-changedText`]: { background: "none" }, [`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT }, [`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" }, [`${M} .cm-deletedLine`]: { backgroundColor: RED }, [`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE }, [`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" }, [`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT }, [`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" }, [`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" }, // The original, left of a split view: its changed lines are removals. [`${A} .cm-changedLine`]: { backgroundColor: RED }, [`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE }, [`${A} .cm-changedText`]: { background: "none" }, [`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT }, [`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" }, "&.cm-editor.cm-editor .cm-collapsedLines": { color: "rgb(var(--fg-muted))", background: "rgb(var(--surface-2))", }, ".cm-chunkButtons": { zIndex: "1" }, }); /** The Revert button on a changed block; @codemirror/merge wires the click. * Between split columns there is room for an arrow only (`→`: left onto right). */ function revertButton(label: string, arrow = false) { const b = document.createElement("button"); b.type = "button"; b.textContent = arrow ? "→" : label; b.title = label; b.setAttribute("aria-label", label); b.className = "rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary"; return b; } export default function DiffEditor({ value, original, lang, layout = "unified", collapse = false, label, revertLabel, onChange, onSave, }: { value: string; /** The text the diff is taken against; read once, at mount. */ original: string; lang: string; /** One editor with removals inline, or the original beside the file. */ layout?: "unified" | "split"; /** Fold unchanged stretches away: only the changes and their context. */ collapse?: boolean; label: string; /** The text of each changed block's Revert button. */ revertLabel: string; onChange: (text: string) => void; onSave: () => void; }) { const host = useRef(null); const view = useRef(null); const cb = useRef({ onChange, onSave }); cb.current = { onChange, onSave }; useEffect(() => { const folded = collapse ? { margin: 3, minSize: 4 } : undefined; const common = [lineNumbers(), diffColours(lang), theme]; const editing = [ ...common, highlightActiveLineGutter(), history(), drawSelection(), highlightActiveLine(), keymap.of([ { key: "Mod-s", preventDefault: true, run: () => { cb.current.onSave(); return true; }, }, indentWithTab, ...defaultKeymap, ...historyKeymap, ]), EditorView.contentAttributes.of({ "aria-label": label }), EditorView.updateListener.of((u) => { if (u.docChanged) cb.current.onChange(u.state.doc.toString()); }), ]; let v: EditorView; let destroy: () => void; if (layout === "split") { const mv = new MergeView({ parent: host.current!, a: { doc: original, extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)], }, b: { doc: value, extensions: editing }, revertControls: "a-to-b", renderRevertControl: () => revertButton(revertLabel, true), collapseUnchanged: folded, }); v = mv.b; destroy = () => mv.destroy(); } else { v = new EditorView({ parent: host.current!, state: EditorState.create({ doc: value, extensions: [ ...editing, unifiedMergeView({ original, syntaxHighlightDeletions: false, collapseUnchanged: folded, // Accept would only hide a block; Revert is the one that edits. mergeControls: (type, action) => { const b = revertButton(revertLabel); if (type === "accept") b.hidden = true; else b.onmousedown = action; return b; }, }), ], }), }); destroy = () => v.destroy(); } v.focus(); view.current = v; return destroy; // One mount per view: the parent keys this component by file and shape. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // A value set from outside (Cancel's revert, a save's fresh text). useEffect(() => { const v = view.current; if (v && v.state.doc.toString() !== value) { v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } }); } }, [value]); return (
); }