Skip to content

Commit f75551e

Browse files
chore(code): github branch picker loading all branches (#1592)
1 parent b6365c0 commit f75551e

5 files changed

Lines changed: 180 additions & 13 deletions

File tree

apps/code/src/renderer/api/posthogClient.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,43 @@ export class PostHogAPIClient {
10031003
};
10041004
}
10051005

1006+
async getGithubBranchesPage(
1007+
integrationId: string | number,
1008+
repo: string,
1009+
offset: number,
1010+
limit: number,
1011+
): Promise<{
1012+
branches: string[];
1013+
defaultBranch: string | null;
1014+
hasMore: boolean;
1015+
}> {
1016+
const teamId = await this.getTeamId();
1017+
const url = new URL(
1018+
`${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`,
1019+
);
1020+
url.searchParams.set("repo", repo);
1021+
url.searchParams.set("offset", String(offset));
1022+
url.searchParams.set("limit", String(limit));
1023+
const response = await this.api.fetcher.fetch({
1024+
method: "get",
1025+
url,
1026+
path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`,
1027+
});
1028+
1029+
if (!response.ok) {
1030+
throw new Error(
1031+
`Failed to fetch GitHub branches: ${response.statusText}`,
1032+
);
1033+
}
1034+
1035+
const data = await response.json();
1036+
return {
1037+
branches: data.branches ?? data.results ?? data ?? [],
1038+
defaultBranch: data.default_branch ?? null,
1039+
hasMore: data.has_more ?? false,
1040+
};
1041+
}
1042+
10061043
async getGithubRepositories(
10071044
integrationId: string | number,
10081045
): Promise<string[]> {

apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ interface BranchSelectorProps {
2121
onBranchSelect?: (branch: string | null) => void;
2222
cloudBranches?: string[];
2323
cloudBranchesLoading?: boolean;
24+
cloudBranchesFetchingMore?: boolean;
25+
onCloudPickerOpen?: () => void;
26+
onCloudBranchCommit?: () => void;
2427
taskId?: string;
2528
}
2629

@@ -36,6 +39,9 @@ export function BranchSelector({
3639
onBranchSelect,
3740
cloudBranches,
3841
cloudBranchesLoading,
42+
cloudBranchesFetchingMore,
43+
onCloudPickerOpen,
44+
onCloudBranchCommit,
3945
taskId,
4046
}: BranchSelectorProps) {
4147
const [open, setOpen] = useState(false);
@@ -61,6 +67,8 @@ export function BranchSelector({
6167

6268
const branches = isCloudMode ? (cloudBranches ?? []) : localBranches;
6369
const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading);
70+
const cloudStillLoading =
71+
isCloudMode && cloudBranchesLoading && branches.length === 0;
6472

6573
const checkoutMutation = useMutation(
6674
trpc.git.checkoutBranch.mutationOptions({
@@ -86,16 +94,36 @@ export function BranchSelector({
8694
branchName: value,
8795
});
8896
}
97+
if (isCloudMode && value) {
98+
// User committed to a branch — pause the background pagination. If they
99+
// later re-open the picker, `onCloudPickerOpen` will resume it from
100+
// wherever the cached pages left off.
101+
onCloudBranchCommit?.();
102+
}
89103
setOpen(false);
90104
};
91105

106+
const handleOpenChange = (next: boolean) => {
107+
setOpen(next);
108+
if (isCloudMode && next) {
109+
onCloudPickerOpen?.();
110+
}
111+
};
112+
92113
const displayText = effectiveLoading
93114
? "Loading..."
94115
: (displayedBranch ?? "No branch");
95116

117+
// Show the spinner on the trigger while the first page is still loading.
118+
// Once we have branches to show, any "loading more" background work is
119+
// surfaced inside the open picker instead, so the trigger goes back to its
120+
// normal branch icon.
121+
const showSpinner =
122+
effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore);
123+
96124
const triggerContent = (
97125
<Flex align="center" gap="1" style={{ minWidth: 0 }}>
98-
{effectiveLoading ? (
126+
{showSpinner ? (
99127
<Spinner size="1" />
100128
) : (
101129
<GitBranch size={16} weight="regular" style={{ flexShrink: 0 }} />
@@ -110,9 +138,9 @@ export function BranchSelector({
110138
value={displayedBranch ?? ""}
111139
onValueChange={handleBranchChange}
112140
open={open}
113-
onOpenChange={setOpen}
141+
onOpenChange={handleOpenChange}
114142
size="1"
115-
disabled={disabled || !repoPath}
143+
disabled={disabled || !repoPath || cloudStillLoading}
116144
>
117145
<Combobox.Trigger variant={variant} placeholder="No branch">
118146
{triggerContent}
@@ -126,6 +154,17 @@ export function BranchSelector({
126154
{({ filtered, hasMore, moreCount }) => (
127155
<>
128156
<Combobox.Input placeholder="Search branches" />
157+
{isCloudMode && cloudBranchesFetchingMore && (
158+
<Flex
159+
align="center"
160+
gap="1"
161+
className="combobox-label"
162+
style={{ padding: "6px 8px" }}
163+
>
164+
<Spinner size="1" />
165+
Loading more ({branches.length})…
166+
</Flex>
167+
)}
129168
<Combobox.Empty>No branches found.</Combobox.Empty>
130169

131170
{filtered.length > 0 && (

apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,13 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) {
102102
? getIntegrationIdForRepo(selectedRepository)
103103
: undefined;
104104

105-
const { data: cloudBranchData, isPending: cloudBranchesLoading } =
106-
useGithubBranches(selectedIntegrationId, selectedRepository);
105+
const {
106+
data: cloudBranchData,
107+
isPending: cloudBranchesLoading,
108+
isFetchingMore: cloudBranchesFetchingMore,
109+
pauseLoadingMore: pauseCloudBranchesLoading,
110+
resumeLoadingMore: resumeCloudBranchesLoading,
111+
} = useGithubBranches(selectedIntegrationId, selectedRepository);
107112
const cloudBranches = cloudBranchData?.branches;
108113
const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null;
109114

@@ -359,6 +364,9 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) {
359364
onBranchSelect={setSelectedBranch}
360365
cloudBranches={cloudBranches}
361366
cloudBranchesLoading={cloudBranchesLoading}
367+
cloudBranchesFetchingMore={cloudBranchesFetchingMore}
368+
onCloudPickerOpen={resumeCloudBranchesLoading}
369+
onCloudBranchCommit={pauseCloudBranchesLoading}
362370
/>
363371
</TourHighlight>
364372
</Flex>

apps/code/src/renderer/features/task-detail/components/TaskInput.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,13 @@ export function TaskInput({
119119
? getIntegrationIdForRepo(selectedCloudRepository)
120120
: undefined;
121121

122-
const { data: cloudBranchData, isPending: cloudBranchesLoading } =
123-
useGithubBranches(selectedIntegrationId, selectedCloudRepository);
122+
const {
123+
data: cloudBranchData,
124+
isPending: cloudBranchesLoading,
125+
isFetchingMore: cloudBranchesFetchingMore,
126+
pauseLoadingMore: pauseCloudBranchesLoading,
127+
resumeLoadingMore: resumeCloudBranchesLoading,
128+
} = useGithubBranches(selectedIntegrationId, selectedCloudRepository);
124129
const cloudBranches = cloudBranchData?.branches;
125130
const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null;
126131

@@ -464,6 +469,9 @@ export function TaskInput({
464469
onBranchSelect={setSelectedBranch}
465470
cloudBranches={cloudBranches}
466471
cloudBranchesLoading={cloudBranchesLoading}
472+
cloudBranchesFetchingMore={cloudBranchesFetchingMore}
473+
onCloudPickerOpen={resumeCloudBranchesLoading}
474+
onCloudBranchCommit={pauseCloudBranchesLoading}
467475
/>
468476
{workspaceMode === "worktree" && (
469477
<EnvironmentSelector

apps/code/src/renderer/hooks/useIntegrations.ts

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
useIntegrationStore,
77
} from "@features/integrations/stores/integrationStore";
88
import { useQueries } from "@tanstack/react-query";
9-
import { useCallback, useEffect, useMemo } from "react";
9+
import { useCallback, useEffect, useMemo, useState } from "react";
10+
import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery";
1011
import { useAuthenticatedQuery } from "./useAuthenticatedQuery";
1112

1213
const integrationKeys = {
@@ -67,18 +68,92 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) {
6768
});
6869
}
6970

71+
// Keep the first page small so it returns in a single upstream GitHub round
72+
// trip (GitHub's max per_page is 100), then fetch the remainder in larger
73+
// chunks to keep the total number of client/PostHog round trips low.
74+
const BRANCHES_FIRST_PAGE_SIZE = 100;
75+
const BRANCHES_PAGE_SIZE = 1000;
76+
77+
interface GithubBranchesPage {
78+
branches: string[];
79+
defaultBranch: string | null;
80+
hasMore: boolean;
81+
}
82+
7083
export function useGithubBranches(
7184
integrationId?: number,
7285
repo?: string | null,
7386
) {
74-
return useAuthenticatedQuery(
87+
// While paused we stop chaining `fetchNextPage` calls. The flag is scoped
88+
// to the current query target and resets whenever it changes, so switching
89+
// repos or integrations starts a fresh fetch.
90+
const [paused, setPaused] = useState(false);
91+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on key change
92+
useEffect(() => {
93+
setPaused(false);
94+
}, [integrationId, repo]);
95+
96+
const query = useAuthenticatedInfiniteQuery<GithubBranchesPage, number>(
7597
integrationKeys.branches(integrationId, repo),
76-
async (client) => {
77-
if (!integrationId || !repo) return { branches: [], defaultBranch: null };
78-
return await client.getGithubBranches(integrationId, repo);
98+
async (client, offset) => {
99+
if (!integrationId || !repo) {
100+
return { branches: [], defaultBranch: null, hasMore: false };
101+
}
102+
const pageSize =
103+
offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE;
104+
return await client.getGithubBranchesPage(
105+
integrationId,
106+
repo,
107+
offset,
108+
pageSize,
109+
);
110+
},
111+
{
112+
initialPageParam: 0,
113+
getNextPageParam: (lastPage, allPages) => {
114+
if (!lastPage.hasMore) return undefined;
115+
return allPages.reduce((n, p) => n + p.branches.length, 0);
116+
},
79117
},
80-
{ staleTime: 0, refetchOnMount: "always" },
81118
);
119+
120+
// Auto-fetch remaining pages in the background whenever we are not paused.
121+
// Any in-flight page is allowed to finish and land in the cache; the pause
122+
// just prevents us from kicking off the next one. Resuming picks up from
123+
// wherever `getNextPageParam` computes the next offset to be.
124+
useEffect(() => {
125+
if (paused) return;
126+
if (query.hasNextPage && !query.isFetchingNextPage) {
127+
query.fetchNextPage();
128+
}
129+
}, [
130+
paused,
131+
query.hasNextPage,
132+
query.isFetchingNextPage,
133+
query.fetchNextPage,
134+
]);
135+
136+
const data = useMemo(() => {
137+
if (!query.data?.pages.length) {
138+
return { branches: [] as string[], defaultBranch: null };
139+
}
140+
return {
141+
branches: query.data.pages.flatMap((p) => p.branches),
142+
defaultBranch: query.data.pages[0]?.defaultBranch ?? null,
143+
};
144+
}, [query.data?.pages]);
145+
146+
const pauseLoadingMore = useCallback(() => setPaused(true), []);
147+
const resumeLoadingMore = useCallback(() => setPaused(false), []);
148+
149+
return {
150+
data,
151+
isPending: query.isPending,
152+
isFetchingMore:
153+
!paused && (query.isFetchingNextPage || (query.hasNextPage ?? false)),
154+
pauseLoadingMore,
155+
resumeLoadingMore,
156+
};
82157
}
83158

84159
export function useRepositoryIntegration() {

0 commit comments

Comments
 (0)