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