feat: Better support for pagination in gh branch dropdown (#430)

* feat: Better support for pagination in gh branch dropdown

* cr

* cr
This commit is contained in:
Brace Sproul 2025-07-16 11:49:46 -07:00 • committed by GitHub
parent 7a06b37bd8
commit 7e5a5a7947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 172 additions and 20 deletions

View file

@ -18,6 +18,8 @@ import { useState, useEffect } from "react";
import { useGitHubAppProvider } from "@/providers/GitHubApp"; import { useGitHubAppProvider } from "@/providers/GitHubApp";
import { GitBranch, Shield } from "lucide-react"; import { GitBranch, Shield } from "lucide-react";
import { TargetRepository } from "@open-swe/shared/open-swe/types"; import { TargetRepository } from "@open-swe/shared/open-swe/types";
import { Branch } from "@/utils/github";
import { toast } from "sonner";
interface BranchSelectorProps { interface BranchSelectorProps {
disabled?: boolean; disabled?: boolean;
@ -27,6 +29,21 @@ interface BranchSelectorProps {
streamTargetRepository?: TargetRepository; streamTargetRepository?: TargetRepository;
} }
const findDefaultBranch = (
branches: Branch[],
defaultBranch: string | null,
) => {
const actualDefaultBranch = defaultBranch
? branches.find((branch) => branch.name === defaultBranch)
: null;
return actualDefaultBranch
? actualDefaultBranch.name
: branches.length > 0
? branches[0].name
: null;
};
export function BranchSelector({ export function BranchSelector({
disabled = false, disabled = false,
placeholder = "Select a branch...", placeholder = "Select a branch...",
@ -35,6 +52,8 @@ export function BranchSelector({
streamTargetRepository, streamTargetRepository,
}: BranchSelectorProps) { }: BranchSelectorProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const { const {
branches, branches,
branchesLoading, branchesLoading,
@ -45,6 +64,7 @@ export function BranchSelector({
branchesHasMore, branchesHasMore,
branchesLoadingMore, branchesLoadingMore,
loadMoreBranches, loadMoreBranches,
searchForBranch,
defaultBranch, defaultBranch,
} = useGitHubAppProvider(); } = useGitHubAppProvider();
@ -61,19 +81,16 @@ export function BranchSelector({
selectedBranch && selectedBranch &&
branches.some((branch) => branch.name === selectedBranch); branches.some((branch) => branch.name === selectedBranch);
// Auto-select default branch if no branch is selected OR if the selected branch doesn't exist in this repo if (selectedBranch && !currentBranchExists) {
if (!selectedBranch || !currentBranchExists) { searchForBranch(selectedBranch).then((b) => {
// Try to find the repository's actual default branch first if (b) {
const actualDefaultBranch = defaultBranch // branch was found after search. can return early
? branches.find((branch) => branch.name === defaultBranch) return;
: null; }
setSelectedBranch(findDefaultBranch(branches, defaultBranch));
if (actualDefaultBranch) { });
setSelectedBranch(actualDefaultBranch.name); } else if (!selectedBranch || !currentBranchExists) {
} else if (branches.length > 0) { setSelectedBranch(findDefaultBranch(branches, defaultBranch));
// If default branch doesn't exist in branches list, select the first available branch
setSelectedBranch(branches[0].name);
}
} }
} }
}, [ }, [
@ -92,6 +109,20 @@ export function BranchSelector({
setOpen(false); setOpen(false);
}; };
const handleSearchForBranch = async () => {
if (!searchQuery.trim() || !selectedRepository) return;
setIsSearching(true);
try {
const foundBranch = await searchForBranch(searchQuery.trim());
if (!foundBranch) {
toast.warning(`Branch "${searchQuery.trim()}" not found`);
}
} finally {
setIsSearching(false);
}
};
if (!selectedRepository) { if (!selectedRepository) {
return ( return (
<Button <Button
@ -106,7 +137,7 @@ export function BranchSelector({
); );
} }
if (branchesLoading) { if (branchesLoading && !branches.length) {
return ( return (
<Button <Button
variant="outline" variant="outline"
@ -198,9 +229,32 @@ export function BranchSelector({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[340px] p-0"> <PopoverContent className="w-[340px] p-0">
<Command> <Command>
<CommandInput placeholder="Search branches..." /> <CommandInput
placeholder="Search branches..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList> <CommandList>
<CommandEmpty>No branches found.</CommandEmpty> <CommandEmpty>
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-muted-foreground text-sm">
No branches found.
</span>
{searchQuery.trim() && (
<Button
variant="outline"
size="sm"
onClick={handleSearchForBranch}
disabled={isSearching}
className="text-xs"
>
{isSearching
? "Searching..."
: `Search for "${searchQuery.trim()}"`}
</Button>
)}
</div>
</CommandEmpty>
<CommandGroup> <CommandGroup>
{branches {branches
.slice() .slice()
@ -244,6 +298,26 @@ export function BranchSelector({
); );
})} })}
</CommandGroup> </CommandGroup>
{/* Show this search button if there is a search query, and there are some results. this is for
cases when some results do show, just not the exact result the user is looking for */}
{searchQuery.trim() &&
branches.some((branch) =>
branch.name.toLowerCase().includes(searchQuery.toLowerCase()),
) && (
<div className="px-2 py-1">
<Button
variant="outline"
size="sm"
onClick={handleSearchForBranch}
disabled={isSearching}
className="w-full text-xs"
>
{isSearching
? "Searching..."
: `Search for "${searchQuery.trim()}"`}
</Button>
</div>
)}
{branchesHasMore && ( {branchesHasMore && (
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem

View file

@ -1,6 +1,11 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { Repository, getRepositoryBranches, Branch } from "@/utils/github"; import {
Repository,
getRepositoryBranches,
Branch,
searchBranch,
} from "@/utils/github";
import { getRepository } from "@/utils/github"; import { getRepository } from "@/utils/github";
import type { TargetRepository } from "@open-swe/shared/open-swe/types"; import type { TargetRepository } from "@open-swe/shared/open-swe/types";
import { import {
@ -88,6 +93,7 @@ interface UseGitHubAppReturn {
selectedBranch: string | null; selectedBranch: string | null;
setSelectedBranch: (branch: string | null) => void; setSelectedBranch: (branch: string | null) => void;
refreshBranches: () => Promise<void>; refreshBranches: () => Promise<void>;
searchForBranch: (branchName: string) => Promise<Branch | null>;
// Repository metadata // Repository metadata
defaultBranch: string | null; defaultBranch: string | null;
@ -294,6 +300,39 @@ export function useGitHubApp(): UseGitHubAppReturn {
} }
}, [branchesHasMore, branchesLoadingMore, branchesPage, fetchBranches]); }, [branchesHasMore, branchesLoadingMore, branchesPage, fetchBranches]);
const searchForBranch = useCallback(
async (branchName: string): Promise<Branch | null> => {
if (!selectedRepository) {
return null;
}
try {
const branch = await searchBranch(
selectedRepository.owner,
selectedRepository.repo,
branchName,
);
if (branch) {
// Add the found branch to the existing branches list if it's not already there
setBranches((prev) => {
const exists = prev.some((b) => b.name === branch.name);
if (!exists) {
return [...prev, branch];
}
return prev;
});
}
return branch;
} catch (error) {
console.error(`Error searching for branch ${branchName}:`, error);
return null;
}
},
[selectedRepository?.owner, selectedRepository?.repo],
);
// Refresh repositories when installation changes // Refresh repositories when installation changes
useEffect(() => { useEffect(() => {
if (currentInstallationId) { if (currentInstallationId) {
@ -483,6 +522,7 @@ export function useGitHubApp(): UseGitHubAppReturn {
selectedBranch, selectedBranch,
setSelectedBranch, setSelectedBranch,
refreshBranches, refreshBranches,
searchForBranch,
setBranchesPage, setBranchesPage,
setBranches, setBranches,

View file

@ -187,7 +187,10 @@ export async function getRepositoryBranches(
/** /**
* Fetches a specific repository using OAuth access token * Fetches a specific repository using OAuth access token
*/ */
export async function getRepository(owner: string, repo: string) { export async function getRepository(
owner: string,
repo: string,
): Promise<Repository> {
const response = await fetch( const response = await fetch(
`${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}`, `${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}`,
{ {
@ -203,8 +206,43 @@ export async function getRepository(owner: string, repo: string) {
throw new Error(`Failed to fetch repository: ${JSON.stringify(errorData)}`); throw new Error(`Failed to fetch repository: ${JSON.stringify(errorData)}`);
} }
const data = await response.json(); return response.json();
return data; }
/**
* Searches for a specific branch by name in a repository
*/
export async function searchBranch(
owner: string,
repo: string,
branchName: string,
): Promise<Branch | null> {
try {
const response = await fetch(
`${getBaseApiUrl()}github/proxy/repos/${owner}/${repo}/branches/${encodeURIComponent(branchName)}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
},
);
if (!response.ok) {
if (response.status === 404) {
return null; // Branch not found
}
const errorData = await response.json();
throw new Error(
`Failed to search for branch: ${JSON.stringify(errorData)}`,
);
}
return response.json();
} catch (error) {
console.error(`Error searching for branch ${branchName}:`, error);
return null;
}
} }
/** /**