/** * @file LaneConflictView.tsx * @description The Changes panel's view of ONE conflicted file during a merge * (issue #45): the file with each conflict block drawn as its two sides — * "mine" and "theirs", labelled from git's own markers and tinted apart — and * a Keep mine / Take theirs / Keep both choice per block, undoable. Save and * mark resolved writes the chosen text over exactly the version it opened * (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs * `resolve done`, which re-checks that no marker is left. Take all mine / * theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the * file to the panel's plain editor. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronUp } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { applyChoices, parseConflicts, type ConflictChoice, type ConflictPart, } from "../../lib/conflicts"; const BASE = "rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40"; const BTN = `${BASE} border-border-light text-fg-secondary hover:text-fg-primary`; function Lines({ lines, className = "" }: { lines: string[]; className?: string }) { return (
      {lines.length ? lines.join("\n") : " "}
    
); } export function LaneConflictView({ laneId, session, source, path, onResolved, onEdit, }: { laneId: number; /** Work in that session's own worktree (see `gitTarget`). */ session?: string; /** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */ source?: number; path: string; /** The file left the conflict (resolved, or a whole side taken). */ onResolved: () => void; /** Open the file in the panel's plain editor instead. */ onEdit: () => void; }) { const { t } = useTranslation(["lanes"]); const [file, setFile] = useState<{ lines: string[]; sha?: string; usable: boolean } | null>(null); const [choices, setChoices] = useState<(ConflictChoice | undefined)[]>([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const blockRefs = useRef<(HTMLDivElement | null)[]>([]); const [at, setAt] = useState(0); useEffect(() => { let live = true; setFile(null); setChoices([]); setError(null); api.lanes .gitFile(gitTarget(laneId, session, source), path, "work") .then((r) => { if (!live) return; setFile( r.available ? { lines: r.lines, sha: r.sha, usable: !r.binary && !r.truncated && !!r.sha } : { lines: [], usable: false } ); }) .catch((e) => live && setError(e instanceof Error ? e.message : String(e))); return () => { live = false; }; }, [laneId, session, source, path]); const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null; const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0; const chosen = choices.filter(Boolean).length; const done = total > 0 && chosen === total; const run = async (fn: () => Promise) => { setBusy(true); setError(null); try { await fn(); onResolved(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const save = () => run(async () => { if (!parts || !file?.sha) return; await api.lanes.gitSaveFile( gitTarget(laneId, session, source), path, applyChoices(parts, choices).join("\n"), file.sha ); await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side: "done", }); }); const takeAll = (side: "ours" | "theirs") => run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side })); const choose = (i: number, c: ConflictChoice | undefined) => setChoices((prev) => { const next = [...prev]; next[i] = c; return next; }); const jump = (step: number) => { if (!total) return; const i = (at + step + total) % total; setAt(i); blockRefs.current[i]?.scrollIntoView({ block: "center" }); }; let blockIndex = -1; return (
{path} {total > 0 && ( {t("conflict.left", { left: total - chosen, total })} )}
{error && (

{error}

)} {!file ? (

{t("changes.loading")}

) : !parts ? (

{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}

) : (
{parts.map((p, n) => { if (p.kind === "text") return ; const i = ++blockIndex; const c = choices[i]; return (
{ blockRefs.current[i] = el; }} data-testid="conflict-block" className="my-1 border-y-2 border-status-warning/70" >
{c ? ( <> {t(`conflict.chose.${c}`)} ) : ( <> )} {i + 1}/{total}
{c ? ( ) : ( <>
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
)}
); })}
)} {parts && total > 0 && (
)}
); }