Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ interface BranchSelectorProps {
onBranchSelect?: (branch: string | null) => void;
cloudBranches?: string[];
cloudBranchesLoading?: boolean;
cloudBranchesFetchingMore?: boolean;
onCloudPickerOpen?: () => void;
onCloudBranchCommit?: () => void;
taskId?: string;
}

Expand All @@ -36,6 +39,9 @@ export function BranchSelector({
onBranchSelect,
cloudBranches,
cloudBranchesLoading,
cloudBranchesFetchingMore,
onCloudPickerOpen,
onCloudBranchCommit,
taskId,
}: BranchSelectorProps) {
const [open, setOpen] = useState(false);
Expand All @@ -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({
Expand All @@ -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 = (
<Flex align="center" gap="1" style={{ minWidth: 0 }}>
{effectiveLoading ? (
{showSpinner ? (
<Spinner size="1" />
) : (
<GitBranch size={16} weight="regular" style={{ flexShrink: 0 }} />
Expand All @@ -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}
>
<Combobox.Trigger variant={variant} placeholder="No branch">
{triggerContent}
Expand All @@ -126,6 +154,17 @@ export function BranchSelector({
{({ filtered, hasMore, moreCount }) => (
<>
<Combobox.Input placeholder="Search branches" />
{isCloudMode && cloudBranchesFetchingMore && (
<Flex
align="center"
gap="1"
className="combobox-label"
style={{ padding: "6px 8px" }}
>
<Spinner size="1" />
Loading more ({branches.length})…
</Flex>
)}
<Combobox.Empty>No branches found.</Combobox.Empty>

{filtered.length > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -359,6 +364,9 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) {
onBranchSelect={setSelectedBranch}
cloudBranches={cloudBranches}
cloudBranchesLoading={cloudBranchesLoading}
cloudBranchesFetchingMore={cloudBranchesFetchingMore}
onCloudPickerOpen={resumeCloudBranchesLoading}
onCloudBranchCommit={pauseCloudBranchesLoading}
/>
</TourHighlight>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -464,6 +469,9 @@ export function TaskInput({
onBranchSelect={setSelectedBranch}
cloudBranches={cloudBranches}
cloudBranchesLoading={cloudBranchesLoading}
cloudBranchesFetchingMore={cloudBranchesFetchingMore}
onCloudPickerOpen={resumeCloudBranchesLoading}
onCloudBranchCommit={pauseCloudBranchesLoading}
/>
{workspaceMode === "worktree" && (
<EnvironmentSelector
Expand Down
87 changes: 81 additions & 6 deletions apps/code/src/renderer/hooks/useIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
useIntegrationStore,
} from "@features/integrations/stores/integrationStore";
import { useQueries } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery";
import { useAuthenticatedQuery } from "./useAuthenticatedQuery";

const integrationKeys = {
Expand Down Expand Up @@ -67,18 +68,92 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) {
});
}

// Keep the first page small so it returns in a single upstream GitHub round
// trip (GitHub's max per_page is 100), then fetch the remainder in larger
// chunks to keep the total number of client/PostHog round trips low.
const BRANCHES_FIRST_PAGE_SIZE = 100;
const BRANCHES_PAGE_SIZE = 1000;

interface GithubBranchesPage {
branches: string[];
defaultBranch: string | null;
hasMore: boolean;
}

export function useGithubBranches(
integrationId?: number,
repo?: string | null,
) {
return useAuthenticatedQuery(
// While paused we stop chaining `fetchNextPage` calls. The flag is scoped
// to the current query target and resets whenever it changes, so switching
// repos or integrations starts a fresh fetch.
const [paused, setPaused] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on key change
useEffect(() => {
setPaused(false);
}, [integrationId, repo]);

const query = useAuthenticatedInfiniteQuery<GithubBranchesPage, number>(
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(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we abort this query once the user selects a branch?

or if we keep this and fetch everything even after the user is done, we should at least be caching the results (see global comment)

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() {
Expand Down
Loading