/** * @file InteractiveRebase.tsx * @description Interactive rebase as a list (JetBrains' "Interactively Rebase * from Here"): the branch's commits since a base — by default the ones no * remote has yet, oldest first — each with Pick / Reword / Squash / Fixup / * Drop, moved up and down, a new message for a reword or a squash. Before it * runs it says what it rewrites ("This rewrites 4 commits; 2 are already * pushed, so a force-push will be needed") and pushed commits need an explicit * tick; Start is a two-click (`useArmed`). The server builds the todo and runs * `git rebase -i` with no editor; a clean run stays open with **Undo**. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp } from "lucide-react"; import type { GitTarget } from "../../../lib/api"; import { errText, gitRewrite, type InteractivePlan, type PlanCommit, type RewriteResult, type TodoAction, } from "../../../lib/gitRewrite"; import { toast } from "../../../lib/toast"; import { useArmed } from "../../../hooks/useArmed"; import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal"; import { RewriteDone } from "./RewriteDone"; import { blockedReason } from "./RebaseDialog"; const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"]; interface Row { commit: PlanCommit; action: TodoAction; message: string; } /** Why the list cannot run as it stands, or null. */ export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null { const kept = rows.filter((r) => r.action !== "drop"); if (!kept.length) return "allDropped"; if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first"; if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword"; return null; } export function InteractiveRebase({ target, base, onClose, onChanged, }: { target: GitTarget; /** Rewrite the commits after this one; absent = the unpushed ones. */ base?: string; onClose: () => void; onChanged: () => void; }) { const { t } = useTranslation(["gitTools"]); const [plan, setPlan] = useState(null); const [rows, setRows] = useState([]); const [force, setForce] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const { isArmed, arm, disarm } = useArmed(); const key = JSON.stringify(target); useEffect(() => { let live = true; gitRewrite .planInteractive(target, base) .then((p) => { if (!live) return; setPlan(p); setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message }))); }) .catch((e) => live && setError(errText(e))); return () => { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, base]); const set = (i: number, patch: Partial) => setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r))); const move = (i: number, step: number) => setRows((prev) => { const j = i + step; if (j < 0 || j >= prev.length) return prev; const next = [...prev]; [next[i], next[j]] = [next[j]!, next[i]!]; return next; }); const blocked = plan ? blockedReason(t, plan) : null; const problem = todoProblem(rows); const unchanged = !!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha); const pushed = plan?.pushed ?? 0; const run = async () => { if (!plan) return; if (!isArmed("run")) return arm("run"); disarm(); setBusy(true); setError(null); try { const r = await gitRewrite.run(target, "rebase-interactive", { head: plan.head, ...(base ? { base: plan.base } : {}), confirm: true, ...(pushed ? { force: true } : {}), todo: rows.map((row) => ({ sha: row.commit.sha, action: row.action, ...((row.action === "reword" || row.action === "squash") && row.message.trim() && row.message.trim() !== row.commit.message ? { message: row.message } : row.action === "reword" ? { message: row.message } : {}), })), }); onChanged(); if (r.conflicts?.length || r.stopped) { toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 })); onClose(); return; } toast.success(t("interactive.done")); setResult(r); } catch (e) { setError(errText(e)); } finally { setBusy(false); } }; return ( {t("close")} ) : ( <> ) } > {result ? ( ) : !plan ? ( error ? ( ) : (

{t("loading")}

) ) : ( <>

{t("interactive.explain")}

{blocked && {blocked}} {plan.merges && {t("interactive.merges")}} {!rows.length ? (

{t("interactive.nothing")}

) : ( <> {pushed ? t("rewritesPushed", { count: rows.length, pushed }) : t("rewrites", { count: rows.length })} {pushed > 0 && ( )}
    {rows.map((r, i) => (
  1. {r.commit.short} {r.commit.subject} {r.commit.pushed && ( {t("pushed")} )}
    {(r.action === "reword" || r.action === "squash") && (