/** Diff viewer editor tab (side-by-side / unified) built on @codemirror/merge. */ import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; import { useCallback, useEffect, useRef, useState } from "react"; import clsx from "clsx"; import { EditorView, keymap } from "@codemirror/view"; import { Text, type Extension } from "@codemirror/state"; import { history, historyKeymap } from "@codemirror/commands"; import { MergeView, getChunks, goToNextChunk, goToPreviousChunk, rejectChunk, unifiedMergeView, Chunk, } from "@codemirror/merge"; import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api"; import { setContext } from "@/platform/context"; import { call, commands } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { IconButton, Select, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { languageFor, viewerBase } from "./cm"; import { applyChunk, arrowControls, hoverRevert } from "./diffControls"; import { HIGHLIGHT_OPTIONS, IGNORE_OPTIONS, loadDiffOptions, saveDiffOptions, whitespaceDiff, type DiffViewOptions, type HighlightPolicy, type IgnorePolicy, } from "./diffOptions"; import { chainNeighbour, diffTitle, isIndex, isWorktree, loadSide, openDiff, sideFile, sideKey, sideLabel, type DiffData, type DiffSide, } from "./diffInput"; import { diffAnnotations } from "./diffAnnotations"; import { groupOf } from "./changes"; import { inclusionGutter } from "./hunkInclusion"; import { hunksOverlapping } from "./hunks"; import { effectiveKind, stripSlash } from "./model"; import { errorMessage, logConsole, readWorkingText, runOp, saveAll } from "./ops"; import { refreshNow, useVcs } from "./store"; import styles from "./DiffEditor.module.css"; /** Commands of the focused diff viewer (diff.* actions, vcs.nextChange / prevChange). */ export { focusedDiffNavigator, type DiffNavigator }; interface Views { /** The right / unified editor (chunks live here). */ b: EditorView; a: EditorView | null; merge: MergeView | null; destroy(): void; } interface Attached { a: DocumentAttachment | null; b: DocumentAttachment | null; } /** 0-based line ranges of a chunk on both sides. */ export function chunkLines(chunk: Chunk, a: Text, b: Text) { const range = (from: number, to: number, end: number, doc: Text) => { const start = doc.lineAt(Math.min(from, doc.length)).number - 1; return from >= to ? [start, start] : [start, doc.lineAt(Math.min(end, doc.length)).number]; }; const [fromA, toA] = range(chunk.fromA, chunk.toA, chunk.endA, a); const [fromB, toB] = range(chunk.fromB, chunk.toB, chunk.endB, b); return { fromA: fromA!, toA: toA!, fromB: fromB!, toB: toB! }; } /** The 0-based line of `to` matching `line` of `from`, given the chunks between them. */ export function mapLine(chunks: readonly Chunk[], line: number, from: Text, to: Text): number { const span = (start: number, end: number, doc: Text) => { const first = doc.lineAt(Math.min(start, doc.length)).number - 1; return [first, end > start ? doc.lineAt(Math.min(end - 1, doc.length)).number : first] as const; }; let shift = 0; for (const c of chunks) { const [fromA, toA] = span(c.fromA, c.toA, from); if (line < fromA) break; const [fromB, toB] = span(c.fromB, c.toB, to); if (line < toA) return Math.min(fromB + (line - fromA), Math.max(fromB, toB - 1)); shift = toB - toA; } return Math.max(0, Math.min(line + shift, to.lines - 1)); } /** Joins the open document of a file-backed side (working file, Compare Files); null for revisions / texts. */ async function attachSide(side: DiffSide, path: string): Promise { const file = sideFile(side, path); if (!file) return null; try { return await attachDocument(file); } catch { return null; } } const editableExtensions = (): Extension => [history(), keymap.of(historyKeymap)]; export function DiffEditor({ input }: { input: EditorInput }) { const data = input.data as DiffData; const { path, left, right } = data; const [attached, setAttached] = useState(null); /** Text of the sides that are not live documents. */ const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null); const [error, setError] = useState(null); const [opts, setOptsState] = useState(loadDiffOptions); const [lang, setLang] = useState(null); const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1 }); const [busy, setBusy] = useState(false); const hostRef = useRef(null); const viewsRef = useRef(null); const staticsRef = useRef(statics); const focusedSide = useRef<"a" | "b">("b"); const version = useVcs((s) => s.version); const leftKey = sideKey(left); const rightKey = sideKey(right); const setOpts = (patch: Partial) => setOptsState((o) => { const next = { ...o, ...patch }; saveDiffOptions(next); return next; }); // Live documents for file-backed sides (edits sync with editors, undo, save). useEffect(() => { let alive = true; let got: Attached | null = null; void Promise.all([attachSide(left, path), attachSide(right, path)]).then(([a, b]) => { got = { a, b }; if (alive) setAttached(got); else { a?.release(); b?.release(); } }); return () => { alive = false; got?.a?.release(); got?.b?.release(); setAttached(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, leftKey, rightKey]); const reload = useCallback(async () => { if (!attached) return; try { const [a, b] = await Promise.all([ attached.a ? null : loadSide(path, left), attached.b ? null : loadSide(path, right), ]); const prev = staticsRef.current; if (!prev || prev.a !== a || prev.b !== b) { staticsRef.current = { a, b }; setStatics({ a, b }); } setError(null); } catch (e) { setError(errorMessage(e)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [path, leftKey, rightKey, attached]); // Initial load and reload whenever repository state changes (staging, commits…). useEffect(() => { void Promise.resolve().then(reload); }, [reload, version]); useEffect(() => { let alive = true; void languageFor(path).then((l) => alive && setLang(() => l ?? [])); return () => { alive = false; }; }, [path]); const updateChunkInfo = useCallback(() => { const v = viewsRef.current?.b; if (!v) return; const chunks = getChunks(v.state)?.chunks ?? []; const head = v.state.selection.main.head; const current = chunks.findIndex((c) => c.fromB <= head && head <= Math.max(c.fromB, c.endB)); setChunkInfo((p) => (p.count === chunks.length && p.current === current ? p : { count: chunks.length, current })); }, []); // A HEAD ↔ working file diff of a tracked change: blocks can be left out of the commit. const includable = useVcs((s) => { const fs = s.status.find((f) => stripSlash(f.path) === path); return !!fs && groupOf(fs) === "changes" && effectiveKind(fs) === "modified"; }) && left.type === "rev" && left.rev === "HEAD" && !left.path && isWorktree(right); const editableA = !!attached?.a && !attached.a.readOnly; const editableB = !!attached?.b && !attached.b.readOnly; const ready = !!attached && !!statics && lang !== null; // (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 : null; viewsRef.current?.destroy(); host.textContent = ""; const listener = EditorView.updateListener.of((u) => { if (u.selectionSet || u.docChanged) updateChunkInfo(); }); const focusTracker = (side: "a" | "b") => EditorView.domEventHandlers({ focus: () => { focusedSide.current = side; }, }); const collapseUnchanged = opts.collapse ? { margin: 3, minSize: 6 } : undefined; const override = whitespaceDiff(opts.ignore); const diffConfig = { override }; const highlightChanges = opts.highlight === "words"; const inclusion = includable ? inclusionGutter(path, textA) : []; const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [ viewerBase({ readOnly: !editable }), lang, att?.extension ?? [], editable ? [editableExtensions(), embeddedEditorExtensions(path)] : [], notes ? diffAnnotations((data.annotations ?? []).filter((n) => n.side === notes), styles) : [], ]; let views: Views; if (opts.mode === "split") { let merge: MergeView | null = null; const mv = new MergeView({ a: { doc: textA, extensions: [ side(attached.a, editableA, "left"), focusTracker("a"), editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [], ], }, b: { doc: textB, extensions: [ side(attached.b, editableB, "right"), listener, focusTracker("b"), inclusion, editableB ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "a")) : [], ], }, parent: host, revertControls: editableB ? "a-to-b" : editableA ? "b-to-a" : undefined, renderRevertControl: editableA || editableB ? arrowControls(() => merge, { toA: editableA, toB: editableB }) : undefined, highlightChanges, gutter: true, 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)); } else { const view = new EditorView({ doc: textB, parent: host, extensions: [ side(attached.b, editableB, "right"), listener, focusTracker("b"), inclusion, unifiedMergeView({ original: textA, highlightChanges, gutter: true, syntaxHighlightDeletions: true, mergeControls: editableB ? (type, action) => { const btn = document.createElement("button"); if (type === "accept") { btn.hidden = true; return btn; } btn.textContent = "Revert"; btn.title = "Revert this change"; btn.onmousedown = action; return btn; } : false, collapseUnchanged, diffConfig, }), ], }); views = { a: null, b: view, merge: null, destroy: () => view.destroy() }; if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop)); } viewsRef.current = views; requestAnimationFrame(updateChunkInfo); }, [attached, statics, lang, opts.mode, opts.collapse, opts.ignore, opts.highlight, editableA, editableB, updateChunkInfo, data.annotations, includable, path]); useEffect( () => () => { viewsRef.current?.destroy(); viewsRef.current = null; }, [], ); // Reveal the requested line (or the first / last difference) once. const revealed = useRef(false); useEffect(() => { const v = viewsRef.current?.b; if (!v || revealed.current || (data.line == null && !data.reveal) || !ready) return; revealed.current = true; let at: number | undefined; if (data.line != null) at = v.state.doc.line(Math.min(v.state.doc.lines, Math.max(1, data.line + 1))).from; else { const chunks = getChunks(v.state)?.chunks ?? []; at = (data.reveal === "last" ? chunks[chunks.length - 1] : chunks[0])?.fromB; } if (at === undefined) return; v.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) }); updateChunkInfo(); }, [ready, statics, data.line, data.reveal, updateChunkInfo]); // IDEA: past the last (first) difference F7 (Shift+F7) first hints, then moves along the chain. const [navHint, setNavHint] = useState(null); const pendingFile = useRef<0 | 1 | -1>(0); useEffect(() => { if (!navHint) return; const t = setTimeout(() => setNavHint(null), 2500); return () => clearTimeout(t); }, [navHint]); const step = useCallback( (dir: 1 | -1) => { const views = viewsRef.current; if (!views) return; // Navigate in the focused pane of a side-by-side diff. const v = focusedSide.current === "a" && views.a ? views.a : views.b; const onA = v !== views.b; const chunks = getChunks(v.state)?.chunks ?? []; const head = v.state.selection.main.head; const start = (c: Chunk) => (onA ? c.fromA : c.fromB); const more = dir === 1 ? chunks.some((c) => start(c) > head) : chunks.some((c) => start(c) < head); if (!more) { const neighbour = chainNeighbour(data, dir); if (!neighbour) return; if (pendingFile.current === dir) { pendingFile.current = 0; setNavHint(null); void openDiff(neighbour, { preview: true }); } else { pendingFile.current = dir; setNavHint(dir === 1 ? "Press again to go to the next file" : "Press again to go to the previous file"); } return; } pendingFile.current = 0; setNavHint(null); (dir === 1 ? goToNextChunk : goToPreviousChunk)({ state: v.state, dispatch: (tr) => v.dispatch(tr) }); if (v !== views.b) { // Mirror the caret into the right pane so the counter follows. const chunks = getChunks(v.state)?.chunks ?? []; const head = v.state.selection.main.head; const c = chunks.find((ch) => ch.fromA <= head && head <= Math.max(ch.fromA, ch.endA)); if (c) views.b.dispatch({ selection: { anchor: Math.min(c.fromB, views.b.state.doc.length) } }); } updateChunkInfo(); }, [updateChunkInfo, data], ); // A file-backed side is the source; for a diff of revisions (Git Log, Compare) the // working copy of the path is, while it exists. Probed once per diff. const fileSource = sideFile(left, path) ?? sideFile(right, path); const [onDisk, setOnDisk] = useState(null); useEffect(() => { if (fileSource) return; let alive = true; void call(commands.fsProbe(path)).then( () => alive && setOnDisk(path), () => alive && setOnDisk(null), ); return () => { alive = false; }; }, [path, fileSource]); const worktreeSource = onDisk === path ? path : null; const source = fileSource ?? worktreeSource; const jumpToSource = useCallback(() => { const views = viewsRef.current; const fileA = sideFile(left, path); const fileB = sideFile(right, path); const onA = focusedSide.current === "a" && !!views?.a; const v = onA ? views!.a! : views?.b; const own = onA ? fileA : fileB; const file = own ?? fileB ?? fileA ?? worktreeSource; if (!file) return; const open = (line: number, col: number) => void editorService().openFile(file, { selection: { line, col }, focus: true }); if (!v) return open(0, 0); const head = v.state.selection.main.head; const at = v.state.doc.lineAt(head); if (own) return open(at.number - 1, head - at.from); // The pane shows another version of the file: follow the diff to the line there. const from = v.state.doc; const known = file === fileB ? views?.b.state.doc : file === fileA ? views?.a?.state.doc : undefined; void (known ? Promise.resolve(known.toString()) : readWorkingText(file)).then((text) => { if (text === null) return open(at.number - 1, head - at.from); const to = Text.of(text.split(/\r?\n/)); const line = mapLine(Chunk.build(from, to), at.number - 1, from, to); open(line, to.line(line + 1).text === at.text ? head - at.from : 0); }); }, [left, right, path, worktreeSource]); const currentChunk = (): { chunk: Chunk; index: number } | undefined => { const v = viewsRef.current?.b; if (!v) return undefined; const chunks = getChunks(v.state)?.chunks ?? []; const head = v.state.selection.main.head; const index = Math.max( 0, chunks.findIndex((c) => c.fromB <= head && head <= Math.max(c.fromB, c.endB)), ); const chunk = chunks[index]; return chunk ? { chunk, index } : undefined; }; const revertChunk = () => { const views = viewsRef.current; const cur = currentChunk(); if (!views || !cur) return; if (views.merge) applyChunk(views.merge, cur.index, "a"); else rejectChunk(views.b, cur.chunk.fromB); }; const stageChunk = async () => { const views = viewsRef.current; const cur = currentChunk(); if (!views || !cur) return; setBusy(true); try { const aDoc = views.a?.state.doc ?? Text.of((attached?.a?.text() ?? statics?.a ?? "").split("\n")); const lines = chunkLines(cur.chunk, aDoc, views.b.state.doc); await saveAll(); const [indexText, disk] = await Promise.all([call(commands.vcsFileAt(path, ":0")), readWorkingText(path)]); const hunks = await commands.vcsDiffHunks(indexText ?? "", disk ?? ""); const leftIsIndex = isIndex(left); const indices = hunksOverlapping(hunks, { fromA: leftIsIndex ? lines.fromA : -1, toA: leftIsIndex ? lines.toA : -1, fromB: lines.fromB, toB: lines.toB, }); if (!indices.length) { logConsole("info", "Nothing to stage for this change"); return; } await runOp(`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices))); await refreshNow(); } finally { setBusy(false); } }; const nav = useRef({ next: () => step(1), prev: () => step(-1), jumpToSource }); useEffect(() => { nav.current = { next: () => step(1), prev: () => step(-1), jumpToSource }; }, [step, jumpToSource]); useEffect( () => () => { if (focusedDiffNavigator() === nav.current) { setFocusedDiffNavigator(null); setContext("diffEditorFocus", false); } }, [], ); const identical = ready && chunkInfo.count === 0 && (attached?.a?.text() ?? statics?.a) === (attached?.b?.text() ?? statics?.b); // Staging applies to local changes against HEAD / the index only. const canStage = editableB && isWorktree(right) && left.type === "rev" && !!useVcs.getState().root; const hasSource = !!source; return (
{ setFocusedDiffNavigator(nav.current); setContext("diffEditorFocus", true); }} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null) && focusedDiffNavigator() === nav.current) { setFocusedDiffNavigator(null); setContext("diffEditorFocus", false); } }} >
step(-1)} /> step(1)} /> {hasSource && ( )} setOpts({ mode: "split" })} /> setOpts({ mode: "unified" })} /> className={styles.select} value={opts.ignore} options={IGNORE_OPTIONS} onChange={(ignore) => setOpts({ ignore })} aria-label="Ignore whitespace" /> className={styles.select} value={opts.highlight} options={HIGHLIGHT_OPTIONS} onChange={(highlight) => setOpts({ highlight })} aria-label="Highlighting" /> setOpts({ collapse: !opts.collapse })} /> {opts.mode === "split" && ( setOpts({ syncScroll: !opts.syncScroll })} /> )} {editableB && ( <> {canStage && ( void stageChunk()} /> )} )} void reload()} /> {!ready ? "" : chunkInfo.count === 0 ? identical ? tx("Contents are identical") : "No differences" : chunkInfo.current >= 0 ? `${chunkInfo.current + 1} of ${chunkInfo.count} differences` : `${chunkInfo.count} difference${chunkInfo.count === 1 ? "" : "s"}`}
{opts.mode === "split" ? (
{sideLabel(left)} {editableA && editable } {sideLabel(right)} {editableB && {left.type === "rev" ? tx("working copy") : "editable"}}
) : (
{sideLabel(left)} → {sideLabel(right)}
)} {error ? (
{error}
) : !ready ? (
) : null} ); }