index.tsx

  1import { createFileRoute } from "@tanstack/react-router";
  2import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
  3import { useState, useEffect } from "react";
  4
  5import { useBugListQuery } from "@/__generated__/graphql";
  6import { BugRow } from "@/components/bugs/BugRow";
  7import { IssueFilters } from "@/components/bugs/IssueFilters";
  8import type { SortValue } from "@/components/bugs/IssueFilters";
  9import { QueryInput } from "@/components/bugs/QueryInput";
 10import { Button } from "@/components/ui/button";
 11import { Skeleton } from "@/components/ui/skeleton";
 12import { useRepo } from "@/lib/repo";
 13import { cn } from "@/lib/utils";
 14
 15export const Route = createFileRoute("/$repo/issues/")({
 16  component: RouteComponent,
 17});
 18
 19const PAGE_SIZE = 25;
 20
 21type StatusFilter = "open" | "closed";
 22
 23// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
 24// label+author filter dropdowns, and paginated bug rows.
 25function RouteComponent() {
 26  const repo = useRepo();
 27  const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
 28  const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
 29  // humanId — uniquely identifies the selection for the dropdown UI
 30  const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
 31  // query value (login/name) — what goes into author:... in the query string
 32  const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
 33  const [freeText, setFreeText] = useState("");
 34  const [sort, setSort] = useState<SortValue>("creation-desc");
 35  const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
 36
 37  // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
 38  // cursors[0] is always undefined (first page needs no cursor).
 39  const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
 40  const page = cursors.length - 1; // 0-indexed current page
 41
 42  // Build separate query strings: two for the always-visible counts (open/closed),
 43  // one for the paginated list. The count queries share all filters except status.
 44  const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
 45  const openQuery = `status:open ${baseQuery}`.trim();
 46  const closedQuery = `status:closed ${baseQuery}`.trim();
 47  const listQuery = buildQueryString(
 48    statusFilter,
 49    selectedLabels,
 50    selectedAuthorQuery,
 51    freeText,
 52    sort,
 53  );
 54
 55  const { data, loading, error } = useBugListQuery({
 56    variables: {
 57      ref: repo,
 58      openQuery,
 59      closedQuery,
 60      listQuery,
 61      first: PAGE_SIZE,
 62      after: cursors[page],
 63    },
 64  });
 65
 66  const openCount = data?.repository?.openCount.totalCount ?? 0;
 67  const closedCount = data?.repository?.closedCount.totalCount ?? 0;
 68  const bugs = data?.repository?.bugs;
 69  const totalCount = bugs?.totalCount ?? 0;
 70  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
 71  const hasNext = bugs?.pageInfo.hasNextPage ?? false;
 72  const hasPrev = page > 0;
 73
 74  // Reset to page 1 whenever the list query changes.
 75  useEffect(() => {
 76    setCursors([undefined]);
 77  }, [listQuery]);
 78
 79  // Apply all filters at once, keeping draft in sync with the structured state.
 80  function applyFilters(
 81    status: StatusFilter,
 82    labels: string[],
 83    authorId: string | null,
 84    authorQuery: string | null,
 85    text: string,
 86    sortVal: SortValue = sort,
 87  ) {
 88    setStatusFilter(status);
 89    setSelectedLabels(labels);
 90    setSelectedAuthorId(authorId);
 91    setSelectedAuthorQuery(authorQuery);
 92    setFreeText(text);
 93    setSort(sortVal);
 94    setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
 95  }
 96
 97  // Parse the draft text box on submit so manual edits update the dropdowns too.
 98  // When parsing we don't know the humanId — clear it so the dropdown resets.
 99  // Called both from the <form> onSubmit (with event) and from QueryInput's
100  // Enter-key handler (without event), so e is optional.
101  function handleSearch(e?: React.FormEvent) {
102    e?.preventDefault();
103    const p = parseQueryString(draft);
104    applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
105  }
106
107  function goNext() {
108    const endCursor = bugs?.pageInfo.endCursor;
109    if (!endCursor) return;
110    setCursors((prev) => [...prev, endCursor]);
111  }
112
113  function goPrev() {
114    setCursors((prev) => prev.slice(0, -1));
115  }
116
117  return (
118    <div>
119      {/* Search bar */}
120      <form onSubmit={handleSearch} className="mb-4 flex gap-2">
121        <QueryInput
122          value={draft}
123          onChange={setDraft}
124          onSubmit={handleSearch}
125          placeholder="status:open author:… label:…"
126        />
127        <Button type="submit">Search</Button>
128      </form>
129
130      {/* List container */}
131      <div className="border-border rounded-md border">
132        {/* Open / Closed toggle + filter dropdowns */}
133        <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
134          <div className="flex shrink-0 items-center gap-1">
135            <button
136              onClick={() =>
137                applyFilters(
138                  "open",
139                  selectedLabels,
140                  selectedAuthorId,
141                  selectedAuthorQuery,
142                  freeText,
143                )
144              }
145              className={cn(
146                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
147                statusFilter === "open"
148                  ? "bg-accent text-accent-foreground"
149                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
150              )}
151            >
152              <CircleDot
153                className={cn(
154                  "size-4",
155                  statusFilter === "open" && "text-green-600 dark:text-green-400",
156                )}
157              />
158              Open
159              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
160                {openCount}
161              </span>
162            </button>
163
164            <button
165              onClick={() =>
166                applyFilters(
167                  "closed",
168                  selectedLabels,
169                  selectedAuthorId,
170                  selectedAuthorQuery,
171                  freeText,
172                )
173              }
174              className={cn(
175                "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
176                statusFilter === "closed"
177                  ? "bg-accent text-accent-foreground"
178                  : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
179              )}
180            >
181              <CircleCheck
182                className={cn(
183                  "size-4",
184                  statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
185                )}
186              />
187              Closed
188              <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
189                {closedCount}
190              </span>
191            </button>
192          </div>
193
194          <div className="ml-auto">
195            <IssueFilters
196              selectedLabels={selectedLabels}
197              onLabelsChange={(labels) =>
198                applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
199              }
200              selectedAuthorId={selectedAuthorId}
201              onAuthorChange={(id, qv) =>
202                applyFilters(statusFilter, selectedLabels, id, qv, freeText)
203              }
204              recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
205              sort={sort}
206              onSortChange={(s) =>
207                applyFilters(
208                  statusFilter,
209                  selectedLabels,
210                  selectedAuthorId,
211                  selectedAuthorQuery,
212                  freeText,
213                  s,
214                )
215              }
216            />
217          </div>
218        </div>
219
220        {/* Bug rows */}
221        {error && (
222          <p className="text-destructive px-4 py-8 text-center text-sm">
223            Failed to load issues: {error.message}
224          </p>
225        )}
226
227        {loading && !data && <BugListSkeleton />}
228
229        {bugs?.nodes.length === 0 && (
230          <p className="text-muted-foreground px-4 py-8 text-center text-sm">
231            No {statusFilter} issues found.
232          </p>
233        )}
234
235        {bugs?.nodes.map((bug) => (
236          <BugRow
237            key={bug.id}
238            id={bug.id}
239            humanId={bug.humanId}
240            status={bug.status}
241            title={bug.title}
242            labels={bug.labels}
243            author={bug.author}
244            createdAt={bug.createdAt}
245            commentCount={bug.comments.totalCount}
246            repo={repo}
247            onLabelClick={(name) => {
248              if (!selectedLabels.includes(name)) {
249                applyFilters(
250                  statusFilter,
251                  [...selectedLabels, name],
252                  selectedAuthorId,
253                  selectedAuthorQuery,
254                  freeText,
255                );
256              }
257            }}
258          />
259        ))}
260
261        {totalPages > 1 && (
262          <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
263            <Button
264              variant="ghost"
265              size="sm"
266              onClick={goPrev}
267              disabled={!hasPrev || loading}
268              className="text-muted-foreground gap-1"
269            >
270              <ChevronLeft className="size-4" />
271              Previous
272            </Button>
273            <span className="text-muted-foreground text-sm">
274              Page {page + 1} of {totalPages}
275            </span>
276            <Button
277              variant="ghost"
278              size="sm"
279              onClick={goNext}
280              disabled={!hasNext || loading}
281              className="text-muted-foreground gap-1"
282            >
283              Next
284              <ChevronRight className="size-4" />
285            </Button>
286          </div>
287        )}
288      </div>
289    </div>
290  );
291}
292
293// buildBaseQuery returns the filter parts (labels, author, freeText) without
294// the status prefix, so it can be combined with "status:open" / "status:closed".
295function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
296  const parts: string[] = [];
297  for (const label of labels) {
298    parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
299  }
300  if (author) {
301    parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
302  }
303  if (freeText.trim()) parts.push(freeText.trim());
304  return parts.join(" ");
305}
306
307// Build the structured query string sent to the GraphQL allBugs(query:) argument.
308// Multi-word label/author values are wrapped in quotes so the backend parser
309// treats them as a single token (e.g. label:"my label" vs label:my label).
310function buildQueryString(
311  status: StatusFilter,
312  labels: string[],
313  author: string | null,
314  freeText: string,
315  sort: SortValue = "creation-desc",
316): string {
317  const parts = [`status:${status}`];
318  const base = buildBaseQuery(labels, author, freeText);
319  if (base) parts.push(base);
320  if (sort !== "creation-desc") parts.push(`sort:${sort}`);
321  return parts.join(" ");
322}
323
324// Tokenize a query string, keeping quoted spans (e.g. author:"René Descartes")
325// as single tokens. Quotes are preserved in the output so callers can strip them
326// when extracting values.
327function tokenizeQuery(input: string): string[] {
328  const tokens: string[] = [];
329  let current = "";
330  let inQuote = false;
331  for (const ch of input.trim()) {
332    if (ch === '"') {
333      inQuote = !inQuote;
334      current += ch;
335    } else if (ch === " " && !inQuote) {
336      if (current) {
337        tokens.push(current);
338        current = "";
339      }
340    } else current += ch;
341  }
342  if (current) tokens.push(current);
343  return tokens;
344}
345
346// Parse a free-text query string back into structured filter state so that
347// manual edits to the search box are reflected in the dropdown UI on submit.
348// Strips surrounding quotes from values (they're an encoding detail, not part
349// of the value itself). Unknown tokens fall through to freeText.
350const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
351
352function parseQueryString(input: string): {
353  status: StatusFilter;
354  labels: string[];
355  author: string | null;
356  freeText: string;
357  sort: SortValue;
358} {
359  let status: StatusFilter = "open";
360  const labels: string[] = [];
361  let author: string | null = null;
362  let sort: SortValue = "creation-desc";
363  const free: string[] = [];
364
365  for (const token of tokenizeQuery(input)) {
366    if (token === "status:open") status = "open";
367    else if (token === "status:closed") status = "closed";
368    else if (token.startsWith("label:")) labels.push(token.slice(6));
369    else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
370    else if (token.startsWith("sort:")) {
371      const v = token.slice(5) as SortValue;
372      if (VALID_SORTS.has(v)) sort = v;
373    } else free.push(token);
374  }
375
376  return { status, labels, author, freeText: free.join(" "), sort };
377}
378
379function BugListSkeleton() {
380  return (
381    <div className="divide-border divide-y">
382      {Array.from({ length: 8 }).map((_, i) => (
383        <div key={i} className="flex items-start gap-3 px-4 py-3">
384          <Skeleton className="mt-0.5 size-4 rounded-full" />
385          <div className="flex-1 space-y-2">
386            <Skeleton className="h-4 w-2/3" />
387            <Skeleton className="h-3 w-1/3" />
388          </div>
389        </div>
390      ))}
391    </div>
392  );
393}