import { type GitRunStackedActionResult, type GitStackedAction, type GitStatusResult, type NativeApi, } from "@t3tools/contracts"; import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckIcon, CloudUploadIcon, GitCommitIcon, Loader2Icon, XIcon, } from "lucide-react"; import { GitHubIcon } from "./Icons"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; } type GitActionIconName = "commit" | "push" | "pr"; interface GitActionMenuItem { id: "commit" | "push" | "pr"; label: string; disabled: boolean; icon: GitActionIconName; kind: "open_modal" | "run_action" | "open_pr"; action?: GitStackedAction; } interface GitModalActionOption { action: GitStackedAction; label: string; disabled: boolean; icon: GitActionIconName; detail?: string; } type GitProgressStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; interface GitProgressStep { id: "generate" | "commit" | "push" | "pr"; label: string; status: GitProgressStepStatus; detail?: string; } interface GitProgressToastAction { kind: "open_pr" | "create_pr"; label: "Open PR" | "Create PR"; } function GitActionIcon(props: { icon: GitActionIconName; disabled: boolean }) { const toneClass = props.disabled ? "text-muted-foreground/45" : "text-foreground/85"; if (props.icon === "commit") { return ; } if (props.icon === "push") { return ; } return ; } function runningStepMessage(stepId: GitProgressStep["id"]): string { if (stepId === "generate") return "Generating commit message..."; if (stepId === "commit") return "Committing changes..."; if (stepId === "push") return "Pushing branch..."; return "Preparing pull request..."; } function gitActionModalTitle(): string { return "Commit your changes"; } function initialGitProgressSteps(action: GitStackedAction, includeGenerateStep: boolean): GitProgressStep[] { const steps: GitProgressStep[] = []; if (includeGenerateStep) { steps.push({ id: "generate", label: "Generate commit message", status: "pending", }); } steps.push({ id: "commit", label: "Commit changes", status: "pending", }); if (action !== "commit") { steps.push({ id: "push", label: "Push branch", status: "pending", }); } if (action === "commit_push_pr") { steps.push({ id: "pr", label: "Create or open PR", status: "pending", }); } return steps; } function updateProgressStep( steps: GitProgressStep[], id: GitProgressStep["id"], status: GitProgressStepStatus, detail?: string, ): GitProgressStep[] { return steps.map((step) => { if (step.id !== id) return step; return { ...step, status, ...(detail ? { detail } : {}), }; }); } function runActionLabel(action: GitStackedAction): string { if (action === "commit") return "Commit changes"; if (action === "commit_push") return "Commit and push"; return "Commit and create PR"; } function canCreatePrFromStatus(gitStatus: GitStatusResult | null): boolean { if (!gitStatus) return false; const hasBranch = gitStatus.branch !== null; const hasOpenPr = gitStatus.openPr !== null; return ( hasBranch && !hasOpenPr && !gitStatus.hasWorkingTreeChanges && gitStatus.hasUpstream && gitStatus.behindCount === 0 ); } export function getGitProgressToastAction(params: { isRunning: boolean; hasError: boolean; result: GitRunStackedActionResult | null; gitStatus: GitStatusResult | null; openPrUrl: string | null; }): GitProgressToastAction | null { if (params.isRunning || params.hasError || !params.result) { return null; } if (params.openPrUrl) { return { kind: "open_pr", label: "Open PR", }; } if (params.result.action === "commit_push" && canCreatePrFromStatus(params.gitStatus)) { return { kind: "create_pr", label: "Create PR", }; } return null; } function buildGitActionMenuItems( gitStatus: GitStatusResult | null, isDisabled: boolean, ): GitActionMenuItem[] { if (!gitStatus) return []; const hasBranch = gitStatus.branch !== null; const hasOpenPr = gitStatus.openPr !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasAhead = gitStatus.aheadCount > 0; const canCommit = !isDisabled && hasChanges; const canPush = !isDisabled && hasBranch && !hasChanges && hasAhead; const canOpenPr = !isDisabled && hasOpenPr; const canCreatePr = !isDisabled && canCreatePrFromStatus(gitStatus); return [ { id: "commit", label: "Commit", disabled: !canCommit, icon: "commit", kind: "open_modal", }, { id: "push", label: "Push", disabled: !canPush, icon: "push", kind: "run_action", action: "commit_push", }, hasOpenPr ? { id: "pr", label: "View PR", disabled: !canOpenPr, icon: "pr", kind: "open_pr", } : { id: "pr", label: "Create PR", disabled: !canCreatePr, icon: "pr", kind: "run_action", action: "commit_push_pr", }, ]; } function buildGitModalActionOptions( gitStatus: GitStatusResult | null, isDisabled: boolean, ): GitModalActionOption[] { const hasBranch = gitStatus?.branch !== null; const hasOpenPr = gitStatus?.openPr !== null; return [ { action: "commit", label: "Commit", disabled: isDisabled, icon: "commit", }, { action: "commit_push", label: "Commit and push", disabled: isDisabled || !hasBranch, icon: "push", ...(!hasBranch ? { detail: "Requires an attached branch." } : {}), }, { action: "commit_push_pr", label: "Commit and create PR", disabled: isDisabled || !hasBranch || hasOpenPr, icon: "pr", ...(!hasBranch ? { detail: "Requires an attached branch." } : hasOpenPr ? { detail: "A PR is already open for this branch." } : {}), }, ]; } function getGitStatusQueryKey(gitCwd: string | null): readonly ["git", "status", string | null] { return ["git", "status", gitCwd]; } function gitStatusQueryOptions(api: NativeApi | undefined, gitCwd: string | null) { return queryOptions({ queryKey: getGitStatusQueryKey(gitCwd), queryFn: async () => { if (!api || !gitCwd) { throw new Error("Git status is unavailable."); } return api.git.status({ cwd: gitCwd }); }, enabled: !!api && !!gitCwd, }); } export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { const queryClient = useQueryClient(); const [isGitMenuOpen, setIsGitMenuOpen] = useState(false); const [isGitModalActionRunning, setIsGitModalActionRunning] = useState(false); const [gitActionError, setGitActionError] = useState(null); const [isGitModalOpen, setIsGitModalOpen] = useState(false); const [gitModalSelectedAction, setGitModalSelectedAction] = useState("commit"); const [gitModalCommitMessage, setGitModalCommitMessage] = useState(""); const [gitModalProgress, setGitModalProgress] = useState([]); const [gitModalError, setGitModalError] = useState(null); const [gitModalResult, setGitModalResult] = useState(null); const [isGitProgressToastVisible, setIsGitProgressToastVisible] = useState(false); const gitMenuRef = useRef(null); const { data: gitStatus = null, error: gitStatusError } = useQuery( gitStatusQueryOptions(api, gitCwd), ); const isGitActionRunning = isGitModalActionRunning; const gitBaseDisabled = !api || !gitCwd || !gitStatus || isGitActionRunning; const gitActionMenuItems = useMemo( () => buildGitActionMenuItems(gitStatus, gitBaseDisabled), [gitBaseDisabled, gitStatus], ); const gitModalActionOptions = useMemo( () => buildGitModalActionOptions(gitStatus, !gitStatus || isGitActionRunning), [gitStatus, isGitActionRunning], ); const selectedGitModalActionOption = useMemo( () => gitModalActionOptions.find((option) => option.action === gitModalSelectedAction) ?? null, [gitModalActionOptions, gitModalSelectedAction], ); const visibleGitActionError = gitActionError ?? gitStatusError?.message; const gitProgressToastOpenPrUrl = gitModalResult?.pr.url ?? gitStatus?.openPr?.url ?? null; const showGitProgressToast = isGitProgressToastVisible && (isGitModalActionRunning || gitModalProgress.length > 0 || gitModalError !== null || gitModalResult !== null); const activeGitProgressStep = useMemo( () => gitModalProgress.find((step) => step.status === "running") ?? null, [gitModalProgress], ); const gitProgressToastStatus = isGitModalActionRunning ? "running" : gitModalError ? "failed" : "done"; const gitProgressToastMessage = isGitModalActionRunning ? runningStepMessage(activeGitProgressStep?.id ?? "commit") : gitModalError ? "Git action failed." : "Done."; const gitProgressToastAction = useMemo( () => getGitProgressToastAction({ isRunning: isGitModalActionRunning, hasError: gitModalError !== null, result: gitModalResult, gitStatus, openPrUrl: gitProgressToastOpenPrUrl, }), [ gitModalError, gitModalResult, gitProgressToastOpenPrUrl, gitStatus, isGitModalActionRunning, ], ); const refreshGitStatus = useCallback(async () => { if (!api || !gitCwd) return; await queryClient.invalidateQueries({ queryKey: ["git"] }); setGitActionError(null); }, [api, gitCwd, queryClient]); const openGitActionModal = useCallback(() => { setIsGitMenuOpen(false); setIsGitModalOpen(true); setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); setIsGitProgressToastVisible(false); setGitModalProgress([]); setGitModalError(null); setGitModalResult(null); setGitActionError(null); }, []); const closeGitActionModal = useCallback(() => { if (isGitActionRunning) return; setIsGitModalOpen(false); setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); }, [isGitActionRunning]); const openExistingPr = useCallback(() => { setIsGitMenuOpen(false); setGitActionError(null); if (!api) { setGitActionError("Link opening is unavailable."); return; } const prUrl = gitStatus?.openPr?.url ?? null; if (!prUrl) { setGitActionError("No open PR found for the current branch."); return; } void api.shell.openExternal(prUrl).catch((error) => { setGitActionError(error instanceof Error ? error.message : "Unable to open PR link."); }); }, [api, gitStatus?.openPr?.url]); const openPrFromToast = useCallback(() => { if (!api) { setGitModalError("Link opening is unavailable."); return; } const prUrl = gitProgressToastOpenPrUrl; if (!prUrl) return; void api.shell.openExternal(prUrl).catch((error) => { setGitModalError(error instanceof Error ? error.message : "Unable to open PR link."); }); }, [api, gitProgressToastOpenPrUrl]); const dismissGitProgressToast = useCallback(() => { if (isGitModalActionRunning) return; setIsGitProgressToastVisible(false); setGitModalProgress([]); setGitModalError(null); setGitModalResult(null); }, [isGitModalActionRunning]); const executeGitAction = useCallback( async (params: { action: GitStackedAction; commitMessage: string; includeGeneratedCommitMessage: boolean; closeModal: boolean; }) => { if (!api || !gitCwd) return; const actionCwd = gitCwd; const { action, closeModal, commitMessage, includeGeneratedCommitMessage } = params; setIsGitMenuOpen(false); if (closeModal) { setIsGitModalOpen(false); } setIsGitProgressToastVisible(true); setIsGitModalActionRunning(true); setGitModalError(null); setGitActionError(null); setGitModalResult(null); setGitModalProgress(initialGitProgressSteps(action, includeGeneratedCommitMessage)); let commit: GitRunStackedActionResult["commit"] = { status: "skipped_no_changes", }; let push: GitRunStackedActionResult["push"] = { status: "skipped_not_requested", }; let pr: GitRunStackedActionResult["pr"] = { status: "skipped_not_requested", }; const updateStep = ( id: GitProgressStep["id"], status: GitProgressStepStatus, detail?: string, ) => { setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail)); }; try { if (includeGeneratedCommitMessage) { updateStep("generate", "running"); } else { updateStep("commit", "running"); } const commitRun = await api.git.runStackedAction({ cwd: actionCwd, action: "commit", ...(commitMessage.length > 0 ? { commitMessage } : {}), }); commit = commitRun.commit; if (includeGeneratedCommitMessage) { if (commitRun.commit.status === "created") { if (commitRun.commit.subject) { setGitModalCommitMessage(commitRun.commit.subject); } updateStep("generate", "completed"); } else { updateStep("generate", "skipped", "No local changes to commit."); } } if (commitRun.commit.status === "created") { updateStep("commit", "completed", commitRun.commit.subject ?? "Committed local changes."); } else { updateStep("commit", "skipped", "No local changes to commit."); } if (action !== "commit") { updateStep("push", "running"); const pushRun = await api.git.runStackedAction({ cwd: actionCwd, action: "commit_push", }); push = pushRun.push; if (pushRun.push.status === "pushed") { updateStep( "push", "completed", pushRun.push.upstreamBranch ? `Pushed to ${pushRun.push.upstreamBranch}.` : "Pushed latest commits.", ); } else { updateStep("push", "skipped", "Branch already up to date."); } } if (action === "commit_push_pr") { updateStep("pr", "running"); const prRun = await api.git.runStackedAction({ cwd: actionCwd, action: "commit_push_pr", }); pr = prRun.pr; if (prRun.pr.status === "opened_existing") { updateStep( "pr", "completed", prRun.pr.number ? `Opened existing PR #${prRun.pr.number}.` : "Opened existing PR.", ); } else if (prRun.pr.status === "created") { updateStep( "pr", "completed", prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.", ); } else { updateStep("pr", "skipped", "PR step was not requested."); } } setGitModalResult({ action, commit, push, pr }); } catch (error) { const message = error instanceof Error ? error.message : "Git action failed."; setGitModalError(message); setGitActionError(message); setGitModalProgress((steps) => { const active = steps.find((step) => step.status === "running"); if (!active) return steps; return updateProgressStep(steps, active.id, "failed", message); }); } finally { setIsGitModalActionRunning(false); await refreshGitStatus().catch(() => undefined); } }, [api, gitCwd, refreshGitStatus], ); const runGitActionImmediately = useCallback( (action: GitStackedAction) => { void executeGitAction({ action, commitMessage: "", includeGeneratedCommitMessage: false, closeModal: false, }); }, [executeGitAction], ); const runGitAction = useCallback(() => { if (!selectedGitModalActionOption || selectedGitModalActionOption.disabled) return; const commitMessage = gitModalCommitMessage.trim(); void executeGitAction({ action: selectedGitModalActionOption.action, commitMessage, includeGeneratedCommitMessage: commitMessage.length === 0, closeModal: true, }); }, [executeGitAction, gitModalCommitMessage, selectedGitModalActionOption]); useEffect(() => { setGitActionError(null); setGitModalError(null); setIsGitModalOpen(false); setIsGitProgressToastVisible(false); setGitModalSelectedAction("commit"); setGitModalCommitMessage(""); setGitModalProgress([]); setGitModalResult(null); }, [gitCwd]); useEffect(() => { if (!isGitModalOpen) return; if (selectedGitModalActionOption && !selectedGitModalActionOption.disabled) return; const fallback = gitModalActionOptions.find((option) => !option.disabled); if (fallback) { setGitModalSelectedAction(fallback.action); } }, [gitModalActionOptions, isGitModalOpen, selectedGitModalActionOption]); useEffect(() => { if (!isGitMenuOpen) return; const handleClickOutside = (event: MouseEvent) => { if (!gitMenuRef.current) return; if (event.target instanceof Node && !gitMenuRef.current.contains(event.target)) { setIsGitMenuOpen(false); } }; window.addEventListener("mousedown", handleClickOutside); return () => { window.removeEventListener("mousedown", handleClickOutside); }; }, [isGitMenuOpen]); useEffect(() => { if (!isGitModalOpen) return; const handleEscape = (event: globalThis.KeyboardEvent) => { if (event.key !== "Escape") return; if (isGitActionRunning) return; closeGitActionModal(); }; window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("keydown", handleEscape); }; }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]); if (!gitCwd) return null; return ( <>
{isGitMenuOpen && (

Git actions

{gitActionMenuItems.map((item) => { return ( ); })} {gitStatus?.branch === null && (

Detached HEAD: push and PR actions are unavailable.

)} {gitStatus && gitStatus.branch !== null && !gitStatus.hasWorkingTreeChanges && gitStatus.aheadCount === 0 && gitStatus.behindCount > 0 && (

Branch is behind upstream. Pull/rebase before opening a PR.

)} {visibleGitActionError && (

{visibleGitActionError}

)}
)}
{isGitModalOpen && (
{ closeGitActionModal(); }} >
{ event.stopPropagation(); }} >

Git actions

{gitActionModalTitle()}

Branch {gitStatus?.branch ?? "(detached HEAD)"}
Changes {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"}