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