import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts";import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { useCallback, useEffect, useMemo, useState } from "react";import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";import { GitHubIcon } from "./Icons";import { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, 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, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, gitStatusQueryOptions, invalidateGitQueries,} from "~/lib/gitReactQuery";import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";import { readNativeApi } from "~/nativeApi"; interface GitActionsControlProps { gitCwd: string | null; activeThreadId: ThreadId | null;} interface PendingDefaultBranchAction { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; forcePushOnlyProgress: boolean; onConfirmed?: () => void;} type GitActionToastId = ReturnType<typeof toastManager.add>; 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.pr?.state === "open"; 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 (isBehind) { return "Branch is behind upstream. Pull/rebase before creating a PR."; } return "Create PR is currently unavailable.";} const COMMIT_DIALOG_TITLE = "Commit changes";const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <GitHubIcon />;} function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "commit_push") return <CloudUploadIcon className={iconClassName} />; return <GitHubIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />;} export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState<PendingDefaultBranchAction | null>(null); const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; const isGitStatusOutOfSync = !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; useEffect(() => { if (!isGitStatusOutOfSync) return; void invalidateGitQueries(queryClient); }, [isGitStatusOutOfSync, queryClient]); const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; const current = branchList?.branches.find((branch) => branch.name === branchName); return current?.isDefault ?? (branchName === "main" || branchName === "master"); }, [branchList?.branches, gitStatusForActions?.branch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning), [gitStatusForActions, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch), [gitStatusForActions, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, }) : null; const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", data: threadToastData, }); return; } const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", title: "No open PR found.", data: threadToastData, }); return; } void api.shell.openExternal(prUrl).catch((err) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); }); }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); const runGitActionWithToast = useCallback( async ({ action, commitMessage, forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, isDefaultBranchOverride, progressToastId, }: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; }) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; const actionIsDefaultBranch = isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); const includesCommit = !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { if (action !== "commit_push" && action !== "commit_push_pr") { return; } setPendingDefaultBranchAction({ action, branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), }); return; } onConfirmed?.(); const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, featureBranch, ...(pushTarget ? { pushTarget } : {}), }); const resolvedProgressToastId = progressToastId ?? toastManager.add({ type: "loading", title: progressStages[0] ?? "Running git action...", timeout: 0, data: threadToastData, }); if (progressToastId) { toastManager.update(progressToastId, { type: "loading", title: progressStages[0] ?? "Running git action...", timeout: 0, data: threadToastData, }); } let stageIndex = 0; const stageInterval = setInterval(() => { stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); toastManager.update(resolvedProgressToastId, { title: progressStages[stageIndex] ?? "Running git action...", type: "loading", timeout: 0, data: threadToastData, }); }, 1100); const stopProgressUpdates = () => { clearInterval(stageInterval); }; const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), }); try { const result = await promise; stopProgressUpdates(); const resultToast = summarizeGitResult(result); const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; const prUrl = result.pr.url ?? existingOpenPrUrl; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl && (!actionIsDefaultBranch || result.pr.status === "created" || result.pr.status === "opened_existing"); const shouldOfferCreatePrCta = action === "commit_push" && !prUrl && result.push.status === "pushed" && !actionIsDefaultBranch; const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; toastManager.update(resolvedProgressToastId, { type: "success", title: resultToast.title, description: resultToast.description, timeout: 0, data: { ...threadToastData, dismissAfterVisibleMs: 10_000, }, ...(shouldOfferPushCta ? { actionProps: { children: "Push", onClick: () => { void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true, onConfirmed: closeResultToast, statusOverride: actionStatus, isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, } : shouldOfferOpenPrCta ? { actionProps: { children: "Open PR", onClick: () => { const api = readNativeApi(); if (!api) return; closeResultToast(); void api.shell.openExternal(prUrl); }, }, } : shouldOfferCreatePrCta ? { actionProps: { children: "Create PR", onClick: () => { closeResultToast(); void runGitActionWithToast({ action: "commit_push_pr", forcePushOnlyProgress: true, statusOverride: actionStatus, isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, } : {}), }); } catch (err) { stopProgressUpdates(); toastManager.update(resolvedProgressToastId, { type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); } }, [ isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, threadToastData, gitStatusForActions, ], ); const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), skipDefaultBranchPrompt: true, }); }, [pendingDefaultBranchAction, runGitActionWithToast]); const checkoutNewBranchAndRunAction = useCallback( (actionParams: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; }) => { void runGitActionWithToast({ ...actionParams, featureBranch: true, skipDefaultBranchPrompt: true, }); }, [runGitActionWithToast], ); const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ action, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), }); }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); const runDialogActionOnNewBranch = useCallback(() => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); checkoutNewBranchAndRunAction({ action: "commit", ...(commitMessage ? { commitMessage } : {}), }); }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]); 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...", data: threadToastData }, 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.`, data: threadToastData, }), error: (err) => ({ title: "Pull failed", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }),Lines 1–500 of 833. Load more
import type { GitStackedAction, GitStatusResult, ThreadId } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, buildMenuItems, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, 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, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; interface GitActionsControlProps { gitCwd: string | null; activeThreadId: ThreadId | null; } interface PendingDefaultBranchAction { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; forcePushOnlyProgress: boolean; onConfirmed?: () => void; } type GitActionToastId = ReturnType<typeof toastManager.add>; 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.pr?.state === "open"; 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 (isBehind) { return "Branch is behind upstream. Pull/rebase before creating a PR."; } return "Create PR is currently unavailable."; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return <GitCommitIcon />; if (icon === "push") return <CloudUploadIcon />; return <GitHubIcon />; } function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />; if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; if (quickAction.action === "commit_push") return <CloudUploadIcon className={iconClassName} />; return <GitHubIcon className={iconClassName} />; } if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; return <InfoIcon className={iconClassName} />; } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState<PendingDefaultBranchAction | null>(null); const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. const isRepo = branchList?.isRepo ?? true; const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; const isGitStatusOutOfSync = !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; useEffect(() => { if (!isGitStatusOutOfSync) return; void invalidateGitQueries(queryClient); }, [isGitStatusOutOfSync, queryClient]); const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); const isRunStackedActionRunning = useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; const current = branchList?.branches.find((branch) => branch.name === branchName); return current?.isDefault ?? (branchName === "main" || branchName === "master"); }, [branchList?.branches, gitStatusForActions?.branch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning), [gitStatusForActions, isGitActionRunning], ); const quickAction = useMemo( () => resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch), [gitStatusForActions, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, }) : null; const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { toastManager.add({ type: "error", title: "Link opening is unavailable.", data: threadToastData, }); return; } const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", title: "No open PR found.", data: threadToastData, }); return; } void api.shell.openExternal(prUrl).catch((err) => { toastManager.add({ type: "error", title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); }); }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); const runGitActionWithToast = useCallback( async ({ action, commitMessage, forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, isDefaultBranchOverride, progressToastId, }: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; }) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; const actionIsDefaultBranch = isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); const includesCommit = !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { if (action !== "commit_push" && action !== "commit_push_pr") { return; } setPendingDefaultBranchAction({ action, branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), }); return; } onConfirmed?.(); const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, featureBranch, ...(pushTarget ? { pushTarget } : {}), }); const resolvedProgressToastId = progressToastId ?? toastManager.add({ type: "loading", title: progressStages[0] ?? "Running git action...", timeout: 0, data: threadToastData, }); if (progressToastId) { toastManager.update(progressToastId, { type: "loading", title: progressStages[0] ?? "Running git action...", timeout: 0, data: threadToastData, }); } let stageIndex = 0; const stageInterval = setInterval(() => { stageIndex = Math.min(stageIndex + 1, progressStages.length - 1); toastManager.update(resolvedProgressToastId, { title: progressStages[stageIndex] ?? "Running git action...", type: "loading", timeout: 0, data: threadToastData, }); }, 1100); const stopProgressUpdates = () => { clearInterval(stageInterval); }; const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), }); try { const result = await promise; stopProgressUpdates(); const resultToast = summarizeGitResult(result); const existingOpenPrUrl = actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; const prUrl = result.pr.url ?? existingOpenPrUrl; const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; const shouldOfferOpenPrCta = (action === "commit_push" || action === "commit_push_pr") && !!prUrl && (!actionIsDefaultBranch || result.pr.status === "created" || result.pr.status === "opened_existing"); const shouldOfferCreatePrCta = action === "commit_push" && !prUrl && result.push.status === "pushed" && !actionIsDefaultBranch; const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; toastManager.update(resolvedProgressToastId, { type: "success", title: resultToast.title, description: resultToast.description, timeout: 0, data: { ...threadToastData, dismissAfterVisibleMs: 10_000, }, ...(shouldOfferPushCta ? { actionProps: { children: "Push", onClick: () => { void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true, onConfirmed: closeResultToast, statusOverride: actionStatus, isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, } : shouldOfferOpenPrCta ? { actionProps: { children: "Open PR", onClick: () => { const api = readNativeApi(); if (!api) return; closeResultToast(); void api.shell.openExternal(prUrl); }, }, } : shouldOfferCreatePrCta ? { actionProps: { children: "Create PR", onClick: () => { closeResultToast(); void runGitActionWithToast({ action: "commit_push_pr", forcePushOnlyProgress: true, statusOverride: actionStatus, isDefaultBranchOverride: actionIsDefaultBranch, }); }, }, } : {}), }); } catch (err) { stopProgressUpdates(); toastManager.update(resolvedProgressToastId, { type: "error", title: "Action failed", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }); } }, [ isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, threadToastData, gitStatusForActions, ], ); const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), skipDefaultBranchPrompt: true, }); }, [pendingDefaultBranchAction, runGitActionWithToast]); const checkoutNewBranchAndRunAction = useCallback( (actionParams: { action: GitStackedAction; commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; }) => { void runGitActionWithToast({ ...actionParams, featureBranch: true, skipDefaultBranchPrompt: true, }); }, [runGitActionWithToast], ); const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ action, ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), }); }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); const runDialogActionOnNewBranch = useCallback(() => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); checkoutNewBranchAndRunAction({ action: "commit", ...(commitMessage ? { commitMessage } : {}), }); }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]); 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...", data: threadToastData }, 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.`, data: threadToastData, }), error: (err) => ({ title: "Pull failed", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, }),