Files
AgentHub/client/src/components/lanes/git/RebaseDialog.tsx
T
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
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.
2026-11-02 12:12:45 +07:00

208 lines
6.9 KiB
TypeScript

/**
* @file RebaseDialog.tsx
* @description "Rebase current branch onto…": pick a local or fetched remote
* branch, see the plan before anything runs (GET /git/rewrite-plan
* kind=rebase — how many commits are replayed, how many are already pushed and
* so will need a force-push, how far behind the branch is), then `rebase`.
* A conflict closes the dialog: the Changes panel's banner then offers
* Continue / Skip / Abort and the merge editor resolves the files. A clean
* rebase stays open on its result with **Undo**.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type GitTarget } from "../../../lib/api";
import { errText, gitRewrite, type RebasePlan, type RewriteResult } from "../../../lib/gitRewrite";
import { toast } from "../../../lib/toast";
import { BTN, BTN_PRIMARY, ErrorLine, GitModal, Warn } from "./GitModal";
import { RewriteDone } from "./RewriteDone";
/** Why a rewrite cannot start, from the plan's common facts — or null. */
export function blockedReason(
t: (k: string, o?: Record<string, unknown>) => string,
p: { subdir: boolean; operation: string | null; dirty: boolean },
needsClean = true
): string | null {
if (p.subdir) return t("blocked.subdir");
if (p.operation) return t("blocked.operation", { op: p.operation });
if (needsClean && p.dirty) return t("blocked.dirty");
return null;
}
export function RebaseDialog({
target,
current,
onClose,
onChanged,
}: {
target: GitTarget;
current: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation(["gitTools"]);
const [refs, setRefs] = useState<string[]>([]);
const [onto, setOnto] = useState("");
const [plan, setPlan] = useState<RebasePlan | null>(null);
const [force, setForce] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RewriteResult | null>(null);
const key = JSON.stringify(target);
useEffect(() => {
api.lanes
.gitBranches(target)
.then((b) => {
const list = [...(b.branches ?? []), ...(b.remotes ?? [])].filter((x) => x !== current);
setRefs(list);
})
.catch((e) => setError(errText(e)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
useEffect(() => {
setPlan(null);
setForce(false);
if (!onto) return;
let live = true;
gitRewrite
.planRebase(target, onto)
.then((p) => live && setPlan(p))
.catch((e) => live && setError(errText(e)));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, onto]);
const blocked = plan ? blockedReason(t, plan) : null;
const run = async () => {
if (!plan) return;
setBusy(true);
setError(null);
try {
const r = await gitRewrite.run(target, "rebase", {
onto,
head: plan.head,
...(plan.pushed ? { force: true } : {}),
});
onChanged();
if (r.conflicts?.length || r.stopped) {
toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 }));
onClose();
return;
}
toast.success(t("rebase.done", { onto }));
setResult(r);
} catch (e) {
setError(errText(e));
} finally {
setBusy(false);
}
};
return (
<GitModal
title={t("rebase.title", { branch: current ?? "HEAD" })}
onClose={onClose}
testId="rebase-dialog"
footer={
result ? (
<button type="button" className={BTN} onClick={onClose}>
{t("close")}
</button>
) : (
<>
<button type="button" className={BTN} onClick={onClose}>
{t("cancel")}
</button>
<button
type="button"
data-testid="rebase-run"
className={BTN_PRIMARY}
disabled={
busy || !plan || !!blocked || !plan.commits.length || (plan.pushed > 0 && !force)
}
onClick={() => void run()}
>
{t("rebase.run")}
</button>
</>
)
}
>
{result ? (
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
) : (
<>
<label className="mb-1 block text-[11px]" htmlFor="rebase-onto">
{t("rebase.onto")}
</label>
<select
id="rebase-onto"
data-testid="rebase-onto"
value={onto}
onChange={(e) => setOnto(e.target.value)}
className="input mb-2 w-full py-1 text-[11px]"
>
<option value="">{t("rebase.pick")}</option>
{refs.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<p className="mb-1 text-[11px] text-fg-muted">{t("rebase.explain")}</p>
{plan && (
<div data-testid="rebase-plan" className="space-y-1">
{blocked ? (
<Warn>{blocked}</Warn>
) : !plan.commits.length ? (
<p>{t("rebase.nothing", { onto })}</p>
) : (
<>
<p>
{t("rebase.summary", {
count: plan.commits.length,
onto,
behind: plan.behind,
})}
</p>
<ul className="max-h-40 overflow-y-auto rounded border border-border-light">
{plan.commits.map((c) => (
<li key={c.sha} className="flex gap-1.5 px-1.5 py-0.5 text-[11px]">
<span className="shrink-0 font-mono text-fg-muted">{c.short}</span>
<span className="min-w-0 flex-1 truncate text-fg-primary">{c.subject}</span>
{c.pushed && (
<span className="shrink-0 text-status-warning">{t("pushed")}</span>
)}
</li>
))}
</ul>
{plan.pushed > 0 && (
<Warn testId="rebase-pushed">
{t("rewritesPushed", { count: plan.commits.length, pushed: plan.pushed })}
<label className="mt-1 flex items-center gap-1.5">
<input
type="checkbox"
data-testid="rebase-force"
checked={force}
onChange={(e) => setForce(e.target.checked)}
/>
{t("forceAck")}
</label>
</Warn>
)}
</>
)}
</div>
)}
<ErrorLine error={error} />
</>
)}
</GitModal>
);
}