diff --git a/frontend/src/components/RepoList.tsx b/frontend/src/components/RepoList.tsx index c76e77c..a350dd5 100644 --- a/frontend/src/components/RepoList.tsx +++ b/frontend/src/components/RepoList.tsx @@ -1,6 +1,24 @@ 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' @@ -8,42 +26,83 @@ 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 ( - - - {isIndexed ? 'Indexed' : 'Pending'} + + + {isIndexed ? 'Indexed' : isFailed ? 'Failed' : 'Pending'} ) } -const RepoCard = ({ repo, index, onSelect }: { +const RepoCard = ({ repo, index, onSelect, onDeleteClick }: { repo: Repository index: number - onSelect: () => void + onSelect: () => void + onDeleteClick?: () => void }) => { const cardRef = useRef(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 ( { if (!cardRef.current) return @@ -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 && (
)} - +
- {/* Header */} -
-
+ {/* Top row: icon + status + menu */} +
+
- +
+ + {onDeleteClick && ( + + + + + + { e.stopPropagation(); onDeleteClick() }} + className="text-destructive focus:text-destructive" + > + + Delete repository + + + + )} +
- {/* Title */} -

+ {/* Repo name + slug */} +

{repo.name}

-

{repo.branch}

- - {/* Stats */} -
-
- Files - - {(repo.file_count || 0).toLocaleString()} + {slug && ( +

{slug}

+ )} + + {/* Stats row */} +
+ + + {(repo.file_count || 0).toLocaleString()} + + {repo.function_count != null && repo.function_count > 0 && ( + + + {repo.function_count.toLocaleString()} -
+ )} + {indexed && ( + + + {indexed} + + )}
) } -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('recent') + const [deleteTarget, setDeleteTarget] = useState(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 || '') + }) + } 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 @@ -133,15 +238,97 @@ export function RepoList({ repos, selectedRepo, onSelect, onAddClick, loading }: } return ( -
- {sortedRepos.map((repo, index) => ( - onSelect(repo.id)} - /> - ))} +
+ {/* Sort bar */} +
+ setSortMode(v as SortMode)}> + + Recent + Name + Size + + + {repos.length} repos +
+ + {/* Grid */} +
+ {sortedRepos.map((repo, index) => ( + onSelect(repo.id)} + onDeleteClick={onDelete ? () => setDeleteTarget(repo) : undefined} + /> + ))} +
+ + {/* Delete confirmation dialog */} + setDeleteTarget(null)} + onConfirm={() => { + if (deleteTarget && onDelete) { + onDelete(deleteTarget.id) + setDeleteTarget(null) + } + }} + />
) } + + +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 ( + { if (!open) { setConfirmText(''); onCancel() } }} + > + + + Delete repository + + This will permanently remove {repoName} and all its + indexed data. This action cannot be undone. + + +
+

+ Type {repoName} to confirm +

