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