@kitlangton/ghui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx ADDED
@@ -0,0 +1,1502 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useAtom } from "@effect/atom-react"
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
4
+ import * as Atom from "effect/unstable/reactivity/Atom"
5
+ import { Fragment, useEffect, useMemo, useRef } from "react"
6
+ import { config } from "./config.js"
7
+ import type { CheckItem, PullRequestItem, PullRequestLabel } from "./domain.js"
8
+ import { daysOpen, formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
9
+ import { addPullRequestLabel, getAuthenticatedUser, listOpenPullRequests as loadOpenPullRequests, listRepoLabels, removePullRequestLabel, toggleDraftStatus } from "./services/GitHubService.js"
10
+
11
+ const toggleDraft = (repository: string, number: number, isDraft: boolean) => toggleDraftStatus(repository, number, isDraft)
12
+
13
+ const colors = {
14
+ text: "#ede7da",
15
+ muted: "#9f9788",
16
+ separator: "#6f685d",
17
+ accent: "#f4a51c",
18
+ inlineCode: "#d7c5a1",
19
+ error: "#f97316",
20
+ selectedBg: "#1d2430",
21
+ selectedText: "#f8fafc",
22
+ count: "#d7c5a1",
23
+ status: {
24
+ draft: "#f59e0b",
25
+ approved: "#7dd3a3",
26
+ changes: "#f87171",
27
+ review: "#93c5fd",
28
+ none: "#9f9788",
29
+ passing: "#7dd3a3",
30
+ pending: "#f4a51c",
31
+ failing: "#f87171",
32
+ },
33
+ repos: {
34
+ opencode: "#60a5fa",
35
+ "effect-smol": "#34d399",
36
+ "opencode-console": "#f472b6",
37
+ opencontrol: "#f59e0b",
38
+ default: "#93c5fd",
39
+ },
40
+ } as const
41
+
42
+ type LoadStatus = "loading" | "ready" | "error"
43
+
44
+ interface PullRequestState {
45
+ readonly status: LoadStatus
46
+ readonly data: readonly PullRequestItem[]
47
+ readonly error: string | null
48
+ readonly fetchedAt: Date | null
49
+ }
50
+
51
+ interface PreviewLine {
52
+ readonly segments: ReadonlyArray<{
53
+ readonly text: string
54
+ readonly fg: string
55
+ readonly bold?: boolean
56
+ }>
57
+ }
58
+
59
+ const pullRequestReferencePattern = /(#[0-9]+)/g
60
+
61
+ const initialPullRequestState: PullRequestState = {
62
+ status: "loading",
63
+ data: [],
64
+ error: null,
65
+ fetchedAt: null,
66
+ }
67
+
68
+ const pullRequestStateAtom = Atom.make(initialPullRequestState).pipe(Atom.keepAlive)
69
+ const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
70
+ const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
71
+ const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
72
+ const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
73
+ const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
74
+ const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
75
+ const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
76
+ const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
77
+ const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
78
+
79
+ const GROUP_ICONS = ["▸", "◆", "●", "▪", "›", "◈", "▹", "◉", "⬥", "⏵", "⊡", "⬩"] as const
80
+ const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
81
+
82
+ interface LabelModalState {
83
+ readonly open: boolean
84
+ readonly repository: string | null
85
+ readonly query: string
86
+ readonly selectedIndex: number
87
+ readonly availableLabels: readonly PullRequestLabel[]
88
+ readonly loading: boolean
89
+ }
90
+
91
+ const initialLabelModalState: LabelModalState = {
92
+ open: false,
93
+ repository: null,
94
+ query: "",
95
+ selectedIndex: 0,
96
+ availableLabels: [],
97
+ loading: false,
98
+ }
99
+
100
+ const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
101
+ const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
102
+ const usernameAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
103
+
104
+ const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
105
+
106
+ const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
107
+
108
+ const BlankRow = () => <box height={1} />
109
+
110
+ const reviewLabel = (pullRequest: PullRequestItem) => {
111
+ if (pullRequest.reviewStatus === "draft") return "draft"
112
+ if (pullRequest.reviewStatus === "approved") return "approved"
113
+ if (pullRequest.reviewStatus === "changes") return "changes"
114
+ if (pullRequest.reviewStatus === "review") return "review"
115
+ return null
116
+ }
117
+
118
+ const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
119
+
120
+ const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
121
+ const DETAIL_BODY_LINES = 6
122
+
123
+ const wrapText = (text: string, width: number): string[] => {
124
+ if (text.length === 0 || width <= 0) return [""]
125
+ const words = text.split(/\s+/)
126
+ const lines: string[] = []
127
+ let current = ""
128
+ for (const word of words) {
129
+ const next = current.length > 0 ? `${current} ${word}` : word
130
+ if (next.length > width && current.length > 0) {
131
+ lines.push(current)
132
+ current = word
133
+ } else {
134
+ current = next
135
+ }
136
+ }
137
+ if (current.length > 0) lines.push(current)
138
+ return lines.length > 0 ? lines : [""]
139
+ }
140
+
141
+ const reviewIcon = (pullRequest: PullRequestItem) => {
142
+ if (pullRequest.reviewStatus === "draft") return "◌"
143
+ if (pullRequest.reviewStatus === "approved") return "✓"
144
+ if (pullRequest.reviewStatus === "changes") return "!"
145
+ if (pullRequest.reviewStatus === "review") return "◐"
146
+ return "·"
147
+ }
148
+
149
+ const getRowLayout = (contentWidth: number, numberWidth = 6) => {
150
+ const reviewWidth = 1
151
+ const checkWidth = 6
152
+ const ageWidth = 4
153
+ const leftWidth = Math.max(24, contentWidth - reviewWidth - checkWidth - ageWidth - 2) // -2 for spaces between columns
154
+ const titleWidth = Math.max(8, leftWidth - numberWidth - 2)
155
+ return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
156
+ }
157
+
158
+ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
159
+ if (pullRequests.length === 0) return 4
160
+ const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
161
+ return maxLen + 1 // +1 for the # prefix
162
+ }
163
+
164
+ const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
165
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
166
+ return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
167
+ }
168
+
169
+ const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
170
+ if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
171
+ return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
172
+ }
173
+
174
+ return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
175
+ }
176
+
177
+ const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
178
+ const junctions = new Set(junctionRows)
179
+ return (
180
+ <box width={1} height={height} flexDirection="column">
181
+ {Array.from({ length: height }, (_, index) => (
182
+ <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
183
+ ))}
184
+ </box>
185
+ )
186
+ }
187
+
188
+ const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
189
+
190
+ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
191
+ const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
192
+ return parts.flatMap((part) => {
193
+ if (part.startsWith("`") && part.endsWith("`")) {
194
+ return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
195
+ }
196
+
197
+ return part
198
+ .split(pullRequestReferencePattern)
199
+ .filter((segment) => segment.length > 0)
200
+ .map((segment) => ({
201
+ text: segment,
202
+ fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
203
+ bold,
204
+ }))
205
+ })
206
+ }
207
+
208
+ const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
209
+ const tokens = segments.flatMap((segment) =>
210
+ segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
211
+ )
212
+
213
+ const lines: Array<PreviewLine> = []
214
+ let current: Array<PreviewLine["segments"][number]> = []
215
+ let currentLength = 0
216
+
217
+ const pushLine = () => {
218
+ lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
219
+ current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
220
+ currentLength = indent.length
221
+ }
222
+
223
+ for (const token of tokens) {
224
+ const tokenLength = token.text.length
225
+ if (currentLength > 0 && currentLength + tokenLength > width) {
226
+ pushLine()
227
+ }
228
+ current.push(token)
229
+ currentLength += tokenLength
230
+ }
231
+
232
+ if (current.length > 0) {
233
+ lines.push({ segments: current })
234
+ }
235
+
236
+ return lines
237
+ }
238
+
239
+ const fallbackLabelColor = (name: string) => {
240
+ let hash = 0
241
+ for (const char of name) {
242
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0
243
+ }
244
+ const hue = hash % 360
245
+ return `hsl(${hue} 55% 35%)`
246
+ }
247
+
248
+ const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
249
+
250
+ const labelTextColor = (color: string) => {
251
+ if (color.startsWith("#") && color.length === 7) {
252
+ const red = Number.parseInt(color.slice(1, 3), 16)
253
+ const green = Number.parseInt(color.slice(3, 5), 16)
254
+ const blue = Number.parseInt(color.slice(5, 7), 16)
255
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
256
+ return luminance > 0.6 ? "#111111" : "#f8fafc"
257
+ }
258
+ return "#f8fafc"
259
+ }
260
+
261
+ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
262
+ const sourceLines = body.replace(/\r/g, "").split("\n")
263
+ const preview: Array<PreviewLine> = []
264
+ let inCodeBlock = false
265
+
266
+ for (const rawLine of sourceLines) {
267
+ if (preview.length >= limit) break
268
+
269
+ const line = rawLine.trim()
270
+ if (line.startsWith("```")) {
271
+ inCodeBlock = !inCodeBlock
272
+ continue
273
+ }
274
+ if (line.length === 0) continue
275
+
276
+ let text = line
277
+ let fg: string = colors.text
278
+ let bold = false
279
+ let indent = ""
280
+
281
+ if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
282
+ if (preview.length > 0) {
283
+ preview.push({ segments: [{ text: "", fg: colors.muted }] })
284
+ if (preview.length >= limit) break
285
+ }
286
+ text = line.replace(/^#{1,6}\s+/, "")
287
+ fg = colors.count
288
+ bold = true
289
+ } else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
290
+ const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
291
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
292
+ fg = checked ? colors.status.passing : colors.text
293
+ indent = " "
294
+ } else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
295
+ const checked = /^\[(x|X)\]\s+/.test(line)
296
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
297
+ fg = checked ? colors.status.passing : colors.text
298
+ indent = " "
299
+ } else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
300
+ text = `• ${line.replace(/^[-*+]\s+/, "")}`
301
+ indent = " "
302
+ } else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
303
+ text = line
304
+ indent = " "
305
+ } else if (!inCodeBlock && /^>\s+/.test(line)) {
306
+ text = `> ${line.replace(/^>\s+/, "")}`
307
+ fg = colors.muted
308
+ indent = " "
309
+ } else if (inCodeBlock) {
310
+ fg = colors.muted
311
+ }
312
+
313
+ const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
314
+ for (const wrappedLine of wrapped) {
315
+ preview.push(wrappedLine)
316
+ if (preview.length >= limit) break
317
+ }
318
+ }
319
+
320
+ if (preview.length === 0) {
321
+ return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
322
+ }
323
+
324
+ return preview.slice(0, limit)
325
+ }
326
+
327
+ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
328
+ const lines = [
329
+ pullRequest.title,
330
+ `${pullRequest.repository} #${pullRequest.number}`,
331
+ pullRequest.url,
332
+ ]
333
+
334
+ const review = reviewLabel(pullRequest)
335
+ if (review) {
336
+ lines.push(`review: ${review}`)
337
+ }
338
+ if (pullRequest.checkSummary) {
339
+ lines.push(pullRequest.checkSummary)
340
+ }
341
+
342
+ const proc = Bun.spawn({
343
+ cmd: ["pbcopy"],
344
+ stdin: "pipe",
345
+ stdout: "ignore",
346
+ stderr: "pipe",
347
+ })
348
+
349
+ if (!proc.stdin) {
350
+ throw new Error("Clipboard is not available")
351
+ }
352
+
353
+ proc.stdin.write(lines.join("\n"))
354
+ proc.stdin.end()
355
+
356
+ const exitCode = await proc.exited
357
+ if (exitCode !== 0) {
358
+ const stderr = await Bun.readableStreamToText(proc.stderr)
359
+ throw new Error(stderr.trim() || "Could not copy PR metadata")
360
+ }
361
+ }
362
+
363
+ const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
364
+ <box height={1}>
365
+ {bold ? (
366
+ <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
367
+ {text}
368
+ </text>
369
+ ) : (
370
+ <text wrapMode="none" truncate fg={fg}>
371
+ {text}
372
+ </text>
373
+ )}
374
+ </box>
375
+ )
376
+
377
+ const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
378
+ <box height={1}>
379
+ {bg ? (
380
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
381
+ {children}
382
+ </text>
383
+ ) : (
384
+ <text wrapMode="none" truncate fg={fg}>
385
+ {children}
386
+ </text>
387
+ )}
388
+ </box>
389
+ )
390
+
391
+ const SectionTitle = ({ title }: { title: string }) => (
392
+ <TextLine>
393
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
394
+ {title}
395
+ </span>
396
+ </TextLine>
397
+ )
398
+
399
+ const FooterHints = ({ showFilterClear, detailFullView }: { showFilterClear: boolean; detailFullView: boolean }) => (
400
+ <TextLine>
401
+ <span fg={colors.count}>↑↓</span>
402
+ <span fg={colors.muted}> move </span>
403
+ <span fg={colors.count}>/</span>
404
+ <span fg={colors.muted}> filter </span>
405
+ {showFilterClear ? (
406
+ <>
407
+ <span fg={colors.count}>esc</span>
408
+ <span fg={colors.muted}> clear </span>
409
+ </>
410
+ ) : null}
411
+ {detailFullView ? (
412
+ <>
413
+ <span fg={colors.count}>esc</span>
414
+ <span fg={colors.muted}> back </span>
415
+ </>
416
+ ) : (
417
+ <>
418
+ <span fg={colors.count}>enter</span>
419
+ <span fg={colors.muted}> expand </span>
420
+ </>
421
+ )}
422
+ <span fg={colors.count}>r</span>
423
+ <span fg={colors.muted}> ref </span>
424
+ <span fg={colors.count}>d</span>
425
+ <span fg={colors.muted}> draft </span>
426
+ <span fg={colors.count}>l</span>
427
+ <span fg={colors.muted}> labels </span>
428
+ <span fg={colors.count}>o</span>
429
+ <span fg={colors.muted}> open </span>
430
+ <span fg={colors.count}>y</span>
431
+ <span fg={colors.muted}> copy </span>
432
+ <span fg={colors.count}>q</span>
433
+ <span fg={colors.muted}> quit</span>
434
+ </TextLine>
435
+ )
436
+
437
+ const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
438
+ <TextLine>
439
+ <span fg={color}>{icon} </span>
440
+ <span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
441
+ </TextLine>
442
+ )
443
+
444
+ const PullRequestRow = ({
445
+ pullRequest,
446
+ selected,
447
+ contentWidth,
448
+ numWidth,
449
+ onSelect,
450
+ }: {
451
+ pullRequest: PullRequestItem
452
+ selected: boolean
453
+ contentWidth: number
454
+ numWidth: number
455
+ onSelect: () => void
456
+ }) => {
457
+ const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
458
+ const ageText = `${daysOpen(pullRequest.createdAt)}d`
459
+ const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
460
+
461
+ return (
462
+ <box height={1} onMouseDown={onSelect}>
463
+ <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
464
+ <span fg={statusColor(pullRequest.reviewStatus)}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
465
+ <span> </span>
466
+ <span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
467
+ <span> </span>
468
+ <span>{fitCell(pullRequest.title, titleWidth)}</span>
469
+ <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
470
+ <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
471
+ </TextLine>
472
+ </box>
473
+ )
474
+ }
475
+
476
+ const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
477
+ const groups = new Map<string, T[]>()
478
+ for (const item of items) {
479
+ const key = getKey(item)
480
+ const existing = groups.get(key)
481
+ if (existing) {
482
+ existing.push(item)
483
+ } else {
484
+ groups.set(key, [item])
485
+ }
486
+ }
487
+
488
+ const order = new Map(orderedKeys.map((key, index) => [key, index]))
489
+ return [...groups.entries()].sort((left, right) => {
490
+ const leftIndex = order.get(left[0])
491
+ const rightIndex = order.get(right[0])
492
+ if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
493
+ if (leftIndex !== undefined) return -1
494
+ if (rightIndex !== undefined) return 1
495
+ return left[0].localeCompare(right[0])
496
+ })
497
+ }
498
+
499
+ type PullRequestGroups = Array<[string, PullRequestItem[]]>
500
+
501
+ const PullRequestList = ({
502
+ groups,
503
+ selectedUrl,
504
+ status,
505
+ error,
506
+ contentWidth,
507
+ filterText,
508
+ showFilterBar,
509
+ isFilterEditing,
510
+ groupIcon,
511
+ onSelectPullRequest,
512
+ }: {
513
+ groups: PullRequestGroups
514
+ selectedUrl: string | null
515
+ status: LoadStatus
516
+ error: string | null
517
+ contentWidth: number
518
+ filterText: string
519
+ showFilterBar: boolean
520
+ isFilterEditing: boolean
521
+ groupIcon: string
522
+ onSelectPullRequest: (url: string) => void
523
+ }) => {
524
+ const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
525
+ const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
526
+
527
+ return (
528
+ <box flexDirection="column">
529
+ <SectionTitle title="PULL REQUESTS" />
530
+ {showFilterBar ? (
531
+ <TextLine>
532
+ <span fg={colors.count}>/</span>
533
+ <span fg={colors.muted}> </span>
534
+ <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
535
+ </TextLine>
536
+ ) : null}
537
+ {status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
538
+ {status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
539
+ {status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
540
+ {groups.map(([repo, pullRequests]) => {
541
+ const numWidth = groupNumberWidth(pullRequests)
542
+ return (
543
+ <Fragment key={repo}>
544
+ <box flexDirection="column">
545
+ <GroupTitle label={repo} color={repoColor(repo)} icon={groupIcon} />
546
+ {pullRequests.map((pullRequest) => (
547
+ <PullRequestRow
548
+ key={pullRequest.url}
549
+ pullRequest={pullRequest}
550
+ selected={pullRequest.url === selectedUrl}
551
+ contentWidth={contentWidth}
552
+ numWidth={numWidth}
553
+ onSelect={() => onSelectPullRequest(pullRequest.url)}
554
+ />
555
+ ))}
556
+ </box>
557
+ </Fragment>
558
+ )
559
+ })}
560
+ </box>
561
+ )
562
+ }
563
+
564
+ const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
565
+ const seen = new Map<string, CheckItem>()
566
+ for (const check of checks) {
567
+ const existing = seen.get(check.name)
568
+ if (!existing || (check.status === "completed" && existing.status !== "completed")) {
569
+ seen.set(check.name, check)
570
+ }
571
+ }
572
+ return [...seen.values()]
573
+ }
574
+
575
+ const checkIcon = (check: CheckItem) => {
576
+ if (check.status === "completed") {
577
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
578
+ if (check.conclusion === "failure") return "✗"
579
+ return "·"
580
+ }
581
+ if (check.status === "in_progress") return "●"
582
+ return "○"
583
+ }
584
+
585
+ const checkColor = (check: CheckItem) => {
586
+ if (check.status === "completed") {
587
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
588
+ if (check.conclusion === "failure") return colors.status.failing
589
+ return colors.muted
590
+ }
591
+ if (check.status === "in_progress") return colors.status.pending
592
+ return colors.muted
593
+ }
594
+
595
+ const checksRowCount = (checks: readonly CheckItem[]) => {
596
+ const unique = deduplicateChecks(checks)
597
+ return Math.ceil(unique.length / 2)
598
+ }
599
+
600
+ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
601
+ const unique = deduplicateChecks(checks)
602
+ if (unique.length === 0) return null
603
+
604
+ const colWidth = Math.floor((contentWidth - 1) / 2) // -1 for gap between columns
605
+ const nameCol = Math.max(4, colWidth - 2) // -2 for icon + space
606
+ const rows = Math.ceil(unique.length / 2)
607
+
608
+ return (
609
+ <box flexDirection="column">
610
+ <TextLine>
611
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
612
+ </TextLine>
613
+ {Array.from({ length: rows }, (_, rowIndex) => {
614
+ const left = unique[rowIndex * 2]
615
+ const right = unique[rowIndex * 2 + 1]
616
+ return (
617
+ <TextLine key={rowIndex}>
618
+ {left ? (
619
+ <>
620
+ <span fg={checkColor(left)}>{checkIcon(left)} </span>
621
+ <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
622
+ </>
623
+ ) : null}
624
+ {right ? (
625
+ <>
626
+ <span fg={colors.muted}> </span>
627
+ <span fg={checkColor(right)}>{checkIcon(right)} </span>
628
+ <span fg={colors.text}>{right.name}</span>
629
+ </>
630
+ ) : null}
631
+ </TextLine>
632
+ )
633
+ })}
634
+ </box>
635
+ )
636
+ }
637
+
638
+ const DetailHeader = ({
639
+ pullRequest,
640
+ contentWidth,
641
+ paneWidth,
642
+ showChecks = false,
643
+ }: {
644
+ pullRequest: PullRequestItem
645
+ contentWidth: number
646
+ paneWidth: number
647
+ showChecks?: boolean
648
+ }) => {
649
+ const labels = pullRequest.labels
650
+ const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
651
+ const unique = deduplicateChecks(pullRequest.checks)
652
+ const checkRows = checksRowCount(unique)
653
+
654
+ return (
655
+ <>
656
+ <box height={1} paddingLeft={1} paddingRight={1}>
657
+ {(() => {
658
+ const opened = formatRelativeDate(pullRequest.createdAt)
659
+ const repo = shortRepoName(pullRequest.repository)
660
+ const number = String(pullRequest.number)
661
+ const review = reviewLabel(pullRequest)
662
+ const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
663
+ const statusParts = [review, checks].filter((part): part is string => Boolean(part))
664
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
665
+ const leftWidth = 1 + number.length + 1 + repo.length
666
+ const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
667
+
668
+ return (
669
+ <TextLine>
670
+ <span fg={colors.count}>#{number}</span>
671
+ <span fg={colors.muted}> {repo}</span>
672
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
673
+ {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
674
+ {review && checks ? <span fg={colors.muted}> </span> : null}
675
+ {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
676
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
677
+ <span fg={colors.muted}>{opened}</span>
678
+ </TextLine>
679
+ )
680
+ })()}
681
+ </box>
682
+ <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
683
+ {wrappedTitle.map((line, index) => (
684
+ <PlainLine key={index} text={line} bold />
685
+ ))}
686
+ </box>
687
+ <box height={1} paddingLeft={1} paddingRight={1}>
688
+ <TextLine>
689
+ {labels.length > 0 ? labels.map((label, index) => (
690
+ <Fragment key={label.name}>
691
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
692
+ <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
693
+ </Fragment>
694
+ )) : <span fg={colors.muted}>no labels</span>}
695
+ </TextLine>
696
+ </box>
697
+ <box height={1}><Divider width={paneWidth} /></box>
698
+ {showChecks && unique.length > 0 ? (
699
+ <>
700
+ <box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
701
+ <ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
702
+ </box>
703
+ <box height={1}><Divider width={paneWidth} /></box>
704
+ </>
705
+ ) : null}
706
+ </>
707
+ )
708
+ }
709
+
710
+ const DetailBody = ({
711
+ pullRequest,
712
+ contentWidth,
713
+ bodyLines = DETAIL_BODY_LINES,
714
+ }: {
715
+ pullRequest: PullRequestItem
716
+ contentWidth: number
717
+ bodyLines?: number
718
+ }) => {
719
+ const previewLines = useMemo(
720
+ () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
721
+ [pullRequest.body, contentWidth, bodyLines],
722
+ )
723
+
724
+ return (
725
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
726
+ {previewLines.map((line, index) => (
727
+ <TextLine key={`${pullRequest.url}-${index}`}>
728
+ {line.segments.map((segment, segmentIndex) => (
729
+ ("bold" in segment && segment.bold === true) ? (
730
+ <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
731
+ {segment.text}
732
+ </span>
733
+ ) : (
734
+ <span key={segmentIndex} fg={segment.fg}>
735
+ {segment.text}
736
+ </span>
737
+ )
738
+ ))}
739
+ </TextLine>
740
+ ))}
741
+ </box>
742
+ )
743
+ }
744
+
745
+ const DetailsPane = ({
746
+ pullRequest,
747
+ contentWidth,
748
+ bodyLines = DETAIL_BODY_LINES,
749
+ paneWidth = contentWidth + 2,
750
+ showChecks = false,
751
+ }: {
752
+ pullRequest: PullRequestItem | null
753
+ contentWidth: number
754
+ bodyLines?: number
755
+ paneWidth?: number
756
+ showChecks?: boolean
757
+ }) => {
758
+ const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
759
+ const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
760
+ const checkRows = checksRowCount(uniqueChecks)
761
+ // checks heading (1) + grid rows + divider (1)
762
+ const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
763
+ const previewLines = useMemo(
764
+ () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
765
+ [pullRequest?.body, contentWidth, bodyLines],
766
+ )
767
+ const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
768
+
769
+ return (
770
+ <box flexDirection="column" height={contentHeight}>
771
+ {pullRequest ? (
772
+ <>
773
+ <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
774
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
775
+ </>
776
+ ) : (
777
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
778
+ <PlainLine text="Select a pull request with up/down." fg={colors.muted} />
779
+ {Array.from({ length: DETAIL_BODY_LINES + 2 }, (_, index) => (
780
+ <BlankRow key={index} />
781
+ ))}
782
+ </box>
783
+ )}
784
+ </box>
785
+ )
786
+ }
787
+
788
+ const LabelModal = ({
789
+ state,
790
+ currentLabels,
791
+ modalWidth,
792
+ modalHeight,
793
+ offsetLeft,
794
+ offsetTop,
795
+ }: {
796
+ state: LabelModalState
797
+ currentLabels: readonly PullRequestLabel[]
798
+ modalWidth: number
799
+ modalHeight: number
800
+ offsetLeft: number
801
+ offsetTop: number
802
+ }) => {
803
+ const contentWidth = modalWidth - 4
804
+ const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
805
+ const filtered = state.availableLabels.filter((label) =>
806
+ state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
807
+ )
808
+ const maxVisible = Math.max(1, modalHeight - 5)
809
+ const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
810
+ const scrollStart = Math.min(
811
+ Math.max(0, filtered.length - maxVisible),
812
+ Math.max(0, selectedIndex - maxVisible + 1),
813
+ )
814
+ const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
815
+
816
+ return (
817
+ <box
818
+ position="absolute"
819
+ left={offsetLeft}
820
+ top={offsetTop}
821
+ width={modalWidth}
822
+ height={modalHeight}
823
+ flexDirection="column"
824
+ backgroundColor="#1a1a2e"
825
+ >
826
+ <box height={1} paddingLeft={1} paddingRight={1}>
827
+ <TextLine>
828
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>Labels</span>
829
+ {state.repository ? <span fg={colors.muted}> {state.repository}</span> : null}
830
+ </TextLine>
831
+ </box>
832
+ <box height={1} paddingLeft={1} paddingRight={1}>
833
+ <TextLine>
834
+ <span fg={colors.count}>&gt; </span>
835
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>
836
+ {state.query.length > 0 ? state.query : "type to filter..."}
837
+ </span>
838
+ </TextLine>
839
+ </box>
840
+ <Divider width={modalWidth} />
841
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
842
+ {state.loading ? (
843
+ <PlainLine text="Loading labels..." fg={colors.muted} />
844
+ ) : visibleLabels.length === 0 ? (
845
+ <PlainLine text={state.query.length > 0 ? "No matching labels." : "No labels found."} fg={colors.muted} />
846
+ ) : (
847
+ visibleLabels.map((label, index) => {
848
+ const actualIndex = scrollStart + index
849
+ const isActive = currentNames.has(label.name.toLowerCase())
850
+ const isSelected = actualIndex === selectedIndex
851
+ return (
852
+ <box key={label.name} height={1}>
853
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
854
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "✓ " : " "}</span>
855
+ <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {fitCell(label.name, Math.min(label.name.length, contentWidth - 6))} </span>
856
+ </TextLine>
857
+ </box>
858
+ )
859
+ })
860
+ )}
861
+ </box>
862
+ <box flexGrow={1} />
863
+ <Divider width={modalWidth} />
864
+ <box height={1} paddingLeft={1} paddingRight={1}>
865
+ <TextLine>
866
+ <span fg={colors.count}>enter</span>
867
+ <span fg={colors.muted}> toggle </span>
868
+ <span fg={colors.count}>esc</span>
869
+ <span fg={colors.muted}> close</span>
870
+ {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
871
+ </TextLine>
872
+ </box>
873
+ </box>
874
+ )
875
+ }
876
+
877
+ export const App = () => {
878
+ const { width, height } = useTerminalDimensions()
879
+ const [pullRequestState, setPullRequestState] = useAtom(pullRequestStateAtom)
880
+ const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
881
+ const [notice, setNotice] = useAtom(noticeAtom)
882
+ const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
883
+ const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
884
+ const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
885
+ const [filterMode, setFilterMode] = useAtom(filterModeAtom)
886
+ const [pendingG, setPendingG] = useAtom(pendingGAtom)
887
+ const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
888
+ const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
889
+ const [labelModal, setLabelModal] = useAtom(labelModalAtom)
890
+ const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
891
+ const [username, setUsername] = useAtom(usernameAtom)
892
+ const [groupIconIndex, setGroupIconIndex] = useAtom(groupIconIndexAtom)
893
+ const groupIcon = GROUP_ICONS[groupIconIndex % GROUP_ICONS.length]!
894
+ const contentWidth = Math.max(60, width ?? 100)
895
+ const isWideLayout = (width ?? 100) >= 100
896
+ const splitGap = 1
897
+ const sectionPadding = 1
898
+ const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
899
+ const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
900
+ const dividerJunctionAt = Math.max(1, leftPaneWidth)
901
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
902
+ const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
903
+ const wideDetailLines = Math.max(8, (height ?? 24) - 8) // fill available vertical space
904
+ const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
905
+ const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
906
+ const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
907
+ const headerFooterWidth = Math.max(24, contentWidth - 2)
908
+
909
+ const flashNotice = (message: string) => {
910
+ if (noticeTimeoutRef.current !== null) {
911
+ clearTimeout(noticeTimeoutRef.current)
912
+ }
913
+ setNotice(message)
914
+ noticeTimeoutRef.current = globalThis.setTimeout(() => {
915
+ setNotice((current) => (current === message ? null : current))
916
+ }, 2500)
917
+ }
918
+
919
+ useEffect(() => () => {
920
+ if (noticeTimeoutRef.current !== null) {
921
+ clearTimeout(noticeTimeoutRef.current)
922
+ }
923
+ if (pendingGTimeoutRef.current !== null) {
924
+ clearTimeout(pendingGTimeoutRef.current)
925
+ }
926
+ }, [])
927
+
928
+ useEffect(() => {
929
+ let cancelled = false
930
+ if (config.author !== "@me") {
931
+ setUsername(config.author.replace(/^@/, ""))
932
+ return () => {
933
+ cancelled = true
934
+ }
935
+ }
936
+
937
+ void getAuthenticatedUser()
938
+ .then((login) => {
939
+ if (!cancelled) setUsername(login)
940
+ })
941
+ .catch(() => {
942
+ if (!cancelled) setUsername(null)
943
+ })
944
+
945
+ return () => {
946
+ cancelled = true
947
+ }
948
+ }, [setUsername])
949
+
950
+ useEffect(() => {
951
+ let cancelled = false
952
+
953
+ setPullRequestState((current) => ({
954
+ ...current,
955
+ status: current.fetchedAt === null ? "loading" : "ready",
956
+ error: null,
957
+ }))
958
+
959
+ loadOpenPullRequests()
960
+ .then((pullRequests) => {
961
+ if (cancelled) return
962
+ setPullRequestState({
963
+ status: "ready",
964
+ data: pullRequests,
965
+ error: null,
966
+ fetchedAt: new Date(),
967
+ })
968
+ })
969
+ .catch((error) => {
970
+ if (cancelled) return
971
+ setPullRequestState((current) => ({
972
+ ...current,
973
+ status: "error",
974
+ error: error instanceof Error ? error.message : String(error),
975
+ }))
976
+ })
977
+
978
+ return () => {
979
+ cancelled = true
980
+ }
981
+ }, [refreshNonce])
982
+
983
+ const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
984
+ const visibleFilterText = filterMode ? filterDraft : filterQuery
985
+
986
+ const filteredPullRequests = pullRequestState.data.filter((pullRequest) => {
987
+ const query = effectiveFilterQuery
988
+ if (query.length === 0) return true
989
+ return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
990
+ .some((value) => value.toLowerCase().includes(query))
991
+ })
992
+
993
+ const visibleGroups = groupBy(
994
+ filteredPullRequests,
995
+ (pullRequest) => pullRequest.repository,
996
+ )
997
+ const visiblePullRequests = visibleGroups.flatMap(([, pullRequests]) => pullRequests)
998
+ const groupStarts = visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
999
+ if (index === 0) {
1000
+ starts.push(0)
1001
+ return starts
1002
+ }
1003
+ starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
1004
+ return starts
1005
+ }, [])
1006
+ const getCurrentGroupIndex = (current: number) => {
1007
+ for (let index = groupStarts.length - 1; index >= 0; index--) {
1008
+ if (groupStarts[index]! <= current) return index
1009
+ }
1010
+ return 0
1011
+ }
1012
+ const summaryRight = pullRequestState.fetchedAt
1013
+ ? `updated ${formatShortDate(pullRequestState.fetchedAt)} ${formatTimestamp(pullRequestState.fetchedAt)}`
1014
+ : pullRequestState.status === "loading"
1015
+ ? "loading pull requests..."
1016
+ : ""
1017
+ const headerLeft = username ? `GHUI ${username}` : "GHUI"
1018
+ const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
1019
+ const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
1020
+ const selectPullRequestByUrl = (url: string) => {
1021
+ const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
1022
+ if (index >= 0) setSelectedIndex(index)
1023
+ }
1024
+ const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
1025
+ setPullRequestState((current) => ({
1026
+ ...current,
1027
+ data: current.data.map((pullRequest) => (pullRequest.url === url ? transform(pullRequest) : pullRequest)),
1028
+ }))
1029
+ }
1030
+ const refreshPullRequests = (message?: string) => {
1031
+ setRefreshNonce((current) => current + 1)
1032
+ if (message) flashNotice(message)
1033
+ }
1034
+
1035
+ useEffect(() => {
1036
+ setSelectedIndex((current) => {
1037
+ if (visiblePullRequests.length === 0) return 0
1038
+ return Math.max(0, Math.min(current, visiblePullRequests.length - 1))
1039
+ })
1040
+ }, [visiblePullRequests.length])
1041
+
1042
+ const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1043
+ const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
1044
+ const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
1045
+ const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
1046
+ const detailChecks = selectedPullRequest ? deduplicateChecks(selectedPullRequest.checks) : []
1047
+ const checksRows = checksRowCount(detailChecks)
1048
+ // checks heading (1) + grid rows + divider
1049
+ const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
1050
+ const detailJunctions = detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1051
+
1052
+ const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1053
+
1054
+ const openLabelModal = () => {
1055
+ if (!selectedPullRequest) return
1056
+ const repository = selectedPullRequest.repository
1057
+ const cachedLabels = labelCache[repository]
1058
+ if (cachedLabels) {
1059
+ setLabelModal({
1060
+ open: true,
1061
+ repository,
1062
+ query: "",
1063
+ selectedIndex: 0,
1064
+ availableLabels: cachedLabels,
1065
+ loading: false,
1066
+ })
1067
+ return
1068
+ }
1069
+
1070
+ setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1071
+ void listRepoLabels(repository)
1072
+ .then((labels) => {
1073
+ setLabelCache((current) => ({ ...current, [repository]: labels }))
1074
+ setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
1075
+ })
1076
+ .catch((error) => {
1077
+ setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
1078
+ flashNotice(error instanceof Error ? error.message : String(error))
1079
+ })
1080
+ }
1081
+
1082
+ const toggleLabelAtIndex = () => {
1083
+ if (!selectedPullRequest) return
1084
+ const filtered = labelModal.availableLabels.filter((label) =>
1085
+ labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1086
+ )
1087
+ const label = filtered[labelModal.selectedIndex]
1088
+ if (!label) return
1089
+
1090
+ const isActive = selectedPullRequest.labels.some((l) => l.name.toLowerCase() === label.name.toLowerCase())
1091
+ const previousPullRequest = selectedPullRequest
1092
+
1093
+ if (isActive) {
1094
+ updatePullRequest(selectedPullRequest.url, (pr) => ({
1095
+ ...pr,
1096
+ labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
1097
+ }))
1098
+ void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1099
+ .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
1100
+ .catch((error) => {
1101
+ updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1102
+ flashNotice(error instanceof Error ? error.message : String(error))
1103
+ })
1104
+ } else {
1105
+ updatePullRequest(selectedPullRequest.url, (pr) => ({
1106
+ ...pr,
1107
+ labels: [...pr.labels, { name: label.name, color: label.color }],
1108
+ }))
1109
+ void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1110
+ .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
1111
+ .catch((error) => {
1112
+ updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1113
+ flashNotice(error instanceof Error ? error.message : String(error))
1114
+ })
1115
+ }
1116
+ }
1117
+
1118
+ useKeyboard((key) => {
1119
+ if (key.name === "q" || (key.ctrl && key.name === "c")) {
1120
+ if (labelModal.open) {
1121
+ setLabelModal(initialLabelModalState)
1122
+ return
1123
+ }
1124
+ if (key.name === "q") {
1125
+ process.exit(0)
1126
+ }
1127
+ process.exit(0)
1128
+ }
1129
+
1130
+ // Label modal takes priority over everything else
1131
+ if (labelModal.open) {
1132
+ if (key.name === "escape") {
1133
+ setLabelModal(initialLabelModalState)
1134
+ return
1135
+ }
1136
+ if (key.name === "return" || key.name === "enter") {
1137
+ toggleLabelAtIndex()
1138
+ return
1139
+ }
1140
+ if (key.name === "up" || key.name === "k") {
1141
+ setLabelModal((current) => ({
1142
+ ...current,
1143
+ selectedIndex: Math.max(0, current.selectedIndex - 1),
1144
+ }))
1145
+ return
1146
+ }
1147
+ if (key.name === "down" || key.name === "j") {
1148
+ const filtered = labelModal.availableLabels.filter((label) =>
1149
+ labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1150
+ )
1151
+ setLabelModal((current) => ({
1152
+ ...current,
1153
+ selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
1154
+ }))
1155
+ return
1156
+ }
1157
+ if (key.name === "backspace") {
1158
+ setLabelModal((current) => ({
1159
+ ...current,
1160
+ query: current.query.slice(0, -1),
1161
+ selectedIndex: 0,
1162
+ }))
1163
+ return
1164
+ }
1165
+ if (key.ctrl && key.name === "u") {
1166
+ setLabelModal((current) => ({ ...current, query: "", selectedIndex: 0 }))
1167
+ return
1168
+ }
1169
+ if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1170
+ setLabelModal((current) => ({
1171
+ ...current,
1172
+ query: current.query + key.sequence,
1173
+ selectedIndex: 0,
1174
+ }))
1175
+ return
1176
+ }
1177
+ return
1178
+ }
1179
+
1180
+ // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1181
+ if (detailFullView) {
1182
+ if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1183
+ setDetailFullView(false)
1184
+ setDetailScrollOffset(0)
1185
+ return
1186
+ }
1187
+ if (key.name === "up" || key.name === "k") {
1188
+ setDetailScrollOffset((current) => Math.max(0, current - 1))
1189
+ return
1190
+ }
1191
+ if (key.name === "down" || key.name === "j") {
1192
+ setDetailScrollOffset((current) => current + 1)
1193
+ return
1194
+ }
1195
+ if (key.ctrl && key.name === "u") {
1196
+ setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1197
+ return
1198
+ }
1199
+ if (key.ctrl && (key.name === "d" || key.name === "v")) {
1200
+ setDetailScrollOffset((current) => current + halfPage)
1201
+ return
1202
+ }
1203
+ if (key.name === "o" && selectedPullRequest) {
1204
+ void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1205
+ flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1206
+ return
1207
+ }
1208
+ if (key.name === "y" && selectedPullRequest) {
1209
+ void copyPullRequestMetadata(selectedPullRequest)
1210
+ .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1211
+ .catch((error) => flashNotice(error instanceof Error ? error.message : String(error)))
1212
+ return
1213
+ }
1214
+ return
1215
+ }
1216
+
1217
+ if (filterMode) {
1218
+ if (key.name === "escape") {
1219
+ setFilterDraft(filterQuery)
1220
+ setFilterMode(false)
1221
+ return
1222
+ }
1223
+ if (key.name === "enter") {
1224
+ setFilterQuery(filterDraft)
1225
+ setFilterMode(false)
1226
+ return
1227
+ }
1228
+ if (key.ctrl && key.name === "u") {
1229
+ setFilterDraft("")
1230
+ return
1231
+ }
1232
+ if (key.ctrl && key.name === "w") {
1233
+ setFilterDraft((current) => deleteLastWord(current))
1234
+ return
1235
+ }
1236
+ if (key.name === "backspace") {
1237
+ setFilterDraft((current) => current.slice(0, -1))
1238
+ return
1239
+ }
1240
+ if (!key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
1241
+ setFilterDraft((current) => current + key.sequence)
1242
+ return
1243
+ }
1244
+ }
1245
+
1246
+ if (key.name === "/") {
1247
+ setFilterDraft(filterQuery)
1248
+ setFilterMode(true)
1249
+ return
1250
+ }
1251
+ if (key.name === "escape" && filterQuery.length > 0) {
1252
+ setFilterQuery("")
1253
+ setFilterDraft("")
1254
+ setFilterMode(false)
1255
+ return
1256
+ }
1257
+ if (key.name === "r") {
1258
+ refreshPullRequests("Refreshing pull requests...")
1259
+ return
1260
+ }
1261
+ if (
1262
+ key.name === "[" ||
1263
+ ((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
1264
+ (key.shift && key.name === "k") ||
1265
+ key.name === "K"
1266
+ ) {
1267
+ setSelectedIndex((current) => {
1268
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
1269
+ const currentGroup = getCurrentGroupIndex(current)
1270
+ if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
1271
+ return groupStarts[currentGroup - 1]!
1272
+ })
1273
+ return
1274
+ }
1275
+ if (
1276
+ key.name === "]" ||
1277
+ ((key.option || key.meta) && (key.name === "down" || key.name === "j")) ||
1278
+ (key.shift && key.name === "j") ||
1279
+ key.name === "J"
1280
+ ) {
1281
+ setSelectedIndex((current) => {
1282
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
1283
+ const currentGroup = getCurrentGroupIndex(current)
1284
+ if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
1285
+ return groupStarts[currentGroup + 1]!
1286
+ })
1287
+ return
1288
+ }
1289
+ if (key.ctrl && key.name === "u") {
1290
+ setSelectedIndex((current) => {
1291
+ if (visiblePullRequests.length === 0) return 0
1292
+ return Math.max(0, current - halfPage)
1293
+ })
1294
+ return
1295
+ }
1296
+ if (key.ctrl && key.name === "d") {
1297
+ setSelectedIndex((current) => {
1298
+ if (visiblePullRequests.length === 0) return 0
1299
+ return Math.min(visiblePullRequests.length - 1, current + halfPage)
1300
+ })
1301
+ return
1302
+ }
1303
+ if (key.name === "up" || key.name === "k") {
1304
+ setSelectedIndex((current) => {
1305
+ if (visiblePullRequests.length === 0) return 0
1306
+ return current <= 0 ? visiblePullRequests.length - 1 : current - 1
1307
+ })
1308
+ return
1309
+ }
1310
+ if (key.name === "down" || key.name === "j") {
1311
+ setSelectedIndex((current) => {
1312
+ if (visiblePullRequests.length === 0) return 0
1313
+ return current >= visiblePullRequests.length - 1 ? 0 : current + 1
1314
+ })
1315
+ return
1316
+ }
1317
+ // Vim-style navigation: gg to go to top, G to go to bottom
1318
+ if (key.name === "G" || key.name === "g" && key.shift) {
1319
+ setSelectedIndex((_current) => {
1320
+ if (visiblePullRequests.length === 0) return 0
1321
+ return visiblePullRequests.length - 1
1322
+ })
1323
+ return
1324
+ }
1325
+ if (key.name === "g") {
1326
+ if (pendingG) {
1327
+ setSelectedIndex(0)
1328
+ setPendingG(false)
1329
+ if (pendingGTimeoutRef.current !== null) {
1330
+ clearTimeout(pendingGTimeoutRef.current)
1331
+ pendingGTimeoutRef.current = null
1332
+ }
1333
+ } else {
1334
+ setPendingG(true)
1335
+ pendingGTimeoutRef.current = setTimeout(() => {
1336
+ setPendingG(false)
1337
+ pendingGTimeoutRef.current = null
1338
+ }, 500)
1339
+ }
1340
+ return
1341
+ }
1342
+ if ((key.name === "return" || key.name === "enter") && !detailFullView) {
1343
+ setDetailFullView(true)
1344
+ setDetailScrollOffset(0)
1345
+ return
1346
+ }
1347
+ if (key.name === "l" && selectedPullRequest) {
1348
+ openLabelModal()
1349
+ return
1350
+ }
1351
+ if (key.name === "p" || key.name === "P") {
1352
+ setGroupIconIndex((current) => {
1353
+ const next = (current + 1) % GROUP_ICONS.length
1354
+ flashNotice(`icon: ${GROUP_ICONS[next]}`)
1355
+ return next
1356
+ })
1357
+ return
1358
+ }
1359
+ if (key.name === "o" && selectedPullRequest) {
1360
+ void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1361
+ flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1362
+ return
1363
+ }
1364
+ if ((key.name === "d" || key.name === "D") && selectedPullRequest) {
1365
+ const previousPullRequest = selectedPullRequest
1366
+ const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1367
+ updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1368
+ ...pullRequest,
1369
+ reviewStatus: nextReviewStatus,
1370
+ }))
1371
+ void toggleDraft(selectedPullRequest.repository, selectedPullRequest.number, selectedPullRequest.reviewStatus === "draft")
1372
+ .then(() => {
1373
+ flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1374
+ })
1375
+ .catch((error) => {
1376
+ updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1377
+ flashNotice(error instanceof Error ? error.message : String(error))
1378
+ })
1379
+ return
1380
+ }
1381
+ if (key.name === "y" && selectedPullRequest) {
1382
+ void copyPullRequestMetadata(selectedPullRequest)
1383
+ .then(() => {
1384
+ flashNotice(`Copied #${selectedPullRequest.number} metadata`)
1385
+ })
1386
+ .catch((error) => {
1387
+ flashNotice(error instanceof Error ? error.message : String(error))
1388
+ })
1389
+ }
1390
+ })
1391
+
1392
+ const fullscreenContentWidth = Math.max(24, contentWidth - 2)
1393
+ const fullscreenBodyLines = Math.max(8, (height ?? 24) - 8)
1394
+
1395
+ const prListProps = {
1396
+ groups: visibleGroups,
1397
+ selectedUrl: selectedPullRequest?.url ?? null,
1398
+ status: pullRequestState.status,
1399
+ error: pullRequestState.error,
1400
+ filterText: visibleFilterText,
1401
+ showFilterBar: filterMode || filterQuery.length > 0,
1402
+ isFilterEditing: filterMode,
1403
+ groupIcon,
1404
+ onSelectPullRequest: selectPullRequestByUrl,
1405
+ } as const
1406
+
1407
+ const labelModalWidth = Math.min(40, contentWidth - 4)
1408
+ const labelModalHeight = Math.min(20, (height ?? 24) - 4)
1409
+ const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1410
+ const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
1411
+
1412
+ return (
1413
+ <box flexGrow={1} flexDirection="column">
1414
+ <box paddingLeft={1} paddingRight={1} flexDirection="column">
1415
+ <PlainLine text={headerLine} fg={colors.muted} bold />
1416
+ </box>
1417
+ {isWideLayout && !detailFullView ? (
1418
+ <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
1419
+ ) : (
1420
+ <Divider width={contentWidth} />
1421
+ )}
1422
+ {isWideLayout && detailFullView ? (
1423
+ <box flexGrow={1} flexDirection="column">
1424
+ <scrollbox flexGrow={1}>
1425
+ <DetailsPane
1426
+ pullRequest={selectedPullRequest}
1427
+ contentWidth={fullscreenContentWidth}
1428
+ bodyLines={fullscreenBodyLines}
1429
+ paneWidth={contentWidth}
1430
+ showChecks
1431
+ />
1432
+ </scrollbox>
1433
+ </box>
1434
+ ) : isWideLayout ? (
1435
+ <box flexGrow={1} flexDirection="row">
1436
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
1437
+ <scrollbox height={wideBodyHeight} flexGrow={0}>
1438
+ <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
1439
+ </scrollbox>
1440
+ </box>
1441
+ <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
1442
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
1443
+ {selectedPullRequest ? (
1444
+ <>
1445
+ <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1446
+ <scrollbox flexGrow={1}>
1447
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} />
1448
+ </scrollbox>
1449
+ </>
1450
+ ) : (
1451
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1452
+ <PlainLine text="Select a pull request with up/down." fg={colors.muted} />
1453
+ </box>
1454
+ )}
1455
+ </box>
1456
+ </box>
1457
+ ) : detailFullView ? (
1458
+ <box flexGrow={1} flexDirection="column">
1459
+ <scrollbox flexGrow={1}>
1460
+ <DetailsPane
1461
+ pullRequest={selectedPullRequest}
1462
+ contentWidth={fullscreenContentWidth}
1463
+ bodyLines={fullscreenBodyLines}
1464
+ paneWidth={contentWidth}
1465
+ />
1466
+ </scrollbox>
1467
+ </box>
1468
+ ) : (
1469
+ <>
1470
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
1471
+ <Divider width={contentWidth} />
1472
+ <box flexGrow={1} flexDirection="column">
1473
+ <scrollbox flexGrow={1}>
1474
+ <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
1475
+ <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
1476
+ </box>
1477
+ </scrollbox>
1478
+ </box>
1479
+ </>
1480
+ )}
1481
+
1482
+ {isWideLayout && !detailFullView ? (
1483
+ <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
1484
+ ) : (
1485
+ <Divider width={contentWidth} />
1486
+ )}
1487
+ <box paddingLeft={1} paddingRight={1}>
1488
+ {footerNotice ? <PlainLine text={footerNotice} fg={colors.count} /> : <FooterHints showFilterClear={filterMode || filterQuery.length > 0} detailFullView={detailFullView} />}
1489
+ </box>
1490
+ {labelModal.open ? (
1491
+ <LabelModal
1492
+ state={labelModal}
1493
+ currentLabels={selectedPullRequest?.labels ?? []}
1494
+ modalWidth={labelModalWidth}
1495
+ modalHeight={labelModalHeight}
1496
+ offsetLeft={labelModalLeft}
1497
+ offsetTop={labelModalTop}
1498
+ />
1499
+ ) : null}
1500
+ </box>
1501
+ )
1502
+ }