153 lines
5.1 KiB
TypeScript
153 lines
5.1 KiB
TypeScript
/**
|
|
* @file The confirmation a cherry-pick or revert opens with, showing exactly
|
|
* what will happen before anything does (the reference tools' preview): the
|
|
* commits in the order git will apply them (numbered — read from the server,
|
|
* which orders them the same way the action will), the branch they land on,
|
|
* whether the working tree is clean, and what happens on a conflict. Confirm
|
|
* is disabled, with the reason, when the server would refuse (a dirty tree, a
|
|
* subfolder workspace, another operation open, a merge commit).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
|
import { api, type GitTarget } from "../../../lib/api";
|
|
import type { PickPlan, PickResult } from "../../../lib/gitHistory";
|
|
import { GitModal, ModalButton } from "./GitModal";
|
|
|
|
export function CherryPickDialog({
|
|
target,
|
|
op,
|
|
shas,
|
|
onClose,
|
|
onDone,
|
|
}: {
|
|
target: GitTarget;
|
|
op: "cherry-pick" | "revert";
|
|
shas: string[];
|
|
onClose: () => void;
|
|
onDone: (r: PickResult) => void;
|
|
}) {
|
|
const { t } = useTranslation(["gitHistory"]);
|
|
const [plan, setPlan] = useState<PickPlan | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [running, setRunning] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let live = true;
|
|
void api.lanes.gitHistory
|
|
.pickPlan(target, op, shas)
|
|
.then((r) => live && (r.available ? setPlan(r) : setError(t("planUnavailable"))))
|
|
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- one plan per dialog
|
|
}, []);
|
|
|
|
const branch = plan?.branch ?? "HEAD";
|
|
const blocker = !plan
|
|
? null
|
|
: plan.busy
|
|
? t("blockBusy")
|
|
: !plan.repoRoot
|
|
? t("blockSubdir")
|
|
: !plan.clean
|
|
? t("blockDirty")
|
|
: null;
|
|
const n = plan?.commits.length ?? shas.length;
|
|
const title =
|
|
op === "revert" ? t("revertTitle", { count: n }) : t("pickTitle", { count: n, branch });
|
|
|
|
const run = async () => {
|
|
setRunning(true);
|
|
setError(null);
|
|
try {
|
|
onDone(
|
|
await api.lanes.gitHistory.pick(
|
|
target,
|
|
op,
|
|
plan!.commits.map((c) => c.sha)
|
|
)
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setRunning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<GitModal
|
|
title={title}
|
|
onClose={onClose}
|
|
testId="pick-dialog"
|
|
footer={
|
|
<>
|
|
<ModalButton onClick={onClose}>{t("cancel")}</ModalButton>
|
|
<ModalButton
|
|
primary
|
|
testId="pick-confirm"
|
|
disabled={!plan || !!blocker || running}
|
|
title={blocker ?? undefined}
|
|
onClick={() => void run()}
|
|
>
|
|
{running
|
|
? t("running")
|
|
: op === "revert"
|
|
? t("revertN", { count: n })
|
|
: t("pickN", { count: n, branch })}
|
|
</ModalButton>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-2 text-[12px] text-fg-secondary">
|
|
{!plan && !error && <p className="text-fg-muted">{t("planning")}</p>}
|
|
{plan && (
|
|
<>
|
|
<p className="[overflow-wrap:anywhere]">
|
|
{op === "revert"
|
|
? t("revertIntro", { count: n, branch })
|
|
: t("pickIntro", { count: n, branch })}
|
|
</p>
|
|
<ol
|
|
data-testid="pick-plan"
|
|
className="space-y-0.5 rounded border border-border-light bg-surface-0 p-1.5"
|
|
>
|
|
{plan.commits.map((c, i) => (
|
|
<li key={c.sha} className="flex min-w-0 items-baseline gap-1.5">
|
|
<span className="w-5 shrink-0 text-right text-fg-muted">{i + 1}.</span>
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">{c.short}</span>
|
|
<span className="min-w-0 flex-1 truncate text-fg-primary" title={c.subject}>
|
|
{c.subject}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
<p className="flex items-center gap-1.5">
|
|
{plan.clean && plan.repoRoot && !plan.busy ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-status-success" aria-hidden />
|
|
) : (
|
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-warning" aria-hidden />
|
|
)}
|
|
<span data-testid="pick-tree-state" className="[overflow-wrap:anywhere]">
|
|
{blocker ?? t("treeClean")}
|
|
</span>
|
|
</p>
|
|
<p className="text-fg-muted">{t(op === "revert" ? "revertResult" : "pickResult")}</p>
|
|
<p className="text-fg-muted">{t("onConflict", { op: t(`op.${op}`) })}</p>
|
|
</>
|
|
)}
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
className="whitespace-pre-wrap text-status-danger [overflow-wrap:anywhere]"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</GitModal>
|
|
);
|
|
}
|