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
@@ -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();