11import { useState , useRef , useMemo } from 'react'
22import { motion } from 'framer-motion'
3- import { FolderGit2 , Plus } from 'lucide-react'
3+ import { FolderGit2 , Plus , Files , FunctionSquare , Clock , MoreVertical , Trash2 } from 'lucide-react'
4+ import { cn } from '@/lib/utils'
5+ import {
6+ DropdownMenu ,
7+ DropdownMenuContent ,
8+ DropdownMenuItem ,
9+ DropdownMenuTrigger ,
10+ } from './ui/dropdown-menu'
11+ import {
12+ Dialog ,
13+ DialogContent ,
14+ DialogDescription ,
15+ DialogFooter ,
16+ DialogHeader ,
17+ DialogTitle ,
18+ } from './ui/dialog'
19+ import { Button } from './ui/button'
20+ import { Input } from './ui/input'
21+ import { Tabs , TabsList , TabsTrigger } from './ui/tabs'
422import type { Repository } from '../types'
523import { RepoGridSkeleton } from './ui/Skeleton'
624
725interface RepoListProps {
826 repos : Repository [ ]
927 selectedRepo : string | null
1028 onSelect : ( repoId : string ) => void
29+ onDelete ?: ( repoId : string ) => void
1130 onAddClick ?: ( ) => void
1231 loading ?: boolean
1332}
1433
15- const StatusBadge = ( { status } : { status : string } ) => {
34+ type SortMode = 'recent' | 'name' | 'size'
35+
36+ /** Extract "owner/repo" from a GitHub URL */
37+ function parseRepoSlug ( gitUrl : string ) : string {
38+ try {
39+ const cleaned = gitUrl . replace ( / \. g i t $ / , '' )
40+ // Match HTTPS: github.com/owner/repo
41+ const https = cleaned . match ( / g i t h u b \. c o m \/ ( [ ^ / ] + \/ [ ^ / ] + ) / )
42+ if ( https ) return https [ 1 ]
43+ // Match SSH: git@github .com:owner/repo
44+ const ssh = cleaned . match ( / g i t h u b \. c o m : ( [ ^ / ] + \/ [ ^ / ] + ) / )
45+ if ( ssh ) return ssh [ 1 ]
46+ return ''
47+ } catch {
48+ return ''
49+ }
50+ }
51+
52+ /** Relative time: "2h ago", "3d ago", "just now" */
53+ function timeAgo ( dateStr ?: string ) : string {
54+ if ( ! dateStr ) return ''
55+ const now = Date . now ( )
56+ const then = new Date ( dateStr ) . getTime ( )
57+ const diff = now - then
58+ if ( diff < 0 ) return ''
59+
60+ const mins = Math . floor ( diff / 60_000 )
61+ if ( mins < 1 ) return 'just now'
62+ if ( mins < 60 ) return `${ mins } m ago`
63+ const hrs = Math . floor ( mins / 60 )
64+ if ( hrs < 24 ) return `${ hrs } h ago`
65+ const days = Math . floor ( hrs / 24 )
66+ if ( days < 30 ) return `${ days } d ago`
67+ return new Date ( dateStr ) . toLocaleDateString ( 'en-US' , { month : 'short' , day : 'numeric' } )
68+ }
69+
70+ const StatusDot = ( { status } : { status : string } ) => {
1671 const isIndexed = status === 'indexed'
17-
72+ const isFailed = status === 'failed'
1873 return (
19- < span className = { `inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border
20- ${ isIndexed
21- ? 'bg-primary/10 text-primary border-primary/20'
22- : 'bg-muted text-muted-foreground border-border'
23- } `}
24- >
25- < span className = { `w-1.5 h-1.5 rounded-full ${ isIndexed ? 'bg-primary' : 'bg-muted-foreground animate-pulse' } ` } />
26- { isIndexed ? 'Indexed' : 'Pending' }
74+ < span className = { cn (
75+ 'inline-flex items-center gap-1.5 text-xs' ,
76+ isIndexed ? 'text-primary' : isFailed ? 'text-destructive' : 'text-muted-foreground' ,
77+ ) } >
78+ < span className = { cn (
79+ 'w-1.5 h-1.5 rounded-full' ,
80+ isIndexed ? 'bg-primary' : isFailed ? 'bg-destructive' : 'bg-muted-foreground animate-pulse' ,
81+ ) } />
82+ { isIndexed ? 'Indexed' : isFailed ? 'Failed' : 'Pending' }
2783 </ span >
2884 )
2985}
3086
31- const RepoCard = ( { repo, index, onSelect } : {
87+ const RepoCard = ( { repo, index, onSelect, onDeleteClick } : {
3288 repo : Repository
3389 index : number
34- onSelect : ( ) => void
90+ onSelect : ( ) => void
91+ onDeleteClick ?: ( ) => void
3592} ) => {
3693 const cardRef = useRef < HTMLButtonElement > ( null )
3794 const [ mousePos , setMousePos ] = useState ( { x : 0 , y : 0 } )
3895 const [ hovering , setHovering ] = useState ( false )
96+ const slug = parseRepoSlug ( repo . git_url )
97+ const indexed = timeAgo ( repo . last_indexed_at )
3998
4099 return (
41100 < motion . button
42101 ref = { cardRef }
43- initial = { { opacity : 0 , y : 20 } }
102+ initial = { { opacity : 0 , y : 12 } }
44103 animate = { { opacity : 1 , y : 0 } }
45- transition = { { delay : index * 0.05 , duration : 0.3 } }
46- whileHover = { { y : - 3 } }
104+ transition = { { delay : index * 0.04 , duration : 0.25 } }
105+ whileHover = { { y : - 2 } }
47106 onClick = { onSelect }
48107 onMouseMove = { ( e ) => {
49108 if ( ! cardRef . current ) return
@@ -56,7 +115,6 @@ const RepoCard = ({ repo, index, onSelect }: {
56115 bg-card border border-border hover:border-primary/40
57116 focus:outline-none focus:ring-2 focus:ring-primary/50 p-5 transition-colors"
58117 >
59- { /* Mouse glow effect */ }
60118 { hovering && (
61119 < div
62120 className = "pointer-events-none absolute inset-0"
@@ -65,45 +123,92 @@ const RepoCard = ({ repo, index, onSelect }: {
65123 } }
66124 />
67125 ) }
68-
126+
69127 < div className = "relative" >
70- { /* Header */ }
71- < div className = "flex items-start justify-between mb-4 " >
72- < 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" >
128+ { /* Top row: icon + status + menu */ }
129+ < div className = "flex items-start justify-between mb-3 " >
130+ < 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" >
73131 < FolderGit2 className = "w-5 h-5 text-primary" />
74132 </ div >
75- < StatusBadge status = { repo . status } />
133+ < div className = "flex items-center gap-1" >
134+ < StatusDot status = { repo . status } />
135+ { onDeleteClick && (
136+ < DropdownMenu >
137+ < DropdownMenuTrigger asChild >
138+ < button
139+ onClick = { ( e ) => e . stopPropagation ( ) }
140+ 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"
141+ >
142+ < MoreVertical className = "w-3.5 h-3.5" />
143+ </ button >
144+ </ DropdownMenuTrigger >
145+ < DropdownMenuContent align = "end" >
146+ < DropdownMenuItem
147+ onClick = { ( e ) => { e . stopPropagation ( ) ; onDeleteClick ( ) } }
148+ className = "text-destructive focus:text-destructive"
149+ >
150+ < Trash2 className = "w-3.5 h-3.5 mr-2" />
151+ Delete repository
152+ </ DropdownMenuItem >
153+ </ DropdownMenuContent >
154+ </ DropdownMenu >
155+ ) }
156+ </ div >
76157 </ div >
77158
78- { /* Title */ }
79- < h3 className = "text-lg font-semibold text-foreground mb-0.5 group-hover:text-primary transition-colors" >
159+ { /* Repo name + slug */ }
160+ < h3 className = "text-base font-semibold text-foreground group-hover:text-primary transition-colors truncate " >
80161 { repo . name }
81162 </ h3 >
82- < p className = "text-xs text-muted-foreground font-mono mb-5" > { repo . branch } </ p >
83-
84- { /* Stats */ }
85- < div className = "pt-4 border-t border-border" >
86- < div className = "flex items-center justify-between" >
87- < span className = "text-sm text-muted-foreground" > Files</ span >
88- < span className = "text-2xl font-bold text-primary" >
89- { ( repo . file_count || 0 ) . toLocaleString ( ) }
163+ { slug && (
164+ < p className = "text-xs text-muted-foreground truncate mt-0.5" > { slug } </ p >
165+ ) }
166+
167+ { /* Stats row */ }
168+ < div className = "flex items-center gap-3 mt-4 pt-3 border-t border-border text-xs text-muted-foreground" >
169+ < span className = "flex items-center gap-1" >
170+ < Files className = "w-3 h-3" />
171+ { ( repo . file_count || 0 ) . toLocaleString ( ) }
172+ </ span >
173+ { repo . function_count != null && repo . function_count > 0 && (
174+ < span className = "flex items-center gap-1" >
175+ < FunctionSquare className = "w-3 h-3" />
176+ { repo . function_count . toLocaleString ( ) }
90177 </ span >
91- </ div >
178+ ) }
179+ { indexed && (
180+ < span className = "flex items-center gap-1 ml-auto" >
181+ < Clock className = "w-3 h-3" />
182+ { indexed }
183+ </ span >
184+ ) }
92185 </ div >
93186 </ div >
94187 </ motion . button >
95188 )
96189}
97190
98- export function RepoList ( { repos, selectedRepo, onSelect, onAddClick, loading } : RepoListProps ) {
99- // Hooks must be called before any conditional returns
191+ export function RepoList ( { repos, selectedRepo, onSelect, onDelete, onAddClick, loading } : RepoListProps ) {
192+ const [ sortMode , setSortMode ] = useState < SortMode > ( 'recent' )
193+ const [ deleteTarget , setDeleteTarget ] = useState < Repository | null > ( null )
194+
100195 const sortedRepos = useMemo ( ( ) => {
101- return [ ...repos ] . sort ( ( a , b ) => {
102- if ( a . status === 'indexed' && b . status !== 'indexed' ) return - 1
103- if ( b . status === 'indexed' && a . status !== 'indexed' ) return 1
104- return ( b . file_count || 0 ) - ( a . file_count || 0 )
105- } )
106- } , [ repos ] )
196+ const sorted = [ ...repos ]
197+ if ( sortMode === 'recent' ) {
198+ sorted . sort ( ( a , b ) => {
199+ // Prefer last_indexed_at; use created_at only as tiebreaker
200+ const aIdx = a . last_indexed_at || ''
201+ const bIdx = b . last_indexed_at || ''
202+ if ( aIdx !== bIdx ) return bIdx . localeCompare ( aIdx )
203+ return ( b . created_at || '' ) . localeCompare ( a . created_at || '' )
204+ } )
205+ } else if ( sortMode === 'name' ) {
206+ sorted . sort ( ( a , b ) => a . name . localeCompare ( b . name ) )
207+ } else {
208+ sorted . sort ( ( a , b ) => ( b . file_count || 0 ) - ( a . file_count || 0 ) )
209+ }
210+ return sorted
211+ } , [ repos , sortMode ] )
107212
108213 if ( loading ) return < RepoGridSkeleton count = { 3 } />
109214
@@ -133,15 +238,97 @@ export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }:
133238 }
134239
135240 return (
136- < div className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
137- { sortedRepos . map ( ( repo , index ) => (
138- < RepoCard
139- key = { repo . id }
140- repo = { repo }
141- index = { index }
142- onSelect = { ( ) => onSelect ( repo . id ) }
143- />
144- ) ) }
241+ < div className = "space-y-4" >
242+ { /* Sort bar */ }
243+ < div className = "flex items-center" >
244+ < Tabs value = { sortMode } onValueChange = { ( v ) => setSortMode ( v as SortMode ) } >
245+ < TabsList className = "h-8" >
246+ < TabsTrigger value = "recent" className = "text-xs px-3" > Recent</ TabsTrigger >
247+ < TabsTrigger value = "name" className = "text-xs px-3" > Name</ TabsTrigger >
248+ < TabsTrigger value = "size" className = "text-xs px-3" > Size</ TabsTrigger >
249+ </ TabsList >
250+ </ Tabs >
251+ < span className = "ml-auto text-xs text-muted-foreground" > { repos . length } repos</ span >
252+ </ div >
253+
254+ { /* Grid */ }
255+ < div className = "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" >
256+ { sortedRepos . map ( ( repo , index ) => (
257+ < RepoCard
258+ key = { repo . id }
259+ repo = { repo }
260+ index = { index }
261+ onSelect = { ( ) => onSelect ( repo . id ) }
262+ onDeleteClick = { onDelete ? ( ) => setDeleteTarget ( repo ) : undefined }
263+ />
264+ ) ) }
265+ </ div >
266+
267+ { /* Delete confirmation dialog */ }
268+ < DeleteConfirmDialog
269+ repo = { deleteTarget }
270+ onCancel = { ( ) => setDeleteTarget ( null ) }
271+ onConfirm = { ( ) => {
272+ if ( deleteTarget && onDelete ) {
273+ onDelete ( deleteTarget . id )
274+ setDeleteTarget ( null )
275+ }
276+ } }
277+ />
145278 </ div >
146279 )
147280}
281+
282+
283+ export function DeleteConfirmDialog ( {
284+ repo,
285+ onCancel,
286+ onConfirm,
287+ } : {
288+ repo : Repository | null
289+ onCancel : ( ) => void
290+ onConfirm : ( ) => void
291+ } ) {
292+ const [ confirmText , setConfirmText ] = useState ( '' )
293+ const repoName = repo ?. name || ''
294+ const isMatch = confirmText === repoName
295+
296+ return (
297+ < Dialog
298+ open = { ! ! repo }
299+ onOpenChange = { ( open ) => { if ( ! open ) { setConfirmText ( '' ) ; onCancel ( ) } } }
300+ >
301+ < DialogContent >
302+ < DialogHeader >
303+ < DialogTitle > Delete repository</ DialogTitle >
304+ < DialogDescription >
305+ This will permanently remove < strong > { repoName } </ strong > and all its
306+ indexed data. This action cannot be undone.
307+ </ DialogDescription >
308+ </ DialogHeader >
309+ < div className = "py-2" >
310+ < p className = "text-sm text-muted-foreground mb-2" >
311+ Type < strong className = "text-foreground" > { repoName } </ strong > to confirm
312+ </ p >
313+ < Input
314+ value = { confirmText }
315+ onChange = { ( e ) => setConfirmText ( e . target . value ) }
316+ placeholder = { repoName }
317+ aria-label = { `Type ${ repoName } to confirm deletion` }
318+ autoFocus
319+ />
320+ </ div >
321+ < DialogFooter className = "gap-2" >
322+ < Button variant = "outline" onClick = { ( ) => { setConfirmText ( '' ) ; onCancel ( ) } } > Cancel</ Button >
323+ < Button
324+ variant = "destructive"
325+ disabled = { ! isMatch }
326+ onClick = { ( ) => { setConfirmText ( '' ) ; onConfirm ( ) } }
327+ >
328+ Delete { repoName }
329+ </ Button >
330+ </ DialogFooter >
331+ </ DialogContent >
332+ </ Dialog >
333+ )
334+ }
0 commit comments