import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; import { buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitDialogAction, type GitQuickAction, requiresDefaultBranchConfirmation, resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; import { gitBranchesQueryOptions, gitInitMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; } function getMenuActionDisabledReason( item: GitActionMenuItem, gitStatus: GitStatusResult | null, isBusy: boolean, ): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.openPr !== null; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; if (item.id === "commit") { if (!hasChanges) { return "Worktree is clean. Make changes before committing."; } return "Commit is currently unavailable."; } if (item.id === "push") { if (!hasBranch) { return "Detached HEAD: checkout a branch before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; } if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } if (!isAhead) { return "No local commits to push."; } return "Push is currently unavailable."; } if (hasOpenPr) { return "Open PR is currently unavailable."; } if (!hasBranch) { return "Detached HEAD: checkout a branch before creating a PR."; } if (hasChanges) { return "Commit local changes before creating a PR."; } if (!isAhead) { return "No local commits to include in a PR."; } if (!gitStatus.hasUpstream) { return "Set an upstream branch before creating a PR."; } if (isBehind) { return "Branch is behind upstream. Pull/rebase before creating a PR."; } return "Create PR is currently unavailable."; } const DIALOG_TITLE_BY_ACTION = { commit: "Commit changes", push: "Push branch", create_pr: "Create pull request", }; const DIALOG_DESCRIPTION_BY_ACTION = { commit: "Review and confirm your commit. Leave the message blank to auto-generate one.", push: "Push this branch now.", create_pr: "Create a pull request using generated title/body content.", }; function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; if (icon === "push") return ; return ; } function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "commit_push") return ; return ; } if (quickAction.label === "Commit") return ; return ; } function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; hasWorkingTreeChanges: boolean; }): string[] { const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; const commitStages = !shouldIncludeCommitStages ? [] : input.hasCustomCommitMessage ? ["Committing..."] : ["Generating commit message...", "Committing..."]; if (input.action === "commit") { return commitStages; } if (input.action === "commit_push") { return [...commitStages, "Pushing..."]; } return [...commitStages, "Pushing...", "Creating PR..."]; } export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { const queryClient = useQueryClient(); const [activeDialogAction, setActiveDialogAction] = useState(null); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const { data: gitStatus = null, error: gitStatusError } = useQuery( gitStatusQueryOptions(api, gitCwd), ); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd)); // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }), ); const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient })); const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending; const isDefaultBranch = useMemo(() => { const currentBranch = gitStatus?.branch; if (!currentBranch) return false; const current = branchList?.branches.find((branch) => branch.name === currentBranch); return current?.isDefault ?? (currentBranch === "main" || currentBranch === "master"); }, [branchList?.branches, gitStatus?.branch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatus, isGitActionRunning), [gitStatus, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatus, isGitActionRunning, isDefaultBranch), [gitStatus, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const maybeConfirmPushToDefaultBranch = useCallback( async (action: GitStackedAction): Promise => { if (!api) return false; if (!requiresDefaultBranchConfirmation(action, isDefaultBranch) || !gitStatus?.branch) { return true; } return api.dialogs.confirm( `You're about to push to the default branch "${gitStatus.branch}". Continue?`, ); }, [api, gitStatus?.branch, isDefaultBranch], ); const openExistingPr = useCallback(async () => { if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable." }); return; } const prUrl = gitStatus?.openPr?.url ?? null; if (!prUrl) { toastManager.add({ type: "error", title: "No open PR found." }); return; } const promise = api.shell.openExternal(prUrl); toastManager.promise(promise, { loading: { title: "Opening PR..." }, success: { title: "PR opened" }, error: (err) => ({ title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", }), }); void promise.catch(() => undefined); }, [api, gitStatus?.openPr?.url]); const runGitActionWithToast = useCallback( async ({ action, commitMessage }: { action: GitStackedAction; commitMessage?: string }) => { const confirmed = await maybeConfirmPushToDefaultBranch(action); if (!confirmed) return; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!gitStatus?.hasWorkingTreeChanges, }); const progressToastId = toastManager.add({ type: "loading", title: progressStages[0] ?? "Running git action...", timeout: 0, }); let stageIndex = 0; const stageInterval = setInterval(() => { stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); toastManager.update(progressToastId, { title: progressStages[stageIndex] ?? "Running git action...", type: "loading", timeout: 0, }); }, 1100); const stopProgressUpdates = () => { clearInterval(stageInterval); }; const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), }); try { const result = await promise; stopProgressUpdates(); const resultToast = summarizeGitResult(result); const prUrl = result.pr.url ?? gitStatus?.openPr?.url; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl; const shouldOfferCreatePrCta = action === "commit_push" && !prUrl && result.push.status === "pushed"; const closeResultToast = () => { toastManager.close(progressToastId); }; toastManager.update(progressToastId, { type: "success", title: resultToast.title, description: resultToast.description, timeout: 0, ...(shouldOfferPushCta ? { actionProps: { children: "Push", onClick: () => { closeResultToast(); void runGitActionWithToast({ action: "commit_push" }); }, }, } : shouldOfferOpenPrCta ? { actionProps: { children: "Open PR", onClick: () => { closeResultToast(); void api?.shell.openExternal(prUrl); }, }, } : shouldOfferCreatePrCta ? { actionProps: { children: "Create PR", onClick: () => { closeResultToast(); setActiveDialogAction("create_pr"); }, }, } : {}), }); } catch (err) { stopProgressUpdates(); toastManager.update(progressToastId, { type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", }); } }, [ api, gitStatus?.hasWorkingTreeChanges, gitStatus?.openPr?.url, maybeConfirmPushToDefaultBranch, runImmediateGitActionMutation, setActiveDialogAction, ], ); const runQuickAction = useCallback(() => { if (quickAction.kind === "open_pr") { void openExistingPr(); return; } if (quickAction.kind === "run_pull") { const promise = pullMutation.mutateAsync(); toastManager.promise(promise, { loading: { title: "Pulling..." }, success: (result) => ({ title: result.status === "pulled" ? "Pulled" : "Already up to date", description: result.status === "pulled" ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` : `${result.branch} is already synchronized.`, }), error: (err) => ({ title: "Pull failed", description: err instanceof Error ? err.message : "An error occurred.", }), }); void promise.catch(() => undefined); return; } if (quickAction.kind === "show_hint") { toastManager.add({ type: "info", title: quickAction.label, description: quickAction.hint, }); return; } if (quickAction.action) { void runGitActionWithToast({ action: quickAction.action }); } }, [openExistingPr, pullMutation, quickAction, runGitActionWithToast]); const openDialogForMenuItem = useCallback( (item: GitActionMenuItem) => { if (item.disabled) return; if (item.kind === "open_pr") { void openExistingPr(); return; } if (item.dialogAction === "push") { void runGitActionWithToast({ action: "commit_push" }); return; } if (item.dialogAction) { setActiveDialogAction(item.dialogAction); } }, [openExistingPr, runGitActionWithToast], ); const runDialogAction = useCallback(() => { if (!activeDialogAction) return; const action: GitStackedAction = activeDialogAction === "commit" ? "commit" : activeDialogAction === "push" ? "commit_push" : "commit_push_pr"; const commitMessage = activeDialogAction === "commit" ? dialogCommitMessage.trim() : ""; setActiveDialogAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), }); }, [activeDialogAction, dialogCommitMessage, runGitActionWithToast]); const openChangedFileInEditor = useCallback( (filePath: string) => { if (!api || !gitCwd) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", }); return; } const target = resolvePathLinkTarget(filePath, gitCwd); void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { toastManager.add({ type: "error", title: "Unable to open file", description: error instanceof Error ? error.message : "An error occurred.", }); }); }, [api, gitCwd], ); if (!gitCwd) return null; return ( <> {!isRepo ? ( ) : ( {quickActionDisabledReason ? ( } > {quickAction.label} {quickActionDisabledReason} ) : ( )} { if (open) void invalidateGitQueries(queryClient); }} > } disabled={isGitActionRunning} > {gitActionMenuItems.map((item) => { const disabledReason = getMenuActionDisabledReason( item, gitStatus, isGitActionRunning, ); if (item.disabled && disabledReason) { return ( } > {item.label} {disabledReason} ); } return ( { openDialogForMenuItem(item); }} > {item.label} ); })} {gitStatus?.branch === null && (

Detached HEAD: create and checkout a branch to enable push and PR actions.

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

Behind upstream. Pull/rebase first.

)} {gitStatusError && (

{gitStatusError.message}

)}
)} { if (!open) { setActiveDialogAction(null); setDialogCommitMessage(""); } }} > {DIALOG_TITLE_BY_ACTION[activeDialogAction ?? "commit"]} {DIALOG_DESCRIPTION_BY_ACTION[activeDialogAction ?? "commit"]}
Branch {gitStatus?.branch ?? "(detached HEAD)"} {activeDialogAction !== "commit" && ( <> Upstream {!gitStatus || !gitStatus.hasUpstream ? "No upstream configured" : gitStatus.aheadCount === 0 && gitStatus.behindCount === 0 ? "Up to date" : gitStatus.aheadCount > 0 && gitStatus.behindCount > 0 ? `Diverged (+${gitStatus.aheadCount} / -${gitStatus.behindCount})` : gitStatus.aheadCount > 0 ? `Ahead by ${gitStatus.aheadCount}` : `Behind by ${gitStatus.behindCount}`} Working tree {!gitStatus || !gitStatus.hasWorkingTreeChanges ? "Clean" : `${gitStatus.workingTree.files.length} file(s)`} Diff {!gitStatus || !gitStatus.hasWorkingTreeChanges ? ( "none" ) : ( <> +{gitStatus.workingTree.insertions} / -{gitStatus.workingTree.deletions} )} )}

Files

{!gitStatus || gitStatus.workingTree.files.length === 0 ? (

none

) : (
{gitStatus.workingTree.files.map((file) => ( ))}
+{gitStatus.workingTree.insertions} / -{gitStatus.workingTree.deletions}
)}
{activeDialogAction === "commit" && (

Commit message (optional)