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">
@@ -0,0 +1,279 @@
/**
* @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>
);
}
@@ -1308,6 +1308,27 @@ describe("a merge in progress (issue #45)", () => {
);
});
it("opens a conflicted file in the conflict view, not the diff", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "a.txt",
rev: "work",
lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"],
truncated: false,
binary: false,
sha: "s1",
} as never);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={3} />);
await user.click(
within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt")
);
expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)");
expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work");
});
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
const user = userEvent.setup();
@@ -0,0 +1,130 @@
/**
* @file Tests for the merge-conflict view: both sides of each block shown
* under their own labels, one choice per block with undo, Save only once
* every block is chosen — and Save writes the chosen text over the version
* opened, then marks the file resolved. Whole-file ours/theirs reuse
* `resolve`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneConflictView } from "../LaneConflictView";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
}));
const LINES = [
"top",
"<<<<<<< HEAD",
"mine",
"=======",
"theirs",
">>>>>>> origin/main",
"middle",
"<<<<<<< HEAD",
"a",
"=======",
"b",
">>>>>>> origin/main",
];
beforeEach(() => {
vi.mocked(api.lanes.gitFile)
.mockReset()
.mockResolvedValue({
available: true,
path: "a.txt",
rev: "work",
lines: LINES,
truncated: false,
binary: false,
sha: "s1",
} as never);
vi.mocked(api.lanes.gitSaveFile)
.mockReset()
.mockResolvedValue({ available: true } as never);
vi.mocked(api.lanes.gitAction)
.mockReset()
.mockResolvedValue({ action: "resolve", ok: true, output: "" });
});
const blocks = () => screen.getAllByTestId("conflict-block");
describe("LaneConflictView", () => {
it("shows both sides under their labels and saves once every block is chosen", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
const [first, second] = (await screen.findAllByTestId("conflict-block")) as [
HTMLElement,
HTMLElement,
];
expect(first).toHaveTextContent("Mine (HEAD)");
expect(first).toHaveTextContent("Theirs (origin/main)");
const save = screen.getByRole("button", { name: "Save and mark resolved" });
expect(save).toBeDisabled();
await user.click(within(first).getByRole("button", { name: "Keep mine" }));
// Chosen: the block is plain text now, with an undo.
await user.click(within(blocks()[0]!).getByRole("button", { name: "Undo" }));
await user.click(within(blocks()[0]!).getByRole("button", { name: "Take theirs" }));
await user.click(within(second).getByRole("button", { name: "Keep both" }));
expect(save).toBeEnabled();
await user.click(save);
await waitFor(() =>
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
3,
"a.txt",
"top\ntheirs\nmiddle\na\nb",
"s1"
)
);
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
path: "a.txt",
side: "done",
})
);
expect(onResolved).toHaveBeenCalled();
});
it("takes a whole side for the file through resolve", async () => {
const user = userEvent.setup();
const onResolved = vi.fn();
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
await screen.findAllByTestId("conflict-block");
await user.click(screen.getByRole("button", { name: "Take all theirs" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
path: "a.txt",
side: "theirs",
})
);
expect(onResolved).toHaveBeenCalled();
expect(api.lanes.gitSaveFile).not.toHaveBeenCalled();
});
it("offers the plain editor when the markers do not balance", async () => {
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "a.txt",
rev: "work",
lines: ["<<<<<<< HEAD", "x"],
truncated: false,
binary: false,
sha: "s1",
} as never);
const user = userEvent.setup();
const onEdit = vi.fn();
render(<LaneConflictView laneId={3} path="a.txt" onResolved={vi.fn()} onEdit={onEdit} />);
await screen.findByText(/markers do not line up/);
await user.click(screen.getByRole("button", { name: "Edit by hand" }));
expect(onEdit).toHaveBeenCalled();
});
});