+ setConfirmText(e.target.value)} + placeholder={repoName} + aria-label={`Type ${repoName} to confirm deletion`} + autoFocus + /> +
+ + + + +
+
+ ) +} diff --git a/frontend/src/components/dashboard/DashboardHome.tsx b/frontend/src/components/dashboard/DashboardHome.tsx index 7cdfd7c..5a77c64 100644 --- a/frontend/src/components/dashboard/DashboardHome.tsx +++ b/frontend/src/components/dashboard/DashboardHome.tsx @@ -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', + }) + } + } + const isRepoView = selectedRepo && selectedRepoData return ( @@ -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)} /> @@ -239,6 +257,7 @@ export function DashboardHome() { onTabChange={setActiveTab} onBack={() => { setSelectedRepo(null); setActiveTab('overview') }} onReindex={handleReindex} + onDelete={() => handleDeleteRepo(selectedRepo)} /> )} diff --git a/frontend/src/components/dashboard/RepoDetailView.tsx b/frontend/src/components/dashboard/RepoDetailView.tsx index dcc88d2..edf5d04 100644 --- a/frontend/src/components/dashboard/RepoDetailView.tsx +++ b/frontend/src/components/dashboard/RepoDetailView.tsx @@ -1,6 +1,7 @@ // Single repo detail view with tabs (Overview, Search, Dependencies, etc.) // Receives repo data and callbacks from DashboardHome +import { useState } from 'react' import { motion } from 'framer-motion' import { LayoutDashboard, @@ -11,7 +12,16 @@ import { ArrowLeft, FolderGit2, ExternalLink, + MoreVertical, + Trash2, } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { DeleteConfirmDialog } from '../RepoList' import { SearchPanel } from '../SearchPanel' import { DependencyGraph } from '../DependencyGraph' import { RepoOverview } from '../RepoOverview' @@ -36,6 +46,7 @@ interface RepoDetailViewProps { onTabChange: (tab: RepoTab) => void onBack: () => void onReindex: () => void + onDelete?: () => void } export function RepoDetailView({ @@ -46,7 +57,10 @@ export function RepoDetailView({ onTabChange, onBack, onReindex, + onDelete, }: RepoDetailViewProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const deleteRepo = showDeleteDialog ? repo : null return (
+ {onDelete && ( + + + + + + setShowDeleteDialog(true)} + className="text-destructive focus:text-destructive" + > + + Delete repository + + + + )}
+ + setShowDeleteDialog(false)} + onConfirm={() => { setShowDeleteDialog(false); onDelete?.() }} + />
{/* tab bar */} diff --git a/frontend/src/components/dashboard/RepoListView.tsx b/frontend/src/components/dashboard/RepoListView.tsx index 26b3ae0..9f0d4fe 100644 --- a/frontend/src/components/dashboard/RepoListView.tsx +++ b/frontend/src/components/dashboard/RepoListView.tsx @@ -15,6 +15,7 @@ interface RepoListViewProps { selectedRepo: string | null maxRepos: number onSelectRepo: (id: string) => void + onDeleteRepo: (id: string) => void onAddClick: () => void onGitHubClick: () => void } @@ -26,6 +27,7 @@ export function RepoListView({ selectedRepo, maxRepos, onSelectRepo, + onDeleteRepo, onAddClick, onGitHubClick, }: RepoListViewProps) { @@ -74,6 +76,7 @@ export function RepoListView({ selectedRepo={selectedRepo} loading={reposLoading} onSelect={onSelectRepo} + onDelete={onDeleteRepo} onAddClick={onAddClick} /> diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c3ef9d2..a7b480f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -20,17 +20,20 @@ import { import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { toast } from 'sonner' import { API_URL } from '@/config/api' +import { useUserUsage } from '@/hooks/useCachedQuery' interface Repository { id: string name: string } -const MAX_REPOS = 3 const DELETE_CONFIRMATION_TEXT = 'delete all' export function SettingsPage() { const { user, session } = useAuth() + const { data: usage } = useUserUsage(session?.access_token, session?.user?.id) + const maxRepos = usage?.repositories?.limit ?? 1 + const tier = usage?.tier || 'free' const { status, checkStatus, disconnect, loading: githubLoading } = useGitHubRepos() const [repos, setRepos] = useState([]) @@ -135,7 +138,7 @@ export function SettingsPage() { } const isDeleteEnabled = deleteConfirmation === DELETE_CONFIRMATION_TEXT - const availableSlots = Math.max(0, MAX_REPOS - repos.length) + const availableSlots = Math.max(0, maxRepos - repos.length) return (
@@ -239,7 +242,7 @@ export function SettingsPage() {

Repository slots

-

Free tier limit

+

{tier} tier limit

{reposLoading ? ( @@ -248,7 +251,7 @@ export function SettingsPage() { <>

{repos.length} - / {MAX_REPOS} + / {maxRepos}

{availableSlots} slot{availableSlots !== 1 ? 's' : ''} available diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f954efc..8944d6d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -5,6 +5,9 @@ export interface Repository { branch: string status: string file_count: number + function_count?: number + created_at?: string + last_indexed_at?: string } export interface SearchResult {