/ 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} &middot; {timeAgo(pr.updatedAt)}
189                      </span>
190                    </button>
191                  </li>
192                ))}
193              </ul>
194            )}
195          </div>
196        </DialogContent>
197      </Dialog>
198    );
199  }