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
@@ -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;
}