From de5936065e6f2ab662a52f4bd75b039c266e0cd9 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 23 Apr 2026 10:06:45 +0100 Subject: [PATCH] feat(code): add paginated github repo picker --- apps/code/src/renderer/api/posthogClient.ts | 38 +++++- .../components/GitHubRepoPicker.tsx | 119 +++++++++++++++--- .../inbox/components/DataSourceSetup.tsx | 40 +++++- .../task-detail/components/TaskInput.tsx | 41 +++++- .../src/renderer/hooks/useIntegrations.ts | 94 ++++++++++++++ 5 files changed, 312 insertions(+), 20 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 53944ed46..202fef6fa 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1292,10 +1292,43 @@ export class PostHogAPIClient { async getGithubRepositories( integrationId: string | number, ): Promise { + const repositories: string[] = []; + let offset = 0; + + while (true) { + const page = await this.getGithubRepositoriesPage( + integrationId, + offset, + 500, + ); + repositories.push(...page.repositories); + + if (!page.hasMore) { + return repositories; + } + + offset += page.repositories.length; + } + } + + async getGithubRepositoriesPage( + integrationId: string | number, + offset: number, + limit: number, + search?: string, + ): Promise<{ + repositories: string[]; + hasMore: boolean; + }> { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, ); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -1309,7 +1342,10 @@ export class PostHogAPIClient { } const data = await response.json(); - return this.normalizeGithubRepositories(data); + return { + repositories: this.normalizeGithubRepositories(data), + hasMore: data.has_more ?? false, + }; } async refreshGithubRepositories( diff --git a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx index d67091cc7..9bae18004 100644 --- a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx @@ -8,11 +8,13 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxListFooter, ComboboxTrigger, } from "@posthog/quill"; -import { type RefObject, useEffect, useRef, useState } from "react"; +import { defaultFilter } from "cmdk"; +import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; -const COMBOBOX_LIMIT = 50; +const COMBOBOX_INITIAL_LIMIT = 50; interface GitHubRepoPickerProps { value: string | null; @@ -27,6 +29,12 @@ interface GitHubRepoPickerProps { showSearchInput?: boolean; onRefresh?: () => void; isRefreshing?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + searchQuery?: string; + onSearchQueryChange?: (value: string) => void; + hasMore?: boolean; + onLoadMore?: () => void; } export function GitHubRepoPicker({ @@ -40,10 +48,39 @@ export function GitHubRepoPicker({ showSearchInput = true, onRefresh, isRefreshing = false, + open: controlledOpen, + onOpenChange, + searchQuery: controlledSearchQuery, + onSearchQueryChange, + hasMore: controlledHasMore, + onLoadMore, }: GitHubRepoPickerProps) { const triggerRef = useRef(null); - const [searchQuery, setSearchQuery] = useState(""); + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState(""); + const [visibleLimit, setVisibleLimit] = useState(COMBOBOX_INITIAL_LIMIT); + const open = controlledOpen ?? uncontrolledOpen; + const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery; + const remoteMode = + controlledSearchQuery !== undefined || + onSearchQueryChange !== undefined || + controlledHasMore !== undefined || + onLoadMore !== undefined; + const showInlineLoadingState = remoteMode && open && isLoading; const onlyRepo = repositories.length === 1 ? repositories[0] : null; + const trimmedSearchQuery = searchQuery.trim(); + const filteredRepositoryCount = useMemo(() => { + if (!trimmedSearchQuery) { + return repositories.length; + } + + return repositories.reduce( + (count, repo) => + count + (defaultFilter(repo, trimmedSearchQuery) > 0 ? 1 : 0), + 0, + ); + }, [repositories, trimmedSearchQuery]); + const hasMore = controlledHasMore ?? filteredRepositoryCount > visibleLimit; useEffect(() => { if (onlyRepo && value !== onlyRepo) { @@ -51,7 +88,7 @@ export function GitHubRepoPicker({ } }, [onlyRepo, value, onChange]); - if (isLoading) { + if (isLoading && !showInlineLoadingState) { return ( + ) : null} + + )} diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index 78230b526..c0ddbc767 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -1,7 +1,10 @@ import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; -import { useRepositoryIntegration } from "@hooks/useIntegrations"; +import { + useGithubRepositories, + useRepositoryIntegration, +} from "@hooks/useIntegrations"; import { Box, Button, Flex, Text, TextField } from "@radix-ui/themes"; import { trpcClient } from "@renderer/trpc"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -67,6 +70,14 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { refreshRepositories, hasGithubIntegration, } = useRepositoryIntegration(); + const [repoPickerSearchQuery, setRepoPickerSearchQuery] = useState(""); + const [isRepoPickerOpen, setIsRepoPickerOpen] = useState(false); + const { + repositories: visibleRepositories, + isPending: visibleRepositoriesLoading, + hasMore: visibleRepositoriesHasMore, + loadMore: loadMoreVisibleRepositories, + } = useGithubRepositories(repoPickerSearchQuery, isRepoPickerOpen); const [repo, setRepo] = useState(null); const [loading, setLoading] = useState(false); const [connecting, setConnecting] = useState(false); @@ -194,6 +205,21 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { }); }, [refreshRepositories]); + const handleRepoPickerOpenChange = useCallback((open: boolean) => { + setIsRepoPickerOpen(open); + if (!open) { + setRepoPickerSearchQuery(""); + } + }, []); + + const handleRepoPickerSearchChange = useCallback((value: string) => { + setRepoPickerSearchQuery(value); + }, []); + + const handleLoadMoreRepositories = useCallback(() => { + loadMoreVisibleRepositories(); + }, [loadMoreVisibleRepositories]); + if (!hasGithubIntegration) { return ( @@ -229,10 +255,18 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { 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 836c3e47e..34f02d182 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -24,6 +24,7 @@ import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { useConnectivity } from "@hooks/useConnectivity"; import { useGithubBranches, + useGithubRepositories, useRepositoryIntegration, } from "@hooks/useIntegrations"; import { ButtonGroup } from "@posthog/quill"; @@ -79,6 +80,8 @@ export function TaskInput({ const [isDraggingFile, setIsDraggingFile] = useState(false); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); + const [cloudRepoSearchQuery, setCloudRepoSearchQuery] = useState(""); + const [isCloudRepoPickerOpen, setIsCloudRepoPickerOpen] = useState(false); const [cloudBranchSearchQuery, setCloudBranchSearchQuery] = useState(""); const [isCloudBranchPickerOpen, setIsCloudBranchPickerOpen] = useState(false); const [selectedEnvironment, setSelectedEnvironmentRaw] = useState< @@ -114,6 +117,12 @@ export function TaskInput({ isRefreshingRepos, refreshRepositories, } = useRepositoryIntegration(); + const { + repositories: visibleCloudRepositories, + isPending: cloudRepositoriesLoading, + hasMore: cloudRepositoriesHasMore, + loadMore: loadMoreCloudRepositories, + } = useGithubRepositories(cloudRepoSearchQuery, isCloudRepoPickerOpen); const [selectedRepository, setSelectedRepository] = useState( () => lastUsedCloudRepository?.toLowerCase() ?? null, ); @@ -219,6 +228,21 @@ export function TaskInput({ setIsCloudBranchPickerOpen(true); }, []); + const handleCloudRepoPickerOpenChange = useCallback((open: boolean) => { + setIsCloudRepoPickerOpen(open); + if (!open) { + setCloudRepoSearchQuery(""); + } + }, []); + + const handleCloudRepoSearchChange = useCallback((value: string) => { + setCloudRepoSearchQuery(value); + }, []); + + const handleLoadMoreCloudRepositories = useCallback(() => { + loadMoreCloudRepositories(); + }, [loadMoreCloudRepositories]); + const handleCloudBranchPickerClose = useCallback(() => { setIsCloudBranchPickerOpen(false); setCloudBranchSearchQuery(""); @@ -521,10 +545,23 @@ export function TaskInput({ [...integrationKeys.all, "list"] as const, repositories: (integrationId?: number) => [...integrationKeys.all, "repositories", integrationId] as const, + repositoryPicker: (integrationId?: number, search?: string, limit?: number) => + [ + ...integrationKeys.all, + "repository-picker", + integrationId, + search, + limit, + ] as const, branches: (integrationId?: number, repo?: string | null, search?: string) => [...integrationKeys.all, "branches", integrationId, repo, search] as const, }; @@ -74,9 +82,91 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) { }); } +const REPOSITORIES_PAGE_SIZE = 50; const BRANCHES_FIRST_PAGE_SIZE = 50; const BRANCHES_PAGE_SIZE = 100; +export function useGithubRepositories( + search?: string, + enabled: boolean = true, +) { + const client = useOptionalAuthenticatedClient(); + const { githubIntegrations } = useIntegrationSelectors(); + const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const [requestedLimit, setRequestedLimit] = useState(REPOSITORIES_PAGE_SIZE); + const queryEnabled = enabled && !!client && githubIntegrations.length > 0; + + useEffect(() => { + setRequestedLimit(REPOSITORIES_PAGE_SIZE); + }, []); + + const { repositoryMap, isPending, isRefreshing, hasMore } = useQueries({ + queries: githubIntegrations.map((integration) => ({ + queryKey: integrationKeys.repositoryPicker( + integration.id, + deferredSearch, + requestedLimit, + ), + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + + const page = await client.getGithubRepositoriesPage( + integration.id, + 0, + requestedLimit, + deferredSearch, + ); + + return { integrationId: integration.id, ...page }; + }, + enabled: queryEnabled, + staleTime: 5 * 60 * 1000, + meta: AUTH_SCOPED_QUERY_META, + })), + combine: (results) => { + const map: Record = {}; + let pending = false; + let refreshing = false; + let hasMoreResults = false; + + for (const result of results) { + if (result.isPending) pending = true; + if (result.isRefetching) refreshing = true; + if (!result.data) continue; + + if (result.data.hasMore) { + hasMoreResults = true; + } + + for (const repo of result.data.repositories ?? []) { + if (!(repo in map)) { + map[repo] = result.data.integrationId; + } + } + } + + return { + repositoryMap: map, + isPending: pending, + isRefreshing: refreshing, + hasMore: hasMoreResults, + }; + }, + }); + + const loadMore = useCallback(() => { + setRequestedLimit((currentLimit) => currentLimit + REPOSITORIES_PAGE_SIZE); + }, []); + + return { + repositories: Object.keys(repositoryMap), + isPending: queryEnabled ? isPending : false, + isRefreshing: queryEnabled ? isRefreshing : false, + hasMore, + loadMore, + }; +} + interface GithubBranchesPage { branches: string[]; defaultBranch: string | null; @@ -199,6 +289,10 @@ export function useRepositoryIntegration() { }), ), ); + + await queryClient.refetchQueries({ + queryKey: [...integrationKeys.all, "repository-picker"], + }); } finally { setIsRefreshingRepos(false); }