/ components / FilePickerDialog.tsx
FilePickerDialog.tsx
1 import { useState, useEffect, useMemo, useRef } from 'react'; 2 import { Loader2 } from 'lucide-react'; 3 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; 4 import { Button } from '@/components/ui/button'; 5 import type { ChangedFile } from '@/lib/types'; 6 7 interface Props { 8 open: boolean; 9 onOpenChange: (open: boolean) => void; 10 prUrl: string; 11 onConfirm: (excludedFiles: string[]) => void; 12 } 13 14 interface ExtGroup { 15 ext: string; 16 files: ChangedFile[]; 17 totalAdditions: number; 18 totalDeletions: number; 19 } 20 21 function getExt(filename: string): string { 22 const slash = filename.lastIndexOf('/'); 23 const base = filename.slice(slash + 1); 24 const dot = base.lastIndexOf('.'); 25 if (dot <= 0) return 'other'; 26 return base.slice(dot); 27 } 28 29 interface CheckboxProps { 30 checked: boolean; 31 indeterminate?: boolean; 32 onChange: () => void; 33 className?: string; 34 } 35 36 function Checkbox({ checked, indeterminate, onChange, className }: CheckboxProps) { 37 const ref = useRef<HTMLInputElement>(null); 38 useEffect(() => { 39 if (ref.current) { 40 ref.current.indeterminate = indeterminate ?? false; 41 } 42 }, [indeterminate]); 43 return ( 44 <input 45 ref={ref} 46 type="checkbox" 47 checked={checked} 48 onChange={onChange} 49 className={`h-4 w-4 shrink-0 cursor-pointer accent-primary ${className ?? ''}`} 50 /> 51 ); 52 } 53 54 export function FilePickerDialog({ open, onOpenChange, prUrl, onConfirm }: Props) { 55 const [files, setFiles] = useState<ChangedFile[]>([]); 56 const [loading, setLoading] = useState(false); 57 const [error, setError] = useState<string | null>(null); 58 const [excluded, setExcluded] = useState<Set<string>>(new Set()); 59 60 useEffect(() => { 61 if (!open) return; 62 setLoading(true); 63 setError(null); 64 setExcluded(new Set()); 65 window.electronAPI 66 .getPrFiles(prUrl) 67 .then(setFiles) 68 .catch((err: unknown) => { 69 setError(err instanceof Error ? err.message : 'Failed to load files'); 70 }) 71 .finally(() => setLoading(false)); 72 }, [open, prUrl]); 73 74 const extGroups = useMemo<ExtGroup[]>(() => { 75 const map = new Map<string, ChangedFile[]>(); 76 for (const f of files) { 77 const ext = getExt(f.filename); 78 const group = map.get(ext) ?? []; 79 group.push(f); 80 map.set(ext, group); 81 } 82 return Array.from(map.entries()) 83 .map(([ext, groupFiles]) => ({ 84 ext, 85 files: groupFiles, 86 totalAdditions: groupFiles.reduce((s, f) => s + f.additions, 0), 87 totalDeletions: groupFiles.reduce((s, f) => s + f.deletions, 0), 88 })) 89 .sort((a, b) => a.ext.localeCompare(b.ext)); 90 }, [files]); 91 92 const includedCount = files.length - excluded.size; 93 94 function toggleFile(filename: string) { 95 setExcluded((prev) => { 96 const next = new Set(prev); 97 if (next.has(filename)) next.delete(filename); 98 else next.add(filename); 99 return next; 100 }); 101 } 102 103 function toggleExt(ext: string) { 104 const group = extGroups.find((g) => g.ext === ext); 105 if (!group) return; 106 const allExcluded = group.files.every((f) => excluded.has(f.filename)); 107 setExcluded((prev) => { 108 const next = new Set(prev); 109 if (allExcluded) { 110 for (const f of group.files) next.delete(f.filename); 111 } else { 112 for (const f of group.files) next.add(f.filename); 113 } 114 return next; 115 }); 116 } 117 118 function handleConfirm() { 119 onConfirm(Array.from(excluded)); 120 } 121 122 return ( 123 <Dialog open={open} onOpenChange={onOpenChange}> 124 <DialogContent className="bg-card sm:max-w-2xl max-h-[80vh] flex flex-col gap-4"> 125 <DialogHeader> 126 <DialogTitle>Select files to include</DialogTitle> 127 <DialogDescription>Choose which files to include in the review</DialogDescription> 128 </DialogHeader> 129 130 {loading && ( 131 <div className="flex items-center justify-center gap-2 py-8 text-muted-foreground"> 132 <Loader2 className="h-4 w-4 animate-spin" /> 133 <span className="text-sm">Loading files…</span> 134 </div> 135 )} 136 137 {error && <p className="text-sm text-destructive py-4 text-center">{error}</p>} 138 139 {!loading && !error && files.length > 0 && ( 140 <> 141 <div className="flex items-center gap-2"> 142 <button 143 type="button" 144 onClick={() => setExcluded(new Set())} 145 className="text-xs text-muted-foreground hover:text-foreground transition-colors" 146 > 147 Select all 148 </button> 149 <span className="text-xs text-muted-foreground">·</span> 150 <button 151 type="button" 152 onClick={() => setExcluded(new Set(files.map((f) => f.filename)))} 153 className="text-xs text-muted-foreground hover:text-foreground transition-colors" 154 > 155 Deselect all 156 </button> 157 </div> 158 159 <div className="overflow-y-auto -mx-6 min-h-0 flex-1 max-h-[50vh]"> 160 <ul> 161 {extGroups.map((group) => { 162 const allExcluded = group.files.every((f) => excluded.has(f.filename)); 163 const someExcluded = group.files.some((f) => excluded.has(f.filename)); 164 const groupChecked = !allExcluded; 165 const groupIndeterminate = someExcluded && !allExcluded; 166 167 return ( 168 <li key={group.ext}> 169 {/* Extension group row */} 170 <div className="flex items-center gap-2.5 px-6 py-2 hover:bg-muted/40 transition-colors"> 171 <Checkbox 172 checked={groupChecked} 173 indeterminate={groupIndeterminate} 174 onChange={() => toggleExt(group.ext)} 175 /> 176 <span className="text-sm font-medium flex-1 min-w-0"> 177 {group.ext === 'other' ? '(no extension)' : group.ext} 178 <span className="ml-2 text-xs text-muted-foreground font-normal"> 179 {group.files.length} {group.files.length === 1 ? 'file' : 'files'} 180 </span> 181 </span> 182 <span className="text-xs text-muted-foreground shrink-0"> 183 <span className="text-green-400">+{group.totalAdditions}</span>{' '} 184 <span className="text-red-400">−{group.totalDeletions}</span> 185 </span> 186 </div> 187 188 {/* File rows */} 189 <ul> 190 {group.files.map((f) => { 191 const isExcluded = excluded.has(f.filename); 192 return ( 193 <li key={f.filename}> 194 <div className="flex items-center gap-2.5 pl-12 pr-6 py-1.5 hover:bg-muted/30 transition-colors"> 195 <Checkbox checked={!isExcluded} onChange={() => toggleFile(f.filename)} /> 196 <span 197 className={`text-xs flex-1 min-w-0 truncate font-mono ${isExcluded ? 'text-muted-foreground line-through' : ''}`} 198 > 199 {f.filename} 200 </span> 201 <span className="text-xs text-muted-foreground shrink-0"> 202 <span className="text-green-400">+{f.additions}</span>{' '} 203 <span className="text-red-400">−{f.deletions}</span> 204 </span> 205 </div> 206 </li> 207 ); 208 })} 209 </ul> 210 </li> 211 ); 212 })} 213 </ul> 214 </div> 215 </> 216 )} 217 218 <div className="flex gap-2 justify-end pt-2 border-t border-border/50"> 219 <Button variant="outline" onClick={() => onOpenChange(false)}> 220 Cancel 221 </Button> 222 <Button onClick={handleConfirm} disabled={loading || includedCount === 0}> 223 Start Review ({includedCount} of {files.length} {files.length === 1 ? 'file' : 'files'}) 224 </Button> 225 </div> 226 </DialogContent> 227 </Dialog> 228 ); 229 }