дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Git operations passed their title, success and failure text to runOp / runGitOperation as plain strings the text catalog check doesn't see, so notifications after a commit, merge, rebase, stash, shelve, tag, patch or rollback stayed in English, several splicing plurals or English words. Call sites now tag the text with tx, each count and variant has its own sentence, and a failure is titled "{title} failed" from the translated title.
617 рядки
22 KiB
TypeScript
617 рядки
22 KiB
TypeScript
/** Three-pane merge editor: Yours | Result | Theirs, after IntelliJ's merge tool. */
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Transaction, type EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
|
import { EditorView, keymap } from "@codemirror/view";
|
|
import { history, historyKeymap, undoDepth } from "@codemirror/commands";
|
|
import { unifiedMergeView } from "@codemirror/merge";
|
|
import type { MergeSides } from "@/bindings";
|
|
import { embeddedEditorExtensions } from "@/editor/api";
|
|
import { executeAction } from "@/platform/actions";
|
|
import { setContext } from "@/platform/context";
|
|
import { tx, useTxTemplate } from "@/platform/i18n";
|
|
import { call, commands } from "@/platform/ipc";
|
|
import { copyText } from "@/ui/selectableText";
|
|
import { Button, Spinner, T } from "@/ui";
|
|
import { editorService, type EditorInput } from "@/workbench/contributions";
|
|
import { blameSlot, toggleAnnotations } from "./blame";
|
|
import { languageFor, viewerBase } from "./cm";
|
|
import { confirmDialog } from "./dialogs";
|
|
import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator";
|
|
import { syncMergeScroll } from "./mergeScroll";
|
|
import { mergeTitles } from "./conflicts";
|
|
import {
|
|
mergeChanged,
|
|
mergeTheme,
|
|
resultControls,
|
|
resultMarks,
|
|
sideControls,
|
|
syncSide,
|
|
type MergeControls,
|
|
} from "./mergeGutters";
|
|
import { COMPARISONS, showCompareMenu, showResultMenu, showSettingsMenu, showSideMenu } from "./mergeMenus";
|
|
import { openDiff } from "./diffInput";
|
|
import {
|
|
clearFocusedMergeEditor,
|
|
refreshMergeActions,
|
|
setFocusedMergeEditor,
|
|
type MergeActionHost,
|
|
type MergeCommand,
|
|
} from "./mergeActions";
|
|
import {
|
|
applyNonConflicting,
|
|
applyTo,
|
|
canApply,
|
|
changesIn,
|
|
changesInSide,
|
|
resolveSimple,
|
|
revertResolved,
|
|
mergeField,
|
|
mergeHistory,
|
|
startMerge,
|
|
summarize,
|
|
unresolved,
|
|
type MergeAction,
|
|
type MergeSide,
|
|
type MergeSummary,
|
|
} from "./mergeModel";
|
|
import { MergeFooter, MergeToolbar } from "./MergeToolbar";
|
|
import { baseName } from "./model";
|
|
import { errorMessage, runOp, writeFileText } from "./ops";
|
|
import { refreshNow } from "./store";
|
|
import styles from "./MergeEditor.module.css";
|
|
|
|
import type { MergeData } from "./mergeKind";
|
|
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
|
|
|
|
const NOTHING_LEFT: MergeSummary = {
|
|
pending: 0,
|
|
conflicts: 0,
|
|
left: false,
|
|
all: false,
|
|
right: false,
|
|
simple: false,
|
|
revert: false,
|
|
};
|
|
|
|
// IntelliJ's "Automatically apply non-conflicting changes" (Tools | Diff & Merge); off by default.
|
|
const AUTO_APPLY_KEY = "openidea.merge.autoApplyNonConflicting";
|
|
const readAutoApply = () => {
|
|
try {
|
|
return localStorage.getItem(AUTO_APPLY_KEY) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const writeAutoApply = (on: boolean) => {
|
|
try {
|
|
localStorage.setItem(AUTO_APPLY_KEY, String(on));
|
|
} catch {
|
|
// The choice then lasts only until the editor closes.
|
|
}
|
|
};
|
|
|
|
export function MergeEditor({ input }: { input: EditorInput }) {
|
|
const { path } = input.data as MergeData;
|
|
const [state, setState] = useState<{
|
|
ours: string;
|
|
theirs: string;
|
|
base: string | null;
|
|
sides: MergeSides;
|
|
deleted: Record<MergeSide, boolean>;
|
|
} | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [lang, setLang] = useState<Extension | null>(null);
|
|
const [summary, setSummary] = useState<MergeSummary>(NOTHING_LEFT);
|
|
const [autoApply, setAutoApply] = useState(readAutoApply);
|
|
const [resolved, setResolved] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const paneRef = useRef<MergeSide | "result">("result");
|
|
const [actionHost] = useState<MergeActionHost>(() => ({
|
|
enabled: () => false,
|
|
run: () => {},
|
|
autoApply: () => false,
|
|
}));
|
|
const oursRef = useRef<HTMLDivElement>(null);
|
|
const resultRef = useRef<HTMLDivElement>(null);
|
|
const theirsRef = useRef<HTMLDivElement>(null);
|
|
const resultView = useRef<EditorView | null>(null);
|
|
const oursView = useRef<EditorView | null>(null);
|
|
const theirsView = useRef<EditorView | null>(null);
|
|
const autoApplyRef = useRef(autoApply);
|
|
useEffect(() => {
|
|
autoApplyRef.current = autoApply;
|
|
}, [autoApply]);
|
|
const txt = useTxTemplate();
|
|
const titles = mergeTitles(state?.sides);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const v = await call(commands.vcsConflictVersions(path));
|
|
if ([v.base, v.ours, v.theirs].some((text) => text?.includes("\0"))) {
|
|
throw new Error(tx("Binary files cannot be resolved in the merge editor."));
|
|
}
|
|
setState({
|
|
ours: v.ours ?? "",
|
|
theirs: v.theirs ?? "",
|
|
base: v.base,
|
|
sides: v.sides,
|
|
deleted: { left: v.ours === null, right: v.theirs === null },
|
|
});
|
|
setError(null);
|
|
} catch (e) {
|
|
setError(errorMessage(e));
|
|
}
|
|
}, [path]);
|
|
|
|
useEffect(() => {
|
|
void Promise.resolve().then(load);
|
|
}, [load]);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
void languageFor(path).then((l) => alive && setLang(() => l ?? []));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [path]);
|
|
|
|
useEffect(() => {
|
|
if (!state || lang === null || !oursRef.current || !resultRef.current || !theirsRef.current) return;
|
|
// Like IntelliJ, the result starts as the base, with every change waiting to be decided.
|
|
const start = startMerge(state.ours, state.base ?? "", state.theirs);
|
|
const change = (index: number, action: MergeAction) => {
|
|
setFocusedMergeEditor(actionHost);
|
|
void executeAction("merge.change", { indexes: [index], action });
|
|
};
|
|
const controls: MergeControls = {
|
|
accept: (index, side, resolve) => change(index, { type: "accept", side, resolve }),
|
|
ignore: (index, side, resolve) => change(index, { type: "ignore", side: resolve ? null : side }),
|
|
resolveAutomatically: (index) => change(index, { type: "auto", side: "base" }),
|
|
};
|
|
const side = (which: MergeSide): Extension[] => [
|
|
viewerBase({ readOnly: true }),
|
|
lang,
|
|
blameSlot(),
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.selectionSet) refreshMergeActions(actionHost);
|
|
}),
|
|
state.base !== null ? unifiedMergeView({ original: state.base, mergeControls: false, gutter: true }) : [],
|
|
sideControls(which, controls),
|
|
mergeTheme,
|
|
];
|
|
const ours = new EditorView({ doc: state.ours, parent: oursRef.current, extensions: side("left") });
|
|
const theirs = new EditorView({ doc: state.theirs, parent: theirsRef.current, extensions: side("right") });
|
|
const result: EditorView = new EditorView({
|
|
doc: start.text,
|
|
parent: resultRef.current,
|
|
extensions: [
|
|
viewerBase({ readOnly: false }),
|
|
history(),
|
|
keymap.of(historyKeymap),
|
|
mergeField.init(() => start.changes),
|
|
mergeHistory,
|
|
embeddedEditorExtensions(path),
|
|
lang,
|
|
resultMarks,
|
|
resultControls(controls),
|
|
mergeTheme,
|
|
EditorView.updateListener.of((u) => {
|
|
if (u.selectionSet || mergeChanged(u)) refreshMergeActions(actionHost);
|
|
if (!mergeChanged(u)) return;
|
|
const changes = u.state.field(mergeField);
|
|
ours.dispatch({ effects: syncSide.of(changes) });
|
|
theirs.dispatch({ effects: syncSide.of(changes) });
|
|
setSummary(summarize(u.state));
|
|
}),
|
|
],
|
|
});
|
|
resultView.current = result;
|
|
oursView.current = ours;
|
|
theirsView.current = theirs;
|
|
ours.dispatch({ effects: syncSide.of(start.changes) });
|
|
theirs.dispatch({ effects: syncSide.of(start.changes) });
|
|
setSummary(summarize(result.state));
|
|
if (autoApplyRef.current) {
|
|
const spec = applyNonConflicting(result.state, "base");
|
|
// The starting point, not a step to undo.
|
|
if (spec) result.dispatch({ ...spec, annotations: Transaction.addToHistory.of(false) });
|
|
}
|
|
// Scroll the three panes together, lined up through their changes.
|
|
const stopSync = syncMergeScroll({ ours, result, theirs });
|
|
return () => {
|
|
stopSync();
|
|
ours.destroy();
|
|
theirs.destroy();
|
|
result.destroy();
|
|
resultView.current = null;
|
|
oursView.current = null;
|
|
theirsView.current = null;
|
|
};
|
|
}, [state, lang, path, actionHost]);
|
|
|
|
/** Puts the caret at a change and brings it into view. */
|
|
const moveTo = (view: EditorView, at: number) =>
|
|
view.dispatch({ selection: { anchor: at }, effects: EditorView.scrollIntoView(at, { y: "center" }) });
|
|
|
|
const goto = (dir: 1 | -1) => {
|
|
const v = resultView.current;
|
|
if (!v) return;
|
|
const open = unresolved(v.state);
|
|
if (!open.length) return;
|
|
const head = v.state.selection.main.head;
|
|
const target =
|
|
dir === 1
|
|
? (open.find((c) => c.from > head) ?? open[0]!)
|
|
: ([...open].reverse().find((c) => c.from < head) ?? open[open.length - 1]!);
|
|
moveTo(v, target.from);
|
|
v.focus();
|
|
};
|
|
|
|
/** Runs a command on the result; `reveal` then moves to what is left to decide. */
|
|
const command = (make: (s: EditorState) => TransactionSpec | null, reveal = false) => {
|
|
const v = resultView.current;
|
|
const spec = v && make(v.state);
|
|
if (!v || !spec) return;
|
|
v.dispatch(spec);
|
|
const first = reveal ? unresolved(v.state)[0] : undefined;
|
|
if (first) moveTo(v, first.from);
|
|
};
|
|
|
|
/** Saves `text` as the resolved file and stages it; like IDEA, that closes the merge editor. */
|
|
const writeResolved = async (text: string | null) => {
|
|
const ok = await runOp(
|
|
tx`Resolve ${baseName(path)}`,
|
|
async () => {
|
|
if (text === null) await call(commands.fsDelete(path));
|
|
else await writeFileText(path, text, { save: true, exact: true });
|
|
await call(commands.vcsStage([path]));
|
|
return true;
|
|
},
|
|
{ success: tx`${baseName(path)}: conflicts resolved` },
|
|
);
|
|
if (ok) {
|
|
setResolved(true);
|
|
await refreshNow();
|
|
await editorService().closeInput?.(input.key);
|
|
}
|
|
};
|
|
|
|
const apply = async () => {
|
|
const v = resultView.current;
|
|
if (!v) return;
|
|
const remaining = unresolved(v.state).length;
|
|
if (
|
|
remaining > 0 &&
|
|
!(await confirmDialog({
|
|
title: "Unresolved Changes",
|
|
message:
|
|
remaining === 1
|
|
? tx`${remaining} change is unresolved and keeps the base text. Save and mark the file as resolved anyway?`
|
|
: tx`${remaining} changes are unresolved and keep the base text. Save and mark the file as resolved anyway?`,
|
|
okLabel: "Mark Resolved",
|
|
}))
|
|
)
|
|
return;
|
|
const separator = (state?.base ?? state?.ours ?? state?.theirs ?? "").includes("\r\n") ? "\r\n" : "\n";
|
|
await writeResolved(v.state.doc.sliceString(0, undefined, separator));
|
|
};
|
|
|
|
/** Whether the user decided or edited anything: what Accept and Cancel ask about. */
|
|
const modified = () => {
|
|
const v = resultView.current;
|
|
return !!v && undoDepth(v.state) > 0;
|
|
};
|
|
|
|
/** Accept Yours / Accept Theirs: that version as it is, whatever was done in the result. */
|
|
const acceptSide = async (side: MergeSide) => {
|
|
if (!state) return;
|
|
if (
|
|
modified() &&
|
|
!(await confirmDialog({
|
|
title: side === "left" ? "Accept Yours" : "Accept Theirs",
|
|
message: "The result was changed. Discard the changes and take this version as it is?",
|
|
okLabel: "Discard Changes",
|
|
danger: true,
|
|
}))
|
|
)
|
|
return;
|
|
await writeResolved(state.deleted[side] ? null : side === "left" ? state.ours : state.theirs);
|
|
};
|
|
|
|
const cancel = async () => {
|
|
if (
|
|
modified() &&
|
|
!(await confirmDialog({
|
|
title: "Cancel Merge",
|
|
message: "Exit without applying the changes made in the result?",
|
|
okLabel: "Exit",
|
|
danger: true,
|
|
}))
|
|
)
|
|
return;
|
|
await editorService().closeInput?.(input.key);
|
|
};
|
|
|
|
// Next / Previous Difference (F7 / Shift+F7 by default) jump between changes through
|
|
// the keymap, as in the diff viewer.
|
|
// `goto` reads only refs, so the first render's one stays valid.
|
|
const [nav] = useState<DiffNavigator>(() => ({
|
|
next: () => goto(1),
|
|
prev: () => goto(-1),
|
|
jumpToSource: () => void editorService().openFile(path, { focus: true }),
|
|
applyChange: () => false,
|
|
file: () => {},
|
|
showSettings: () => void actionHost.run("settings"),
|
|
}));
|
|
useEffect(
|
|
() => () => {
|
|
if (focusedDiffNavigator() === nav) {
|
|
setFocusedDiffNavigator(null);
|
|
setContext("diffEditorFocus", false);
|
|
}
|
|
},
|
|
[nav],
|
|
);
|
|
|
|
const setAutoApplyNonConflicting = (on: boolean) => {
|
|
setAutoApply(on);
|
|
writeAutoApply(on);
|
|
if (on) command((s) => applyNonConflicting(s, "base"), true);
|
|
};
|
|
|
|
const activePane = () => {
|
|
const which = paneRef.current;
|
|
return (which === "left" ? oursView : which === "right" ? theirsView : resultView).current;
|
|
};
|
|
|
|
const selectionDecision = (id: MergeCommand) => {
|
|
const view = activePane();
|
|
const result = resultView.current;
|
|
if (!view || !result) return null;
|
|
const { from, to } = view.state.selection.main;
|
|
const side = paneRef.current;
|
|
const indexes =
|
|
side === "result"
|
|
? changesIn(result.state, from, to)
|
|
: changesInSide(result.state, side, view.state.doc, from, to);
|
|
let action: MergeAction;
|
|
if (id === "resolveAutomatically") action = { type: "auto", side: "base" };
|
|
else if (id === "ignore") action = { type: "ignore", side: side === "result" ? null : side };
|
|
else if (id === "resolveUsingYours" || id === "resolveUsingTheirs")
|
|
action = { type: "accept", side: id === "resolveUsingYours" ? "left" : "right", resolve: true };
|
|
else if (id === "accept" && side !== "result") action = { type: "accept", side, resolve: false };
|
|
else return null;
|
|
return { indexes, action };
|
|
};
|
|
|
|
const enabled = (id: MergeCommand) => {
|
|
if (id === "reload" || id === "cancel") return !resolved;
|
|
const v = resultView.current;
|
|
if (!v || error || resolved) return false;
|
|
const available = summarize(v.state);
|
|
switch (id) {
|
|
case "applyLeft":
|
|
return available.left;
|
|
case "applyAll":
|
|
return available.all;
|
|
case "applyRight":
|
|
return available.right;
|
|
case "resolveSimple":
|
|
return available.simple;
|
|
case "revert":
|
|
return available.revert;
|
|
case "accept":
|
|
case "resolveUsingYours":
|
|
case "resolveUsingTheirs":
|
|
case "ignore":
|
|
case "resolveAutomatically": {
|
|
const decision = selectionDecision(id);
|
|
return !!decision?.indexes.some((i) => canApply(v.state.doc, v.state.field(mergeField)[i]!, decision.action));
|
|
}
|
|
case "annotate":
|
|
return paneRef.current !== "result";
|
|
case "cut":
|
|
return paneRef.current === "result" && !v.state.selection.main.empty;
|
|
case "copy":
|
|
return !!activePane() && !activePane()!.state.selection.main.empty;
|
|
case "paste":
|
|
return paneRef.current === "result";
|
|
default:
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const menuPosition = (name: "compare" | "settings") => {
|
|
const button = rootRef.current?.querySelector(`[data-merge-menu="${name}"]`);
|
|
const rect = (button ?? rootRef.current)?.getBoundingClientRect();
|
|
return { clientX: rect?.left ?? 0, clientY: rect?.bottom ?? 0 };
|
|
};
|
|
|
|
const runAction = async (id: MergeCommand, arg?: unknown) => {
|
|
switch (id) {
|
|
case "applyLeft":
|
|
return command((s) => applyNonConflicting(s, "left"), true);
|
|
case "applyAll":
|
|
return command((s) => applyNonConflicting(s, "base"), true);
|
|
case "applyRight":
|
|
return command((s) => applyNonConflicting(s, "right"), true);
|
|
case "resolveSimple":
|
|
return command(resolveSimple, true);
|
|
case "revert":
|
|
return command(revertResolved, true);
|
|
case "acceptYours":
|
|
return acceptSide("left");
|
|
case "acceptTheirs":
|
|
return acceptSide("right");
|
|
case "apply":
|
|
return apply();
|
|
case "cancel":
|
|
return cancel();
|
|
case "reload":
|
|
return load();
|
|
case "compare":
|
|
return showCompareMenu(menuPosition("compare"));
|
|
case "settings":
|
|
return showSettingsMenu(menuPosition("settings"));
|
|
case "autoApply":
|
|
return setAutoApplyNonConflicting(!autoApply);
|
|
case "accept":
|
|
case "resolveUsingYours":
|
|
case "resolveUsingTheirs":
|
|
case "ignore":
|
|
case "resolveAutomatically": {
|
|
const decision = selectionDecision(id);
|
|
if (decision) command((s) => applyTo(s, decision.indexes, decision.action));
|
|
return;
|
|
}
|
|
case "change": {
|
|
const change = arg as { indexes: number[]; action: MergeAction } | undefined;
|
|
if (change) command((s) => applyTo(s, change.indexes, change.action));
|
|
return;
|
|
}
|
|
case "annotate": {
|
|
const which = paneRef.current;
|
|
const view = activePane();
|
|
if (!state || !view || which === "result") return;
|
|
const isHead = which === "left" ? !state.sides.reversed : state.sides.reversed;
|
|
await toggleAnnotations(
|
|
view,
|
|
path,
|
|
which === "left" ? state.ours : state.theirs,
|
|
isHead ? "HEAD" : (state.sides.commit ?? "HEAD"),
|
|
);
|
|
return;
|
|
}
|
|
case "cut":
|
|
case "copy":
|
|
case "paste":
|
|
case "selectAll": {
|
|
const view = activePane();
|
|
if (!view) return;
|
|
const { from, to } = view.state.selection.main;
|
|
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
|
else if (id === "paste") {
|
|
const text = await navigator.clipboard?.readText().catch(() => undefined);
|
|
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
|
} else {
|
|
await copyText(view.state.sliceDoc(from, to));
|
|
if (id === "cut") view.dispatch({ changes: { from, to }, userEvent: "delete.cut" });
|
|
}
|
|
return;
|
|
}
|
|
default: {
|
|
const v = resultView.current;
|
|
if (!state || !v || !(id in COMPARISONS)) return;
|
|
const versions = {
|
|
yours: { text: state.ours, label: txt(titles.yours) },
|
|
base: { text: state.base ?? "", label: tx("Base") },
|
|
result: { text: v.state.doc.toString(), label: tx("Result") },
|
|
theirs: { text: state.theirs, label: txt(titles.theirs) },
|
|
};
|
|
const [a, b] = COMPARISONS[id as keyof typeof COMPARISONS];
|
|
await openDiff({ path, left: { type: "text", ...versions[a] }, right: { type: "text", ...versions[b] } });
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
Object.assign(actionHost, { enabled, run: runAction, autoApply: () => autoApply });
|
|
refreshMergeActions(actionHost);
|
|
});
|
|
|
|
useEffect(() => {
|
|
// Popup focus belongs to the editor that opened it; a real focus change releases it.
|
|
const onFocus = (e: FocusEvent) => {
|
|
const target = e.target as Element | null;
|
|
if (!rootRef.current?.contains(target) && !target?.closest("[data-popup], [role=dialog]"))
|
|
clearFocusedMergeEditor(actionHost);
|
|
};
|
|
document.addEventListener("focusin", onFocus);
|
|
return () => {
|
|
document.removeEventListener("focusin", onFocus);
|
|
clearFocusedMergeEditor(actionHost);
|
|
};
|
|
}, [actionHost]);
|
|
|
|
const focusProps = {
|
|
onFocusCapture: (e: React.FocusEvent<HTMLDivElement>) => {
|
|
const target = e.target as Node;
|
|
if (oursRef.current?.contains(target)) paneRef.current = "left";
|
|
else if (theirsRef.current?.contains(target)) paneRef.current = "right";
|
|
else if (resultRef.current?.contains(target)) paneRef.current = "result";
|
|
setFocusedMergeEditor(actionHost);
|
|
setFocusedDiffNavigator(nav);
|
|
setContext("diffEditorFocus", true);
|
|
},
|
|
onBlurCapture: (e: React.FocusEvent<HTMLDivElement>) => {
|
|
const target = e.relatedTarget as Element | null;
|
|
if (e.currentTarget.contains(target) || target?.closest("[data-popup], [role=dialog]")) return;
|
|
clearFocusedMergeEditor(actionHost);
|
|
if (focusedDiffNavigator() === nav) {
|
|
setFocusedDiffNavigator(null);
|
|
setContext("diffEditorFocus", false);
|
|
}
|
|
},
|
|
};
|
|
|
|
if (error)
|
|
return (
|
|
<div ref={rootRef} className={styles.message} {...focusProps}>
|
|
{error}
|
|
<Button onClick={() => void executeAction("merge.reload")}>
|
|
<T>Retry</T>
|
|
</Button>
|
|
</div>
|
|
);
|
|
if (resolved)
|
|
return (
|
|
<div className={styles.message}>
|
|
{tx`${baseName(path)} has been resolved and staged.`}
|
|
<Button onClick={() => void editorService().openFile(path, { focus: true })}>
|
|
<T>Open File</T>
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div ref={rootRef} className={styles.root} {...focusProps}>
|
|
<MergeToolbar summary={summary} />
|
|
<div className={styles.labels}>
|
|
<span>{txt(titles.yours)}</span>
|
|
<span>
|
|
<T>Result</T>
|
|
</span>
|
|
<span>{txt(titles.theirs)}</span>
|
|
</div>
|
|
{!state || lang === null ? (
|
|
<div className={styles.message}>
|
|
<Spinner size={14} />
|
|
</div>
|
|
) : null}
|
|
<div className={styles.panes} hidden={!state || lang === null}>
|
|
<div
|
|
ref={oursRef}
|
|
className={styles.pane}
|
|
aria-label={tx("Your version")}
|
|
role="region"
|
|
onContextMenu={(e) => oursView.current && showSideMenu(e, oursView.current, "left")}
|
|
/>
|
|
<div
|
|
ref={resultRef}
|
|
className={styles.pane}
|
|
aria-label={tx("Result")}
|
|
role="region"
|
|
onContextMenu={(e) => resultView.current && showResultMenu(e, resultView.current)}
|
|
/>
|
|
<div
|
|
ref={theirsRef}
|
|
className={styles.pane}
|
|
aria-label={tx("Their version")}
|
|
role="region"
|
|
onContextMenu={(e) => theirsView.current && showSideMenu(e, theirsView.current, "right")}
|
|
/>
|
|
</div>
|
|
<MergeFooter />
|
|
</div>
|
|
);
|
|
}
|