diff --git a/app/api/github/user-repos/route.ts b/app/api/github/user-repos/route.ts new file mode 100644 index 0000000..515d980 --- /dev/null +++ b/app/api/github/user-repos/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getUserGitHubToken } from '@/lib/github/user-token' + +interface GitHubRepo { + name: string + full_name: string + description?: string + private: boolean + clone_url: string + updated_at: string + language?: string + owner: { + login: string + } +} + +interface GitHubSearchResult { + total_count: number + incomplete_results: boolean + items: GitHubRepo[] +} + +export async function GET(request: NextRequest) { + try { + const token = await getUserGitHubToken(request) + + if (!token) { + return NextResponse.json({ error: 'GitHub not connected' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const page = parseInt(searchParams.get('page') || '1', 10) + const perPage = parseInt(searchParams.get('per_page') || '25', 10) + const search = searchParams.get('search') || '' + + // Get authenticated user + const userResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + if (!userResponse.ok) { + return NextResponse.json({ error: 'Failed to fetch user' }, { status: 401 }) + } + + const user = await userResponse.json() + const username = user.login + + // If there's a search query, use GitHub search API + if (search.trim()) { + // Search for repos the user has access to matching the query + const searchQuery = encodeURIComponent(`${search} in:name user:${username} fork:true`) + const searchUrl = `https://api.github.com/search/repositories?q=${searchQuery}&sort=updated&order=desc&per_page=${perPage}&page=${page}` + + const searchResponse = await fetch(searchUrl, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + if (!searchResponse.ok) { + throw new Error('Failed to search repositories') + } + + const searchResult: GitHubSearchResult = await searchResponse.json() + + return NextResponse.json({ + repos: searchResult.items.map((repo) => ({ + name: repo.name, + full_name: repo.full_name, + owner: repo.owner.login, + description: repo.description, + private: repo.private, + clone_url: repo.clone_url, + updated_at: repo.updated_at, + language: repo.language, + })), + page, + per_page: perPage, + has_more: searchResult.total_count > page * perPage, + total_count: searchResult.total_count, + username, + }) + } + + // No search query - fetch repos sorted by updated_at (most recent first) for pagination + // We use a larger page size and handle deduplication ourselves + const githubPerPage = 100 + const githubPage = Math.ceil((page * perPage) / githubPerPage) + + // Fetch user's repos (owned repos, sorted by recently updated) + const apiUrl = `https://api.github.com/user/repos?sort=updated&direction=desc&per_page=${githubPerPage}&page=${githubPage}&visibility=all&affiliation=owner,organization_member` + + const response = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + if (!response.ok) { + throw new Error('Failed to fetch repositories') + } + + const repos: GitHubRepo[] = await response.json() + + // Calculate the offset within the GitHub page + const offsetInGithubPage = ((page - 1) * perPage) % githubPerPage + const slicedRepos = repos.slice(offsetInGithubPage, offsetInGithubPage + perPage) + + // Check if there are more repos + const hasMore = repos.length === githubPerPage || slicedRepos.length === perPage + + return NextResponse.json({ + repos: slicedRepos.map((repo) => ({ + name: repo.name, + full_name: repo.full_name, + owner: repo.owner.login, + description: repo.description, + private: repo.private, + clone_url: repo.clone_url, + updated_at: repo.updated_at, + language: repo.language, + })), + page, + per_page: perPage, + has_more: hasMore, + username, + }) + } catch (error) { + console.error('Error fetching user repositories:', error) + return NextResponse.json({ error: 'Failed to fetch repositories' }, { status: 500 }) + } +} diff --git a/components/app-layout.tsx b/components/app-layout.tsx index 2dc156a..991c27d 100644 --- a/components/app-layout.tsx +++ b/components/app-layout.tsx @@ -46,12 +46,26 @@ export const useTasks = () => { function SidebarLoader({ width }: { width: number }) { return (
-
+ {/* Tabs */} +
+ + +
- {/* Tabs */} -
- - -
diff --git a/components/task-sidebar.tsx b/components/task-sidebar.tsx index 5fb35e4..6f06f58 100644 --- a/components/task-sidebar.tsx +++ b/components/task-sidebar.tsx @@ -3,7 +3,7 @@ import { Task } from '@/lib/db/schema' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { AlertCircle, Plus, Trash2, GitBranch } from 'lucide-react' +import { AlertCircle, Plus, Trash2, GitBranch, Loader2, Search, X } from 'lucide-react' import { cn } from '@/lib/utils' import Link from 'next/link' import { usePathname } from 'next/navigation' @@ -19,13 +19,15 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Checkbox } from '@/components/ui/checkbox' -import { useState, useMemo } from 'react' +import { Input } from '@/components/ui/input' +import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { toast } from 'sonner' import { useTasks } from '@/components/app-layout' import { useAtomValue } from 'jotai' import { sessionAtom } from '@/lib/atoms/session' import { PRStatusIcon } from '@/components/pr-status-icon' import { PRCheckStatus } from '@/components/pr-check-status' +import { githubConnectionAtom } from '@/lib/atoms/github-connection' // Model mappings for human-friendly names const AGENT_MODELS = { @@ -81,18 +83,22 @@ interface TaskSidebarProps { type TabType = 'tasks' | 'repos' -interface RepoInfo { - url: string - owner: string +interface GitHubRepoInfo { name: string - taskCount: number - lastUsed: Date + full_name: string + owner: string + description?: string + private: boolean + clone_url: string + updated_at: string + language?: string } export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const pathname = usePathname() const { refreshTasks, toggleSidebar } = useTasks() const session = useAtomValue(sessionAtom) + const githubConnection = useAtomValue(githubConnectionAtom) const [isDeleting, setIsDeleting] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deleteCompleted, setDeleteCompleted] = useState(true) @@ -100,6 +106,20 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const [deleteStopped, setDeleteStopped] = useState(true) const [activeTab, setActiveTab] = useState('tasks') + // State for repos from API + const [repos, setRepos] = useState([]) + const [reposLoading, setReposLoading] = useState(false) + const [reposPage, setReposPage] = useState(1) + const [hasMoreRepos, setHasMoreRepos] = useState(true) + const [reposInitialized, setReposInitialized] = useState(false) + const [repoSearchQuery, setRepoSearchQuery] = useState('') + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchPage, setSearchPage] = useState(1) + const [searchHasMore, setSearchHasMore] = useState(false) + const loadMoreRef = useRef(null) + // Close sidebar on mobile when clicking any link const handleLinkClick = () => { if (typeof window !== 'undefined' && window.innerWidth < 1024) { @@ -107,9 +127,9 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { } } - // Extract unique repositories from tasks - const repositories = useMemo(() => { - const repoMap = new Map() + // Extract task counts per repo from tasks + const taskCountByRepo = useMemo(() => { + const counts = new Map() tasks.forEach((task) => { if (task.repoUrl) { @@ -120,23 +140,7 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { const owner = pathParts[0] const name = pathParts[1].replace(/\.git$/, '') const repoKey = `${owner}/${name}` - - if (repoMap.has(repoKey)) { - const existing = repoMap.get(repoKey)! - existing.taskCount++ - const taskCreatedAt = new Date(task.createdAt) - if (taskCreatedAt > existing.lastUsed) { - existing.lastUsed = taskCreatedAt - } - } else { - repoMap.set(repoKey, { - url: task.repoUrl, - owner, - name, - taskCount: 1, - lastUsed: new Date(task.createdAt), - }) - } + counts.set(repoKey, (counts.get(repoKey) || 0) + 1) } } catch { // Invalid URL, skip @@ -144,10 +148,161 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { } }) - // Sort by last used (most recent first) - return Array.from(repoMap.values()).sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()) + return counts }, [tasks]) + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(repoSearchQuery) + }, 300) + + return () => clearTimeout(timer) + }, [repoSearchQuery]) + + // Fetch search results when debounced query changes + const fetchSearchResults = useCallback(async (query: string, page: number, append: boolean = false) => { + if (!query.trim()) { + setSearchResults([]) + setSearchHasMore(false) + return + } + + setSearchLoading(true) + try { + const response = await fetch( + `/api/github/user-repos?page=${page}&per_page=25&search=${encodeURIComponent(query)}`, + ) + + if (!response.ok) { + throw new Error('Failed to search repos') + } + + const data = await response.json() + + if (append) { + setSearchResults((prev) => [...prev, ...data.repos]) + } else { + setSearchResults(data.repos) + } + + setSearchHasMore(data.has_more) + setSearchPage(page) + } catch (error) { + console.error('Error searching repos:', error) + } finally { + setSearchLoading(false) + } + }, []) + + // Fetch search results when debounced query changes + useEffect(() => { + if (debouncedSearchQuery.trim()) { + fetchSearchResults(debouncedSearchQuery, 1) + } else { + setSearchResults([]) + setSearchHasMore(false) + } + }, [debouncedSearchQuery, fetchSearchResults]) + + // Get the repos to display (search results or regular repos) + const displayedRepos = debouncedSearchQuery.trim() ? searchResults : repos + const displayedHasMore = debouncedSearchQuery.trim() ? searchHasMore : hasMoreRepos + const isSearching = debouncedSearchQuery.trim().length > 0 + + // Fetch repos from API + const fetchRepos = useCallback( + async (page: number, append: boolean = false) => { + if (reposLoading) return + + setReposLoading(true) + try { + const response = await fetch(`/api/github/user-repos?page=${page}&per_page=25`) + + if (!response.ok) { + throw new Error('Failed to fetch repos') + } + + const data = await response.json() + + if (append) { + setRepos((prev) => [...prev, ...data.repos]) + } else { + setRepos(data.repos) + } + + setHasMoreRepos(data.has_more) + setReposPage(page) + setReposInitialized(true) + } catch (error) { + console.error('Error fetching repos:', error) + } finally { + setReposLoading(false) + } + }, + [reposLoading], + ) + + // Load repos when switching to repos tab or when GitHub is connected + useEffect(() => { + if (activeTab === 'repos' && session.user && githubConnection.connected && !reposInitialized && !reposLoading) { + fetchRepos(1) + } + }, [activeTab, session.user, githubConnection.connected, reposInitialized, reposLoading, fetchRepos]) + + // Reset repos when GitHub connection changes + useEffect(() => { + if (!githubConnection.connected) { + setRepos([]) + setReposPage(1) + setHasMoreRepos(true) + setReposInitialized(false) + } + }, [githubConnection.connected]) + + // Infinite scroll observer + useEffect(() => { + const isLoading = isSearching ? searchLoading : reposLoading + const hasMore = displayedHasMore + + if (activeTab !== 'repos' || !hasMore || isLoading) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + if (isSearching) { + fetchSearchResults(debouncedSearchQuery, searchPage + 1, true) + } else { + fetchRepos(reposPage + 1, true) + } + } + }, + { threshold: 0.1 }, + ) + + const currentRef = loadMoreRef.current + if (currentRef) { + observer.observe(currentRef) + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef) + } + } + }, [ + activeTab, + displayedHasMore, + reposLoading, + searchLoading, + reposPage, + searchPage, + isSearching, + debouncedSearchQuery, + fetchRepos, + fetchSearchResults, + ]) + const handleDeleteTasks = async () => { if (!deleteCompleted && !deleteFailed && !deleteStopped) { toast.error('Please select at least one task type to delete') @@ -450,49 +605,112 @@ export function TaskSidebar({ tasks, width = 288 }: TaskSidebarProps) { {/* Repos Tab Content */} {activeTab === 'repos' && ( -
- {repositories.length === 0 ? ( - - - No repositories yet. Create a task with a repository! - - - ) : ( - repositories.map((repo) => { - const repoPath = `/repos/${repo.owner}/${repo.name}` - const isActive = pathname === repoPath || pathname.startsWith(repoPath + '/') - - return ( - + {/* Search input */} + {githubConnection.connected && (repos.length > 0 || repoSearchQuery) && ( +
+ + setRepoSearchQuery(e.target.value)} + className="h-8 pl-7 pr-7 text-xs" + /> + {repoSearchQuery && ( + + )} +
)} + +
+ {!githubConnection.connected ? ( + + + Connect GitHub to view your repositories + + + ) : (reposLoading && repos.length === 0 && !isSearching) || + (searchLoading && searchResults.length === 0 && isSearching) ? ( + + + + {isSearching ? 'Searching...' : 'Loading repositories...'} + + + ) : displayedRepos.length === 0 && !isSearching ? ( + + + No repositories found + + + ) : displayedRepos.length === 0 && isSearching && !searchLoading ? ( + + + No repos match "{repoSearchQuery}" + + + ) : ( + <> + {displayedRepos.map((repo) => { + const repoPath = `/repos/${repo.owner}/${repo.name}` + const isActive = pathname === repoPath || pathname.startsWith(repoPath + '/') + const repoKey = `${repo.owner}/${repo.name}` + const taskCount = taskCountByRepo.get(repoKey) || 0 + + return ( + + + +
+ +
+

+ {repo.owner}/{repo.name} +

+ {taskCount > 0 && ( +
+ {taskCount} {taskCount === 1 ? 'task' : 'tasks'} +
+ )} +
+ {repo.private && ( + + Private + + )} +
+
+
+ + ) + })} + {/* Load more trigger */} + {displayedHasMore && ( +
+ {(isSearching ? searchLoading : reposLoading) && ( + + )} +
+ )} + + )} +
)}