/** * @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, JetBrains-shaped — * two scrollers kept on matching lines, no padding, a curved band per changed * block between them (`splitDiff.ts`). * `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. * Given `onBlock`, each changed block also gets the read-only diff's bar under * its last line — Undo (asks twice) and the block's commit checkbox * (`blockIncluded`) — in place of its Revert; the panel turns it off while * the editor holds unsaved text, because git acts on the file on disk. * Given `blame`, a gutter left of the line numbers names each line's last * commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets * a function that moves the cursor to the next / previous changed block. * Given `onAddToChat`, a selection in the editable file gets Cursor's Add to * Chat chip floating above its last line, at the column where it ends, * which hands the panel the selection's 1-based lines. * 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 { useTranslation } from "react-i18next"; import { Compartment, EditorSelection, EditorState, RangeSetBuilder, StateEffect, StateField, Text, } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType, drawSelection, gutter, GutterMarker, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, showTooltip, type Tooltip, type DecorationSet, type ViewUpdate, } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { unifiedMergeView, type Chunk } from "@codemirror/merge"; import { chunksOf, splitDiff } from "./splitDiff"; import { highlight, tokenClass } from "../../lib/highlight"; import type { LaneBlame } from "../../lib/types"; type Blame = Extract; /** * 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 = chunksOf(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 } ); /** A changed block by line (1-based; a count of 0 is a pure insertion or * removal): what the panel matches against git's own change blocks. */ export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number }; export type BlockAction = "include" | "undo"; /** One Annotate cell: the line's commit date and author, its subject on hover. */ class BlameMarker extends GutterMarker { constructor( readonly text: string, readonly title: string ) { super(); } override eq(other: BlameMarker) { return other.text === this.text && other.title === this.title; } override toDOM() { const el = document.createElement("span"); el.textContent = this.text; el.title = this.title; return el; } } /** The Annotate gutter for the editable file's lines (the B side). ponytail: * lines are matched by number, so unsaved edits above a line shift its * annotation until the file is saved and re-annotated. */ function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) { const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1]; return gutter({ class: "cm-ah-blame", lineMarker(view, line) { const sha = shaAt(view.state, line.from); if (sha === undefined) return null; const c = sha ? blame.commits[sha] : undefined; return c ? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`) : new BlameMarker(notCommitted, notCommitted); }, initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""), domEventHandlers: { mousedown(view, line) { const sha = shaAt(view.state, line.from); if (sha) onClick(sha); return !!sha; }, }, }); } /** A chunk side's first line and line count. `to` is the start of the line * after the chunk (or the end + 1), so its last line holds `to - 1`. */ const lineSpan = (doc: Text, from: number, to: number): [number, number] => { const first = doc.lineAt(Math.min(from, doc.length)).number; return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1]; }; class BlockBar extends WidgetType { constructor( readonly block: EditorBlock, readonly build: (b: EditorBlock) => HTMLElement ) { super(); } eq(o: BlockBar) { const a = this.block; const b = o.block; return ( a.oldFrom === b.oldFrom && a.oldCount === b.oldCount && a.newFrom === b.newFrom && a.newCount === b.newCount ); } toDOM() { return this.build(this.block); } } const barsOn = StateEffect.define(); /** One bar per chunk, under its last line (above a pure removal), rebuilt * whenever the chunks are — and dropped while `barsOn` is false. */ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) { const decos = (doc: Text, chunks: readonly Chunk[]) => { const out = new RangeSetBuilder(); for (const c of chunks) { const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA); const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB); const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length); out.add( pos, pos, Decoration.widget({ block: true, side: newCount ? 1 : -1, widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build), }) ); } return out.finish(); }; return StateField.define<{ on: boolean; deco: DecorationSet }>({ create: () => ({ on: false, deco: Decoration.none }), update(v, tr) { let on = v.on; for (const e of tr.effects) if (e.is(barsOn)) on = e.value; const chunks = chunksOf(tr.state)?.chunks; if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) { return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v; } return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none }; }, provide: (f) => EditorView.decorations.from(f, (v) => v.deco), }); } /** Add to Chat floating just above the selection's last line, at the column * where the selection ends — so a long line never pushes it out of view. * `build` makes its DOM from the selection's first and last 1-based line. A * selection ending at a line's start (a whole-line drag) does not take that * line, and the chip then sits at the start of the last line it does take. */ function chatChip(build: (start: number, end: number) => HTMLElement) { return StateField.define({ create: () => null, update(tip, tr) { if (!tr.docChanged && !tr.selection) return tip; const { from, to, empty } = tr.state.selection.main; if (empty) return null; const doc = tr.state.doc; const first = doc.lineAt(from); const last = doc.lineAt(to); const wholeLine = to === last.from && last.number > first.number; const end = wholeLine ? last.number - 1 : last.number; return { pos: wholeLine ? doc.line(end).from : to, above: true, create: () => { const dom = build(first.number, end); dom.classList.add("cm-chat-chip"); return { dom }; }, }; }, provide: (f) => showTooltip.from(f), }); } const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer"; const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"; const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger"; 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))" }, // Doubled classes: drawSelection's base theme scopes its light/dark colours // as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a // plain `.cm-selectionBackground` never outranks. "&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection": { background: "rgb(var(--accent) / 0.3)", }, // Doubled class: outranks the base theme's light/dark-scoped tooltip frame. ".cm-tooltip.cm-chat-chip.cm-chat-chip": { border: "1px solid rgb(var(--accent) / 0.6)", backgroundColor: "rgb(var(--surface-1))", }, ".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" }, [`${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" }, ".cm-ah-blame .cm-gutterElement": { color: "rgb(var(--fg-muted))", cursor: "pointer", fontSize: "10px", paddingRight: "6px", whiteSpace: "nowrap", }, }); /** The Revert button on a changed block (unified); @codemirror/merge wires * the click. The split layout draws its own » in the gutter. */ function revertButton(label: string) { const b = document.createElement("button"); b.type = "button"; b.textContent = 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, onBlock, blockIncluded, blame, onBlameClick, navRef, onAddToChat, }: { 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; /** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */ onBlock?: (action: BlockAction, block: EditorBlock) => void; /** Whether a block is ticked for the commit (its checkbox's state). */ blockIncluded?: (block: EditorBlock) => boolean; /** Annotate: each line's last commit; absent = no gutter. */ blame?: Blame; onBlameClick?: (sha: string) => void; /** Set to "go to the next (1) / previous (-1) changed block". */ navRef?: { current: ((dir: 1 | -1) => void) | null }; /** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */ onAddToChat?: (start: number, end: number) => void; }) { const { t } = useTranslation("lanes"); const host = useRef(null); const view = useRef(null); const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t }); cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t }; const blameSlot = useRef(new Compartment()).current; /** The diff view's review bar, as DOM: pinned to the visible right edge, * zero height so it never pushes lines (or a split view's alignment). */ const buildBar = (block: EditorBlock) => { const tr = cb.current.t; const wrap = document.createElement("div"); wrap.className = "flex h-0"; const pin = wrap.appendChild(document.createElement("span")); pin.className = "sticky right-2 ml-auto h-0 w-0 self-start"; const bar = pin.appendChild(document.createElement("span")); bar.dataset.testid = "lane-editor-block-bar"; bar.className = "absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"; const chip = (text: string, title: string, cls: string) => { const b = bar.appendChild(document.createElement("button")); b.type = "button"; b.textContent = text; b.title = title; b.setAttribute("aria-label", title); b.className = `${CHIP} ${cls}`; return b; }; const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO); // The block's commit checkbox, a real one so it reads like the diff's. const include = bar.appendChild(document.createElement("input")); include.type = "checkbox"; include.checked = cb.current.blockIncluded?.(block) ?? true; include.title = tr("changes.includeBlock"); include.setAttribute("aria-label", tr("changes.includeBlock")); include.className = "h-3 w-3 cursor-pointer"; // Two clicks, like every undo git cannot give back; disarms after 3 s. let timer = 0; const arm = (on: boolean) => { undo.dataset.armed = on ? "1" : ""; undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort"); const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk"); undo.title = title; undo.setAttribute("aria-label", title); undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`; }; undo.onclick = () => { window.clearTimeout(timer); if (undo.dataset.armed) { arm(false); cb.current.onBlock?.("undo", block); return; } arm(true); timer = window.setTimeout(() => arm(false), 3000); }; include.onchange = () => cb.current.onBlock?.("include", block); return wrap; }; useEffect(() => { const folded = collapse ? { margin: 3, minSize: 4 } : undefined; // A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the // panel would read that as an unsaved edit (no Undo / Keep bars) and Save // would rewrite every line ending. const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : []; const common = [lineNumbers(), diffColours(lang), theme, ...eol]; 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.sliceDoc()); }), blockBars(Text.of(original.split(/\r?\n/)), buildBar), blameSlot.of([]), ...(onAddToChat ? [ chatChip((start, end) => { const b = document.createElement("button"); b.type = "button"; b.dataset.testid = "lane-editor-add-to-chat"; b.textContent = cb.current.t("changes.addToChat"); b.className = "rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2"; // Keep the editor's focus (and so its selection) through the click. b.onmousedown = (e) => e.preventDefault(); b.onclick = () => cb.current.onAddToChat?.(start, end); return b; }), ] : []), ]; let v: EditorView; let destroy: () => void; if (layout === "split") { const sd = splitDiff({ parent: host.current!, original, value, extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)], extB: editing, collapse: folded, revertLabel, }); v = sd.b; destroy = sd.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; if (navRef) { // The B side's chunks, unified or split: the cursor goes to the start // of the next / previous one and it scrolls into view. navRef.current = (dir) => { const ed = view.current; const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : []; if (!ed || !chunks.length) return; const at = ed.state.selection.main.head; const next = dir === 1 ? chunks.find((c) => c.fromB > at) : [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from); if (!next) return; ed.dispatch({ selection: EditorSelection.cursor(next.fromB), effects: EditorView.scrollIntoView(next.fromB, { y: "center" }), }); ed.focus(); }; } return () => { if (navRef) navRef.current = null; destroy(); }; // One mount per view: the parent keys this component by file and shape. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Annotate on / off without remounting the view. useEffect(() => { view.current?.dispatch({ effects: blameSlot.reconfigure( blame ? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) => cb.current.onBlameClick?.(sha) ) : [] ), }); }, [blame, blameSlot]); const bars = !!onBlock; useEffect(() => { view.current?.dispatch({ effects: barsOn.of(bars) }); }, [bars]); // A value set from outside (Cancel's revert, a save's fresh text). useEffect(() => { const v = view.current; if (v && v.state.sliceDoc() !== value) { v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } }); } }, [value]); return (
); }