Files
openidea/src/features/vcs/DiffEditor.tsx
T
Jaw 70f10e741c fix(i18n): Translate the remaining UI text (Git, hierarchy, pull requests, dialogs, aria labels) (#114)
Hard-coded UI text, titles and aria-labels in about 110 components (Git panels, hierarchy, pull requests, dialogs, settings, welcome) now go through tx; spliced fragments became whole sentences (fileCount, noneFoundLabel, hierarchyTitle) and pull request states use keyed messages. 225 zh entries added, stale fragment entries removed.

Also: tx used as a tag without interpolations (tx`No checks`) now looks the text up exactly instead of as a template, so it is translated like tx("No checks"); the text-catalog test checks such tags as plain literals. Check and test counts reuse the existing "{count} passed" / "{failed} failing" entries.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Co-authored-by: jaw52 <[email protected]>
2026-10-28 12:18:46 +08:00

647 lines
24 KiB
TypeScript

/** 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<DocumentAttachment | null> {
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<Attached | null>(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<string | null>(null);
const [opts, setOptsState] = useState<DiffViewOptions>(loadDiffOptions);
const [lang, setLang] = useState<Extension | null>(null);
const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1 });
const [busy, setBusy] = useState(false);
const hostRef = useRef<HTMLDivElement>(null);
const viewsRef = useRef<Views | null>(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<DiffViewOptions>) =>
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<string | null>(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<string | null>(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<DiffNavigator>({ 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 (
<div
className={styles.root}
onFocusCapture={() => {
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);
}
}}
>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Diff")}>
<IconButton icon="arrow-up" title="Previous Difference" shortcut={shortcutLabel("diff.prevDifference")} size="small" onClick={() => step(-1)} />
<IconButton icon="arrow-down" title="Next Difference" shortcut={shortcutLabel("diff.nextDifference")} size="small" onClick={() => step(1)} />
{hasSource && (
<IconButton icon="edit" title="Jump to Source" shortcut={shortcutLabel("diff.jumpToSource")} size="small" onClick={jumpToSource} />
)}
<span className={styles.sep} />
<IconButton
icon="split"
title="Side-by-side Viewer"
size="small"
pressed={opts.mode === "split"}
onClick={() => setOpts({ mode: "split" })}
/>
<IconButton
icon="list"
title="Unified Viewer"
size="small"
pressed={opts.mode === "unified"}
onClick={() => setOpts({ mode: "unified" })}
/>
<span className={styles.sep} />
<Select<IgnorePolicy>
className={styles.select}
value={opts.ignore}
options={IGNORE_OPTIONS}
onChange={(ignore) => setOpts({ ignore })}
aria-label="Ignore whitespace"
/>
<Select<HighlightPolicy>
className={styles.select}
value={opts.highlight}
options={HIGHLIGHT_OPTIONS}
onChange={(highlight) => setOpts({ highlight })}
aria-label="Highlighting"
/>
<IconButton
icon="collapse-all"
title="Collapse Unchanged Fragments"
size="small"
pressed={opts.collapse}
onClick={() => setOpts({ collapse: !opts.collapse })}
/>
{opts.mode === "split" && (
<IconButton
icon="lock"
title="Synchronize Scrolling"
size="small"
pressed={opts.syncScroll}
onClick={() => setOpts({ syncScroll: !opts.syncScroll })}
/>
)}
{editableB && (
<>
<span className={styles.sep} />
<IconButton icon="revert" title="Revert Change" size="small" disabled={!chunkInfo.count} onClick={revertChunk} />
{canStage && (
<IconButton
icon="plus"
title="Stage Change (git add hunk)"
size="small"
disabled={!chunkInfo.count || busy}
onClick={() => void stageChunk()}
/>
)}
</>
)}
<span className={styles.sep} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
<span className={styles.count}>
{!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"}`}
</span>
</div>
{opts.mode === "split" ? (
<div className={styles.labels}>
<span className={styles.label} title={sideLabel(left)}>
{sideLabel(left)}
{editableA && <span className={styles.badge}>
<T>editable</T>
</span>}
</span>
<span className={styles.label} title={sideLabel(right)}>
{sideLabel(right)}
{editableB && <span className={styles.badge}>{left.type === "rev" ? tx("working copy") : "editable"}</span>}
</span>
</div>
) : (
<div className={styles.labels}>
<span className={styles.label}>
{sideLabel(left)} → {sideLabel(right)}
</span>
</div>
)}
{error ? (
<div className={styles.message}>{error}</div>
) : !ready ? (
<div className={styles.message}>
<Spinner size={14} />
</div>
) : null}
<div
ref={hostRef}
className={clsx(
styles.host,
opts.highlight === "none" && styles.noHighlight,
opts.mode === "split" && !opts.syncScroll && styles.unsynced,
)}
hidden={!!error || !ready}
aria-label={diffTitle(data)}
role="region"
/>
{navHint && (
<div className={styles.navHint} role="status">
{tx(navHint)}
</div>
)}
</div>
);
}