1da46f10f4
Opening a conflicted file in the Changes panel now shows each conflict block as its two sides, labelled from git's markers and tinted apart, with Keep mine / Take theirs / Keep both per block (undoable, ^/v to jump). Save and mark resolved writes the chosen text through the whole-file editor's PUT /git/file (baseSha stale check) then runs resolve done. The Merge conflicts group's buttons are text, and a conflicted file is marked with a red ! in the Files tree. Fix: ls-files --cached lists an unmerged path once per stage, so the tree showed a conflicted file three times; lsFiles now dedupes.
280 lines
9.8 KiB
TypeScript
280 lines
9.8 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
import { api } 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 (
|
|
<pre className={`whitespace-pre px-2 font-mono text-[11px] leading-5 ${className}`}>
|
|
{lines.length ? lines.join("\n") : " "}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export function LaneConflictView({
|
|
laneId,
|
|
path,
|
|
onResolved,
|
|
onEdit,
|
|
}: {
|
|
laneId: 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<string | null>(null);
|
|
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const [at, setAt] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
setFile(null);
|
|
setChoices([]);
|
|
setError(null);
|
|
api.lanes
|
|
.gitFile(laneId, 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, 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<unknown>) => {
|
|
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(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
|
|
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
|
|
});
|
|
const takeAll = (side: "ours" | "theirs") =>
|
|
run(() => api.lanes.gitAction(laneId, "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 (
|
|
<div data-testid="lane-conflict-view" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
|
{path}
|
|
</span>
|
|
{total > 0 && (
|
|
<span className="text-[11px] text-status-warning">
|
|
{t("conflict.left", { left: total - chosen, total })}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("conflict.prev")}
|
|
disabled={!total}
|
|
onClick={() => jump(-1)}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("conflict.next")}
|
|
disabled={!total}
|
|
onClick={() => jump(1)}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
<button type="button" className={BTN} disabled={busy} onClick={() => void takeAll("ours")}>
|
|
{t("conflict.allOurs")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
disabled={busy}
|
|
onClick={() => void takeAll("theirs")}
|
|
>
|
|
{t("conflict.allTheirs")}
|
|
</button>
|
|
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
|
{t("conflict.editByHand")}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="whitespace-pre-wrap text-[11px] text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{!file ? (
|
|
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
|
) : !parts ? (
|
|
<p className="text-[11px] text-status-warning">
|
|
{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}
|
|
</p>
|
|
) : (
|
|
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0">
|
|
{parts.map((p, n) => {
|
|
if (p.kind === "text")
|
|
return <Lines key={n} lines={p.lines} className="text-fg-primary" />;
|
|
const i = ++blockIndex;
|
|
const c = choices[i];
|
|
return (
|
|
<div
|
|
key={n}
|
|
ref={(el) => {
|
|
blockRefs.current[i] = el;
|
|
}}
|
|
data-testid="conflict-block"
|
|
className="my-1 border-y-2 border-status-warning/70"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-2 py-0.5 text-[11px]">
|
|
{c ? (
|
|
<>
|
|
<span className="text-fg-muted">{t(`conflict.chose.${c}`)}</span>
|
|
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
|
{t("conflict.undo")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={`${BASE} border-status-success/70 font-medium text-status-success`}
|
|
onClick={() => choose(i, "ours")}
|
|
>
|
|
{t("conflict.keepOurs")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`${BASE} border-accent/70 font-medium text-accent`}
|
|
onClick={() => choose(i, "theirs")}
|
|
>
|
|
{t("conflict.takeTheirs")}
|
|
</button>
|
|
<button type="button" className={BTN} onClick={() => choose(i, "both")}>
|
|
{t("conflict.keepBoth")}
|
|
</button>
|
|
</>
|
|
)}
|
|
<span className="ml-auto text-fg-muted">
|
|
{i + 1}/{total}
|
|
</span>
|
|
</div>
|
|
{c ? (
|
|
<Lines
|
|
lines={
|
|
c === "ours" ? p.ours : c === "theirs" ? p.theirs : [...p.ours, ...p.theirs]
|
|
}
|
|
className="bg-surface-2 text-fg-primary"
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="border-l-4 border-status-success bg-status-success/20">
|
|
<div className="bg-status-success/30 px-2 py-0.5 text-[11px] font-semibold text-status-success">
|
|
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
|
|
</div>
|
|
<Lines lines={p.ours} className="text-fg-primary" />
|
|
</div>
|
|
<div className="border-l-4 border-accent bg-accent/20">
|
|
<div className="bg-accent/30 px-2 py-0.5 text-[11px] font-semibold text-accent">
|
|
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
|
|
</div>
|
|
<Lines lines={p.theirs} className="text-fg-primary" />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{parts && total > 0 && (
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
disabled={busy || !chosen}
|
|
onClick={() => setChoices([])}
|
|
>
|
|
{t("conflict.reset")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy || !done}
|
|
onClick={() => void save()}
|
|
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{t("conflict.save")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|