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">
|
||||
|
||||
Reference in New Issue
Block a user