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}