1import { useReadQuery } from "@apollo/client/react";
2import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
3import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
4import { useState } from "react";
5import * as v from "valibot";
6
7import {
8 type BugListQuery,
9 BugListDocument,
10 type ValidLabelsQuery,
11 ValidLabelsDocument,
12 type AllIdentitiesQuery,
13 AllIdentitiesDocument,
14} from "@/__generated__/graphql";
15import { BugRow } from "@/components/bugs/BugRow";
16import { IssueFilters } from "@/components/bugs/IssueFilters";
17import type { SortValue } from "@/components/bugs/IssueFilters";
18import { QueryInput } from "@/components/bugs/QueryInput";
19import { Button } from "@/components/ui/button";
20import { ButtonLink } from "@/components/ui/button-link";
21import { Skeleton } from "@/components/ui/skeleton";
22import { preloadQuery } from "@/lib/apollo";
23import { useRepo } from "@/lib/repo";
24import { cn } from "@/lib/utils";
25
26const issuesSearchSchema = v.object({
27 q: v.fallback(v.string(), "status:open"),
28 after: v.fallback(v.string(), ""),
29});
30
31export const Route = createFileRoute("/$repo/issues/")({
32 component: RouteComponent,
33 pendingComponent: BugListSkeleton,
34 validateSearch: (search) => v.parse(issuesSearchSchema, search),
35 loaderDeps: ({ search: { q, after } }) => ({ q, after }),
36 loader: async ({ params: { repo }, deps: { q, after } }) => {
37 const ref = repo === "_" ? null : repo;
38 const parsed = parseQueryString(q);
39 const baseQuery = buildBaseQuery(parsed.labels, parsed.author, parsed.freeText);
40 const bugListRef = preloadQuery<BugListQuery>(BugListDocument, {
41 variables: {
42 ref,
43 openQuery: `status:open ${baseQuery}`.trim(),
44 closedQuery: `status:closed ${baseQuery}`.trim(),
45 listQuery: q,
46 first: PAGE_SIZE,
47 after: after || undefined,
48 },
49 });
50 const labelsRef = preloadQuery<ValidLabelsQuery>(ValidLabelsDocument, {
51 variables: { ref },
52 });
53 const identitiesRef = preloadQuery<AllIdentitiesQuery>(AllIdentitiesDocument, {
54 variables: { ref },
55 });
56 await Promise.all([
57 preloadQuery.toPromise(bugListRef),
58 preloadQuery.toPromise(labelsRef),
59 preloadQuery.toPromise(identitiesRef),
60 ]);
61 return { bugListRef, labelsRef, identitiesRef };
62 },
63});
64
65const PAGE_SIZE = 25;
66
67type StatusFilter = "open" | "closed";
68
69function RouteComponent() {
70 const repo = useRepo();
71 const navigate = useNavigate({ from: "/$repo/issues/" });
72 const { q, after } = Route.useSearch();
73
74 // Parse the URL query into structured filter state for the dropdowns
75 const parsed = parseQueryString(q);
76 const {
77 status: statusFilter,
78 labels: selectedLabels,
79 author: selectedAuthorQuery,
80 sort,
81 } = parsed;
82 // We don't have the humanId from URL — the dropdown will match by query value
83 const selectedAuthorId: string | null = null;
84
85 // Draft is the text input value — starts from URL, only committed on submit
86 const [draft, setDraft] = useState(q);
87
88 const { bugListRef, labelsRef, identitiesRef } = Route.useLoaderData();
89 const { data } = useReadQuery(bugListRef);
90 const { data: labelsData } = useReadQuery(labelsRef);
91 const { data: identitiesData } = useReadQuery(identitiesRef);
92
93 const openCount = data?.repository?.openCount.totalCount ?? 0;
94 const closedCount = data?.repository?.closedCount.totalCount ?? 0;
95 const bugs = data?.repository?.bugs;
96 const validLabels = labelsData?.repository?.validLabels.nodes ?? [];
97 const allIdentities = identitiesData?.repository?.allIdentities.nodes ?? [];
98 const totalCount = bugs?.totalCount ?? 0;
99 const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
100 const hasNext = bugs?.pageInfo.hasNextPage ?? false;
101 const hasPrev = !!after;
102
103 // Navigate to new search params (resets pagination)
104 function setSearch(newQ: string) {
105 setDraft(newQ);
106 void navigate({ search: { q: newQ, after: "" } });
107 }
108
109 // Apply structured filters → build query string → navigate
110 function applyFilters(
111 status: StatusFilter,
112 labels: string[],
113 authorQuery: string | null,
114 text: string,
115 sortVal: SortValue = sort,
116 ) {
117 setSearch(buildQueryString(status, labels, authorQuery, text, sortVal));
118 }
119
120 // Parse the draft text box on submit
121 function handleSearch(e?: React.FormEvent) {
122 e?.preventDefault();
123 setSearch(draft);
124 }
125
126 // Build query string with toggled status
127 function queryWithStatus(status: StatusFilter): string {
128 return buildQueryString(status, selectedLabels, selectedAuthorQuery, parsed.freeText, sort);
129 }
130
131 return (
132 <div>
133 {/* Search bar */}
134 <form onSubmit={handleSearch} className="mb-4 flex gap-2">
135 <QueryInput
136 value={draft}
137 onChange={setDraft}
138 onSubmit={handleSearch}
139 placeholder="status:open author:… label:…"
140 labels={validLabels}
141 identities={allIdentities}
142 />
143 <Button type="submit">Search</Button>
144 </form>
145
146 {/* List container */}
147 <div className="border-border rounded-md border">
148 {/* Open / Closed toggle + filter dropdowns */}
149 <div className="border-border flex items-center gap-2 overflow-x-auto border-b px-4 py-2">
150 <div className="flex shrink-0 items-center gap-1">
151 <Link
152 to="/$repo/issues"
153 params={{ repo: repo! }}
154 search={{ q: queryWithStatus("open"), after: "" }}
155 className={cn(
156 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
157 statusFilter === "open"
158 ? "bg-accent text-accent-foreground"
159 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
160 )}
161 >
162 <CircleDot
163 className={cn(
164 "size-4",
165 statusFilter === "open" && "text-green-600 dark:text-green-400",
166 )}
167 />
168 Open
169 <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
170 {openCount}
171 </span>
172 </Link>
173
174 <Link
175 to="/$repo/issues"
176 params={{ repo: repo! }}
177 search={{ q: queryWithStatus("closed"), after: "" }}
178 className={cn(
179 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
180 statusFilter === "closed"
181 ? "bg-accent text-accent-foreground"
182 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
183 )}
184 >
185 <CircleCheck
186 className={cn(
187 "size-4",
188 statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
189 )}
190 />
191 Closed
192 <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none tabular-nums">
193 {closedCount}
194 </span>
195 </Link>
196 </div>
197
198 <div className="ml-auto">
199 <IssueFilters
200 labels={validLabels}
201 identities={allIdentities}
202 selectedLabels={selectedLabels}
203 onLabelsChange={(labels) =>
204 applyFilters(statusFilter, labels, selectedAuthorQuery, parsed.freeText)
205 }
206 selectedAuthorId={selectedAuthorId}
207 onAuthorChange={(_id, qv) =>
208 applyFilters(statusFilter, selectedLabels, qv, parsed.freeText)
209 }
210 recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
211 sort={sort}
212 onSortChange={(s) =>
213 applyFilters(statusFilter, selectedLabels, selectedAuthorQuery, parsed.freeText, s)
214 }
215 />
216 </div>
217 </div>
218
219 {/* Bug rows */}
220 {bugs?.nodes.length === 0 && (
221 <p className="text-muted-foreground px-4 py-8 text-center text-sm">
222 No {statusFilter} issues found.
223 </p>
224 )}
225
226 {bugs?.nodes.map((bug) => (
227 <BugRow
228 key={bug.id}
229 id={bug.id}
230 humanId={bug.humanId}
231 status={bug.status}
232 title={bug.title}
233 labels={bug.labels}
234 author={bug.author}
235 createdAt={bug.createdAt}
236 commentCount={bug.comments.totalCount}
237 repo={repo}
238 onLabelClick={(name) => {
239 if (!selectedLabels.includes(name)) {
240 applyFilters(
241 statusFilter,
242 [...selectedLabels, name],
243 selectedAuthorQuery,
244 parsed.freeText,
245 );
246 }
247 }}
248 />
249 ))}
250
251 {totalPages > 1 && (
252 <div className="border-border flex items-center justify-center gap-2 border-t px-4 py-2">
253 <ButtonLink
254 to="/$repo/issues"
255 params={{ repo: repo! }}
256 search={{ q, after: "" }}
257 variant="ghost"
258 size="sm"
259 disabled={!hasPrev}
260 className="text-muted-foreground gap-1"
261 >
262 <ChevronLeft className="size-4" />
263 Previous
264 </ButtonLink>
265 <span className="text-muted-foreground text-sm">
266 Page {after ? 2 : 1} of {totalPages}
267 </span>
268 <ButtonLink
269 to="/$repo/issues"
270 params={{ repo: repo! }}
271 search={{ q, after: bugs?.pageInfo.endCursor ?? "" }}
272 variant="ghost"
273 size="sm"
274 disabled={!hasNext}
275 className="text-muted-foreground gap-1"
276 >
277 Next
278 <ChevronRight className="size-4" />
279 </ButtonLink>
280 </div>
281 )}
282 </div>
283 </div>
284 );
285}
286
287// buildBaseQuery returns the filter parts (labels, author, freeText) without
288// the status prefix, so it can be combined with "status:open" / "status:closed".
289function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
290 const parts: string[] = [];
291 for (const label of labels) {
292 parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
293 }
294 if (author) {
295 parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
296 }
297 if (freeText.trim()) parts.push(freeText.trim());
298 return parts.join(" ");
299}
300
301// Build the structured query string sent to the GraphQL allBugs(query:) argument.
302function buildQueryString(
303 status: StatusFilter,
304 labels: string[],
305 author: string | null,
306 freeText: string,
307 sort: SortValue = "creation-desc",
308): string {
309 const parts = [`status:${status}`];
310 const base = buildBaseQuery(labels, author, freeText);
311 if (base) parts.push(base);
312 if (sort !== "creation-desc") parts.push(`sort:${sort}`);
313 return parts.join(" ");
314}
315
316// Tokenize a query string, keeping quoted spans as single tokens.
317function tokenizeQuery(input: string): string[] {
318 const tokens: string[] = [];
319 let current = "";
320 let inQuote = false;
321 for (const ch of input.trim()) {
322 if (ch === '"') {
323 inQuote = !inQuote;
324 current += ch;
325 } else if (ch === " " && !inQuote) {
326 if (current) {
327 tokens.push(current);
328 current = "";
329 }
330 } else current += ch;
331 }
332 if (current) tokens.push(current);
333 return tokens;
334}
335
336// Parse a query string back into structured filter state.
337const VALID_SORTS = new Set<string>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
338
339function isValidSort(val: string): val is SortValue {
340 return VALID_SORTS.has(val);
341}
342
343function parseQueryString(input: string): {
344 status: StatusFilter;
345 labels: string[];
346 author: string | null;
347 freeText: string;
348 sort: SortValue;
349} {
350 let status: StatusFilter = "open";
351 const labels: string[] = [];
352 let author: string | null = null;
353 let sort: SortValue = "creation-desc";
354 const free: string[] = [];
355
356 for (const token of tokenizeQuery(input)) {
357 if (token === "status:open") status = "open";
358 else if (token === "status:closed") status = "closed";
359 else if (token.startsWith("label:")) labels.push(token.slice(6));
360 else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
361 else if (token.startsWith("sort:")) {
362 const val = token.slice(5);
363 if (isValidSort(val)) sort = val;
364 } else free.push(token);
365 }
366
367 return { status, labels, author, freeText: free.join(" "), sort };
368}
369
370function BugListSkeleton() {
371 return (
372 <div className="divide-border divide-y">
373 {Array.from({ length: 8 }).map((_, i) => (
374 <div key={i} className="flex items-start gap-3 px-4 py-3">
375 <Skeleton className="mt-0.5 size-4 rounded-full" />
376 <div className="flex-1 space-y-2">
377 <Skeleton className="h-4 w-2/3" />
378 <Skeleton className="h-3 w-1/3" />
379 </div>
380 </div>
381 ))}
382 </div>
383 );
384}