1import { CircleDot, CircleCheck, ChevronLeft, ChevronRight } from "lucide-react";
2import { useState, useEffect } from "react";
3
4import { useBugListQuery } from "@/__generated__/graphql";
5import { BugRow } from "@/components/bugs/BugRow";
6import { IssueFilters } from "@/components/bugs/IssueFilters";
7import type { SortValue } from "@/components/bugs/IssueFilters";
8import { QueryInput } from "@/components/bugs/QueryInput";
9import { Button } from "@/components/ui/button";
10import { Skeleton } from "@/components/ui/skeleton";
11import { useRepo } from "@/lib/repo";
12import { cn } from "@/lib/utils";
13
14const PAGE_SIZE = 25;
15
16type StatusFilter = "open" | "closed";
17
18// Issue list page (/:repo/issues). Search bar with structured query, open/closed toggle,
19// label+author filter dropdowns, and paginated bug rows.
20export function BugListPage() {
21 const repo = useRepo();
22 const [statusFilter, setStatusFilter] = useState<StatusFilter>("open");
23 const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
24 // humanId — uniquely identifies the selection for the dropdown UI
25 const [selectedAuthorId, setSelectedAuthorId] = useState<string | null>(null);
26 // query value (login/name) — what goes into author:... in the query string
27 const [selectedAuthorQuery, setSelectedAuthorQuery] = useState<string | null>(null);
28 const [freeText, setFreeText] = useState("");
29 const [sort, setSort] = useState<SortValue>("creation-desc");
30 const [draft, setDraft] = useState(() => buildQueryString("open", [], null, "", "creation-desc"));
31
32 // Cursor-stack pagination: cursors[i] is the `after` value to fetch page i.
33 // cursors[0] is always undefined (first page needs no cursor).
34 const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]);
35 const page = cursors.length - 1; // 0-indexed current page
36
37 // Build separate query strings: two for the always-visible counts (open/closed),
38 // one for the paginated list. The count queries share all filters except status.
39 const baseQuery = buildBaseQuery(selectedLabels, selectedAuthorQuery, freeText);
40 const openQuery = `status:open ${baseQuery}`.trim();
41 const closedQuery = `status:closed ${baseQuery}`.trim();
42 const listQuery = buildQueryString(
43 statusFilter,
44 selectedLabels,
45 selectedAuthorQuery,
46 freeText,
47 sort,
48 );
49
50 const { data, loading, error } = useBugListQuery({
51 variables: {
52 ref: repo,
53 openQuery,
54 closedQuery,
55 listQuery,
56 first: PAGE_SIZE,
57 after: cursors[page],
58 },
59 });
60
61 const openCount = data?.repository?.openCount.totalCount ?? 0;
62 const closedCount = data?.repository?.closedCount.totalCount ?? 0;
63 const bugs = data?.repository?.bugs;
64 const totalCount = bugs?.totalCount ?? 0;
65 const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
66 const hasNext = bugs?.pageInfo.hasNextPage ?? false;
67 const hasPrev = page > 0;
68
69 // Reset to page 1 whenever the list query changes.
70 useEffect(() => {
71 setCursors([undefined]);
72 }, [listQuery]);
73
74 // Apply all filters at once, keeping draft in sync with the structured state.
75 function applyFilters(
76 status: StatusFilter,
77 labels: string[],
78 authorId: string | null,
79 authorQuery: string | null,
80 text: string,
81 sortVal: SortValue = sort,
82 ) {
83 setStatusFilter(status);
84 setSelectedLabels(labels);
85 setSelectedAuthorId(authorId);
86 setSelectedAuthorQuery(authorQuery);
87 setFreeText(text);
88 setSort(sortVal);
89 setDraft(buildQueryString(status, labels, authorQuery, text, sortVal));
90 }
91
92 // Parse the draft text box on submit so manual edits update the dropdowns too.
93 // When parsing we don't know the humanId — clear it so the dropdown resets.
94 // Called both from the <form> onSubmit (with event) and from QueryInput's
95 // Enter-key handler (without event), so e is optional.
96 function handleSearch(e?: React.FormEvent) {
97 e?.preventDefault();
98 const p = parseQueryString(draft);
99 applyFilters(p.status, p.labels, null, p.author, p.freeText, p.sort);
100 }
101
102 function goNext() {
103 const endCursor = bugs?.pageInfo.endCursor;
104 if (!endCursor) return;
105 setCursors((prev) => [...prev, endCursor]);
106 }
107
108 function goPrev() {
109 setCursors((prev) => prev.slice(0, -1));
110 }
111
112 return (
113 <div>
114 {/* Search bar */}
115 <form onSubmit={handleSearch} className="mb-4 flex gap-2">
116 <QueryInput
117 value={draft}
118 onChange={setDraft}
119 onSubmit={handleSearch}
120 placeholder="status:open author:… label:…"
121 />
122 <Button type="submit">Search</Button>
123 </form>
124
125 {/* List container */}
126 <div className="rounded-md border border-border">
127 {/* Open / Closed toggle + filter dropdowns */}
128 <div className="flex items-center gap-2 overflow-x-auto border-b border-border px-4 py-2">
129 <div className="flex shrink-0 items-center gap-1">
130 <button
131 onClick={() =>
132 applyFilters(
133 "open",
134 selectedLabels,
135 selectedAuthorId,
136 selectedAuthorQuery,
137 freeText,
138 )
139 }
140 className={cn(
141 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
142 statusFilter === "open"
143 ? "bg-accent text-accent-foreground"
144 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
145 )}
146 >
147 <CircleDot
148 className={cn(
149 "size-4",
150 statusFilter === "open" && "text-green-600 dark:text-green-400",
151 )}
152 />
153 Open
154 <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
155 {openCount}
156 </span>
157 </button>
158
159 <button
160 onClick={() =>
161 applyFilters(
162 "closed",
163 selectedLabels,
164 selectedAuthorId,
165 selectedAuthorQuery,
166 freeText,
167 )
168 }
169 className={cn(
170 "flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
171 statusFilter === "closed"
172 ? "bg-accent text-accent-foreground"
173 : "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
174 )}
175 >
176 <CircleCheck
177 className={cn(
178 "size-4",
179 statusFilter === "closed" && "text-purple-600 dark:text-purple-400",
180 )}
181 />
182 Closed
183 <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs tabular-nums leading-none">
184 {closedCount}
185 </span>
186 </button>
187 </div>
188
189 <div className="ml-auto">
190 <IssueFilters
191 selectedLabels={selectedLabels}
192 onLabelsChange={(labels) =>
193 applyFilters(statusFilter, labels, selectedAuthorId, selectedAuthorQuery, freeText)
194 }
195 selectedAuthorId={selectedAuthorId}
196 onAuthorChange={(id, qv) =>
197 applyFilters(statusFilter, selectedLabels, id, qv, freeText)
198 }
199 recentAuthorIds={bugs?.nodes?.map((b) => b.author.humanId) ?? []}
200 sort={sort}
201 onSortChange={(s) =>
202 applyFilters(
203 statusFilter,
204 selectedLabels,
205 selectedAuthorId,
206 selectedAuthorQuery,
207 freeText,
208 s,
209 )
210 }
211 />
212 </div>
213 </div>
214
215 {/* Bug rows */}
216 {error && (
217 <p className="px-4 py-8 text-center text-sm text-destructive">
218 Failed to load issues: {error.message}
219 </p>
220 )}
221
222 {loading && !data && <BugListSkeleton />}
223
224 {bugs?.nodes.length === 0 && (
225 <p className="px-4 py-8 text-center text-sm text-muted-foreground">
226 No {statusFilter} issues found.
227 </p>
228 )}
229
230 {bugs?.nodes.map((bug) => (
231 <BugRow
232 key={bug.id}
233 id={bug.id}
234 humanId={bug.humanId}
235 status={bug.status}
236 title={bug.title}
237 labels={bug.labels}
238 author={bug.author}
239 createdAt={bug.createdAt}
240 commentCount={bug.comments.totalCount}
241 repo={repo}
242 onLabelClick={(name) => {
243 if (!selectedLabels.includes(name)) {
244 applyFilters(
245 statusFilter,
246 [...selectedLabels, name],
247 selectedAuthorId,
248 selectedAuthorQuery,
249 freeText,
250 );
251 }
252 }}
253 />
254 ))}
255
256 {totalPages > 1 && (
257 <div className="flex items-center justify-center gap-2 border-t border-border px-4 py-2">
258 <Button
259 variant="ghost"
260 size="sm"
261 onClick={goPrev}
262 disabled={!hasPrev || loading}
263 className="gap-1 text-muted-foreground"
264 >
265 <ChevronLeft className="size-4" />
266 Previous
267 </Button>
268 <span className="text-sm text-muted-foreground">
269 Page {page + 1} of {totalPages}
270 </span>
271 <Button
272 variant="ghost"
273 size="sm"
274 onClick={goNext}
275 disabled={!hasNext || loading}
276 className="gap-1 text-muted-foreground"
277 >
278 Next
279 <ChevronRight className="size-4" />
280 </Button>
281 </div>
282 )}
283 </div>
284 </div>
285 );
286}
287
288// buildBaseQuery returns the filter parts (labels, author, freeText) without
289// the status prefix, so it can be combined with "status:open" / "status:closed".
290function buildBaseQuery(labels: string[], author: string | null, freeText: string): string {
291 const parts: string[] = [];
292 for (const label of labels) {
293 parts.push(label.includes(" ") ? `label:"${label}"` : `label:${label}`);
294 }
295 if (author) {
296 parts.push(author.includes(" ") ? `author:"${author}"` : `author:${author}`);
297 }
298 if (freeText.trim()) parts.push(freeText.trim());
299 return parts.join(" ");
300}
301
302// Build the structured query string sent to the GraphQL allBugs(query:) argument.
303// Multi-word label/author values are wrapped in quotes so the backend parser
304// treats them as a single token (e.g. label:"my label" vs label:my label).
305function buildQueryString(
306 status: StatusFilter,
307 labels: string[],
308 author: string | null,
309 freeText: string,
310 sort: SortValue = "creation-desc",
311): string {
312 const parts = [`status:${status}`];
313 const base = buildBaseQuery(labels, author, freeText);
314 if (base) parts.push(base);
315 if (sort !== "creation-desc") parts.push(`sort:${sort}`);
316 return parts.join(" ");
317}
318
319// Tokenize a query string, keeping quoted spans (e.g. author:"René Descartes")
320// as single tokens. Quotes are preserved in the output so callers can strip them
321// when extracting values.
322function tokenizeQuery(input: string): string[] {
323 const tokens: string[] = [];
324 let current = "";
325 let inQuote = false;
326 for (const ch of input.trim()) {
327 if (ch === '"') {
328 inQuote = !inQuote;
329 current += ch;
330 } else if (ch === " " && !inQuote) {
331 if (current) {
332 tokens.push(current);
333 current = "";
334 }
335 } else current += ch;
336 }
337 if (current) tokens.push(current);
338 return tokens;
339}
340
341// Parse a free-text query string back into structured filter state so that
342// manual edits to the search box are reflected in the dropdown UI on submit.
343// Strips surrounding quotes from values (they're an encoding detail, not part
344// of the value itself). Unknown tokens fall through to freeText.
345const VALID_SORTS = new Set<SortValue>(["creation-desc", "creation-asc", "edit-desc", "edit-asc"]);
346
347function parseQueryString(input: string): {
348 status: StatusFilter;
349 labels: string[];
350 author: string | null;
351 freeText: string;
352 sort: SortValue;
353} {
354 let status: StatusFilter = "open";
355 const labels: string[] = [];
356 let author: string | null = null;
357 let sort: SortValue = "creation-desc";
358 const free: string[] = [];
359
360 for (const token of tokenizeQuery(input)) {
361 if (token === "status:open") status = "open";
362 else if (token === "status:closed") status = "closed";
363 else if (token.startsWith("label:")) labels.push(token.slice(6));
364 else if (token.startsWith("author:")) author = token.slice(7).replace(/^"|"$/g, "");
365 else if (token.startsWith("sort:")) {
366 const v = token.slice(5) as SortValue;
367 if (VALID_SORTS.has(v)) sort = v;
368 } else free.push(token);
369 }
370
371 return { status, labels, author, freeText: free.join(" "), sort };
372}
373
374function BugListSkeleton() {
375 return (
376 <div className="divide-y divide-border">
377 {Array.from({ length: 8 }).map((_, i) => (
378 <div key={i} className="flex items-start gap-3 px-4 py-3">
379 <Skeleton className="mt-0.5 size-4 rounded-full" />
380 <div className="flex-1 space-y-2">
381 <Skeleton className="h-4 w-2/3" />
382 <Skeleton className="h-3 w-1/3" />
383 </div>
384 </div>
385 ))}
386 </div>
387 );
388}