/** Diff viewer editor tab (side-by-side / unified) built on @codemirror/merge. */ import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import clsx from "clsx"; import { EditorView, keymap } from "@codemirror/view"; import { Prec, Text, type Extension } from "@codemirror/state"; import { history, historyKeymap } from "@codemirror/commands"; import { MergeView, getChunks, getOriginalDoc, goToNextChunk, goToPreviousChunk, unifiedMergeView, uncollapseUnchanged, Chunk, } from "@codemirror/merge"; import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api"; import { setFindOpen } from "@/editor/core/cm/find"; import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar"; import { getContext, setContext } from "@/platform/context"; import { call, commands } from "@/platform/ipc"; import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { changedLineGutters, diffOverview, diffWords, languageFor, viewerBase } from "./cm"; import { applyChunk, arrowControls, chunkAt, hoverRevert, revertUnifiedChunk } from "./diffControls"; import { HIGHLIGHT_OPTIONS, IGNORE_OPTIONS, ideaDiffConfig, loadDiffOptions, saveDiffOptions, getDiffInsights, type DiffMode, 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 { annotationsShown, blameSlot, toggleAnnotations } from "./blame"; import { diffBlameTarget } from "./diffBlame"; import { changedChainNeighbour } from "./diffNavigation"; import { movedCodeHighlights } from "./movedCode"; import { createDiffViewerContributions, type DiffPaneSide, type DiffViewerContribution, type DiffViewerHost, type DiffViewerPane, } from "./diffViewerProviders"; import { UnalignedPanes } from "./diffScroll"; 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; /** Unpadded side-by-side panes with synchronized scrolling (IDEA). */ panes: UnalignedPanes | 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)); } /** `chunks` seen from the right side (B ↔ A swapped), for `mapLine` from right to left. */ const swapped = (chunks: readonly Chunk[]) => chunks.map((c) => ({ fromA: c.fromB, toA: c.toB, fromB: c.fromA, toB: c.toA }) as unknown as Chunk); /** Selects `pos` and scrolls it to the middle, first expanding the collapsed unchanged lines holding it. */ function revealPos(v: EditorView, pos: number) { const block = v.lineBlockAt(pos); const collapsed = v.state.doc.lineAt(block.from).number !== v.state.doc.lineAt(block.to).number; v.dispatch({ selection: { anchor: pos }, effects: [ ...(collapsed ? [uncollapseUnchanged.of(block.from)] : []), EditorView.scrollIntoView(pos, { y: "center" }), ], }); } /** The panes as diff viewer contributions see them. */ function viewerHost(views: Views, data: DiffData, mode: DiffMode, focused: () => "a" | "b"): DiffViewerHost { const docA = () => views.a?.state.doc ?? getOriginalDoc(views.b.state); const chunks = () => getChunks(views.b.state)?.chunks ?? []; const panes = (): DiffViewerPane[] => views.a ? [ { side: "left", view: views.a }, { side: "right", view: views.b }, ] : [{ side: "right", view: views.b }]; const mapSide = (from: DiffPaneSide, line: number) => from === "left" ? mapLine(chunks(), line, docA(), views.b.state.doc) : mapLine(swapped(chunks()), line, views.b.state.doc, docA()); const locate = (side: DiffPaneSide, line: number) => { const all = panes(); if (side === "left" && !views.a) return { pane: all[0]!, line: mapSide("left", line) }; const pane = all.find((p) => p.side === side)!; return line >= 0 && line < pane.view.state.doc.lines ? { pane, line } : null; }; return { data, mode, panes, doc: (side) => (side === "left" ? docA() : views.b.state.doc), mapLine: mapSide, locate, reveal(side, line) { const at = locate(side, line); if (!at) return; const v = at.pane.view; revealPos(v, v.state.doc.line(at.line + 1).from); v.focus(); }, focusedPane: () => { const all = panes(); return focused() === "a" && views.a ? all[0]! : all[all.length - 1]!; }, }; } const NO_CONTRIBUTIONS: DiffViewerContribution[] = []; /** 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)]; type Side = "a" | "b"; type ViewUpdateFn = (u: { docChanged: boolean; selectionSet: boolean }) => void; /** Whether `e` is one of the strokes bound to `actionId`. */ function isStrokeOf(e: KeyboardEvent, actionId: string): boolean { const stroke = strokeOf(e.nativeEvent); return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke); } export function DiffEditor({ input, toolbarActions }: { input: EditorInput; toolbarActions?: ReactNode }) { const data = input.data as DiffData; const { path, left, right } = data; const [attached, setAttached] = useState(null); const [binary, setBinary] = 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(() => { const saved = loadDiffOptions(); return data.mode ? { ...saved, mode: data.mode } : saved; }); const [lang, setLang] = useState(null); const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1, moves: 0, whitespaceOnly: false }); const [busy, setBusy] = useState(false); const hostRef = useRef(null); const viewsRef = useRef(null); const staticsRef = useRef(statics); const focusedSide = useRef("b"); /** Bumped when the views are rebuilt, so the find bar re-applies its query. */ const [viewsGen, setViewsGen] = useState(0); const version = useVcs((s) => s.version); const leftKey = sideKey(left); const rightKey = sideKey(right); const contextKey = JSON.stringify(data.context ?? null); const dataRef = useRef(data); dataRef.current = data; /** Diff viewer providers' contributions for this diff (`registerDiffViewerProvider`). */ const [contribs, setContribs] = useState(NO_CONTRIBUTIONS); const viewerRef = useRef(null); const [viewer, setViewer] = useState(null); useEffect(() => { const list = createDiffViewerContributions(dataRef.current); setContribs(list.length ? list : NO_CONTRIBUTIONS); return () => { list.forEach((c) => c.dispose?.()); setContribs(NO_CONTRIBUTIONS); }; }, [path, leftKey, rightKey, contextKey]); 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 paths = [sideFile(left, path), sideFile(right, path)].filter((p): p is string => !!p); const [a, b, probes] = await Promise.all([ attached.a ? null : loadSide(path, left), attached.b ? null : loadSide(path, right), Promise.all(paths.map((p) => call(commands.fsProbe(p)).catch(() => null))), ]); const prev = staticsRef.current; if (!prev || prev.a !== a || prev.b !== b) { staticsRef.current = { a, b }; setStatics({ a, b }); } setBinary(probes.some((p) => p?.binary)); 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)); const { moves, whitespaceOnly } = getDiffInsights(chunks); setChunkInfo((p) => p.count === chunks.length && p.current === current && p.moves === moves.length && p.whitespaceOnly === whitespaceOnly ? p : { count: chunks.length, current, moves: moves.length, whitespaceOnly }, ); }, []); // 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 = !binary && !!attached?.a && !attached.a.readOnly; const editableB = !binary && !!attached?.b && !attached.b.readOnly; const ready = !!attached && !!statics && lang !== null && binary !== 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 prev = viewsRef.current; const scrollTop = prev ? prev.merge && !prev.panes ? (prev.merge.dom as HTMLElement).scrollTop : prev.b.scrollDOM.scrollTop : null; prev?.destroy(); host.textContent = ""; const listener = EditorView.updateListener.of((u) => { if (u.selectionSet || u.docChanged) updateChunkInfo(); }); const findWatch = (s: Side) => EditorView.updateListener.of((u) => findWatchers.current[s].forEach((fn) => fn(u))); const focusTracker = (side: "a" | "b") => EditorView.domEventHandlers({ focus: () => { focusedSide.current = side; }, }); const collapseUnchanged = opts.collapse ? { margin: 3, minSize: 6 } : undefined; const diffConfig = ideaDiffConfig(opts.ignore, opts.highlight); 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, findBar: true }), // Esc closes the find bar before it collapses the selection (a found match); open popups go first. Prec.high(keymap.of([{ key: "Escape", run: () => findEscape.current() }])), lang, blameSlot(), att?.extension ?? [], editable ? [editableExtensions(), embeddedEditorExtensions(path)] : [], notes ? diffAnnotations( (data.annotations ?? []).filter((n) => n.side === notes), styles, ) : [], notes ? contribs.map((c) => c.extensions?.({ side: notes, unified: opts.mode !== "split" }) ?? []) : [], ]; 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, }); // IDEA's colouring: lines by chunk type, carried into the gutters, and typed word highlights. const colours = [ changedLineGutters, highlightChanges ? diffWords : [], opts.highlight !== "none" ? movedCodeHighlights(highlightChanges) : [], ]; const mv = new MergeView({ a: { doc: textA, extensions: [ side(attached.a, editableA, "left"), colours, // Aligned panes scroll together: one stripe, on the right (as IDEA's shared scrollbar). panes ? diffOverview("split") : [], panes?.extension ?? [], focusTracker("a"), findWatch("a"), editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [], ], }, b: { doc: textB, extensions: [ side(attached.b, editableB, "right"), colours, diffOverview(panes ? "split" : "aligned"), panes?.extension ?? [], listener, focusTracker("b"), findWatch("b"), inclusion, editableB ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "a")) : [], ], }, parent: host, // Unaligned panes draw their arrows on their own divider. revertControls: panes ? undefined : editableB ? "a-to-b" : editableA ? "b-to-a" : undefined, renderRevertControl: !panes && (editableA || editableB) ? arrowControls(() => merge, { toA: editableA, toB: editableB }) : undefined, // Words are highlighted by type (`diffWords`), and gutters tinted instead of a change bar. highlightChanges: false, gutter: false, collapseUnchanged, diffConfig, }); merge = mv; 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, parent: host, extensions: [ side(attached.b, editableB, "right"), diffOverview("unified"), listener, focusTracker("b"), findWatch("b"), inclusion, highlightChanges ? diffWords : [], unifiedMergeView({ original: textA, highlightChanges, gutter: true, syntaxHighlightDeletions: true, mergeControls: editableB ? (type) => { const btn = document.createElement("button"); if (type === "accept") { btn.hidden = true; return btn; } btn.textContent = "Revert"; btn.title = "Revert this change"; btn.onmousedown = (event) => { event.preventDefault(); revertUnifiedChunk(view, view.posAtDOM(btn)); }; return btn; } : false, collapseUnchanged, diffConfig, }), ], }); views = { a: null, b: view, merge: null, panes: null, destroy: () => view.destroy() }; if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop)); } viewsRef.current = views; const built = viewerHost(views, dataRef.current, opts.mode, () => focusedSide.current); const detach = contribs.map((c) => c.attach?.(built)); const destroyViews = views.destroy; views.destroy = () => { detach.forEach((d) => d?.()); destroyViews(); }; viewerRef.current = built; setViewer(built); setViewsGen((g) => g + 1); requestAnimationFrame(updateChunkInfo); }, [ contribs, attached, statics, lang, opts.mode, opts.alignChanges, opts.collapse, opts.ignore, opts.highlight, editableA, editableB, updateChunkInfo, data.annotations, includable, path, ]); useEffect(() => viewsRef.current?.panes?.setSync(opts.syncScroll), [opts.syncScroll]); useEffect( () => () => { viewsRef.current?.destroy(); viewsRef.current = null; }, [], ); // Reveal the requested line (or the first / last difference) once per request. const revealed = useRef(null); const revealKey = data.line == null && !data.reveal ? null : `${data.line}:${data.lineSide}:${data.reveal}`; useEffect(() => { let v = viewsRef.current?.b; if (!v || revealKey === null || revealed.current === revealKey || !ready) return; revealed.current = revealKey; let at: number | undefined; if (data.line != null) { let line = data.line; if (data.lineSide === "left") { const found = viewerRef.current?.locate("left", line); if (found) [v, line] = [found.pane.view, found.line]; } at = v.state.doc.line(Math.min(v.state.doc.lines, Math.max(1, 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; if (data.line != null) revealPos(v, at); else v.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) }); // Keep keyboard navigation in the revealed pane after moving to another file. if (data.reveal || v === viewsRef.current?.a) v.focus(); updateChunkInfo(); }, [ready, statics, revealKey, data.line, data.lineSide, 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); const navigation = useRef(null); useEffect( () => () => { navigation.current = null; }, [data], ); const openNeighbour = useCallback( async (dir: 1 | -1) => { if (navigation.current) return; const request = {}; navigation.current = request; try { const neighbour = await changedChainNeighbour(data, dir, opts.ignore); if (navigation.current === request && neighbour) await openDiff(neighbour, { preview: true }); } finally { if (navigation.current === request) navigation.current = null; } }, [data, opts.ignore], ); 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 openNeighbour(dir); } 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, openNeighbour], ); // 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 revertUnifiedChunk(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(tx`Stage hunk of ${path}`, () => call(commands.vcsStageHunks(path, indices))); await refreshNow(); } finally { setBusy(false); } }; // Find (⌘F / ⌘R / ⌘G): the editor's find bar over the focused side. const [find, setFind] = useState<{ side: Side; replace: boolean; seq: number } | null>(null); const findWatchers = useRef>>({ a: new Set(), b: new Set() }); const findEscape = useRef<() => boolean>(() => false); /** The side the bar searches (the last one, once closed: closing clears its highlights). */ const findSide = useRef("b"); const findOpen = useRef(false); const findId = useId(); const viewOf = (s: Side) => (s === "a" && viewsRef.current?.a) || viewsRef.current?.b || null; const findTarget = useMemo(() => { const sideOf = (): Side => (findSide.current === "a" && viewsRef.current?.a ? "a" : "b"); return { id: `diff:${findId}`, view: () => (sideOf() === "a" ? viewsRef.current?.a : viewsRef.current?.b) ?? null, watch(fn) { const set = findWatchers.current[sideOf()]; set.add(fn); return () => void set.delete(fn); }, isOpen: () => findOpen.current, }; }, [findId]); const editableSide = (s: Side) => (s === "a" ? editableA : editableB); const openFind = (replace: boolean) => { const views = viewsRef.current; if (!views) return; const s: Side = focusedSide.current === "a" && views.a ? "a" : "b"; if (findOpen.current && findSide.current !== s) viewOf(findSide.current)?.dispatch({ effects: setFindOpen.of(false) }); findSide.current = s; findOpen.current = true; const withReplace = replace && editableSide(s); seedFind(findTarget, withReplace); setFind((f) => ({ side: s, replace: withReplace, seq: (f?.seq ?? 0) + 1 })); }; const closeFind = () => { findOpen.current = false; setFind(null); viewOf(findSide.current)?.focus(); }; findEscape.current = () => { if (!findOpen.current) return false; closeFind(); return true; }; const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; if (isStrokeOf(e, "find.inFile")) openFind(false); else if (isStrokeOf(e, "find.replaceInFile")) openFind(true); else if (isStrokeOf(e, "find.next") || isStrokeOf(e, "find.previous")) { if (!findOpen.current) findSide.current = focusedSide.current; if (!searchAgain(findTarget, isStrokeOf(e, "find.next"))) openFind(false); } else if (e.key === "Escape" && findOpen.current) closeFind(); else return; e.preventDefault(); e.stopPropagation(); }; /** ⌃⌘→ / ⌃⌘←: the change at the caret applied toward the other, editable side. */ const applyChange = (source: "a" | "b") => { const views = viewsRef.current; const merge = views?.merge; if (!merge || !views.a || !(source === "a" ? editableB : editableA)) return false; const side = focusedSide.current; const view = side === "a" ? views.a : views.b; const index = chunkAt(merge.chunks, side, view.state.selection.main.head, view.state.doc.length); if (index < 0) return false; applyChunk(merge, index, source); return true; }; const file = (dir: 1 | -1) => { void openNeighbour(dir); }; const toolbarRef = useRef(null); /** 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", label: "Side-by-side Viewer", checked: opts.mode === "split", run: () => setOpts({ mode: "split" }), }, { id: "unified", label: "Unified Viewer", checked: opts.mode === "unified", run: () => setOpts({ mode: "unified" }), }, { kind: "separator" }, { id: "ignore", label: "Ignore Whitespace", submenu: IGNORE_OPTIONS.map((o) => ({ id: o.value, label: o.label, checked: opts.ignore === o.value, run: () => setOpts({ ignore: o.value }), })), }, { id: "highlight", label: "Highlighting", submenu: HIGHLIGHT_OPTIONS.map((o) => ({ id: o.value, label: o.label, checked: opts.highlight === o.value, run: () => setOpts({ highlight: o.value }), })), }, { kind: "separator" }, { id: "collapse", label: "Collapse Unchanged Fragments", checked: opts.collapse, run: () => setOpts({ collapse: !opts.collapse }), }, { id: "sync", label: "Synchronize Scrolling", checked: opts.syncScroll, 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 }), }, ...contribs.flatMap((c) => { const items = c.settingsItems?.() ?? []; return items.length ? [{ kind: "separator" } as const, ...items] : []; }), ]); }; /** Annotate with Git Blame for a pane, by that side's own revision. */ const blameItems = (pane: DiffViewerPane) => { const target = diffBlameTarget(pane.side === "left" ? left : right, path, !!getContext().vcsAvailable); if (!target) return []; const view = pane.view; const text = target.revision ? view.state.doc.toString() : undefined; return [ { label: annotationsShown(view) ? "Close Annotations" : "Annotate with Git Blame", run: () => void toggleAnnotations(view, target.path, text, target.revision), }, ]; }; /** * The panes' right-click menu: the contributions' items for the clicked line and Git annotations * (none: the default menu). The divider between the panes acts on the pane focused last. */ const onContextMenu = (e: MouseEvent) => { const viewer = viewerRef.current; if (!viewer || e.defaultPrevented) return; const hit = viewer.panes().find((p) => p.view.dom.contains(e.target as Node | null)); const pane = hit ?? viewer.focusedPane(); const line = hit ? hit.view.state.doc.lineAt(hit.view.posAtCoords({ x: e.clientX, y: e.clientY }, false)).number - 1 : -1; const groups = [ ...(hit ? contribs.map((c) => c.contextMenuItems?.({ side: hit.side, line }) ?? []) : []), blameItems(pane), ].filter((g) => g.length); if (!groups.length) return; e.preventDefault(); showContextMenu( e, groups.flatMap((g, i) => (i ? [{ kind: "separator" } as const, ...g] : g)), ); }; const viewerOf = () => viewerRef.current; const nav = useRef({ next: () => step(1), prev: () => step(-1), jumpToSource, applyChange, file, showSettings, viewer: viewerOf, }); useEffect(() => { nav.current = { next: () => step(1), prev: () => step(-1), jumpToSource, applyChange, file, showSettings, viewer: viewerOf, }; }); 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()} /> {binary && tx("Binary content")} {!ready || binary ? "" : chunkInfo.count === 0 ? identical ? tx("Contents are identical") : chunkInfo.whitespaceOnly ? tx("Whitespace-only changes") : tx("No differences") : chunkInfo.current >= 0 ? tx`${chunkInfo.current + 1} of ${chunkInfo.count} differences` : chunkInfo.count === 1 ? tx`${chunkInfo.count} difference` : tx`${chunkInfo.count} differences`} {ready && !binary && chunkInfo.count > 0 && chunkInfo.whitespaceOnly && tx(" · Whitespace-only changes")} {ready && !binary && chunkInfo.moves > 0 && (chunkInfo.moves === 1 ? tx(" · 1 moved block") : tx` · ${chunkInfo.moves} moved blocks`)} {contribs.map((c, i) => c.Toolbar && )} {toolbarActions} showSettings(e.currentTarget)} />
{opts.mode === "split" ? (
{sideLabel(left)} {editableA && ( editable )} {sideLabel(right)} {editableB && ( {left.type === "rev" ? tx("working copy") : tx("editable")} )}
) : (
{sideLabel(left)} → {sideLabel(right)}
)} {find && ready && (
setFind((f) => f && { ...f, replace }) : undefined} onClose={closeFind} />
)} {binary ? (
Binary files cannot be shown as text
) : error ? (
{error}
) : !ready ? (
) : null} ); }