diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 913308d60..ea16375f1 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -995,6 +995,43 @@ export class PostHogAPIClient { }; } + async getGithubBranchesPage( + integrationId: string | number, + repo: string, + offset: number, + limit: number, + ): Promise<{ + branches: string[]; + defaultBranch: string | null; + hasMore: boolean; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + ); + url.searchParams.set("repo", repo); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch GitHub branches: ${response.statusText}`, + ); + } + + const data = await response.json(); + return { + branches: data.branches ?? data.results ?? data ?? [], + defaultBranch: data.default_branch ?? null, + hasMore: data.has_more ?? false, + }; + } + async getGithubRepositories( integrationId: string | number, ): Promise { diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index 55530a2bf..fa2264aec 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -21,6 +21,9 @@ interface BranchSelectorProps { onBranchSelect?: (branch: string | null) => void; cloudBranches?: string[]; cloudBranchesLoading?: boolean; + cloudBranchesFetchingMore?: boolean; + onCloudPickerOpen?: () => void; + onCloudBranchCommit?: () => void; taskId?: string; } @@ -36,6 +39,9 @@ export function BranchSelector({ onBranchSelect, cloudBranches, cloudBranchesLoading, + cloudBranchesFetchingMore, + onCloudPickerOpen, + onCloudBranchCommit, taskId, }: BranchSelectorProps) { const [open, setOpen] = useState(false); @@ -61,6 +67,8 @@ export function BranchSelector({ const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); + const cloudStillLoading = + isCloudMode && cloudBranchesLoading && branches.length === 0; const checkoutMutation = useMutation( trpc.git.checkoutBranch.mutationOptions({ @@ -86,16 +94,36 @@ export function BranchSelector({ branchName: value, }); } + if (isCloudMode && value) { + // User committed to a branch — pause the background pagination. If they + // later re-open the picker, `onCloudPickerOpen` will resume it from + // wherever the cached pages left off. + onCloudBranchCommit?.(); + } setOpen(false); }; + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (isCloudMode && next) { + onCloudPickerOpen?.(); + } + }; + const displayText = effectiveLoading ? "Loading..." : (displayedBranch ?? "No branch"); + // Show the spinner on the trigger while the first page is still loading. + // Once we have branches to show, any "loading more" background work is + // surfaced inside the open picker instead, so the trigger goes back to its + // normal branch icon. + const showSpinner = + effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); + const triggerContent = ( - {effectiveLoading ? ( + {showSpinner ? ( ) : ( @@ -110,9 +138,9 @@ export function BranchSelector({ value={displayedBranch ?? ""} onValueChange={handleBranchChange} open={open} - onOpenChange={setOpen} + onOpenChange={handleOpenChange} size="1" - disabled={disabled || !repoPath} + disabled={disabled || !repoPath || cloudStillLoading} > {triggerContent} @@ -126,6 +154,17 @@ export function BranchSelector({ {({ filtered, hasMore, moreCount }) => ( <> + {isCloudMode && cloudBranchesFetchingMore && ( + + + Loading more ({branches.length})… + + )} No branches found. {filtered.length > 0 && ( diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 6e9bd4cb9..03c473876 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -102,8 +102,13 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { ? getIntegrationIdForRepo(selectedRepository) : undefined; - const { data: cloudBranchData, isPending: cloudBranchesLoading } = - useGithubBranches(selectedIntegrationId, selectedRepository); + const { + data: cloudBranchData, + isPending: cloudBranchesLoading, + isFetchingMore: cloudBranchesFetchingMore, + pauseLoadingMore: pauseCloudBranchesLoading, + resumeLoadingMore: resumeCloudBranchesLoading, + } = useGithubBranches(selectedIntegrationId, selectedRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -359,6 +364,9 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { onBranchSelect={setSelectedBranch} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} + onCloudPickerOpen={resumeCloudBranchesLoading} + onCloudBranchCommit={pauseCloudBranchesLoading} /> diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index fde1e8f4a..10bacc8af 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -119,8 +119,13 @@ export function TaskInput({ ? getIntegrationIdForRepo(selectedCloudRepository) : undefined; - const { data: cloudBranchData, isPending: cloudBranchesLoading } = - useGithubBranches(selectedIntegrationId, selectedCloudRepository); + const { + data: cloudBranchData, + isPending: cloudBranchesLoading, + isFetchingMore: cloudBranchesFetchingMore, + pauseLoadingMore: pauseCloudBranchesLoading, + resumeLoadingMore: resumeCloudBranchesLoading, + } = useGithubBranches(selectedIntegrationId, selectedCloudRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -464,6 +469,9 @@ export function TaskInput({ onBranchSelect={setSelectedBranch} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} + onCloudPickerOpen={resumeCloudBranchesLoading} + onCloudBranchCommit={pauseCloudBranchesLoading} /> {workspaceMode === "worktree" && ( { + setPaused(false); + }, [integrationId, repo]); + + const query = useAuthenticatedInfiniteQuery( integrationKeys.branches(integrationId, repo), - async (client) => { - if (!integrationId || !repo) return { branches: [], defaultBranch: null }; - return await client.getGithubBranches(integrationId, repo); + async (client, offset) => { + if (!integrationId || !repo) { + return { branches: [], defaultBranch: null, hasMore: false }; + } + const pageSize = + offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE; + return await client.getGithubBranchesPage( + integrationId, + repo, + offset, + pageSize, + ); + }, + { + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.hasMore) return undefined; + return allPages.reduce((n, p) => n + p.branches.length, 0); + }, }, - { staleTime: 0, refetchOnMount: "always" }, ); + + // Auto-fetch remaining pages in the background whenever we are not paused. + // Any in-flight page is allowed to finish and land in the cache; the pause + // just prevents us from kicking off the next one. Resuming picks up from + // wherever `getNextPageParam` computes the next offset to be. + useEffect(() => { + if (paused) return; + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [ + paused, + query.hasNextPage, + query.isFetchingNextPage, + query.fetchNextPage, + ]); + + const data = useMemo(() => { + if (!query.data?.pages.length) { + return { branches: [] as string[], defaultBranch: null }; + } + return { + branches: query.data.pages.flatMap((p) => p.branches), + defaultBranch: query.data.pages[0]?.defaultBranch ?? null, + }; + }, [query.data?.pages]); + + const pauseLoadingMore = useCallback(() => setPaused(true), []); + const resumeLoadingMore = useCallback(() => setPaused(false), []); + + return { + data, + isPending: query.isPending, + isFetchingMore: + !paused && (query.isFetchingNextPage || (query.hasNextPage ?? false)), + pauseLoadingMore, + resumeLoadingMore, + }; } export function useRepositoryIntegration() {