import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; import { useRouter } from "expo-router"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; function openURLInNewTab(url: string): void { void openExternalUrl(url); } interface UseGitActionsInput { serverId: string; cwd: string; icons: { commit: ReactElement; pull: ReactElement; push: ReactElement; viewPr: ReactElement; createPr: ReactElement; merge: ReactElement; mergeFromBase: ReactElement; archive: ReactElement; }; } interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; isGit: boolean; } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { const router = useRouter(); const toast = useToast(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; const baseRef = gitStatus?.baseRef ?? undefined; const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, }); // Ship default persistence const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { return null; } return `@paseo:changes-ship-default:${gitStatus.repoRoot}`; }, [gitStatus?.repoRoot]); useEffect(() => { if (!shipDefaultStorageKey) { return; } let isActive = true; AsyncStorage.getItem(shipDefaultStorageKey) .then((value) => { if (!isActive) return; if (value === "pr" || value === "merge") { setShipDefault(value); } }) .catch(() => undefined); return () => { isActive = false; }; }, [shipDefaultStorageKey]); const persistShipDefault = useCallback( async (next: "merge" | "pr") => { setShipDefault(next); if (!shipDefaultStorageKey) return; try { await AsyncStorage.setItem(shipDefaultStorageKey, next); } catch { // Ignore persistence failures; default will reset to "merge". } }, [shipDefaultStorageKey], ); useEffect(() => { setPostShipArchiveSuggested(false); }, [cwd]); // Store selectors const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }), ); const pullStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "pull" }), ); const pushStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "push" }), ); const prCreateStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "create-pr" }), ); const mergeStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "merge-branch" }), ); const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), ); const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); const runPull = useCheckoutGitActionsStore((state) => state.pull); const runPush = useCheckoutGitActionsStore((state) => state.push); const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const toastActionError = useCallback( (error: unknown, fallback: string) => { const message = error instanceof Error ? error.message : fallback; toast.error(message); }, [toast], ); const toastActionSuccess = useCallback( (message: string) => { toast.show(message, { variant: "success" }); }, [toast], ); // Handlers const handleCommit = useCallback(() => { void runCommit({ serverId, cwd }) .then(() => { toastActionSuccess("Committed"); }) .catch((err) => { toastActionError(err, "Failed to commit"); }); }, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]); const handlePull = useCallback(() => { void runPull({ serverId, cwd }) .then(() => { toastActionSuccess("Pulled"); }) .catch((err) => { toastActionError(err, "Failed to pull"); }); }, [cwd, runPull, serverId, toastActionError, toastActionSuccess]); const handlePush = useCallback(() => { void runPush({ serverId, cwd }) .then(() => { toastActionSuccess("Pushed"); }) .catch((err) => { toastActionError(err, "Failed to push"); }); }, [cwd, runPush, serverId, toastActionError, toastActionSuccess]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); void runCreatePr({ serverId, cwd }) .then(() => { toastActionSuccess("PR created"); }) .catch((err) => { toastActionError(err, "Failed to create PR"); }); }, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]); const handleMergeBranch = useCallback(() => { if (!baseRef) { toast.error("Base ref unavailable"); return; } void persistShipDefault("merge"); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); toastActionSuccess("Merged"); }) .catch((err) => { toastActionError(err, "Failed to merge"); }); }, [ baseRef, cwd, persistShipDefault, runMergeBranch, serverId, toast, toastActionError, toastActionSuccess, ]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { toast.error("Base ref unavailable"); return; } void runMergeFromBase({ serverId, cwd, baseRef }) .then(() => { toastActionSuccess("Updated"); }) .catch((err) => { toastActionError(err, "Failed to merge from base"); }); }, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { toast.error("Worktree path unavailable"); return; } const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); }) .catch((err) => { toastActionError(err, "Failed to archive worktree"); }); }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; }, [baseRef]); const hasPullRequest = Boolean(prStatus?.url); const hasRemote = gitStatus?.hasRemote ?? false; const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; const isMergedPullRequest = Boolean(prStatus?.isMerged); const currentBranch = gitStatus?.currentBranch; const isOnBaseBranch = currentBranch === baseRefLabel; const shouldPromoteArchive = isPaseoOwnedWorktree && !hasUncommittedChanges && (postShipArchiveSuggested || isMergedPullRequest); const commitDisabled = actionsDisabled || commitStatus === "pending"; const pullDisabled = actionsDisabled || pullStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending"; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; const pushDisabled = actionsDisabled || pushStatus === "pending"; const archiveDisabled = actionsDisabled || archiveStatus === "pending"; const branchLabel = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : notGit ? "Not a git repository" : "Unknown"; // Build actions const gitActions: GitActions = useMemo(() => { return buildGitActions({ isGit, githubFeaturesEnabled, hasPullRequest, pullRequestUrl: prStatus?.url ?? null, hasRemote, isPaseoOwnedWorktree, isOnBaseBranch, hasUncommittedChanges, baseRefAvailable: Boolean(baseRef), baseRefLabel, aheadCount, behindBaseCount, aheadOfOrigin, behindOfOrigin, shouldPromoteArchive, shipDefault, runtime: { commit: { disabled: commitDisabled, status: commitStatus, icon: icons.commit, handler: handleCommit, }, pull: { disabled: pullDisabled, status: pullStatus, icon: icons.pull, handler: handlePull, }, push: { disabled: pushDisabled, status: pushStatus, icon: icons.push, handler: handlePush, }, pr: { disabled: prDisabled, status: hasPullRequest ? "idle" : prCreateStatus, icon: hasPullRequest ? icons.viewPr : icons.createPr, handler: () => { if (prStatus?.url) { openURLInNewTab(prStatus.url); return; } handleCreatePr(); }, }, "merge-branch": { disabled: mergeDisabled, status: mergeStatus, icon: icons.merge, handler: handleMergeBranch, }, "merge-from-base": { disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, icon: icons.mergeFromBase, handler: handleMergeFromBase, }, "archive-worktree": { disabled: archiveDisabled, status: archiveStatus, icon: icons.archive, handler: handleArchiveWorktree, }, }, }); }, [ isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, behindBaseCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, commitDisabled, pullDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pullStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, handlePull, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, icons, baseRef, ]); return { gitActions, branchLabel, isGit }; }