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
231 changes: 231 additions & 0 deletions packages/web/src/app/[domain]/components/branchTagSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
'use client';

import * as React from "react";
import { Check, ChevronDown, GitBranch, Search, Tag } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getRefs } from "@/app/api/(client)/client";
import { isServiceError } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";

interface BranchTagSelectorProps {
repoName: string;
currentRef: string;
onRefChange: (ref: string) => void;
}

export function BranchTagSelector({ repoName, currentRef, onRefChange }: BranchTagSelectorProps) {
const [open, setOpen] = React.useState(false);
const [activeTab, setActiveTab] = React.useState<'branches' | 'tags'>('branches');
const [searchQuery, setSearchQuery] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null);

const { data: refsData, isLoading } = useQuery({
queryKey: ['refs', repoName],
queryFn: async () => {
const result = await getRefs({ repoName });
if (isServiceError(result)) {
throw new Error('Failed to fetch refs');
}
return result;
},
enabled: open || currentRef === 'HEAD',
});
Comment on lines +30 to +40
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the file exists and read it
FILE="packages/web/src/app/[domain]/components/branchTagSelector.tsx"
if [ -f "$FILE" ]; then
    wc -l "$FILE"
else
    echo "File not found: $FILE"
    exit 1
fi

Repository: sourcebot-dev/sourcebot

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Read the relevant sections of the file
FILE="packages/web/src/app/[domain]/components/branchTagSelector.tsx"
# Read lines 25-45 to see context around line 30-40
sed -n '25,45p' "$FILE"
echo "---"
# Read lines 155-170 to see context around line 160-165
sed -n '155,170p' "$FILE"

Repository: sourcebot-dev/sourcebot

Length of output: 1649


🏁 Script executed:

#!/bin/bash
# Search for the useQuery call to see all destructured variables
FILE="packages/web/src/app/[domain]/components/branchTagSelector.tsx"
rg "useQuery" "$FILE" -A 10 -B 2

Repository: sourcebot-dev/sourcebot

Length of output: 990


🏁 Script executed:

#!/bin/bash
FILE="packages/web/src/app/[domain]/components/branchTagSelector.tsx"
# Read the complete conditional rendering block
sed -n '157,180p' "$FILE"

Repository: sourcebot-dev/sourcebot

Length of output: 1501


🏁 Script executed:

#!/bin/bash
FILE="packages/web/src/app/[domain]/components/branchTagSelector.tsx"
# Check the entire file for any error handling or isError references
rg "isError|error" "$FILE" -i

Repository: sourcebot-dev/sourcebot

Length of output: 211


Add error state handling to surface fetch failures.

The useQuery hook only destructures isLoading, leaving isError unchecked. When getRefs fails and throws an error, React Query sets isError internally, but the UI silently falls back to empty lists ("No branches available") rather than showing a failure message. This masks failures from the user.

Destructure isError from useQuery and add a conditional branch to render an error state.

