mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
- Debug console: typed expressions are kept per project, and Browse Console History (⌥⌘E / Ctrl+Alt+E) lists them, most recent first, to put one back into the input. - Diff viewer: Diff Settings (⇧⌘D / Ctrl+Shift+D) opens the viewer options (side-by-side / unified, whitespace, highlighting, collapsing, scroll sync) as a popup menu.
877 lines
31 KiB
TypeScript
877 lines
31 KiB
TypeScript
/** 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 } 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,
|
|
goToNextChunk,
|
|
goToPreviousChunk,
|
|
rejectChunk,
|
|
unifiedMergeView,
|
|
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 { 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 { languageFor, viewerBase } from "./cm";
|
|
import { applyChunk, arrowControls, chunkAt, 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)];
|
|
|
|
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 }: { 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<Side>("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 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 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 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, 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,
|
|
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"),
|
|
findWatch("a"),
|
|
editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [],
|
|
],
|
|
},
|
|
b: {
|
|
doc: textB,
|
|
extensions: [
|
|
side(attached.b, editableB, "right"),
|
|
listener,
|
|
focusTracker("b"),
|
|
findWatch("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"),
|
|
findWatch("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;
|
|
setViewsGen((g) => g + 1);
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 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<Record<Side, Set<ViewUpdateFn>>>({ 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<Side>("b");
|
|
const findOpen = useRef(false);
|
|
const findId = useId();
|
|
const viewOf = (s: Side) => (s === "a" && viewsRef.current?.a) || viewsRef.current?.b || null;
|
|
const findTarget = useMemo<FindTarget>(() => {
|
|
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) => {
|
|
const neighbour = chainNeighbour(data, dir);
|
|
if (neighbour) void openDiff(neighbour, { preview: true });
|
|
};
|
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
|
const showSettings = () => {
|
|
const r = toolbarRef.current?.getBoundingClientRect();
|
|
showContextMenu({ x: r ? r.left + 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 }),
|
|
},
|
|
]);
|
|
};
|
|
const nav = useRef<DiffNavigator>({
|
|
next: () => step(1),
|
|
prev: () => step(-1),
|
|
jumpToSource,
|
|
applyChange,
|
|
file,
|
|
showSettings,
|
|
});
|
|
useEffect(() => {
|
|
nav.current = { next: () => step(1), prev: () => step(-1), jumpToSource, applyChange, file, showSettings };
|
|
});
|
|
|
|
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}
|
|
onKeyDown={onKeyDown}
|
|
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 ref={toolbarRef} 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")
|
|
: 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`}
|
|
</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") : tx("editable")}</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className={styles.labels}>
|
|
<span className={styles.label}>
|
|
{sideLabel(left)} → {sideLabel(right)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{find && ready && (
|
|
<div
|
|
className={clsx(
|
|
styles.findRow,
|
|
opts.mode === "split" && styles.findRowSplit,
|
|
opts.mode === "split" && find.side === "b" && styles.findRowRight,
|
|
)}
|
|
>
|
|
<FindPanel
|
|
target={findTarget}
|
|
version={`${viewsGen}:${find.side}`}
|
|
replace={find.replace}
|
|
seq={find.seq}
|
|
onReplaceShown={editableSide(find.side) ? (replace) => setFind((f) => f && { ...f, replace }) : undefined}
|
|
onClose={closeFind}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|