Skip to content

Commit 60eab8a

Browse files
committed
feat: Added the ability to browser different branches and tag from the /browse page.
- Added a branch/tag selector dropdown component.
1 parent f176173 commit 60eab8a

File tree

10 files changed

+458
-39
lines changed

10 files changed

+458
-39
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
'use client';
2+
3+
import * as React from "react";
4+
import { Check, ChevronDown, GitBranch, Search, Tag } from "lucide-react";
5+
import { useQuery } from "@tanstack/react-query";
6+
import { getRefs } from "@/app/api/(client)/client";
7+
import { isServiceError } from "@/lib/utils";
8+
import { cn } from "@/lib/utils";
9+
import {
10+
DropdownMenu,
11+
DropdownMenuContent,
12+
DropdownMenuItem,
13+
DropdownMenuTrigger,
14+
} from "@/components/ui/dropdown-menu";
15+
import { Input } from "@/components/ui/input";
16+
import { ScrollArea } from "@/components/ui/scroll-area";
17+
18+
interface BranchTagSelectorProps {
19+
repoName: string;
20+
currentRef: string;
21+
onRefChange: (ref: string) => void;
22+
}
23+
24+
export function BranchTagSelector({ repoName, currentRef, onRefChange }: BranchTagSelectorProps) {
25+
const [open, setOpen] = React.useState(false);
26+
const [activeTab, setActiveTab] = React.useState<'branches' | 'tags'>('branches');
27+
const [searchQuery, setSearchQuery] = React.useState('');
28+
const inputRef = React.useRef<HTMLInputElement>(null);
29+
30+
const { data: refsData, isLoading } = useQuery({
31+
queryKey: ['refs', repoName],
32+
queryFn: async () => {
33+
const result = await getRefs({ repoName });
34+
if (isServiceError(result)) {
35+
throw new Error('Failed to fetch refs');
36+
}
37+
return result;
38+
},
39+
enabled: open || currentRef === 'HEAD',
40+
});
41+
42+
// Filter refs based on search query
43+
const filteredBranches = React.useMemo(() => {
44+
const branches = refsData?.branches || [];
45+
if (!searchQuery) return branches;
46+
return branches.filter(branch =>
47+
branch.toLowerCase().includes(searchQuery.toLowerCase())
48+
);
49+
}, [refsData?.branches, searchQuery]);
50+
51+
const filteredTags = React.useMemo(() => {
52+
const tags = refsData?.tags || [];
53+
if (!searchQuery) return tags;
54+
return tags.filter(tag =>
55+
tag.toLowerCase().includes(searchQuery.toLowerCase())
56+
);
57+
}, [refsData?.tags, searchQuery]);
58+
59+
const resolvedRef = currentRef === 'HEAD' ? (refsData?.defaultBranch || 'HEAD') : currentRef;
60+
const displayRef = resolvedRef.replace(/^refs\/(heads|tags)\//, '');
61+
62+
const handleRefSelect = (ref: string) => {
63+
onRefChange(ref);
64+
setOpen(false);
65+
setSearchQuery('');
66+
};
67+
68+
// Prevent dropdown items from stealing focus while user is typing
69+
const handleItemFocus = (e: React.FocusEvent) => {
70+
if (searchQuery) {
71+
e.preventDefault();
72+
inputRef.current?.focus();
73+
}
74+
};
75+
76+
// Keep focus on the search input when typing
77+
React.useEffect(() => {
78+
if (open && searchQuery && inputRef.current) {
79+
const timeoutId = setTimeout(() => {
80+
inputRef.current?.focus();
81+
}, 0);
82+
return () => clearTimeout(timeoutId);
83+
}
84+
}, [open, searchQuery]);
85+
86+
return (
87+
<DropdownMenu open={open} onOpenChange={setOpen}>
88+
<DropdownMenuTrigger asChild>
89+
<button
90+
className="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
91+
aria-label="Switch branches or tags"
92+
>
93+
<GitBranch className="h-3.5 w-3.5 flex-shrink-0" />
94+
<span className="truncate max-w-[150px]">{displayRef}</span>
95+
<ChevronDown className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
96+
</button>
97+
</DropdownMenuTrigger>
98+
<DropdownMenuContent
99+
align="start"
100+
className="w-[320px] p-0"
101+
onCloseAutoFocus={(e) => e.preventDefault()}
102+
>
103+
<div className="flex flex-col" onKeyDown={(e) => {
104+
// Prevent dropdown keyboard navigation from interfering with search input
105+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
106+
e.stopPropagation();
107+
}
108+
}}>
109+
{/* Search input */}
110+
<div className="p-2 border-b">
111+
<div className="relative">
112+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
113+
<Input
114+
ref={inputRef}
115+
type="text"
116+
placeholder={activeTab === 'branches' ? "Find a branch..." : "Find a tag..."}
117+
value={searchQuery}
118+
onChange={(e) => setSearchQuery(e.target.value)}
119+
onKeyDown={(e) => {
120+
// Prevent dropdown menu keyboard navigation
121+
e.stopPropagation();
122+
}}
123+
className="pl-8 h-8 text-sm"
124+
autoFocus
125+
/>
126+
</div>
127+
</div>
128+
129+
<div className="flex border-b">
130+
<button
131+
onClick={() => setActiveTab('branches')}
132+
className={cn(
133+
"flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2",
134+
activeTab === 'branches'
135+
? "border-blue-600 text-blue-600"
136+
: "border-transparent text-gray-600 hover:text-gray-900"
137+
)}
138+
>
139+
<div className="flex items-center justify-center gap-1.5">
140+
<GitBranch className="h-4 w-4" />
141+
Branches
142+
</div>
143+
</button>
144+
<button
145+
onClick={() => setActiveTab('tags')}
146+
className={cn(
147+
"flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2",
148+
activeTab === 'tags'
149+
? "border-blue-600 text-blue-600"
150+
: "border-transparent text-gray-600 hover:text-gray-900"
151+
)}
152+
>
153+
<div className="flex items-center justify-center gap-1.5">
154+
<Tag className="h-4 w-4" />
155+
Tags
156+
</div>
157+
</button>
158+
</div>
159+
160+
<ScrollArea className="h-[300px]">
161+
{isLoading ? (
162+
<div className="p-4 text-sm text-gray-500 text-center">
163+
Loading...
164+
</div>
165+
) : (
166+
<div className="p-1">
167+
{activeTab === 'branches' && (
168+
<>
169+
{filteredBranches.length === 0 ? (
170+
<div className="p-4 text-sm text-gray-500 text-center">
171+
{searchQuery ? 'No branches found' : 'No branches available'}
172+
</div>
173+
) : (
174+
filteredBranches.map((branch) => (
175+
<DropdownMenuItem
176+
key={branch}
177+
onClick={() => handleRefSelect(branch)}
178+
onFocus={handleItemFocus}
179+
className="flex items-center justify-between px-3 py-2 cursor-pointer"
180+
>
181+
<div className="flex items-center gap-2 flex-1 min-w-0">
182+
<GitBranch className="h-4 w-4 text-gray-500 flex-shrink-0" />
183+
<span className="truncate text-sm">{branch}</span>
184+
{branch === refsData?.defaultBranch && (
185+
<span className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 px-1.5 py-0.5 rounded font-medium flex-shrink-0">
186+
default
187+
</span>
188+
)}
189+
</div>
190+
{branch === resolvedRef && (
191+
<Check className="h-4 w-4 text-blue-600 flex-shrink-0" />
192+
)}
193+
</DropdownMenuItem>
194+
))
195+
)}
196+
</>
197+
)}
198+
{activeTab === 'tags' && (
199+
<>
200+
{filteredTags.length === 0 ? (
201+
<div className="p-4 text-sm text-gray-500 text-center">
202+
{searchQuery ? 'No tags found' : 'No tags available'}
203+
</div>
204+
) : (
205+
filteredTags.map((tag) => (
206+
<DropdownMenuItem
207+
key={tag}
208+
onClick={() => handleRefSelect(tag)}
209+
onFocus={handleItemFocus}
210+
className="flex items-center justify-between px-3 py-2 cursor-pointer"
211+
>
212+
<div className="flex items-center gap-2 flex-1 min-w-0">
213+
<Tag className="h-4 w-4 text-gray-500 flex-shrink-0" />
214+
<span className="truncate text-sm">{tag}</span>
215+
</div>
216+
{tag === resolvedRef && (
217+
<Check className="h-4 w-4 text-blue-600 flex-shrink-0" />
218+
)}
219+
</DropdownMenuItem>
220+
))
221+
)}
222+
</>
223+
)}
224+
</div>
225+
)}
226+
</ScrollArea>
227+
</div>
228+
</DropdownMenuContent>
229+
</DropdownMenu>
230+
);
231+
}

