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",
]);
});
});