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 { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import type { ActionStatus } from "@/components/ui/dropdown-menu"; export type GitActionId = | "commit" | "push" | "view-pr" | "create-pr" | "merge-branch" | "merge-from-base" | "archive-worktree"; export interface GitAction { id: GitActionId; label: string; pendingLabel: string; successLabel: string; disabled: boolean; status: ActionStatus; description?: string; icon?: ReactElement; handler: () => void; } export interface GitActions { primary: GitAction | null; secondary: GitAction[]; menu: GitAction[]; } function openURLInNewTab(url: string): void { void openExternalUrl(url); } interface UseGitActionsInput { serverId: string; cwd: string; icons: { commit: ReactElement; push: ReactElement; viewPr: ReactElement; createPr: ReactElement; merge: ReactElement; mergeFromBase: ReactElement; archive: ReactElement; }; } interface UseGitActionsResult { gitActions: GitActions; branchLabel: string; actionError: string | null; isGit: boolean; } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { const router = useRouter(); const [actionError, setActionError] = useState(null); 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 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 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); // Handlers const handleCommit = useCallback(() => { setActionError(null); void runCommit({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to commit"; setActionError(message); }); }, [runCommit, serverId, cwd]); const handlePush = useCallback(() => { setActionError(null); void runPush({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to push"; setActionError(message); }); }, [runPush, serverId, cwd]); const handleCreatePr = useCallback(() => { void persistShipDefault("pr"); setActionError(null); void runCreatePr({ serverId, cwd }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to create PR"; setActionError(message); }); }, [persistShipDefault, runCreatePr, serverId, cwd]); const handleMergeBranch = useCallback(() => { if (!baseRef) { setActionError("Base ref unavailable"); return; } void persistShipDefault("merge"); setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { setPostShipArchiveSuggested(true); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to merge"; setActionError(message); }); }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { setActionError("Base ref unavailable"); return; } setActionError(null); void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { const message = err instanceof Error ? err.message : "Failed to merge from base"; setActionError(message); }); }, [baseRef, runMergeFromBase, serverId, cwd]); const handleArchiveWorktree = useCallback(() => { const worktreePath = status?.cwd; if (!worktreePath) { setActionError("Worktree path unavailable"); return; } setActionError(null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree"; setActionError(message); }); }, [runArchiveWorktree, router, serverId, cwd, status]); // Derived state const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 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 prDisabled = actionsDisabled || prCreateStatus === "pending"; const mergeDisabled = actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef; const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef || (isOnBaseBranch && !hasRemote); const pushDisabled = actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false); const archiveDisabled = actionsDisabled || archiveStatus === "pending" || !gitStatus?.isPaseoOwnedWorktree; const branchLabel = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : notGit ? "Not a git repository" : "Unknown"; // Build actions const gitActions: GitActions = useMemo(() => { if (!isGit) { return { primary: null, secondary: [], menu: [] }; } const allActions = new Map(); allActions.set("commit", { id: "commit", label: "Commit", pendingLabel: "Committing...", successLabel: "Committed", disabled: commitDisabled, status: commitStatus, icon: icons.commit, handler: handleCommit, }); if (hasRemote) { allActions.set("push", { id: "push", label: "Push", pendingLabel: "Pushing...", successLabel: "Pushed", disabled: pushDisabled, status: pushStatus, description: !hasRemote ? "No remote configured" : undefined, icon: icons.push, handler: handlePush, }); } if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) { const prUrl = prStatus.url; allActions.set("view-pr", { id: "view-pr", label: "View PR", pendingLabel: "View PR", successLabel: "View PR", disabled: false, status: "idle", icon: icons.viewPr, handler: () => openURLInNewTab(prUrl), }); } if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) { allActions.set("create-pr", { id: "create-pr", label: "Create PR", pendingLabel: "Creating PR...", successLabel: "PR Created", disabled: prDisabled, status: prCreateStatus, icon: icons.createPr, handler: handleCreatePr, }); } if (aheadCount > 0) { allActions.set("merge-branch", { id: "merge-branch", label: `Merge into ${baseRefLabel}`, pendingLabel: "Merging...", successLabel: "Merged", disabled: mergeDisabled, status: mergeStatus, description: hasUncommittedChanges ? "Requires clean working tree" : undefined, icon: icons.merge, handler: handleMergeBranch, }); } if ( shouldShowMergeFromBaseAction({ isOnBaseBranch, hasRemote, aheadOfOrigin, behindOfOrigin, }) ) { allActions.set("merge-from-base", { id: "merge-from-base", label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`, pendingLabel: "Updating...", successLabel: "Updated", disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, description: hasUncommittedChanges ? "Requires clean working tree" : isOnBaseBranch && !hasRemote ? "No remote configured" : undefined, icon: icons.mergeFromBase, handler: handleMergeFromBase, }); } if (isPaseoOwnedWorktree) { allActions.set("archive-worktree", { id: "archive-worktree", label: "Archive worktree", pendingLabel: "Archiving...", successLabel: "Archived", disabled: archiveDisabled, status: archiveStatus, icon: icons.archive, handler: handleArchiveWorktree, }); } // Select primary action (priority rules) let primaryActionId: GitActionId | null = null; if (shouldPromoteArchive && allActions.has("archive-worktree")) { primaryActionId = "archive-worktree"; } else if (hasUncommittedChanges) { primaryActionId = "commit"; } else if (aheadOfOrigin > 0 && allActions.has("push")) { primaryActionId = "push"; } else if (hasPullRequest) { primaryActionId = "view-pr"; } else if (isOnBaseBranch && allActions.has("merge-from-base")) { primaryActionId = "merge-from-base"; } else if (aheadCount > 0) { const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr"; const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch"; const preferredAction = allActions.get(preferred); const fallbackAction = allActions.get(fallback); if (preferredAction && !preferredAction.disabled) { primaryActionId = preferred; } else if (fallbackAction && !fallbackAction.disabled) { primaryActionId = fallback; } else if (preferredAction) { primaryActionId = preferred; } } const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null; const secondaryIds: GitActionId[] = [ "merge-branch", "create-pr", "view-pr", "merge-from-base", "push", "archive-worktree", ]; const secondary = secondaryIds .filter(id => id !== primaryActionId && allActions.has(id)) .map(id => allActions.get(id)!); const menu: GitAction[] = []; return { primary, secondary, menu }; }, [ isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, icons, baseRef, ]); return { gitActions, branchLabel, actionError, isGit }; }