packages/web/src/app/[domain]/components/pathHeader.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const PathHeader = ({
5757
revisionName,
5858
branchDisplayName = revisionName,
5959
isBranchDisplayNameVisible = !!branchDisplayName,
60-
branchDisplayTitle,
60+
branchDisplayTitle: _branchDisplayTitle,
6161
pathType = 'blob',
6262
isCodeHostIconVisible = true,
6363
isFileIconVisible = true,
@@ -231,7 +231,6 @@ export const PathHeader = ({
231231
{(isBranchDisplayNameVisible && branchDisplayName) && (
232232
<p
233233
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
234-
title={branchDisplayTitle}
235234
style={{
236235
marginBottom: "0.1rem",
237236
}}

packages/web/src/app/api/(client)/client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
'use client';
22

33
import { ServiceError } from "@/lib/serviceError";
4-
import { GetVersionResponse, ListReposQueryParams, ListReposResponse } from "@/lib/types";
4+
import {
5+
GetVersionResponse,
6+
GetRefsRequest,
7+
GetRefsResponse,
8+
ListReposQueryParams,
9+
ListReposResponse
10+
} from "@/lib/types";
511
import { isServiceError } from "@/lib/utils";
612
import {
713
SearchRequest,
@@ -108,3 +114,11 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
108114
}).then(response => response.json());
109115
return result as GetFilesResponse | ServiceError;
110116
}
117+
118+
export const getRefs = async (body: GetRefsRequest): Promise<GetRefsResponse | ServiceError> => {
119+
const result = await fetch("/api/refs", {
120+
method: "POST",
121+
body: JSON.stringify(body),
122+
}).then(response => response.json());
123+
return result as GetRefsResponse | ServiceError;
124+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import 'server-only';
2+
3+
import { sew } from '@/actions';
4+
import { notFound, unexpectedError } from '@/lib/serviceError';
5+
import { withOptionalAuthV2 } from '@/withAuthV2';
6+
import { createLogger, getRepoPath, repoMetadataSchema } from '@sourcebot/shared';
7+
import { simpleGit } from 'simple-git';
8+
9+
const logger = createLogger('refs');
10+
11+
export const getRefs = async (params: { repoName: string }) => sew(() =>
12+
withOptionalAuthV2(async ({ org, prisma }) => {
13+
const { repoName } = params;
14+
const repo = await prisma.repo.findFirst({
15+
where: {
16+
name: repoName,
17+
orgId: org.id,
18+
},
19+
});
20+
21+
if (!repo) {
22+
return notFound();
23+
}
24+
25+
const metadata = repoMetadataSchema.safeParse(repo.metadata);
26+
const indexedRevisions = metadata.success ? (metadata.data.indexedRevisions || []) : [];
27+
28+
const { path: repoPath } = getRepoPath(repo);
29+
30+
const git = simpleGit().cwd(repoPath);
31+
32+
let allBranches: string[] = [];
33+
let allTags: string[] = [];
34+
let defaultBranch: string | null = null;
35+
36+
try {
37+
const branchResult = await git.branch();
38+
allBranches = branchResult.all;
39+
defaultBranch = branchResult.current || null;
40+
} catch (error) {
41+
logger.error('git branch failed.', { error });
42+
return unexpectedError('git branch command failed.');
43+
}
44+
45+
try {
46+
const tagResult = await git.tags();
47+
allTags = tagResult.all;
48+
} catch (error) {
49+
logger.error('git tags failed.', { error });
50+
return unexpectedError('git tags command failed.');
51+
}
52+
53+
const indexedRefsSet = new Set(indexedRevisions);
54+
55+
const branches = allBranches.filter(branch => {
56+
return indexedRefsSet.has(`refs/heads/${branch}`);
57+
});
58+
59+
const tags = allTags.filter(tag => {
60+
return indexedRefsSet.has(`refs/tags/${tag}`);
61+
});
62+
63+
return {
64+
branches,
65+
tags,
66+
defaultBranch,
67+
};
68+
}));
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use server';
2+
3+
import { getRefsRequestSchema } from "@/lib/schemas";
4+
import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
5+
import { isServiceError } from "@/lib/utils";
6+
import { NextRequest } from "next/server";
7+
import { getRefs } from "./getRefs";
8+
9+
export const POST = async (request: NextRequest) => {
10+
const body = await request.json();
11+
const parsed = await getRefsRequestSchema.safeParseAsync(body);
12+
if (!parsed.success) {
13+
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
14+
}
15+
16+
const response = await getRefs(parsed.data);
17+
if (isServiceError(response)) {
18+
return serviceErrorResponse(response);
19+
}
20+
21+
return Response.json(response);
22+
}

0 commit comments

Comments
 (0)