/ 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 { Skeleton } from '@/components/ui/skeleton'; 5 import { timeAgo } from '@/lib/utils'; 6 import type { PrSearchResult } from '@/lib/types'; 7 8 interface Props { 9 open: boolean; 10 onOpenChange: (open: boolean) => void; 11 onSelect: (url: string) => void; 12 } 13 14 export function PRPickerDialog({ open, onOpenChange, onSelect }: Props) { 15 const [prs, setPrs] = useState<PrSearchResult[]>([]); 16 const [loading, setLoading] = useState(false); 17 const [error, setError] = useState<string | null>(null); 18 const [filter, setFilter] = useState(''); 19 const [repoFilter, setRepoFilter] = useState<string | null>(null); 20 const [roleFilter, setRoleFilter] = useState<'author' | 'review-requested' | null>(null); 21 22 useEffect(() => { 23 if (!open) return; 24 setLoading(true); 25 setError(null); 26 setFilter(''); 27 setRepoFilter(null); 28 setRoleFilter(null); 29 window.electronAPI 30 .searchPullRequests() 31 .then(setPrs) 32 .catch((err: unknown) => { 33 setError(err instanceof Error ? err.message : "Couldn't load pull requests."); 34 }) 35 .finally(() => setLoading(false)); 36 }, [open]); 37 38 const repos = useMemo(() => { 39 const set = new Set<string>(); 40 for (const pr of prs) set.add(`${pr.repoOwner}/${pr.repoName}`); 41 return Array.from(set).sort(); 42 }, [prs]); 43 44 const filtered = useMemo(() => { 45 let list = prs; 46 if (roleFilter) { 47 list = list.filter((pr) => pr.role === roleFilter); 48 } 49 if (repoFilter) { 50 list = list.filter((pr) => `${pr.repoOwner}/${pr.repoName}` === repoFilter); 51 } 52 if (filter) { 53 const q = filter.toLowerCase(); 54 list = list.filter( 55 (pr) => 56 pr.title.toLowerCase().includes(q) || 57 pr.repoName.toLowerCase().includes(q) || 58 pr.repoOwner.toLowerCase().includes(q) || 59 `${pr.repoOwner}/${pr.repoName}`.toLowerCase().includes(q) 60 ); 61 } 62 return list; 63 }, [prs, filter, repoFilter, roleFilter]); 64 65 function handleSelect(url: string) { 66 onSelect(url); 67 onOpenChange(false); 68 } 69 70 return ( 71 <Dialog open={open} onOpenChange={onOpenChange}> 72 <DialogContent className="bg-card sm:max-w-2xl max-h-[80vh] flex flex-col gap-5"> 73 <DialogHeader> 74 <DialogTitle className="editorial-heading">Pull requests</DialogTitle> 75 <DialogDescription className="slide-meta">Pick a pull request to review</DialogDescription> 76 </DialogHeader> 77 78 <div className="flex flex-col gap-4"> 79 <div className="relative"> 80 <Search className="absolute left-0 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/60" /> 81 <input 82 type="text" 83 placeholder="Filter by title…" 84 value={filter} 85 onChange={(e) => setFilter(e.target.value)} 86 className="w-full bg-transparent border-0 border-b border-border pl-6 pr-1 py-2 text-sm placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-[var(--ring)] transition-colors" 87 /> 88 </div> 89 90 {/* Quiet text-only filter row — same vocabulary as the 91 DiffLayoutToggle in SlideView. Active state is a hairline 92 underline in the brand amber, no fills, no borders. */} 93 <div className="flex flex-wrap items-center gap-x-5 gap-y-2 slide-meta"> 94 {( 95 [ 96 { value: null, label: 'All' }, 97 { value: 'review-requested', label: 'Assigned to me' }, 98 { value: 'author', label: 'By me' }, 99 ] as const 100 ).map(({ value, label }) => ( 101 <button 102 key={label} 103 type="button" 104 onClick={() => setRoleFilter(roleFilter === value ? null : value)} 105 className={`pb-0.5 border-b transition-colors ${ 106 roleFilter === value 107 ? 'text-foreground border-[var(--ring)]' 108 : 'border-transparent hover:text-foreground' 109 }`} 110 > 111 {label} 112 </button> 113 ))} 114 </div> 115 116 {repos.length > 1 && ( 117 <div className="flex flex-wrap items-center gap-x-5 gap-y-2 slide-meta"> 118 <button 119 type="button" 120 onClick={() => setRepoFilter(null)} 121 className={`pb-0.5 border-b transition-colors ${ 122 repoFilter === null 123 ? 'text-foreground border-[var(--ring)]' 124 : 'border-transparent hover:text-foreground' 125 }`} 126 > 127 All repos 128 </button> 129 {repos.map((repo) => ( 130 <button 131 key={repo} 132 type="button" 133 onClick={() => setRepoFilter(repoFilter === repo ? null : repo)} 134 className={`pb-0.5 border-b transition-colors ${ 135 repoFilter === repo 136 ? 'text-foreground border-[var(--ring)]' 137 : 'border-transparent hover:text-foreground' 138 }`} 139 > 140 {repo} 141 </button> 142 ))} 143 </div> 144 )} 145 </div> 146 147 <div className="overflow-y-auto -mx-6 min-h-0 max-h-[50vh]"> 148 {loading && ( 149 <div className="flex flex-col gap-3 px-6 py-2"> 150 <Skeleton className="h-14 w-full rounded-sm" /> 151 <Skeleton className="h-14 w-full rounded-sm" /> 152 <Skeleton className="h-14 w-full rounded-sm" /> 153 </div> 154 )} 155 156 {error && <p className="text-sm text-destructive py-4 text-center">{error}</p>} 157 158 {!loading && !error && filtered.length === 0 && ( 159 <div className="px-6 py-8 flex flex-col gap-2 max-w-sm"> 160 {prs.length === 0 ? ( 161 <> 162 <p className="editorial-label text-sm">Nothing to review yet.</p> 163 <p className="slide-meta"> 164 Once you open a PR or get added as a reviewer on GitHub, it'll show up here. You can also paste a 165 PR URL directly into the box on the home screen. 166 </p> 167 </> 168 ) : ( 169 <> 170 <p className="editorial-label text-sm">No matches.</p> 171 <p className="slide-meta">Try a different filter, or clear the search to see everything.</p> 172 </> 173 )} 174 </div> 175 )} 176 177 {!loading && !error && filtered.length > 0 && ( 178 <ul> 179 {filtered.map((pr) => ( 180 <li key={pr.url}> 181 <button 182 type="button" 183 onClick={() => handleSelect(pr.url)} 184 className="group w-full flex flex-col gap-1 px-6 py-3.5 text-left border-b border-border/60 last:border-b-0 hover:bg-muted/40 transition-colors" 185 > 186 <div className="flex items-center gap-2 min-w-0 slide-meta"> 187 <span className="shrink-0"> 188 {pr.repoOwner}/{pr.repoName}#{pr.number} 189 </span> 190 {pr.isDraft && <span className="statusPill-neutral text-[10px] px-1.5 py-0">Draft</span>} 191 {pr.role === 'review-requested' && ( 192 <span className="statusPill-amber text-[10px] px-1.5 py-0">Review requested</span> 193 )} 194 </div> 195 <span className="font-serif text-base leading-snug text-foreground/85 group-hover:text-foreground transition-colors truncate"> 196 {pr.title} 197 </span> 198 <span className="slide-meta"> 199 {pr.author} · {timeAgo(pr.updatedAt)} 200 </span> 201 </button> 202 </li> 203 ))} 204 </ul> 205 )} 206 </div> 207 </DialogContent> 208 </Dialog> 209 ); 210 }