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();
});
});
+22 -1
View File
@@ -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"
}
+22 -1
View 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",
]);
});
});
+87
View File
@@ -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;
}