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