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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -287,5 +287,26 @@
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)"
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
"conflict.left": "{{left}} of {{total}} conflicts left",
|
||||
"conflict.prev": "Previous conflict",
|
||||
"conflict.next": "Next conflict",
|
||||
"conflict.allOurs": "Keep all mine",
|
||||
"conflict.allTheirs": "Take all theirs",
|
||||
"conflict.editByHand": "Edit by hand",
|
||||
"conflict.unbalanced": "The conflict markers do not line up (edited by hand?) — use Edit by hand.",
|
||||
"conflict.unusable": "This file cannot be shown here (binary or too large) — use Keep all mine / Take all theirs, or a terminal.",
|
||||
"conflict.keepOurs": "Keep mine",
|
||||
"conflict.takeTheirs": "Take theirs",
|
||||
"conflict.keepBoth": "Keep both",
|
||||
"conflict.chose.ours": "Kept mine",
|
||||
"conflict.chose.theirs": "Took theirs",
|
||||
"conflict.chose.both": "Kept both",
|
||||
"conflict.undo": "Undo",
|
||||
"conflict.ours": "Mine ({{label}})",
|
||||
"conflict.theirs": "Theirs ({{label}})",
|
||||
"conflict.reset": "Cancel",
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file"
|
||||
}
|
||||
|
||||
@@ -287,5 +287,26 @@
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)"
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
"conflict.left": "Còn {{left}}/{{total}} khối conflict",
|
||||
"conflict.prev": "Khối trước",
|
||||
"conflict.next": "Khối sau",
|
||||
"conflict.allOurs": "Giữ hết của mình",
|
||||
"conflict.allTheirs": "Lấy hết bản kia",
|
||||
"conflict.editByHand": "Sửa tay",
|
||||
"conflict.unbalanced": "Dấu conflict không khớp nhau (đã sửa tay?) - hãy dùng Sửa tay.",
|
||||
"conflict.unusable": "Không hiển thị được file này ở đây (nhị phân hoặc quá lớn) - hãy dùng Giữ hết của mình / Lấy hết bản kia, hoặc terminal.",
|
||||
"conflict.keepOurs": "Giữ của mình",
|
||||
"conflict.takeTheirs": "Lấy bản kia",
|
||||
"conflict.keepBoth": "Giữ cả hai",
|
||||
"conflict.chose.ours": "Đã giữ của mình",
|
||||
"conflict.chose.theirs": "Đã lấy bản kia",
|
||||
"conflict.chose.both": "Đã giữ cả hai",
|
||||
"conflict.undo": "Hoàn tác",
|
||||
"conflict.ours": "Của mình ({{label}})",
|
||||
"conflict.theirs": "Bản kia ({{label}})",
|
||||
"conflict.reset": "Huỷ",
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict parser behind the Changes panel's
|
||||
* conflict view: plain and diff3 blocks, the two side labels, applying a
|
||||
* choice per block, and refusing a file whose markers do not balance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConflicts, applyChoices } from "../conflicts";
|
||||
|
||||
const FILE = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"=======",
|
||||
"theirs 1",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"||||||| base",
|
||||
"old",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"end",
|
||||
];
|
||||
|
||||
describe("parseConflicts", () => {
|
||||
it("splits text from conflict blocks and reads both labels", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(parts.map((p) => p.kind)).toEqual(["text", "conflict", "text", "conflict", "text"]);
|
||||
expect(parts[1]).toMatchObject({
|
||||
ours: ["mine 1", "mine 2"],
|
||||
theirs: ["theirs 1"],
|
||||
oursLabel: "HEAD",
|
||||
theirsLabel: "origin/main",
|
||||
});
|
||||
// diff3: the base section is not either side.
|
||||
expect(parts[3]).toMatchObject({ ours: ["a"], theirs: ["b"], theirsLabel: "feat" });
|
||||
});
|
||||
|
||||
it("returns an empty list of blocks for a file without markers", () => {
|
||||
expect(parseConflicts(["a", "b"])).toEqual([{ kind: "text", lines: ["a", "b"] }]);
|
||||
});
|
||||
|
||||
it("refuses markers that do not balance", () => {
|
||||
expect(parseConflicts(["<<<<<<< HEAD", "x", "======="])).toBeNull();
|
||||
expect(parseConflicts(["x", ">>>>>>> feat"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChoices", () => {
|
||||
it("writes each block as the chosen side, both sides in order for 'both'", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(applyChoices(parts, ["ours", "both"])).toEqual([
|
||||
"top",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"middle",
|
||||
"a",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
expect(applyChoices(parts, ["theirs", "theirs"])).toEqual([
|
||||
"top",
|
||||
"theirs 1",
|
||||
"middle",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Parses git's merge-conflict markers out of a file's lines, and writes
|
||||
* the file back with one side (or both) chosen per block — the model behind
|
||||
* the Changes panel's conflict view. Handles diff3 style (`|||||||` base
|
||||
* section, dropped: it is neither side). Pure, no React.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type ConflictChoice = "ours" | "theirs" | "both";
|
||||
|
||||
export type ConflictPart =
|
||||
| { kind: "text"; lines: string[] }
|
||||
| { kind: "conflict"; ours: string[]; theirs: string[]; oursLabel: string; theirsLabel: string };
|
||||
|
||||
const OPEN = /^<{7}(?: (.*))?$/;
|
||||
const BASE = /^\|{7}(?: .*)?$/;
|
||||
const MID = /^={7}$/;
|
||||
const CLOSE = /^>{7}(?: (.*))?$/;
|
||||
|
||||
/**
|
||||
* The file as text runs and conflict blocks, or null when its markers do not
|
||||
* balance (a hand edit left half a block) — the caller falls back to the
|
||||
* plain editor then rather than guess.
|
||||
*/
|
||||
export function parseConflicts(lines: string[]): ConflictPart[] | null {
|
||||
const parts: ConflictPart[] = [];
|
||||
let text: string[] = [];
|
||||
let block: { ours: string[]; theirs: string[]; oursLabel: string } | null = null;
|
||||
let section: "ours" | "base" | "theirs" = "ours";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!block) {
|
||||
const open = line.match(OPEN);
|
||||
if (open) {
|
||||
if (text.length) parts.push({ kind: "text", lines: text });
|
||||
text = [];
|
||||
block = { ours: [], theirs: [], oursLabel: open[1] ?? "" };
|
||||
section = "ours";
|
||||
} else if (CLOSE.test(line) || MID.test(line)) {
|
||||
return null;
|
||||
} else {
|
||||
text.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section === "ours" && BASE.test(line)) section = "base";
|
||||
else if (section !== "theirs" && MID.test(line)) section = "theirs";
|
||||
else if (section === "theirs" && CLOSE.test(line)) {
|
||||
const close = line.match(CLOSE)!;
|
||||
parts.push({ kind: "conflict", ...block, theirsLabel: close[1] ?? "" });
|
||||
block = null;
|
||||
} else if (OPEN.test(line)) return null;
|
||||
else if (section === "ours") block.ours.push(line);
|
||||
else if (section === "theirs") block.theirs.push(line);
|
||||
}
|
||||
if (block) return null;
|
||||
if (text.length || !parts.length) parts.push({ kind: "text", lines: text });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The file's lines with block `i` replaced by `choices[i]`; an unchosen block keeps its markers. */
|
||||
export function applyChoices(
|
||||
parts: ConflictPart[],
|
||||
choices: (ConflictChoice | undefined)[]
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const c = choices[i++];
|
||||
if (c === "ours") out.push(...p.ours);
|
||||
else if (c === "theirs") out.push(...p.theirs);
|
||||
else if (c === "both") out.push(...p.ours, ...p.theirs);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+12
-3
@@ -567,9 +567,18 @@ its recovery is a button too: a merge that stops on conflicts puts a banner at
|
||||
the top of the Changes panel — **Complete merge** (enabled once nothing is
|
||||
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
|
||||
and lists the conflicted files in their own **Merge conflicts** group with
|
||||
**Keep mine**, **Take theirs** and **Mark resolved** (after editing the
|
||||
`<<<<<<<`/`>>>>>>>` markers out in the file editor; refused while any are
|
||||
left). Pull and branch switch are disabled until the merge is finished or
|
||||
**Keep mine** / **Take theirs** for the whole file and **Mark resolved**.
|
||||
Opening a conflicted file shows the **conflict view** instead of its diff:
|
||||
each conflict block drawn as its two sides — *Mine (HEAD)* tinted green,
|
||||
*Theirs (<branch>)* tinted blue, labelled from git's own markers — with
|
||||
**Keep mine**, **Take theirs** or **Keep both** per block (undoable, ^/v to
|
||||
jump between blocks). **Save and mark resolved** is enabled once every block
|
||||
is chosen; it saves over exactly the version opened (a file changed meanwhile
|
||||
is refused, `409 ESTALE`) and then marks the file resolved. **Edit by hand**
|
||||
opens the plain editor, for a block neither side gets right; marking resolved
|
||||
is refused while any `<<<<<<<`/`>>>>>>>` marker is left. In the Files tree a
|
||||
conflicted file is listed once (git indexes it once per side) and marked with
|
||||
a red **!**. Pull and branch switch are disabled until the merge is finished or
|
||||
aborted.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
|
||||
@@ -16,6 +16,7 @@ const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
const { listTree, listPaths } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
@@ -87,6 +88,15 @@ describe("merge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listing files mid-merge", () => {
|
||||
it("lists a conflicted file once, not once per merge stage", async () => {
|
||||
await merge();
|
||||
const root = await listTree(REPO, {});
|
||||
assert.equal(root.entries.filter((e) => e.name === "a.txt").length, 1);
|
||||
assert.deepEqual((await listPaths(REPO, { q: "a.txt" })).paths, ["a.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("takes our side or theirs, and only for a conflicted path", async () => {
|
||||
await merge();
|
||||
|
||||
@@ -760,7 +760,9 @@ async function lsFiles(dir, args) {
|
||||
const all = out.split("\0");
|
||||
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
|
||||
const cut = all.pop() !== "";
|
||||
return { all: all.filter(Boolean), cut };
|
||||
// Mid-merge, `ls-files --cached` prints a conflicted path once per stage
|
||||
// (base, ours, theirs): one path is one file.
|
||||
return { all: [...new Set(all.filter(Boolean))], cut };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
|
||||
Reference in New Issue
Block a user