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