2b5d7268ed
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease, remote-branch-delete, remote add/rename/remove) and git-conflicts.js (conflict stages read + conflict-save, reflog), dispatched from runGitAction; operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes, /git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView, RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog, rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
/**
|
|
* @file MergeEditor.tsx
|
|
* @description The 3-way merge editor for ONE conflicted file — a merge,
|
|
* cherry-pick, revert or rebase (VS Code's / JetBrains' layout): Incoming
|
|
* (theirs, git stage :3) | Result | Current (ours, stage :2), with the common
|
|
* base (stage :1) one click away. Each conflict block of the result takes
|
|
* Accept current / Accept incoming / Accept both (either order) / a hand edit,
|
|
* undoable; next/previous conflict and "N conflicts left"; or the whole result
|
|
* edited as text. Save writes the result through the `conflict-save` action —
|
|
* the whole-file editor's own guards (baseSha → 409 ESTALE, secret refusal)
|
|
* then `git add` — which the server refuses while a marker is left.
|
|
* In a rebase git's "ours" is the branch being rebased ONTO and "theirs" the
|
|
* commit being replayed, so the labels say that rather than "mine".
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
import type { GitTarget } from "../../../lib/api";
|
|
import { parseConflicts, type ConflictPart } from "../../../lib/conflicts";
|
|
import { errCode, errText, gitRewrite, type ConflictSides } from "../../../lib/gitRewrite";
|
|
import { toast } from "../../../lib/toast";
|
|
import { BTN, BTN_PRIMARY, ErrorLine, GitModal } from "./GitModal";
|
|
|
|
export type BlockChoice =
|
|
{ kind: "ours" | "theirs" | "ours-theirs" | "theirs-ours" } | { kind: "custom"; text: string };
|
|
|
|
type Block = Extract<ConflictPart, { kind: "conflict" }>;
|
|
|
|
/** The lines a block resolves to, or null while it is unresolved. */
|
|
export function blockLines(p: Block, c: BlockChoice | undefined): string[] | null {
|
|
if (!c) return null;
|
|
if (c.kind === "custom") return c.text === "" ? [] : c.text.split("\n");
|
|
if (c.kind === "ours") return p.ours;
|
|
if (c.kind === "theirs") return p.theirs;
|
|
if (c.kind === "ours-theirs") return [...p.ours, ...p.theirs];
|
|
return [...p.theirs, ...p.ours];
|
|
}
|
|
|
|
/** The result file: resolved blocks as chosen, unresolved ones with their markers. */
|
|
export function composeResult(parts: ConflictPart[], choices: (BlockChoice | undefined)[]): string {
|
|
const out: string[] = [];
|
|
let i = 0;
|
|
for (const p of parts) {
|
|
if (p.kind === "text") {
|
|
out.push(...p.lines);
|
|
continue;
|
|
}
|
|
const lines = blockLines(p, choices[i++]);
|
|
if (lines) out.push(...lines);
|
|
else
|
|
out.push(
|
|
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
|
...p.ours,
|
|
"=======",
|
|
...p.theirs,
|
|
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
|
);
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
function Pane({
|
|
title,
|
|
text,
|
|
testId,
|
|
tone,
|
|
}: {
|
|
title: string;
|
|
text: string | null;
|
|
testId: string;
|
|
tone: "incoming" | "current" | "base";
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const head =
|
|
tone === "incoming"
|
|
? "bg-accent/20 text-accent"
|
|
: tone === "current"
|
|
? "bg-status-success/20 text-status-success"
|
|
: "bg-surface-3 text-fg-secondary";
|
|
return (
|
|
<section
|
|
data-testid={testId}
|
|
className="flex min-h-0 min-w-0 flex-col rounded border border-border-light"
|
|
>
|
|
<h3 className={`truncate px-2 py-1 text-[11px] font-semibold ${head}`} title={title}>
|
|
{title}
|
|
</h3>
|
|
<pre className="max-h-60 min-h-0 flex-1 overflow-auto whitespace-pre bg-surface-0 px-2 py-1 font-mono text-[11px] leading-5 text-fg-primary md:max-h-none">
|
|
{text == null ? <span className="italic text-fg-muted">{t("merge.sideGone")}</span> : text}
|
|
</pre>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function MergeEditor({
|
|
target,
|
|
path,
|
|
onClose,
|
|
onResolved,
|
|
}: {
|
|
target: GitTarget;
|
|
path: string;
|
|
onClose: () => void;
|
|
/** The file was saved and marked resolved. */
|
|
onResolved: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const [sides, setSides] = useState<ConflictSides | null>(null);
|
|
const [choices, setChoices] = useState<(BlockChoice | undefined)[]>([]);
|
|
const [editing, setEditing] = useState<{ i: number; text: string } | null>(null);
|
|
const [whole, setWhole] = useState<string | null>(null);
|
|
const [showBase, setShowBase] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [at, setAt] = useState(0);
|
|
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
|
|
const load = useCallback(() => {
|
|
let live = true;
|
|
setSides(null);
|
|
setChoices([]);
|
|
setEditing(null);
|
|
setWhole(null);
|
|
setError(null);
|
|
gitRewrite
|
|
.conflict(target, path)
|
|
.then((s) => live && setSides(s))
|
|
.catch((e) => live && setError(errText(e)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// `target` is rebuilt by the caller on every render; its parts are stable.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [JSON.stringify(target), path]);
|
|
useEffect(load, [load]);
|
|
|
|
const parts =
|
|
sides?.usable && sides.working != null ? parseConflicts(sides.working.split("\n")) : null;
|
|
const blocks = (parts ?? []).filter((p): p is Block => p.kind === "conflict");
|
|
const total = blocks.length;
|
|
const left = blocks.filter((_, i) => !choices[i]).length;
|
|
|
|
const rebase = sides?.operation === "rebase";
|
|
const incomingLabel = sides?.incoming
|
|
? t(rebase ? "merge.incomingRebase" : "merge.incoming", {
|
|
ref: `${sides.incoming.short} ${sides.incoming.subject}`,
|
|
})
|
|
: t("merge.incomingPlain");
|
|
const currentLabel = t(rebase ? "merge.currentRebase" : "merge.current", {
|
|
branch: sides?.branch ?? "HEAD",
|
|
});
|
|
|
|
const choose = (i: number, c: BlockChoice | undefined) =>
|
|
setChoices((prev) => {
|
|
const next = [...prev];
|
|
next[i] = c;
|
|
return next;
|
|
});
|
|
|
|
const jump = (step: number) => {
|
|
if (!total) return;
|
|
const open = blocks.map((_, i) => i).filter((i) => !choices[i]);
|
|
const pool = open.length ? open : blocks.map((_, i) => i);
|
|
const from = pool.findIndex((i) => i > at);
|
|
let i: number;
|
|
if (step > 0) i = from === -1 ? pool[0]! : pool[from]!;
|
|
else {
|
|
const before = pool.filter((x) => x < at);
|
|
i = before.length ? before[before.length - 1]! : pool[pool.length - 1]!;
|
|
}
|
|
setAt(i);
|
|
blockRefs.current[i]?.scrollIntoView?.({ block: "center" });
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!sides?.sha || !parts) return;
|
|
const content = whole ?? composeResult(parts, choices);
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await gitRewrite.run(target, "conflict-save", { path, content, baseSha: sides.sha });
|
|
toast.success(t("merge.saved", { path }));
|
|
onResolved();
|
|
onClose();
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
setError(
|
|
code === "EMARKERS"
|
|
? t("merge.markersLeft")
|
|
: code === "ESTALE"
|
|
? t("merge.stale")
|
|
: errText(e)
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const choiceBtn = (i: number, c: BlockChoice, label: string, testId: string) => (
|
|
<button type="button" data-testid={testId} className={BTN} onClick={() => choose(i, c)}>
|
|
{label}
|
|
</button>
|
|
);
|
|
|
|
let blockIndex = -1;
|
|
const result = parts && (
|
|
<section
|
|
data-testid="merge-result"
|
|
className="flex min-h-0 min-w-0 flex-col rounded border border-accent/50"
|
|
>
|
|
<h3 className="truncate bg-surface-3 px-2 py-1 text-[11px] font-semibold text-fg-primary">
|
|
{t("merge.result")}
|
|
</h3>
|
|
{whole != null ? (
|
|
<textarea
|
|
data-testid="merge-whole"
|
|
aria-label={t("merge.result")}
|
|
value={whole}
|
|
onChange={(e) => setWhole(e.target.value)}
|
|
spellCheck={false}
|
|
className="input min-h-60 flex-1 resize-none whitespace-pre rounded-none border-0 font-mono text-[11px] leading-5"
|
|
/>
|
|
) : (
|
|
<div className="max-h-[50vh] min-h-0 flex-1 overflow-auto bg-surface-0 md:max-h-none">
|
|
{parts.map((p, n) => {
|
|
if (p.kind === "text")
|
|
return (
|
|
<pre
|
|
key={n}
|
|
className="whitespace-pre px-2 font-mono text-[11px] leading-5 text-fg-primary"
|
|
>
|
|
{p.lines.length ? p.lines.join("\n") : " "}
|
|
</pre>
|
|
);
|
|
const i = ++blockIndex;
|
|
const c = choices[i];
|
|
const lines = blockLines(p, c);
|
|
return (
|
|
<div
|
|
key={n}
|
|
ref={(el) => {
|
|
blockRefs.current[i] = el;
|
|
}}
|
|
data-testid="merge-block"
|
|
className={`my-1 border-y-2 ${
|
|
c ? "border-status-success/60" : "border-status-warning/70"
|
|
} ${at === i ? "ring-1 ring-accent" : ""}`}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-1.5 py-1 text-[11px]">
|
|
<span className="mr-auto text-fg-muted">
|
|
{t("merge.blockOf", { n: i + 1, total })}
|
|
</span>
|
|
{editing?.i === i ? (
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
onClick={() => {
|
|
choose(i, { kind: "custom", text: editing.text });
|
|
setEditing(null);
|
|
}}
|
|
>
|
|
{t("merge.editDone")}
|
|
</button>
|
|
) : c ? (
|
|
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
|
{t("merge.undoChoice")}
|
|
</button>
|
|
) : (
|
|
<>
|
|
{choiceBtn(
|
|
i,
|
|
{ kind: "ours" },
|
|
t("merge.acceptCurrent"),
|
|
"merge-accept-current"
|
|
)}
|
|
{choiceBtn(
|
|
i,
|
|
{ kind: "theirs" },
|
|
t("merge.acceptIncoming"),
|
|
"merge-accept-incoming"
|
|
)}
|
|
{choiceBtn(
|
|
i,
|
|
{ kind: "ours-theirs" },
|
|
t("merge.bothCurrentFirst"),
|
|
"merge-both-ct"
|
|
)}
|
|
{choiceBtn(
|
|
i,
|
|
{ kind: "theirs-ours" },
|
|
t("merge.bothIncomingFirst"),
|
|
"merge-both-ic"
|
|
)}
|
|
</>
|
|
)}
|
|
{editing?.i !== i && (
|
|
<button
|
|
type="button"
|
|
data-testid="merge-edit-block"
|
|
className={BTN}
|
|
onClick={() =>
|
|
setEditing({ i, text: (lines ?? [...p.ours, ...p.theirs]).join("\n") })
|
|
}
|
|
>
|
|
{t("merge.editBlock")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{editing?.i === i ? (
|
|
<textarea
|
|
data-testid="merge-block-text"
|
|
aria-label={t("merge.editBlock")}
|
|
value={editing.text}
|
|
onChange={(e) => setEditing({ i, text: e.target.value })}
|
|
spellCheck={false}
|
|
rows={Math.min(14, Math.max(3, editing.text.split("\n").length + 1))}
|
|
className="input w-full whitespace-pre rounded-none font-mono text-[11px] leading-5"
|
|
/>
|
|
) : lines ? (
|
|
<pre className="whitespace-pre bg-status-success/10 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
|
{lines.length ? lines.join("\n") : " "}
|
|
</pre>
|
|
) : (
|
|
<>
|
|
<pre className="whitespace-pre border-l-4 border-status-success bg-status-success/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
|
{p.ours.length ? p.ours.join("\n") : " "}
|
|
</pre>
|
|
<pre className="whitespace-pre border-l-4 border-accent bg-accent/15 px-2 font-mono text-[11px] leading-5 text-fg-primary">
|
|
{p.theirs.length ? p.theirs.join("\n") : " "}
|
|
</pre>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
|
|
return (
|
|
<GitModal
|
|
wide
|
|
testId="merge-editor"
|
|
title={t("merge.title", { path })}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<ErrorLine error={error} />
|
|
<button type="button" className={BTN} onClick={onClose}>
|
|
{t("cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="merge-save"
|
|
className={BTN_PRIMARY}
|
|
disabled={busy || !parts || !sides?.sha || (whole == null && left > 0)}
|
|
onClick={() => void save()}
|
|
>
|
|
{t("merge.save")}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
{!sides ? (
|
|
!error && <p className="text-fg-muted">{t("loading")}</p>
|
|
) : !sides.usable || !parts ? (
|
|
<p data-testid="merge-unusable" className="text-status-warning">
|
|
{sides.binary
|
|
? t("merge.binary")
|
|
: sides.tooBig
|
|
? t("merge.tooBig")
|
|
: sides.working == null
|
|
? t("merge.noWorking")
|
|
: t("merge.unbalanced")}
|
|
</p>
|
|
) : (
|
|
<div className="flex min-h-0 flex-col gap-2">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span
|
|
data-testid="merge-left"
|
|
className={`text-[11px] ${left ? "text-status-warning" : "text-status-success"}`}
|
|
>
|
|
{whole != null
|
|
? t("merge.wholeMode")
|
|
: left
|
|
? t("merge.left", { count: left, total })
|
|
: t("merge.allChosen")}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("merge.prev")}
|
|
disabled={!total || whole != null}
|
|
onClick={() => jump(-1)}
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={BTN}
|
|
aria-label={t("merge.next")}
|
|
disabled={!total || whole != null}
|
|
onClick={() => jump(1)}
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</button>
|
|
<label className="ml-auto flex items-center gap-1 text-[11px]">
|
|
<input
|
|
type="checkbox"
|
|
checked={showBase}
|
|
onChange={(e) => setShowBase(e.target.checked)}
|
|
/>
|
|
{t("merge.showBase")}
|
|
</label>
|
|
<button
|
|
type="button"
|
|
data-testid="merge-whole-toggle"
|
|
className={BTN}
|
|
onClick={() => setWhole(whole == null ? composeResult(parts, choices) : null)}
|
|
>
|
|
{whole == null ? t("merge.editWhole") : t("merge.backToBlocks")}
|
|
</button>
|
|
</div>
|
|
{showBase && (
|
|
<Pane title={t("merge.base")} text={sides.base} testId="merge-base" tone="base" />
|
|
)}
|
|
<div className="grid min-h-0 grid-cols-1 gap-2 md:h-[60vh] md:grid-cols-3">
|
|
<Pane
|
|
title={incomingLabel}
|
|
text={sides.theirs}
|
|
testId="merge-incoming"
|
|
tone="incoming"
|
|
/>
|
|
{result}
|
|
<Pane title={currentLabel} text={sides.ours} testId="merge-current" tone="current" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</GitModal>
|
|
);
|
|
}
|