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