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