🔧 Proposed fix
-    const { data: refsData, isLoading } = useQuery({
+    const { data: refsData, isLoading, isError } = useQuery({
         queryKey: ['refs', repoName],
         queryFn: async () => {
             const result = await getRefs({ repoName });
             if (isServiceError(result)) {
                 throw new Error('Failed to fetch refs');
             }
             return result;
         },
         enabled: open || currentRef === 'HEAD',
     });
@@
-                        {isLoading ? (
+                        {isLoading ? (
                             <div className="p-4 text-sm text-gray-500 text-center">
                                 Loading...
                             </div>
-                        ) : (
+                        ) : isError ? (
+                            <div className="p-4 text-sm text-red-600 text-center">
+                                Failed to load branches/tags
+                            </div>
+                        ) : (
                             <div className="p-1">

Also applies to the same rendering pattern at lines 160-165.

🤖 Prompt for AI Agents
In `@packages/web/src/app/`[domain]/components/branchTagSelector.tsx around lines
30 - 40, Destructure isError from the useQuery call that fetches refs (the one
using queryKey ['refs', repoName] and queryFn that calls getRefs and
isServiceError) and add a conditional render branch that shows an error
UI/message when isError is true instead of falling back to "No branches
available"; do the same change for the other identical rendering block around
the later lines (the same pattern that lists branches/tags), ensuring the UI
surfaces the failure and does not silently render empty lists when getRefs
throws.


// Filter refs based on search query
const filteredBranches = React.useMemo(() => {
const branches = refsData?.branches || [];
if (!searchQuery) return branches;
return branches.filter(branch =>
branch.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [refsData?.branches, searchQuery]);

const filteredTags = React.useMemo(() => {
const tags = refsData?.tags || [];
if (!searchQuery) return tags;
return tags.filter(tag =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [refsData?.tags, searchQuery]);

const resolvedRef = currentRef === 'HEAD' ? (refsData?.defaultBranch || 'HEAD') : currentRef;
const displayRef = resolvedRef.replace(/^refs\/(heads|tags)\//, '');

const handleRefSelect = (ref: string) => {
onRefChange(ref);
setOpen(false);
setSearchQuery('');
};

// Prevent dropdown items from stealing focus while user is typing
const handleItemFocus = (e: React.FocusEvent) => {
if (searchQuery) {
e.preventDefault();
inputRef.current?.focus();
}
};

// Keep focus on the search input when typing
React.useEffect(() => {
if (open && searchQuery && inputRef.current) {
const timeoutId = setTimeout(() => {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(timeoutId);
}
}, [open, searchQuery]);

return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
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"
aria-label="Switch branches or tags"
>
<GitBranch className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate max-w-[150px]">{displayRef}</span>
<ChevronDown className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[320px] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col" onKeyDown={(e) => {
// Prevent dropdown keyboard navigation from interfering with search input
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.stopPropagation();
}
}}>
{/* Search input */}
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
ref={inputRef}
type="text"
placeholder={activeTab === 'branches' ? "Find a branch..." : "Find a tag..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
// Prevent dropdown menu keyboard navigation
e.stopPropagation();
}}
className="pl-8 h-8 text-sm"
autoFocus
/>
</div>
</div>

<div className="flex border-b">
<button
onClick={() => setActiveTab('branches')}
className={cn(
"flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2",
activeTab === 'branches'
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-600 hover:text-gray-900"
)}
>
<div className="flex items-center justify-center gap-1.5">
<GitBranch className="h-4 w-4" />
Branches
</div>
</button>
<button
onClick={() => setActiveTab('tags')}
className={cn(
"flex-1 px-4 py-2 text-sm font-medium transition-colors border-b-2",
activeTab === 'tags'
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-600 hover:text-gray-900"
)}
>
<div className="flex items-center justify-center gap-1.5">
<Tag className="h-4 w-4" />
Tags
</div>
</button>
</div>

<ScrollArea className="h-[300px]">
{isLoading ? (
<div className="p-4 text-sm text-gray-500 text-center">
Loading...
</div>
) : (
<div className="p-1">
{activeTab === 'branches' && (
<>
{filteredBranches.length === 0 ? (
<div className="p-4 text-sm text-gray-500 text-center">
{searchQuery ? 'No branches found' : 'No branches available'}
</div>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch}
onClick={() => handleRefSelect(branch)}
onFocus={handleItemFocus}
className="flex items-center justify-between px-3 py-2 cursor-pointer"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<GitBranch className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="truncate text-sm">{branch}</span>
{branch === refsData?.defaultBranch && (
<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">
default
</span>
)}
</div>
{branch === resolvedRef && (
<Check className="h-4 w-4 text-blue-600 flex-shrink-0" />
)}
</DropdownMenuItem>
))
)}
</>
)}
{activeTab === 'tags' && (
<>
{filteredTags.length === 0 ? (
<div className="p-4 text-sm text-gray-500 text-center">
{searchQuery ? 'No tags found' : 'No tags available'}
</div>
) : (
filteredTags.map((tag) => (
<DropdownMenuItem
key={tag}
onClick={() => handleRefSelect(tag)}
onFocus={handleItemFocus}
className="flex items-center justify-between px-3 py-2 cursor-pointer"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Tag className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="truncate text-sm">{tag}</span>
</div>
{tag === resolvedRef && (
<Check className="h-4 w-4 text-blue-600 flex-shrink-0" />
)}
</DropdownMenuItem>
))
)}
</>
)}
</div>
)}
</ScrollArea>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
3 changes: 1 addition & 2 deletions packages/web/src/app/[domain]/components/pathHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const PathHeader = ({
revisionName,
branchDisplayName = revisionName,
isBranchDisplayNameVisible = !!branchDisplayName,
branchDisplayTitle,
branchDisplayTitle: _branchDisplayTitle,
pathType = 'blob',
isCodeHostIconVisible = true,
isFileIconVisible = true,
Expand Down Expand Up @@ -231,7 +231,6 @@ export const PathHeader = ({
{(isBranchDisplayNameVisible && branchDisplayName) && (
<p
className="text-xs font-semibold text-gray-500 dark:text-gray-400 mt-[3px] flex items-center gap-0.5"
title={branchDisplayTitle}
style={{
marginBottom: "0.1rem",
}}
Expand Down
16 changes: 15 additions & 1 deletion packages/web/src/app/api/(client)/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use client';

import { ServiceError } from "@/lib/serviceError";
import { GetVersionResponse, ListReposQueryParams, ListReposResponse } from "@/lib/types";
import {
GetVersionResponse,
GetRefsRequest,
GetRefsResponse,
ListReposQueryParams,
ListReposResponse
} from "@/lib/types";
import { isServiceError } from "@/lib/utils";
import {
SearchRequest,
Expand Down Expand Up @@ -108,3 +114,11 @@ export const getFiles = async (body: GetFilesRequest): Promise<GetFilesResponse
}).then(response => response.json());
return result as GetFilesResponse | ServiceError;
}

export const getRefs = async (body: GetRefsRequest): Promise<GetRefsResponse | ServiceError> => {
const result = await fetch("/api/refs", {
method: "POST",
body: JSON.stringify(body),
}).then(response => response.json());
return result as GetRefsResponse | ServiceError;
}
68 changes: 68 additions & 0 deletions packages/web/src/app/api/(server)/refs/getRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import 'server-only';

import { sew } from '@/actions';
import { notFound, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuthV2 } from '@/withAuthV2';
import { createLogger, getRepoPath, repoMetadataSchema } from '@sourcebot/shared';
import { simpleGit } from 'simple-git';

const logger = createLogger('refs');

export const getRefs = async (params: { repoName: string }) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
const { repoName } = params;
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});

if (!repo) {
return notFound();
}

const metadata = repoMetadataSchema.safeParse(repo.metadata);
const indexedRevisions = metadata.success ? (metadata.data.indexedRevisions || []) : [];

const { path: repoPath } = getRepoPath(repo);

const git = simpleGit().cwd(repoPath);

let allBranches: string[] = [];
let allTags: string[] = [];
let defaultBranch: string | null = null;

try {
const branchResult = await git.branch();
allBranches = branchResult.all;
defaultBranch = branchResult.current || null;
} catch (error) {
logger.error('git branch failed.', { error });
return unexpectedError('git branch command failed.');
}

try {
const tagResult = await git.tags();
allTags = tagResult.all;
} catch (error) {
logger.error('git tags failed.', { error });
return unexpectedError('git tags command failed.');
}

const indexedRefsSet = new Set(indexedRevisions);

const branches = allBranches.filter(branch => {
return indexedRefsSet.has(`refs/heads/${branch}`);
});

const tags = allTags.filter(tag => {
return indexedRefsSet.has(`refs/tags/${tag}`);
});

return {
branches,
tags,
defaultBranch,
};
}));
22 changes: 22 additions & 0 deletions packages/web/src/app/api/(server)/refs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use server';

import { getRefsRequestSchema } from "@/lib/schemas";
import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { getRefs } from "./getRefs";

export const POST = async (request: NextRequest) => {
const body = await request.json();
const parsed = await getRefsRequestSchema.safeParseAsync(body);
if (!parsed.success) {
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
}

const response = await getRefs(parsed.data);
if (isServiceError(response)) {
return serviceErrorResponse(response);
}

return Response.json(response);
Comment on lines +9 to +21
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the codebase structure and look for error handling patterns
find packages/web -name "*.ts" -o -name "*.tsx" | head -20

Repository: sourcebot-dev/sourcebot

Length of output: 1265


🏁 Script executed:

# Search for middleware or error handling related to API routes
rg -t ts "middleware|error.*handler|catch.*json\(\)" packages/web/src --max-count 20

Repository: sourcebot-dev/sourcebot

Length of output: 631


🏁 Script executed:

# Check the specific file and its surrounding context
cat -n packages/web/src/app/api/\(server\)/refs/route.ts

Repository: sourcebot-dev/sourcebot

Length of output: 966


🏁 Script executed:

# Look for other API routes to understand error handling patterns
fd -t f "route.ts" packages/web/src/app/api --exec cat -n {} \;

Repository: sourcebot-dev/sourcebot

Length of output: 50044


🏁 Script executed:

# Check if there's a global error handler or middleware setup
rg -t ts "error.*Middleware|Error.*Handler|catch.*Error" packages/web/src/app --max-count 15

Repository: sourcebot-dev/sourcebot

Length of output: 145


Wrap request.json() in try-catch to prevent unhandled errors on malformed JSON.

request.json() throws on invalid JSON, returning a 500 instead of a validation error. Catching the error and passing null to schema validation provides consistent error handling.

🔧 Proposed fix
 export const POST = async (request: NextRequest) => {
-    const body = await request.json();
+    let body: unknown = null;
+    try {
+        body = await request.json();
+    } catch {
+        // fall through to schema validation error
+    }
     const parsed = await getRefsRequestSchema.safeParseAsync(body);
     if (!parsed.success) {
         return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
     }

Note: This pattern should be applied to all POST/PUT endpoints that call request.json().

🤖 Prompt for AI Agents
In `@packages/web/src/app/api/`(server)/refs/route.ts around lines 9 - 21, The
POST handler should catch JSON parse errors from request.json() so malformed
JSON results in schema validation errors instead of an unhandled 500; wrap the
call to request.json() in a try/catch inside the exported POST function, on
parse error set body to null (or undefined) before calling
getRefsRequestSchema.safeParseAsync(body) and let the existing validation/error
paths handle the response. Update the block around request.json(),
parsed/getRefs, and serviceErrorResponse to use this guarded body, and apply the
same pattern to other POST/PUT route handlers that call request.json().

}
Loading
Loading