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.
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
/**
|
|
* @file ResetDialog.tsx
|
|
* @description "Reset current branch to here": the plan first (GET
|
|
* /git/rewrite-plan kind=reset — how many commits leave or join the branch and
|
|
* how many of those leaving are already pushed), then one of four modes, each
|
|
* explained: soft and mixed never touch the working tree, keep refuses to
|
|
* overwrite a local change, and hard runs only on a clean tree with the branch
|
|
* name typed back. Pushed commits need an explicit tick (a force-push will be
|
|
* needed). A done reset 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 type { GitTarget } from "../../../lib/api";
|
|
import {
|
|
errText,
|
|
gitRewrite,
|
|
type ResetMode,
|
|
type ResetPlan,
|
|
type RewriteResult,
|
|
} from "../../../lib/gitRewrite";
|
|
import { toast } from "../../../lib/toast";
|
|
import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal";
|
|
import { RewriteDone } from "./RewriteDone";
|
|
import { blockedReason } from "./RebaseDialog";
|
|
|
|
const MODES: ResetMode[] = ["soft", "mixed", "keep", "hard"];
|
|
|
|
export function ResetDialog({
|
|
target,
|
|
commit,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
target: GitTarget;
|
|
commit: { sha: string; short?: string; subject?: string };
|
|
onClose: () => void;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitTools"]);
|
|
const [plan, setPlan] = useState<ResetPlan | null>(null);
|
|
const [mode, setMode] = useState<ResetMode>("mixed");
|
|
const [force, setForce] = useState(false);
|
|
const [typed, setTyped] = useState("");
|
|
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(() => {
|
|
let live = true;
|
|
gitRewrite
|
|
.planReset(target, commit.sha)
|
|
.then((p) => live && setPlan(p))
|
|
.catch((e) => live && setError(errText(e)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, commit.sha]);
|
|
|
|
const branch = plan?.branch ?? "HEAD";
|
|
const blocked = plan ? blockedReason(t, plan, false) : null;
|
|
const hardBlocked = mode === "hard" && !!plan?.dirty;
|
|
const ready =
|
|
!!plan &&
|
|
!blocked &&
|
|
plan.related &&
|
|
plan.head !== plan.target &&
|
|
!(plan.pushed > 0 && !force) &&
|
|
!hardBlocked &&
|
|
(mode !== "hard" || typed === branch);
|
|
|
|
const run = async () => {
|
|
if (!plan) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const r = await gitRewrite.run(target, "reset", {
|
|
target: plan.target,
|
|
mode,
|
|
head: plan.head,
|
|
confirm: true,
|
|
...(plan.pushed ? { force: true } : {}),
|
|
...(mode === "hard" ? { confirmBranch: typed } : {}),
|
|
});
|
|
onChanged();
|
|
toast.success(t("reset.done", { short: commit.short ?? plan.target.slice(0, 7) }));
|
|
setResult(r);
|
|
} catch (e) {
|
|
setError(errText(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GitModal
|
|
title={t("reset.title", { branch, short: commit.short ?? commit.sha.slice(0, 7) })}
|
|
onClose={onClose}
|
|
testId="reset-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="reset-run"
|
|
className={BTN_DANGER}
|
|
disabled={busy || !ready}
|
|
onClick={() => void run()}
|
|
>
|
|
{t("reset.run", { mode })}
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
>
|
|
{result ? (
|
|
<RewriteDone target={target} result={result} onChanged={onChanged} onUndone={onClose} />
|
|
) : !plan ? (
|
|
error ? (
|
|
<ErrorLine error={error} />
|
|
) : (
|
|
<p className="text-fg-muted">{t("loading")}</p>
|
|
)
|
|
) : (
|
|
<>
|
|
{commit.subject && (
|
|
<p className="mb-1 truncate text-[11px] text-fg-primary" title={commit.subject}>
|
|
<span className="mr-1 font-mono text-fg-muted">{commit.short}</span>
|
|
{commit.subject}
|
|
</p>
|
|
)}
|
|
{blocked && <Warn>{blocked}</Warn>}
|
|
{!plan.related ? (
|
|
<Warn>{t("reset.unrelated")}</Warn>
|
|
) : plan.head === plan.target ? (
|
|
<p>{t("reset.already")}</p>
|
|
) : (
|
|
<p data-testid="reset-summary" className="mb-1.5">
|
|
{t("reset.summary", { dropped: plan.dropped, added: plan.added, branch })}
|
|
</p>
|
|
)}
|
|
<fieldset className="space-y-1">
|
|
<legend className="mb-1 text-[11px] font-medium text-fg-primary">
|
|
{t("reset.mode")}
|
|
</legend>
|
|
{MODES.map((m) => (
|
|
<label key={m} className="flex items-start gap-1.5 text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="reset-mode"
|
|
data-testid={`reset-mode-${m}`}
|
|
value={m}
|
|
checked={mode === m}
|
|
onChange={() => setMode(m)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="font-medium text-fg-primary">{t(`reset.modes.${m}`)}</span>
|
|
<span className="block text-fg-muted">{t(`reset.modesHelp.${m}`)}</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
{mode === "hard" &&
|
|
(plan.dirty ? (
|
|
<Warn testId="reset-hard-dirty">{t("reset.hardDirty")}</Warn>
|
|
) : (
|
|
<div className="mt-1.5">
|
|
<label className="mb-0.5 block text-[11px]" htmlFor="reset-confirm-branch">
|
|
{t("reset.typeBranch", { branch })}
|
|
</label>
|
|
<input
|
|
id="reset-confirm-branch"
|
|
data-testid="reset-confirm-branch"
|
|
value={typed}
|
|
onChange={(e) => setTyped(e.target.value)}
|
|
autoComplete="off"
|
|
className="input w-full font-mono text-[11px]"
|
|
/>
|
|
</div>
|
|
))}
|
|
{plan.pushed > 0 && (
|
|
<Warn testId="reset-pushed">
|
|
{t("reset.pushed", { count: plan.pushed })}
|
|
<label className="mt-1 flex items-center gap-1.5">
|
|
<input
|
|
type="checkbox"
|
|
data-testid="reset-force"
|
|
checked={force}
|
|
onChange={(e) => setForce(e.target.checked)}
|
|
/>
|
|
{t("forceAck")}
|
|
</label>
|
|
</Warn>
|
|
)}
|
|
<ErrorLine error={error} />
|
|
</>
|
|
)}
|
|
</GitModal>
|
|
);
|
|
}
|