Skip to content
Merged
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
287 changes: 237 additions & 50 deletions frontend/src/components/RepoList.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,108 @@
import { useState, useRef, useMemo } from 'react'
import { motion } from 'framer-motion'
import { FolderGit2, Plus } from 'lucide-react'
import { FolderGit2, Plus, Files, FunctionSquare, Clock, MoreVertical, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Tabs, TabsList, TabsTrigger } from './ui/tabs'
import type { Repository } from '../types'
import { RepoGridSkeleton } from './ui/Skeleton'

interface RepoListProps {
repos: Repository[]
selectedRepo: string | null
onSelect: (repoId: string) => void
onDelete?: (repoId: string) => void
onAddClick?: () => void
loading?: boolean
}

const StatusBadge = ({ status }: { status: string }) => {
type SortMode = 'recent' | 'name' | 'size'

/** Extract "owner/repo" from a GitHub URL */
function parseRepoSlug(gitUrl: string): string {
try {
const cleaned = gitUrl.replace(/\.git$/, '')
// Match HTTPS: github.com/owner/repo
const https = cleaned.match(/github\.com\/([^/]+\/[^/]+)/)
if (https) return https[1]
// Match SSH: git@github.com:owner/repo
const ssh = cleaned.match(/github\.com:([^/]+\/[^/]+)/)
if (ssh) return ssh[1]
return ''
} catch {
return ''
}
}

/** Relative time: "2h ago", "3d ago", "just now" */
function timeAgo(dateStr?: string): string {
if (!dateStr) return ''
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
if (diff < 0) return ''

const mins = Math.floor(diff / 60_000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}

const StatusDot = ({ status }: { status: string }) => {
const isIndexed = status === 'indexed'

const isFailed = status === 'failed'
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border
${isIndexed
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-muted text-muted-foreground border-border'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${isIndexed ? 'bg-primary' : 'bg-muted-foreground animate-pulse'}`} />
{isIndexed ? 'Indexed' : 'Pending'}
<span className={cn(
'inline-flex items-center gap-1.5 text-xs',
isIndexed ? 'text-primary' : isFailed ? 'text-destructive' : 'text-muted-foreground',
)}>
<span className={cn(
'w-1.5 h-1.5 rounded-full',
isIndexed ? 'bg-primary' : isFailed ? 'bg-destructive' : 'bg-muted-foreground animate-pulse',
)} />
{isIndexed ? 'Indexed' : isFailed ? 'Failed' : 'Pending'}
</span>
Comment thread
DevanshuNEU marked this conversation as resolved.
)
}

const RepoCard = ({ repo, index, onSelect }: {
const RepoCard = ({ repo, index, onSelect, onDeleteClick }: {
repo: Repository
index: number
onSelect: () => void
onSelect: () => void
onDeleteClick?: () => void
}) => {
const cardRef = useRef<HTMLButtonElement>(null)
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [hovering, setHovering] = useState(false)
const slug = parseRepoSlug(repo.git_url)
const indexed = timeAgo(repo.last_indexed_at)

return (
<motion.button
ref={cardRef}
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.3 }}
whileHover={{ y: -3 }}
transition={{ delay: index * 0.04, duration: 0.25 }}
whileHover={{ y: -2 }}
onClick={onSelect}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onMouseMove={(e) => {
if (!cardRef.current) return
Expand All @@ -56,7 +115,6 @@ const RepoCard = ({ repo, index, onSelect }: {
bg-card border border-border hover:border-primary/40
focus:outline-none focus:ring-2 focus:ring-primary/50 p-5 transition-colors"
>
{/* Mouse glow effect */}
{hovering && (
<div
className="pointer-events-none absolute inset-0"
Expand All @@ -65,45 +123,92 @@ const RepoCard = ({ repo, index, onSelect }: {
}}
/>
)}

<div className="relative">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="w-11 h-11 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center group-hover:bg-primary/15 transition-colors">
{/* Top row: icon + status + menu */}
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center group-hover:bg-primary/15 transition-colors">
<FolderGit2 className="w-5 h-5 text-primary" />
</div>
<StatusBadge status={repo.status} />
<div className="flex items-center gap-1">
<StatusDot status={repo.status} />
{onDeleteClick && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="w-6 h-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => { e.stopPropagation(); onDeleteClick() }}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5 mr-2" />
Delete repository
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>

{/* Title */}
<h3 className="text-lg font-semibold text-foreground mb-0.5 group-hover:text-primary transition-colors">
{/* Repo name + slug */}
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{repo.name}
</h3>
<p className="text-xs text-muted-foreground font-mono mb-5">{repo.branch}</p>

{/* Stats */}
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Files</span>
<span className="text-2xl font-bold text-primary">
{(repo.file_count || 0).toLocaleString()}
{slug && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{slug}</p>
)}

{/* Stats row */}
<div className="flex items-center gap-3 mt-4 pt-3 border-t border-border text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Files className="w-3 h-3" />
{(repo.file_count || 0).toLocaleString()}
</span>
{repo.function_count != null && repo.function_count > 0 && (
<span className="flex items-center gap-1">
<FunctionSquare className="w-3 h-3" />
{repo.function_count.toLocaleString()}
</span>
</div>
)}
Comment thread
DevanshuNEU marked this conversation as resolved.
{indexed && (
<span className="flex items-center gap-1 ml-auto">
<Clock className="w-3 h-3" />
{indexed}
</span>
)}
</div>
</div>
</motion.button>
)
}

export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }: RepoListProps) {
// Hooks must be called before any conditional returns
export function RepoList({ repos, selectedRepo, onSelect, onDelete, onAddClick, loading }: RepoListProps) {
const [sortMode, setSortMode] = useState<SortMode>('recent')
const [deleteTarget, setDeleteTarget] = useState<Repository | null>(null)

const sortedRepos = useMemo(() => {
return [...repos].sort((a, b) => {
if (a.status === 'indexed' && b.status !== 'indexed') return -1
if (b.status === 'indexed' && a.status !== 'indexed') return 1
return (b.file_count || 0) - (a.file_count || 0)
})
}, [repos])
const sorted = [...repos]
if (sortMode === 'recent') {
sorted.sort((a, b) => {
// Prefer last_indexed_at; use created_at only as tiebreaker
const aIdx = a.last_indexed_at || ''
const bIdx = b.last_indexed_at || ''
if (aIdx !== bIdx) return bIdx.localeCompare(aIdx)
return (b.created_at || '').localeCompare(a.created_at || '')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (sortMode === 'name') {
sorted.sort((a, b) => a.name.localeCompare(b.name))
} else {
sorted.sort((a, b) => (b.file_count || 0) - (a.file_count || 0))
}
return sorted
}, [repos, sortMode])

if (loading) return <RepoGridSkeleton count={3} />

Expand Down Expand Up @@ -133,15 +238,97 @@ export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }:
}

return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedRepos.map((repo, index) => (
<RepoCard
key={repo.id}
repo={repo}
index={index}
onSelect={() => onSelect(repo.id)}
/>
))}
<div className="space-y-4">
{/* Sort bar */}
<div className="flex items-center">
<Tabs value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<TabsList className="h-8">
<TabsTrigger value="recent" className="text-xs px-3">Recent</TabsTrigger>
<TabsTrigger value="name" className="text-xs px-3">Name</TabsTrigger>
<TabsTrigger value="size" className="text-xs px-3">Size</TabsTrigger>
</TabsList>
</Tabs>
<span className="ml-auto text-xs text-muted-foreground">{repos.length} repos</span>
</div>

{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sortedRepos.map((repo, index) => (
<RepoCard
key={repo.id}
repo={repo}
index={index}
onSelect={() => onSelect(repo.id)}
onDeleteClick={onDelete ? () => setDeleteTarget(repo) : undefined}
/>
))}
</div>

{/* Delete confirmation dialog */}
<DeleteConfirmDialog
repo={deleteTarget}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget && onDelete) {
onDelete(deleteTarget.id)
setDeleteTarget(null)
}
}}
/>
</div>
)
}


