/** * @file MergeEditor.tsx * @description The 3-way merge editor for ONE conflicted file — a merge, * cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming * (theirs, git stage :3) | Result | Current (ours, stage :2), with the common * base (stage :1) one click away. Each conflict block of the result takes * Accept current / Accept incoming / Accept both (either order) / a hand edit, * undoable; next/previous conflict and "N conflicts left"; or the whole result * edited as text. Save writes the result through the `conflict-save` action — * the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal) * then `git add` — which the server refuses while a marker is left. * In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the * commit being replayed, so the labels say that rather than "mine". * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronUp } from "lucide-react"; import type { GitTarget } from "../../../lib/api"; import { parseConflicts, type ConflictPart } from "../../../lib/conflicts"; import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite"; import { toast } from "../../../lib/toast"; import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal"; export type BlockChoice = { kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string }; type Block = Extract; /** The lines a block resolves to, or null while it is unresolved. */ export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null { if (!c) return null; if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n"); if (c.kind === "ours") return p.ours; if (c.kind === "theirs") return p.theirs; if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs]; return [...p.theirs, ...p.ours]; } /** The result file: resolved blocks as chosen, unresolved ones with their markers. */ export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string { const out: string[] = []; let i = 0; for (const p of parts) { if (p.kind === "text") { out.push(...p.lines); continue; } const lines = blockLines(p, choices[i++]); if (lines) out.push(...lines); else out.push( `<<<<<<< ${p.oursLabel}`.trimEnd(), ...p.ours, "=======", ...p.theirs, `>>>>>>> ${p.theirsLabel}`.trimEnd() ); } return out.join("\n"); } function Pane({ title, text, testId, tone, }: { title: string; text: string | null; testId: string; tone: "incoming" | "current" | "base"; }) { const { t } = useTranslation(["gitTools"]); const head = tone === "incoming" ? "bg-accent/20 text-accent" : tone === "current" ? "bg-status-success/20 text-status-success" : "bg-surface-3 text-fg-secondary"; return (

{title}

        {text == null ? {t("merge.sideGone")} : text}
      
); } export function MergeEditor({ target, path, onClose, onResolved, }: { target: GitTarget; path: string; onClose: () => void; /** The file was saved and marked resolved. */ onResolved: () => void; }) { const { t } = useTranslation(["gitTools"]); const [sides, setSides] = useState(null); const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]); const [editing, setEditing] = useState<{ i: number; text: string } | null>(null); const [whole, setWhole] = useState(null); const [showBase, setShowBase] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [at, setAt] = useState(0); const blockRefs = useRef<(HTMLDivElement | null)[]>([]); const load = useCallback(() => { let live = true; setSides(null); setChoices([]); setEditing(null); setWhole(null); setError(null); gitRewrite .conflict(target, path) .then((s) => live && setSides(s)) .catch((e) => live && setError(errText(e))); return () => { live = false; }; // `target` is rebuilt by the caller on every render; its parts are stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(target), path]); useEffect(load, [load]); const parts = sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null; const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict"); const total = blocks.length; const left = blocks.filter((_, i) => !choices[i]).length; const rebase = sides?.operation === "rebase"; const incomingLabel = sides?.incoming ? t(rebase ? "merge.incomingRebase" : "merge.incoming", { ref: `${sides.incoming.short} ${sides.incoming.subject}`, }) : t("merge.incomingPlain"); const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", { branch: sides?.branch ?? "HEAD", }); const choose = (i: number, c: BlockChoice | undefined) => setChoices((prev) => { const next = [...prev]; next[i] = c; return next; }); const jump = (step: number) => { if (!total) return; const open = blocks.map((_, i) => i).filter((i) => !choices[i]); const pool = open.length ? open : blocks.map((_, i) => i); const from = pool.findIndex((i) => i > at); let i: number; if (step > 0) i = from === -1 ? pool[0]! : pool[from]!; else { const before = pool.filter((x) => x < at); i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!; } setAt(i); blockRefs.current[i]?.scrollIntoView?.({ block: "center" }); }; const save = async () => { if (!sides?.sha || !parts) return; const content = whole ?? composeResult(parts, choices); setBusy(true); setError(null); try { await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha }); toast.success(t("merge.saved", { path })); onResolved(); onClose(); } catch (e) { const code = errCode(e); setError( code === "EMARKERS" ? t("merge.markersLeft") : code === "ESTALE" ? t("merge.stale") : errText(e) ); } finally { setBusy(false); } }; const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => ( ); let blockIndex = -1; const result = parts && (

{t("merge.result")}

{whole != null ? (