diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 9213526a0e..67c9225753 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -1,6 +1,6 @@ import { Button } from "@components/ui/Button"; -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useGithubUserConnect } from "@features/integrations/hooks/useGithubUserConnect"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; import { @@ -9,18 +9,6 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { Spinner } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { queryClient } from "@utils/queryClient"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; - -async function openUrlInBrowser(url: string): Promise { - try { - await trpcClient.os.openExternal.mutate({ url }); - } catch { - window.open(url, "_blank", "noopener,noreferrer"); - } -} export function GitHubConnectionBanner() { const { data: githubLogin, isLoading: loginLoading } = useAuthenticatedQuery( @@ -30,33 +18,12 @@ export function GitHubConnectionBanner() { ); const { hasGithubIntegration: hasGithubForProject } = useUserRepositoryIntegration(); - const apiClient = useOptionalAuthenticatedClient(); const projectId = useAuthStateValue((s) => s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const awaitingLink = useRef(false); - const connectInFlight = useRef(false); - const [connecting, setConnecting] = useState(false); - - const canConnectCloud = - apiClient != null && projectId != null && cloudRegion != null; - // After the user clicks connect and returns to the app, refetch to pick up the new github_login - useEffect(() => { - const onFocus = () => { - if (awaitingLink.current) { - awaitingLink.current = false; - void queryClient.invalidateQueries({ queryKey: ["github_login"] }); - void queryClient.invalidateQueries({ - queryKey: ["integrations", "list"], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - } - }; - window.addEventListener("focus", onFocus); - return () => window.removeEventListener("focus", onFocus); - }, []); + const { state, connect } = useGithubUserConnect({ projectId }); + const connecting = state === "connecting"; + const canConnectCloud = projectId != null && cloudRegion != null; if (loginLoading) { return null; @@ -109,37 +76,8 @@ export function GitHubConnectionBanner() { } onClick={() => { - if (!canConnectCloud || connectInFlight.current) { - return; - } - connectInFlight.current = true; - awaitingLink.current = true; - setConnecting(true); - void (async () => { - try { - const res = - await apiClient.startGithubUserIntegrationConnect(projectId); - const installUrl = res.install_url?.trim() ?? ""; - if (!installUrl) { - awaitingLink.current = false; - toast.error( - "GitHub connection did not return a URL. Please try again.", - ); - return; - } - await openUrlInBrowser(installUrl); - } catch (e) { - awaitingLink.current = false; - toast.error( - e instanceof Error - ? e.message - : "Failed to start GitHub connection", - ); - } finally { - connectInFlight.current = false; - setConnecting(false); - } - })(); + if (!canConnectCloud) return; + void connect(); }} > {connecting ? ( diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts new file mode 100644 index 0000000000..7314d63f1b --- /dev/null +++ b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts @@ -0,0 +1,141 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useGitHubIntegrationCallback } from "@features/integrations/hooks/useGitHubIntegrationCallback"; +import { trpcClient } from "@renderer/trpc/client"; +import { IS_DEV } from "@shared/constants/environment"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 300_000; + +export type GithubUserConnectState = "idle" | "connecting" | "timed-out"; + +interface Options { + projectId: number | null; +} + +interface Result { + state: GithubUserConnectState; + connect: () => Promise; + reset: () => void; +} + +async function openUrlInBrowser(url: string): Promise { + try { + await trpcClient.os.openExternal.mutate({ url }); + } catch { + window.open(url, "_blank", "noopener,noreferrer"); + } +} + +export function useGithubUserConnect({ projectId }: Options): Result { + const client = useOptionalAuthenticatedClient(); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const queryClient = useQueryClient(); + const [state, setState] = useState("idle"); + const stateRef = useRef(state); + stateRef.current = state; + const pollTimerRef = useRef | null>(null); + const pollTimeoutRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + const invalidate = useCallback( + (pid: number | null) => { + if (pid !== null) { + void queryClient.invalidateQueries({ + queryKey: ["integrations", pid], + }); + } + void queryClient.invalidateQueries({ + queryKey: ["integrations", "list"], + }); + void queryClient.invalidateQueries({ + queryKey: ["user-github-integrations"], + }); + void queryClient.invalidateQueries({ queryKey: ["github_login"] }); + }, + [queryClient], + ); + + useEffect(() => stopPolling, [stopPolling]); + + // Window-focus fallback: deep link from PostHog Cloud may not fire reliably, + // so refetch when the user returns to the app while a connect is in flight. + useEffect(() => { + if (state !== "connecting") return; + const onFocus = () => invalidate(projectId); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [state, projectId, invalidate]); + + useGitHubIntegrationCallback({ + onSuccess: (callbackProjectId) => { + stopPolling(); + setState("idle"); + invalidate(callbackProjectId ?? projectId); + }, + onError: (message) => { + stopPolling(); + setState("idle"); + toast.error(message); + }, + onTimedOut: () => { + stopPolling(); + setState("timed-out"); + invalidate(projectId); + }, + }); + + const connect = useCallback(async () => { + if (stateRef.current !== "idle") return; + if (!cloudRegion || projectId === null || !client) return; + stopPolling(); + setState("connecting"); + try { + const res = await client.startGithubUserIntegrationConnect(projectId); + const installUrl = res.install_url?.trim() ?? ""; + if (!installUrl) { + throw new Error("GitHub connection did not return a URL"); + } + await openUrlInBrowser(installUrl); + + if (IS_DEV) { + pollTimerRef.current = setInterval( + () => invalidate(projectId), + POLL_INTERVAL_MS, + ); + } + + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + setState("timed-out"); + }, POLL_TIMEOUT_MS); + } catch (error) { + setState("idle"); + toast.error( + error instanceof Error + ? error.message + : "Failed to start GitHub connection", + ); + } + }, [client, cloudRegion, projectId, invalidate, stopPolling]); + + const reset = useCallback(() => { + stopPolling(); + setState("idle"); + }, [stopPolling]); + + return { state, connect, reset }; +} diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index a790be1f39..abe161279d 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -1,7 +1,7 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; +import { useGithubUserConnect } from "@features/integrations/hooks/useGithubUserConnect"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useUserGithubIntegrations, @@ -28,20 +28,14 @@ import { } from "@radix-ui/themes"; import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { trpcClient } from "@renderer/trpc/client"; -import { IS_DEV } from "@shared/constants/environment"; import { useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { useGitHubIntegrationCallback } from "../../integrations/hooks/useGitHubIntegrationCallback"; +import { useMemo, useState } from "react"; import type { DetectedRepo } from "../hooks/useOnboardingFlow"; import { useProjectsWithIntegrations } from "../hooks/useProjectsWithIntegrations"; import { OnboardingHogTip } from "./OnboardingHogTip"; import { StepActions } from "./StepActions"; -const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 300_000; - interface GitIntegrationStepProps { onNext: () => void; onBack: () => void; @@ -59,28 +53,19 @@ export function GitIntegrationStep({ isDetectingRepo, onDirectoryChange, }: GitIntegrationStepProps) { - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const currentProjectId = useAuthStateValue((state) => state.projectId); - const client = useOptionalAuthenticatedClient(); const selectProjectMutation = useSelectProjectMutation(); const queryClient = useQueryClient(); const { projects, projectsWithGithub, isLoading } = useProjectsWithIntegrations(); - const isConnecting = useOnboardingStore((state) => state.isConnectingGithub); - const setConnectingGithub = useOnboardingStore( - (state) => state.setConnectingGithub, - ); const manuallySelectedProjectId = useOnboardingStore( (state) => state.selectedProjectId, ); const setSelectedProjectId = useOnboardingStore( (state) => state.selectProjectId, ); - const pollTimerRef = useRef | null>(null); - const pollTimeoutRef = useRef | null>(null); - const [timedOut, setTimedOut] = useState(false); const selectedProjectId = useMemo(() => { if (manuallySelectedProjectId !== null) { @@ -89,6 +74,11 @@ export function GitIntegrationStep({ return currentProjectId ?? projects[0]?.id ?? null; }, [manuallySelectedProjectId, currentProjectId, projects]); + const { state: connectState, connect: handleConnectGitHub } = + useGithubUserConnect({ projectId: selectedProjectId }); + const isConnecting = connectState === "connecting"; + const timedOut = connectState === "timed-out"; + const selectedProject = useMemo( () => projects.find((p) => p.id === selectedProjectId), [projects, selectedProjectId], @@ -135,109 +125,6 @@ export function GitIntegrationStep({ ); }, [detectedRepo, repositories]); - const stopPolling = useCallback(() => { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - if (pollTimeoutRef.current) { - clearTimeout(pollTimeoutRef.current); - pollTimeoutRef.current = null; - } - }, []); - - useEffect(() => { - if (hasGitIntegration && isConnecting) { - stopPolling(); - setConnectingGithub(false); - setTimedOut(false); - } - }, [hasGitIntegration, isConnecting, setConnectingGithub, stopPolling]); - - useEffect(() => stopPolling, [stopPolling]); - - const invalidateProject = useCallback( - (projectId: number | null) => { - if (projectId === null) { - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - return; - } - void queryClient.invalidateQueries({ - queryKey: ["integrations", projectId], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - }, - [queryClient], - ); - - useGitHubIntegrationCallback({ - onSuccess: (projectId) => { - stopPolling(); - setTimedOut(false); - setConnectingGithub(false); - invalidateProject(projectId ?? selectedProjectId); - }, - onError: (message) => { - stopPolling(); - setTimedOut(false); - setConnectingGithub(false); - toast.error(message); - }, - onTimedOut: () => { - stopPolling(); - setConnectingGithub(false); - setTimedOut(true); - }, - }); - - const handleConnectGitHub = async () => { - if (!cloudRegion || !selectedProjectId || !client) return; - stopPolling(); - setTimedOut(false); - setConnectingGithub(true); - try { - const res = - await client.startGithubUserIntegrationConnect(selectedProjectId); - const installUrl = res.install_url?.trim() ?? ""; - if (!installUrl) { - throw new Error("GitHub connection did not return a URL"); - } - await trpcClient.os.openExternal.mutate({ url: installUrl }); - - // Dev-only fallback: GitHub returns via posthog-code-dev:// while the - // browser flow may not always surface the same path; poll integrations. - if (IS_DEV) { - pollTimerRef.current = setInterval(() => { - void queryClient.invalidateQueries({ - queryKey: ["integrations", selectedProjectId], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - }, POLL_INTERVAL_MS); - } - - // Safety timeout in case the subscription drops or the user abandons the browser. - pollTimeoutRef.current = setTimeout(() => { - stopPolling(); - setConnectingGithub(false); - setTimedOut(true); - }, POLL_TIMEOUT_MS); - } catch (error) { - setConnectingGithub(false); - toast.error( - error instanceof Error - ? error.message - : "Failed to start GitHub connection", - ); - } - }; - const handleContinue = () => { if (selectedProjectId && selectedProjectId !== currentProjectId) { selectProjectMutation.mutate(selectedProjectId); diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts index c2563bc634..1835733ac9 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts @@ -8,7 +8,6 @@ const log = logger.scope("onboarding-store"); interface OnboardingStoreState { currentStep: OnboardingStep; hasCompletedOnboarding: boolean; - isConnectingGithub: boolean; selectedProjectId: number | null; selectedDirectory: string; } @@ -18,7 +17,6 @@ interface OnboardingStoreActions { completeOnboarding: () => void; resetOnboarding: () => void; resetSelections: () => void; - setConnectingGithub: (isConnecting: boolean) => void; selectProjectId: (projectId: number | null) => void; setSelectedDirectory: (path: string) => void; } @@ -28,7 +26,6 @@ type OnboardingStore = OnboardingStoreState & OnboardingStoreActions; const initialState: OnboardingStoreState = { currentStep: "welcome", hasCompletedOnboarding: false, - isConnectingGithub: false, selectedProjectId: null, selectedDirectory: "", }; @@ -47,10 +44,8 @@ export const useOnboardingStore = create()( resetSelections: () => set({ currentStep: "welcome", - isConnectingGithub: false, selectedProjectId: null, }), - setConnectingGithub: (isConnectingGithub) => set({ isConnectingGithub }), selectProjectId: (selectedProjectId) => set({ selectedProjectId }), setSelectedDirectory: (selectedDirectory) => set({ selectedDirectory }), }),