/ 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  }