feat(git): per-block conflict view with mine/theirs choices (#45)

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.
This commit is contained in:
2026-09-25 13:35:50 +07:00
parent 52392962fb
commit 1da46f10f4
12 changed files with 709 additions and 31 deletions
@@ -41,8 +41,6 @@ import {
Columns2,
FileText,
FoldVertical,
ArrowLeftToLine,
ArrowRightToLine,
Folder,
Minus,
Pencil,
@@ -54,6 +52,7 @@ import {
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
@@ -121,6 +120,9 @@ function treeMark(code: string): { letter: string; tone: string } {
// The tint fills the whole row, not just the letter, so a changed file
// stands out of a long tree at a glance.
if (code === "??") return { letter: "U", tone: "text-status-success bg-status-success/10" };
// A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes.
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code))
return { letter: "!", tone: "text-status-danger bg-status-danger/10" };
if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
if (code.includes("D")) return { letter: "D", tone: "text-status-danger bg-status-danger/10" };
if (code.includes("R")) return { letter: "R", tone: "text-status-warning bg-status-warning/10" };
@@ -321,20 +323,22 @@ function ConflictActions({
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
<IconButton
label={t("changes.takeOurs")}
disabled={busy}
onClick={() => onResolve(f.path, "ours")}
>
<ArrowLeftToLine className="h-3 w-3" />
</IconButton>
<IconButton
label={t("changes.takeTheirs")}
disabled={busy}
onClick={() => onResolve(f.path, "theirs")}
>
<ArrowRightToLine className="h-3 w-3" />
</IconButton>
{(["ours", "theirs"] as const).map((side) => (
<button
key={side}
type="button"
disabled={busy}
onClick={() => onResolve(f.path, side)}
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
side === "ours"
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: "border-accent/40 text-accent hover:bg-accent/10"
}`}
>
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
</button>
))}
<IconButton
label={t("changes.markResolved")}
disabled={busy}
@@ -463,6 +467,8 @@ export function LaneChangesPanel({
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [armedAbort, setArmedAbort] = useState(false);
// A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
@@ -697,9 +703,19 @@ export function LaneChangesPanel({
setWhole(full && range !== "commit");
setText(null);
setDraft(null);
setRawEdit(false);
setMode("file");
};
const goBack = () => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
const saveDraft = async () => {
@@ -997,14 +1013,7 @@ export function LaneChangesPanel({
{mode === "file" ? (
<button
type="button"
onClick={() => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
}}
onClick={goBack}
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
<ChevronLeft className="h-3 w-3" />
@@ -1368,6 +1377,21 @@ export function LaneChangesPanel({
)}
</div>
</>
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
<LaneConflictView
laneId={laneId}
path={openFile}
onResolved={() => {
refreshGit();
void load();
goBack();
}}
onEdit={() => {
setRawEdit(true);
editWanted.current = true;
setWhole(true);
}}
/>
) : mode === "file" ? (
current || (whole && text) ? (
<div className="flex min-h-0 flex-1 flex-col">