export function DeleteConfirmDialog({
repo,
onCancel,
onConfirm,
}: {
repo: Repository | null
onCancel: () => void
onConfirm: () => void
}) {
const [confirmText, setConfirmText] = useState('')
const repoName = repo?.name || ''
const isMatch = confirmText === repoName

return (
<Dialog
open={!!repo}
onOpenChange={(open) => { if (!open) { setConfirmText(''); onCancel() } }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete repository</DialogTitle>
<DialogDescription>
This will permanently remove <strong>{repoName}</strong> and all its
indexed data. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<p className="text-sm text-muted-foreground mb-2">
Type <strong className="text-foreground">{repoName}</strong> to confirm
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={repoName}
aria-label={`Type ${repoName} to confirm deletion`}
autoFocus
/>
Comment thread
DevanshuNEU marked this conversation as resolved.
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => { setConfirmText(''); onCancel() }}>Cancel</Button>
<Button
variant="destructive"
disabled={!isMatch}
onClick={() => { setConfirmText(''); onConfirm() }}
>
Delete {repoName}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
19 changes: 19 additions & 0 deletions frontend/src/components/dashboard/DashboardHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ export function DashboardHome() {
}

const selectedRepoData = repos.find((r) => r.id === selectedRepo)
const handleDeleteRepo = async (repoId: string) => {
try {
const response = await fetch(`${API_URL}/repos/${repoId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session?.access_token}` },
})
if (!response.ok) throw new Error('Failed to delete')
toast.success('Repository deleted')
refreshRepos()
if (selectedRepo === repoId) setSelectedRepo(null)
} catch (error) {
toast.error('Failed to delete repository', {
description: error instanceof Error ? error.message : 'Please try again',
Comment thread
DevanshuNEU marked this conversation as resolved.
})
}
}

const isRepoView = selectedRepo && selectedRepoData

return (
Expand All @@ -226,6 +243,7 @@ export function DashboardHome() {
selectedRepo={selectedRepo}
maxRepos={maxRepos}
onSelectRepo={(id) => { setSelectedRepo(id); setActiveTab('overview') }}
onDeleteRepo={handleDeleteRepo}
onAddClick={() => setShowAddForm(true)}
onGitHubClick={() => setShowGitHubSelector(true)}
/>
Expand All @@ -239,6 +257,7 @@ export function DashboardHome() {
onTabChange={setActiveTab}
onBack={() => { setSelectedRepo(null); setActiveTab('overview') }}
onReindex={handleReindex}
onDelete={() => handleDeleteRepo(selectedRepo)}
/>
)}
</AnimatePresence>
Expand Down
Loading