/ components / PRPickerDialog.tsx
PRPickerDialog.tsx
1 import { useState, useEffect, useMemo } from 'react'; 2 import { Search } from 'lucide-react'; 3 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 4 import { Badge } from '@/components/ui/badge'; 5 import { Skeleton } from '@/components/ui/skeleton'; 6 import { timeAgo } from '@/lib/utils'; 7 import type { PrSearchResult } from '@/lib/types'; 8 9 interface Props { 10 open: boolean; 11 onOpenChange: (open: boolean) => void; 12 onSelect: (url: string) => void; 13 } 14 15 export function PRPickerDialog({ open, onOpenChange, onSelect }: Props) { 16 const [prs, setPrs] = useState<PrSearchResult[]>([]); 17 const [loading, setLoading] = useState(false); 18 const [error, setError] = useState<string | null>(null); 19 const [filter, setFilter] = useState(''); 20 const [repoFilter, setRepoFilter] = useState<string | null>(null); 21 const [roleFilter, setRoleFilter] = useState<'author' | 'review-requested' | null>(null); 22 23 useEffect(() => { 24 if (!open) return; 25 setLoading(true); 26 setError(null); 27 setFilter(''); 28 setRepoFilter(null); 29 setRoleFilter(null); 30 window.electronAPI 31 .searchPullRequests() 32 .then(setPrs) 33 .catch((err: unknown) => { 34 setError(err instanceof Error ? err.message : 'Failed to load pull requests'); 35 }) 36 .finally(() => setLoading(false)); 37 }, [open]); 38 39 const repos = useMemo(() => { 40 const set = new Set<string>(); 41 for (const pr of prs) set.add(`${pr.repoOwner}/${pr.repoName}`); 42 return Array.from(set).sort(); 43 }, [prs]); 44 45 const filtered = useMemo(() => { 46 let list = prs; 47 if (roleFilter) { 48 list = list.filter((pr) => pr.role === roleFilter); 49 } 50 if (repoFilter) { 51 list = list.filter((pr) => `${pr.repoOwner}/${pr.repoName}` === repoFilter); 52 } 53 if (filter) { 54 const q = filter.toLowerCase(); 55 list = list.filter( 56 (pr) => 57 pr.title.toLowerCase().includes(q) || 58 pr.repoName.toLowerCase().includes(q) || 59 pr.repoOwner.toLowerCase().includes(q) || 60 `${pr.repoOwner}/${pr.repoName}`.toLowerCase().includes(q) 61 ); 62 } 63 return list; 64 }, [prs, filter, repoFilter, roleFilter]); 65 66 function handleSelect(url: string) { 67 onSelect(url); 68 onOpenChange(false); 69 } 70 71 return ( 72 <Dialog open={open} onOpenChange={onOpenChange}> 73 <DialogContent className="bg-card sm:max-w-2xl max-h-[80vh] flex flex-col gap-4"> 74 <DialogHeader> 75 <DialogTitle>Select a Pull Request</DialogTitle> 76 <DialogDescription>Your open PRs and review requests</DialogDescription> 77 </DialogHeader> 78 79 <div className="flex flex-col gap-3"> 80 <div className="relative"> 81 <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> 82 <input 83 type="text" 84 placeholder="Filter by title..." 85 value={filter} 86 onChange={(e) => setFilter(e.target.value)} 87 className="flex h-9 w-full rounded-md border border-input bg-background/50 pl-9 pr-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 88 /> 89 </div> 90 91 <div className="flex flex-wrap gap-1.5"> 92 {( 93 [ 94 { value: null, label: 'All' }, 95 { value: 'review-requested', label: 'Assigned to me' }, 96 { value: 'author', label: 'By me' }, 97 ] as const 98 ).map(({ value, label }) => ( 99 <button 100 key={label} 101 type="button" 102 onClick={() => setRoleFilter(roleFilter === value ? null : value)} 103 className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ 104 roleFilter === value 105 ? 'border-primary bg-primary text-primary-foreground' 106 : 'border-input text-muted-foreground hover:text-foreground hover:border-foreground/30' 107 }`} 108 > 109 {label} 110 </button> 111 ))} 112 </div> 113 114 {repos.length > 1 && ( 115 <div className="flex flex-wrap gap-1.5"> 116 <button 117 type="button" 118 onClick={() => setRepoFilter(null)} 119 className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ 120 repoFilter === null 121 ? 'border-primary bg-primary text-primary-foreground' 122 : 'border-input text-muted-foreground hover:text-foreground hover:border-foreground/30' 123 }`} 124 > 125 All 126 </button> 127 {repos.map((repo) => ( 128 <button 129 key={repo} 130 type="button" 131 onClick={() => setRepoFilter(repoFilter === repo ? null : repo)} 132 className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ 133 repoFilter === repo 134 ? 'border-primary bg-primary text-primary-foreground' 135 : 'border-input text-muted-foreground hover:text-foreground hover:border-foreground/30' 136 }`} 137 > 138 {repo} 139 </button> 140 ))} 141 </div> 142 )} 143 </div> 144 145 <div className="overflow-y-auto -mx-6 min-h-0 max-h-[50vh]"> 146 {loading && ( 147 <div className="flex flex-col gap-3 px-6 py-2"> 148 <Skeleton className="h-16 w-full rounded-md" /> 149 <Skeleton className="h-16 w-full rounded-md" /> 150 <Skeleton className="h-16 w-full rounded-md" /> 151 </div> 152 )} 153 154 {error && <p className="text-sm text-destructive py-4 text-center">{error}</p>} 155 156 {!loading && !error && filtered.length === 0 && ( 157 <p className="text-sm text-muted-foreground py-4 text-center"> 158 {prs.length === 0 ? 'No open pull requests found' : 'No results match your filter'} 159 </p> 160 )} 161 162 {!loading && !error && filtered.length > 0 && ( 163 <ul className="divide-y divide-border"> 164 {filtered.map((pr) => ( 165 <li key={pr.url}> 166 <button 167 type="button" 168 onClick={() => handleSelect(pr.url)} 169 className="w-full flex flex-col gap-1 px-6 py-3 text-left hover:bg-muted/60 transition-colors" 170 > 171 <div className="flex items-center gap-2 min-w-0"> 172 <span className="text-xs text-muted-foreground shrink-0"> 173 {pr.repoOwner}/{pr.repoName}#{pr.number} 174 </span> 175 {pr.isDraft && ( 176 <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-zinc-600 text-zinc-400"> 177 Draft 178 </Badge> 179 )} 180 {pr.role === 'review-requested' && ( 181 <Badge variant="outline" className="text-[10px] px-1.5 py-0 border-blue-700 text-blue-400"> 182 Review requested 183 </Badge> 184 )} 185 </div> 186 <span className="text-sm font-medium truncate">{pr.title}</span> 187 <span className="text-xs text-muted-foreground"> 188 {pr.author} · {timeAgo(pr.updatedAt)} 189 </span> 190 </button> 191 </li> 192 ))} 193 </ul> 194 )} 195 </div> 196 </DialogContent> 197 </Dialog> 198 ); 199 }