@kitlangton/ghui 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { TextAttributes } from "@opentui/core"
1
+ import { parseColor, SyntaxStyle, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
3
  import { useKeyboard, useTerminalDimensions } from "@opentui/react"
4
4
  import { Cause, Effect, Schedule } from "effect"
@@ -66,6 +66,17 @@ interface RetryProgress {
66
66
  readonly max: number
67
67
  }
68
68
 
69
+ interface DiffFilePatch {
70
+ readonly name: string
71
+ readonly filetype: string | undefined
72
+ readonly patch: string
73
+ }
74
+
75
+ type PullRequestDiffState =
76
+ | { readonly status: "loading" }
77
+ | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
78
+ | { readonly status: "error"; readonly error: string }
79
+
69
80
  interface DetailPlaceholderInput {
70
81
  readonly status: LoadStatus
71
82
  readonly retryProgress: RetryProgress | null
@@ -108,6 +119,11 @@ const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
108
119
  const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
109
120
  const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
110
121
  const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
122
+ const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
123
+ const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
124
+ const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
125
+ const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
126
+ const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
111
127
 
112
128
  const GROUP_ICON = "◆"
113
129
 
@@ -150,6 +166,9 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
150
166
  const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
151
167
  GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
152
168
  )
169
+ const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
170
+ GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
171
+ )
153
172
 
154
173
  const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
155
174
 
@@ -200,8 +219,8 @@ const getRowLayout = (contentWidth: number, numberWidth = 6) => {
200
219
  const reviewWidth = 1
201
220
  const checkWidth = 6
202
221
  const ageWidth = 4
203
- const leftWidth = Math.max(24, contentWidth - reviewWidth - checkWidth - ageWidth - 2) // -2 for spaces between columns
204
- const titleWidth = Math.max(8, leftWidth - numberWidth - 2)
222
+ const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
223
+ const titleWidth = Math.max(8, contentWidth - fixedWidth)
205
224
  return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
206
225
  }
207
226
 
@@ -216,6 +235,8 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
216
235
  return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
217
236
  }
218
237
 
238
+ const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
239
+
219
240
  const centerCell = (text: string, width: number) => {
220
241
  const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
221
242
  const left = Math.floor((width - trimmed.length) / 2)
@@ -316,6 +337,154 @@ const labelTextColor = (color: string) => {
316
337
  return "#f8fafc"
317
338
  }
318
339
 
340
+ const diffSyntaxStyle = SyntaxStyle.fromStyles({
341
+ keyword: { fg: parseColor("#f4a51c"), bold: true },
342
+ "keyword.import": { fg: parseColor("#f4a51c"), bold: true },
343
+ string: { fg: parseColor("#d7c5a1") },
344
+ comment: { fg: parseColor(colors.muted), italic: true },
345
+ number: { fg: parseColor("#93c5fd") },
346
+ boolean: { fg: parseColor("#93c5fd") },
347
+ constant: { fg: parseColor("#93c5fd") },
348
+ function: { fg: parseColor("#7dd3a3") },
349
+ "function.call": { fg: parseColor("#7dd3a3") },
350
+ constructor: { fg: parseColor("#f59e0b") },
351
+ type: { fg: parseColor("#f59e0b") },
352
+ operator: { fg: parseColor("#f87171") },
353
+ variable: { fg: parseColor(colors.text) },
354
+ property: { fg: parseColor("#93c5fd") },
355
+ bracket: { fg: parseColor(colors.text) },
356
+ punctuation: { fg: parseColor(colors.text) },
357
+ default: { fg: parseColor(colors.text) },
358
+ })
359
+
360
+ const extensionFiletypes: Record<string, string> = {
361
+ c: "c",
362
+ cc: "cpp",
363
+ cpp: "cpp",
364
+ cs: "csharp",
365
+ css: "css",
366
+ go: "go",
367
+ h: "c",
368
+ hpp: "cpp",
369
+ html: "html",
370
+ java: "java",
371
+ js: "javascript",
372
+ jsx: "javascript",
373
+ json: "json",
374
+ kt: "kotlin",
375
+ md: "markdown",
376
+ mjs: "javascript",
377
+ py: "python",
378
+ rs: "rust",
379
+ rb: "ruby",
380
+ sh: "bash",
381
+ svelte: "svelte",
382
+ toml: "toml",
383
+ ts: "typescript",
384
+ tsx: "typescript",
385
+ txt: "text",
386
+ vue: "vue",
387
+ yaml: "yaml",
388
+ yml: "yaml",
389
+ zig: "zig",
390
+ }
391
+
392
+ const filetypeForPath = (path: string) => {
393
+ const basename = path.split("/").at(-1) ?? path
394
+ if (basename === "Dockerfile") return "dockerfile"
395
+ const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
396
+ return extension ? extensionFiletypes[extension] : undefined
397
+ }
398
+
399
+ const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
400
+
401
+ const patchFileName = (patch: string) => {
402
+ const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
403
+ if (diffLine) {
404
+ const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
405
+ if (match) {
406
+ const next = unquoteDiffPath(match[2]!)
407
+ if (next !== "/dev/null") return next
408
+ return unquoteDiffPath(match[1]!)
409
+ }
410
+ }
411
+
412
+ const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
413
+ return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
414
+ }
415
+
416
+ const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
417
+ const trimmed = patch.trimEnd()
418
+ if (trimmed.length === 0) return []
419
+
420
+ const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
421
+ if (matches.length === 0) {
422
+ return [{ name: "diff", filetype: undefined, patch: trimmed }]
423
+ }
424
+
425
+ return matches.map((match, index) => {
426
+ const start = match.index ?? 0
427
+ const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
428
+ const filePatch = trimmed.slice(start, end).trimEnd()
429
+ const name = patchFileName(filePatch)
430
+ return { name, filetype: filetypeForPath(name), patch: filePatch }
431
+ })
432
+ }
433
+
434
+ const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
435
+
436
+ const diffStatText = (pullRequest: PullRequestItem) => {
437
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
438
+ return `+${pullRequest.additions} -${pullRequest.deletions} ${files}`
439
+ }
440
+
441
+ const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
442
+ let count = 0
443
+ let inHunk = false
444
+ let deletions = 0
445
+ let additions = 0
446
+
447
+ const flushChangeBlock = () => {
448
+ if (deletions === 0 && additions === 0) return
449
+ count += view === "split" ? Math.max(deletions, additions) : deletions + additions
450
+ deletions = 0
451
+ additions = 0
452
+ }
453
+
454
+ for (const line of patch.split("\n")) {
455
+ if (line.startsWith("@@")) {
456
+ flushChangeBlock()
457
+ inHunk = true
458
+ continue
459
+ }
460
+
461
+ if (!inHunk) continue
462
+
463
+ const firstChar = line[0]
464
+ if (firstChar === "\\") continue
465
+
466
+ if (firstChar === "-") {
467
+ deletions++
468
+ continue
469
+ }
470
+
471
+ if (firstChar === "+") {
472
+ additions++
473
+ continue
474
+ }
475
+
476
+ if (firstChar === " ") {
477
+ flushChangeBlock()
478
+ count++
479
+ }
480
+ }
481
+
482
+ flushChangeBlock()
483
+ return Math.max(1, count)
484
+ }
485
+
486
+ const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
487
+
319
488
  const getDetailPlaceholderContent = ({
320
489
  status,
321
490
  retryProgress,
@@ -499,6 +668,7 @@ const FooterHints = ({
499
668
  filterEditing,
500
669
  showFilterClear,
501
670
  detailFullView,
671
+ diffFullView,
502
672
  hasSelection,
503
673
  hasError,
504
674
  isLoading,
@@ -508,6 +678,7 @@ const FooterHints = ({
508
678
  filterEditing: boolean
509
679
  showFilterClear: boolean
510
680
  detailFullView: boolean
681
+ diffFullView: boolean
511
682
  hasSelection: boolean
512
683
  hasError: boolean
513
684
  isLoading: boolean
@@ -533,6 +704,42 @@ const FooterHints = ({
533
704
  )
534
705
  }
535
706
 
707
+ if (diffFullView) {
708
+ return (
709
+ <TextLine>
710
+ <span fg={colors.count}>esc</span>
711
+ <span fg={colors.muted}> back </span>
712
+ <span fg={colors.count}>v</span>
713
+ <span fg={colors.muted}> view </span>
714
+ <span fg={colors.count}>w</span>
715
+ <span fg={colors.muted}> wrap </span>
716
+ <span fg={colors.count}>[]</span>
717
+ <span fg={colors.muted}> files </span>
718
+ <span fg={colors.count}>r</span>
719
+ <span fg={colors.muted}> reload </span>
720
+ <span fg={colors.count}>o</span>
721
+ <span fg={colors.muted}> open </span>
722
+ <span fg={colors.count}>q</span>
723
+ <span fg={colors.muted}> quit</span>
724
+ </TextLine>
725
+ )
726
+ }
727
+
728
+ if (detailFullView) {
729
+ return (
730
+ <TextLine>
731
+ <span fg={colors.count}>esc</span>
732
+ <span fg={colors.muted}> back </span>
733
+ <span fg={colors.count}>o</span>
734
+ <span fg={colors.muted}> open </span>
735
+ <span fg={colors.count}>y</span>
736
+ <span fg={colors.muted}> copy </span>
737
+ <span fg={colors.count}>q</span>
738
+ <span fg={colors.muted}> quit</span>
739
+ </TextLine>
740
+ )
741
+ }
742
+
536
743
  return (
537
744
  <TextLine>
538
745
  <span fg={colors.count}>/</span>
@@ -577,6 +784,8 @@ const FooterHints = ({
577
784
  <>
578
785
  <span fg={colors.count}>d</span>
579
786
  <span fg={colors.muted}> draft </span>
787
+ <span fg={colors.count}>p</span>
788
+ <span fg={colors.muted}> diff </span>
580
789
  <span fg={colors.count}>l</span>
581
790
  <span fg={colors.muted}> labels </span>
582
791
  <span fg={colors.count}>o</span>
@@ -614,6 +823,8 @@ const PullRequestRow = ({
614
823
  const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
615
824
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
616
825
  const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
826
+ const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
827
+ const fillerWidth = Math.max(0, contentWidth - rowWidth)
617
828
 
618
829
  return (
619
830
  <box height={1} onMouseDown={onSelect}>
@@ -625,6 +836,7 @@ const PullRequestRow = ({
625
836
  <span>{fitCell(pullRequest.title, titleWidth)}</span>
626
837
  <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
627
838
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
839
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
628
840
  </TextLine>
629
841
  </box>
630
842
  )
@@ -807,6 +1019,12 @@ const DetailHeader = ({
807
1019
  const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
808
1020
  const unique = deduplicateChecks(pullRequest.checks)
809
1021
  const checkRows = checksRowCount(unique)
1022
+ const statsText = diffStatText(pullRequest)
1023
+ const labelsWidth = labels.length > 0
1024
+ ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
1025
+ : "no labels".length
1026
+ const showStats = contentWidth - labelsWidth - statsText.length >= 2
1027
+ const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
810
1028
 
811
1029
  return (
812
1030
  <>
@@ -849,6 +1067,15 @@ const DetailHeader = ({
849
1067
  <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
850
1068
  </Fragment>
851
1069
  )) : <span fg={colors.muted}>no labels</span>}
1070
+ {showStats ? (
1071
+ <>
1072
+ <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
1073
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1074
+ <span fg={colors.muted}> </span>
1075
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1076
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1077
+ </>
1078
+ ) : null}
852
1079
  </TextLine>
853
1080
  </box>
854
1081
  <box height={1}><Divider width={paneWidth} /></box>
@@ -993,6 +1220,127 @@ const DetailsPane = ({
993
1220
  )
994
1221
  }
995
1222
 
1223
+ const PullRequestDiffPane = ({
1224
+ pullRequest,
1225
+ diffState,
1226
+ fileIndex,
1227
+ view,
1228
+ wrapMode,
1229
+ paneWidth,
1230
+ height,
1231
+ loadingIndicator,
1232
+ scrollRef,
1233
+ }: {
1234
+ pullRequest: PullRequestItem | null
1235
+ diffState: PullRequestDiffState | undefined
1236
+ fileIndex: number
1237
+ view: "unified" | "split"
1238
+ wrapMode: "none" | "word"
1239
+ paneWidth: number
1240
+ height: number
1241
+ loadingIndicator: string
1242
+ scrollRef: React.Ref<ScrollBoxRenderable>
1243
+ }) => {
1244
+ if (!pullRequest) {
1245
+ return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
1246
+ }
1247
+
1248
+ const stats = diffStatText(pullRequest)
1249
+ const headerWidth = Math.max(24, paneWidth - 2)
1250
+ const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
1251
+ const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
1252
+
1253
+ if (!diffState || diffState.status === "loading") {
1254
+ return (
1255
+ <box height={height} flexDirection="column">
1256
+ <box height={1} paddingLeft={1} paddingRight={1}>
1257
+ <TextLine>
1258
+ <span fg={colors.count}>#{pullRequest.number}</span>
1259
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1260
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1261
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1262
+ <span fg={colors.muted}> </span>
1263
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1264
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1265
+ </TextLine>
1266
+ </box>
1267
+ <Divider width={paneWidth} />
1268
+ <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
1269
+ </box>
1270
+ )
1271
+ }
1272
+
1273
+ if (diffState.status === "error") {
1274
+ return (
1275
+ <box height={height} flexDirection="column">
1276
+ <box height={1} paddingLeft={1} paddingRight={1}>
1277
+ <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
1278
+ </box>
1279
+ <Divider width={paneWidth} />
1280
+ <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
1281
+ </box>
1282
+ )
1283
+ }
1284
+
1285
+ if (diffState.files.length === 0) {
1286
+ return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
1287
+ }
1288
+
1289
+ const safeIndex = Math.max(0, Math.min(fileIndex, diffState.files.length - 1))
1290
+ const file = diffState.files[safeIndex]!
1291
+ const fileCounter = `${safeIndex + 1}/${diffState.files.length}`
1292
+ const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
1293
+ const diffHeight = patchRenderableLineCount(file.patch, view)
1294
+
1295
+ return (
1296
+ <box height={height} flexDirection="column">
1297
+ <box height={1} paddingLeft={1} paddingRight={1}>
1298
+ <TextLine>
1299
+ <span fg={colors.count}>#{pullRequest.number}</span>
1300
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1301
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1302
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1303
+ <span fg={colors.muted}> </span>
1304
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1305
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1306
+ </TextLine>
1307
+ </box>
1308
+ <box height={1} paddingLeft={1} paddingRight={1}>
1309
+ <TextLine>
1310
+ <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
1311
+ <span fg={colors.muted}> {fileCounter}</span>
1312
+ </TextLine>
1313
+ </box>
1314
+ <Divider width={paneWidth} />
1315
+ <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
1316
+ <diff
1317
+ key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
1318
+ diff={file.patch}
1319
+ view={view}
1320
+ syncScroll
1321
+ filetype={file.filetype ?? "text"}
1322
+ syntaxStyle={diffSyntaxStyle}
1323
+ showLineNumbers
1324
+ wrapMode={wrapMode}
1325
+ addedBg="#17351f"
1326
+ removedBg="#3a1e22"
1327
+ contextBg="transparent"
1328
+ addedSignColor={colors.status.passing}
1329
+ removedSignColor={colors.status.failing}
1330
+ lineNumberFg={colors.muted}
1331
+ lineNumberBg="#151515"
1332
+ addedLineNumberBg="#12301a"
1333
+ removedLineNumberBg="#35171b"
1334
+ selectionBg={colors.selectedBg}
1335
+ selectionFg={colors.selectedText}
1336
+ height={diffHeight}
1337
+ style={{ flexShrink: 0 }}
1338
+ />
1339
+ </scrollbox>
1340
+ </box>
1341
+ )
1342
+ }
1343
+
996
1344
  const LabelModal = ({
997
1345
  state,
998
1346
  currentLabels,
@@ -1066,17 +1414,17 @@ const LabelModal = ({
1066
1414
  const isActive = currentNames.has(label.name.toLowerCase())
1067
1415
  const isSelected = actualIndex === selectedIndex
1068
1416
  const status = isActive ? "added" : ""
1069
- const nameWidth = Math.max(8, contentWidth - 10 - status.length)
1070
- const gap = Math.max(1, contentWidth - 8 - Math.min(label.name.length, nameWidth) - status.length)
1417
+ const statusText = status.length > 0 ? ` ${status}` : ""
1418
+ const nameWidth = Math.max(1, contentWidth - 5 - statusText.length)
1071
1419
  return (
1072
1420
  <box key={label.name} height={1}>
1073
1421
  <TextLine bg={isSelected ? colors.selectedBg : undefined}>
1074
- <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "" : " "}</span>
1075
- <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
1422
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "" : " "}</span>
1423
+ <span> </span>
1076
1424
  <span bg={labelColor(label)}> </span>
1077
- <span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
1078
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
1079
- {status ? <span fg={colors.status.passing}>{status}</span> : null}
1425
+ <span> </span>
1426
+ <span fg={isSelected ? colors.selectedText : colors.text}>{trimCell(label.name, nameWidth)}</span>
1427
+ {statusText ? <span fg={colors.status.passing}>{statusText}</span> : null}
1080
1428
  </TextLine>
1081
1429
  </box>
1082
1430
  )
@@ -1091,7 +1439,7 @@ const LabelModal = ({
1091
1439
  <span fg={colors.muted}> move </span>
1092
1440
  <span fg={colors.count}>enter</span>
1093
1441
  <span fg={colors.muted}> toggle </span>
1094
- <span fg={colors.count}>/type</span>
1442
+ <span fg={colors.count}>/</span>
1095
1443
  <span fg={colors.muted}> filter </span>
1096
1444
  <span fg={colors.count}>esc</span>
1097
1445
  <span fg={colors.muted}> close</span>
@@ -1114,6 +1462,11 @@ export const App = () => {
1114
1462
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
1115
1463
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
1116
1464
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
1465
+ const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
1466
+ const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
1467
+ const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
1468
+ const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
1469
+ const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
1117
1470
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
1118
1471
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
1119
1472
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
@@ -1124,6 +1477,7 @@ export const App = () => {
1124
1477
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1125
1478
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1126
1479
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1480
+ const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
1127
1481
  const groupIcon = GROUP_ICON
1128
1482
  const contentWidth = Math.max(60, width ?? 100)
1129
1483
  const isWideLayout = (width ?? 100) >= 100
@@ -1138,6 +1492,9 @@ export const App = () => {
1138
1492
  const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
1139
1493
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1140
1494
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1495
+ const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1496
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1497
+ const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1141
1498
  const headerFooterWidth = Math.max(24, contentWidth - 2)
1142
1499
 
1143
1500
  const flashNotice = (message: string) => {
@@ -1157,6 +1514,9 @@ export const App = () => {
1157
1514
  if (pendingGTimeoutRef.current !== null) {
1158
1515
  clearTimeout(pendingGTimeoutRef.current)
1159
1516
  }
1517
+ if (diffPrefetchTimeoutRef.current !== null) {
1518
+ clearTimeout(diffPrefetchTimeoutRef.current)
1519
+ }
1160
1520
  }, [])
1161
1521
 
1162
1522
  const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
@@ -1237,7 +1597,13 @@ export const App = () => {
1237
1597
  })
1238
1598
  }, [visiblePullRequests.length])
1239
1599
 
1600
+ useEffect(() => {
1601
+ setDiffFileIndex(0)
1602
+ }, [selectedIndex])
1603
+
1240
1604
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1605
+ const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
1606
+ const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
1241
1607
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1242
1608
  const detailPlaceholderContent = getDetailPlaceholderContent({
1243
1609
  status: pullRequestStatus,
@@ -1259,6 +1625,54 @@ export const App = () => {
1259
1625
 
1260
1626
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1261
1627
 
1628
+ const loadPullRequestDiff = (pullRequest: PullRequestItem, force = false) => {
1629
+ const key = pullRequestDiffKey(pullRequest)
1630
+ const existing = pullRequestDiffCache[key]
1631
+ if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
1632
+
1633
+ setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
1634
+ void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
1635
+ .then((patch) => {
1636
+ setPullRequestDiffCache((current) => ({
1637
+ ...current,
1638
+ [key]: { status: "ready", patch, files: splitPatchFiles(patch) },
1639
+ }))
1640
+ })
1641
+ .catch((error) => {
1642
+ setPullRequestDiffCache((current) => ({
1643
+ ...current,
1644
+ [key]: { status: "error", error: errorMessage(error) },
1645
+ }))
1646
+ flashNotice(errorMessage(error))
1647
+ })
1648
+ }
1649
+
1650
+ useEffect(() => {
1651
+ if (!selectedPullRequest || diffFullView) return
1652
+ if (diffPrefetchTimeoutRef.current !== null) {
1653
+ clearTimeout(diffPrefetchTimeoutRef.current)
1654
+ }
1655
+ diffPrefetchTimeoutRef.current = setTimeout(() => {
1656
+ loadPullRequestDiff(selectedPullRequest)
1657
+ }, 250)
1658
+ return () => {
1659
+ if (diffPrefetchTimeoutRef.current !== null) {
1660
+ clearTimeout(diffPrefetchTimeoutRef.current)
1661
+ diffPrefetchTimeoutRef.current = null
1662
+ }
1663
+ }
1664
+ }, [selectedIndex, selectedPullRequest?.url, diffFullView])
1665
+
1666
+ const openDiffView = () => {
1667
+ if (!selectedPullRequest) return
1668
+ setDiffFullView(true)
1669
+ setDetailFullView(false)
1670
+ setDiffFileIndex(0)
1671
+ setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1672
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1673
+ loadPullRequestDiff(selectedPullRequest)
1674
+ }
1675
+
1262
1676
  const openLabelModal = () => {
1263
1677
  if (!selectedPullRequest) return
1264
1678
  const repository = selectedPullRequest.repository
@@ -1385,26 +1799,167 @@ export const App = () => {
1385
1799
  return
1386
1800
  }
1387
1801
 
1388
- // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1802
+ if (diffFullView) {
1803
+ if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1804
+ setDiffFullView(false)
1805
+ return
1806
+ }
1807
+ if (key.name === "home") {
1808
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1809
+ return
1810
+ }
1811
+ if (key.name === "end") {
1812
+ diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1813
+ return
1814
+ }
1815
+ if (key.name === "pageup") {
1816
+ diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1817
+ return
1818
+ }
1819
+ if (key.name === "pagedown") {
1820
+ diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1821
+ return
1822
+ }
1823
+ if (isShiftG(key)) {
1824
+ diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1825
+ setPendingG(false)
1826
+ if (pendingGTimeoutRef.current !== null) {
1827
+ clearTimeout(pendingGTimeoutRef.current)
1828
+ pendingGTimeoutRef.current = null
1829
+ }
1830
+ return
1831
+ }
1832
+ if (key.name === "g") {
1833
+ if (pendingG) {
1834
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1835
+ setPendingG(false)
1836
+ if (pendingGTimeoutRef.current !== null) {
1837
+ clearTimeout(pendingGTimeoutRef.current)
1838
+ pendingGTimeoutRef.current = null
1839
+ }
1840
+ } else {
1841
+ setPendingG(true)
1842
+ pendingGTimeoutRef.current = setTimeout(() => {
1843
+ setPendingG(false)
1844
+ pendingGTimeoutRef.current = null
1845
+ }, 500)
1846
+ }
1847
+ return
1848
+ }
1849
+ if (key.name === "up" || key.name === "k") {
1850
+ diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
1851
+ return
1852
+ }
1853
+ if (key.name === "down" || key.name === "j") {
1854
+ diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
1855
+ return
1856
+ }
1857
+ if (key.ctrl && key.name === "u") {
1858
+ diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1859
+ return
1860
+ }
1861
+ if (key.ctrl && (key.name === "d" || key.name === "v")) {
1862
+ diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1863
+ return
1864
+ }
1865
+ if (key.name === "v") {
1866
+ setDiffRenderView((current) => current === "unified" ? "split" : "unified")
1867
+ return
1868
+ }
1869
+ if (key.name === "w") {
1870
+ setDiffWrapMode((current) => current === "none" ? "word" : "none")
1871
+ return
1872
+ }
1873
+ if (key.name === "r" && selectedPullRequest) {
1874
+ loadPullRequestDiff(selectedPullRequest, true)
1875
+ flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1876
+ return
1877
+ }
1878
+ if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
1879
+ setDiffFileIndex((current) => Math.min(Math.max(0, selectedDiffState.files.length - 1), current + 1))
1880
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1881
+ return
1882
+ }
1883
+ if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
1884
+ setDiffFileIndex((current) => Math.max(0, current - 1))
1885
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1886
+ return
1887
+ }
1888
+ if (key.name === "o" && selectedPullRequest) {
1889
+ void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1890
+ flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1891
+ return
1892
+ }
1893
+ return
1894
+ }
1895
+
1896
+ // Fullscreen detail mode handles its own navigation keys.
1389
1897
  if (detailFullView) {
1390
1898
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1391
1899
  setDetailFullView(false)
1392
1900
  setDetailScrollOffset(0)
1393
1901
  return
1394
1902
  }
1903
+ if (key.name === "home") {
1904
+ detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1905
+ setDetailScrollOffset(0)
1906
+ return
1907
+ }
1908
+ if (key.name === "end" || isShiftG(key)) {
1909
+ detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1910
+ setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
1911
+ setPendingG(false)
1912
+ if (pendingGTimeoutRef.current !== null) {
1913
+ clearTimeout(pendingGTimeoutRef.current)
1914
+ pendingGTimeoutRef.current = null
1915
+ }
1916
+ return
1917
+ }
1918
+ if (key.name === "pageup") {
1919
+ detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1920
+ setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1921
+ return
1922
+ }
1923
+ if (key.name === "pagedown") {
1924
+ detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1925
+ setDetailScrollOffset((current) => current + halfPage)
1926
+ return
1927
+ }
1928
+ if (key.name === "g") {
1929
+ if (pendingG) {
1930
+ detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1931
+ setDetailScrollOffset(0)
1932
+ setPendingG(false)
1933
+ if (pendingGTimeoutRef.current !== null) {
1934
+ clearTimeout(pendingGTimeoutRef.current)
1935
+ pendingGTimeoutRef.current = null
1936
+ }
1937
+ } else {
1938
+ setPendingG(true)
1939
+ pendingGTimeoutRef.current = setTimeout(() => {
1940
+ setPendingG(false)
1941
+ pendingGTimeoutRef.current = null
1942
+ }, 500)
1943
+ }
1944
+ return
1945
+ }
1395
1946
  if (key.name === "up" || key.name === "k") {
1947
+ detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1396
1948
  setDetailScrollOffset((current) => Math.max(0, current - 1))
1397
1949
  return
1398
1950
  }
1399
1951
  if (key.name === "down" || key.name === "j") {
1952
+ detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
1400
1953
  setDetailScrollOffset((current) => current + 1)
1401
1954
  return
1402
1955
  }
1403
1956
  if (key.ctrl && key.name === "u") {
1957
+ detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1404
1958
  setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1405
1959
  return
1406
1960
  }
1407
1961
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
1962
+ detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1408
1963
  setDetailScrollOffset((current) => current + halfPage)
1409
1964
  return
1410
1965
  }
@@ -1523,7 +2078,7 @@ export const App = () => {
1523
2078
  return
1524
2079
  }
1525
2080
  // Vim-style navigation: gg to go to top, G to go to bottom
1526
- if (key.name === "G" || key.name === "g" && key.shift) {
2081
+ if (isShiftG(key)) {
1527
2082
  setSelectedIndex((_current) => {
1528
2083
  if (visiblePullRequests.length === 0) return 0
1529
2084
  return visiblePullRequests.length - 1
@@ -1552,6 +2107,10 @@ export const App = () => {
1552
2107
  setDetailScrollOffset(0)
1553
2108
  return
1554
2109
  }
2110
+ if (key.name === "p" && selectedPullRequest) {
2111
+ openDiffView()
2112
+ return
2113
+ }
1555
2114
  if (key.name === "l" && selectedPullRequest) {
1556
2115
  openLabelModal()
1557
2116
  return
@@ -1604,7 +2163,8 @@ export const App = () => {
1604
2163
  onSelectPullRequest: selectPullRequestByUrl,
1605
2164
  } as const
1606
2165
 
1607
- const labelModalWidth = Math.min(40, contentWidth - 4)
2166
+ const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
2167
+ const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1608
2168
  const labelModalHeight = Math.min(20, (height ?? 24) - 4)
1609
2169
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1610
2170
  const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
@@ -1614,16 +2174,28 @@ export const App = () => {
1614
2174
  <box paddingLeft={1} paddingRight={1} flexDirection="column">
1615
2175
  <PlainLine text={headerLine} fg={colors.muted} bold />
1616
2176
  </box>
1617
- {isWideLayout && !detailFullView && !isInitialLoading ? (
2177
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1618
2178
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
1619
2179
  ) : (
1620
2180
  <Divider width={contentWidth} />
1621
2181
  )}
1622
2182
  {isInitialLoading ? (
1623
2183
  <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
2184
+ ) : diffFullView ? (
2185
+ <PullRequestDiffPane
2186
+ pullRequest={selectedPullRequest}
2187
+ diffState={selectedDiffState}
2188
+ fileIndex={diffFileIndex}
2189
+ view={effectiveDiffRenderView}
2190
+ wrapMode={diffWrapMode}
2191
+ paneWidth={contentWidth}
2192
+ height={wideBodyHeight}
2193
+ loadingIndicator={loadingIndicator}
2194
+ scrollRef={diffScrollRef}
2195
+ />
1624
2196
  ) : isWideLayout && detailFullView ? (
1625
2197
  <box flexGrow={1} flexDirection="column">
1626
- <scrollbox flexGrow={1}>
2198
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1627
2199
  <DetailsPane
1628
2200
  pullRequest={selectedPullRequest}
1629
2201
  contentWidth={fullscreenContentWidth}
@@ -1657,7 +2229,7 @@ export const App = () => {
1657
2229
  </box>
1658
2230
  ) : detailFullView ? (
1659
2231
  <box flexGrow={1} flexDirection="column">
1660
- <scrollbox flexGrow={1}>
2232
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1661
2233
  <DetailsPane
1662
2234
  pullRequest={selectedPullRequest}
1663
2235
  contentWidth={fullscreenContentWidth}
@@ -1681,7 +2253,7 @@ export const App = () => {
1681
2253
  </>
1682
2254
  )}
1683
2255
 
1684
- {isWideLayout && !detailFullView && !isInitialLoading ? (
2256
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1685
2257
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
1686
2258
  ) : (
1687
2259
  <Divider width={contentWidth} />
@@ -1694,6 +2266,7 @@ export const App = () => {
1694
2266
  filterEditing={filterMode}
1695
2267
  showFilterClear={filterMode || filterQuery.length > 0}
1696
2268
  detailFullView={detailFullView}
2269
+ diffFullView={diffFullView}
1697
2270
  hasSelection={selectedPullRequest !== null}
1698
2271
  hasError={pullRequestStatus === "error"}
1699
2272
  isLoading={pullRequestStatus === "loading"}
package/src/domain.ts CHANGED
@@ -19,6 +19,9 @@ export interface PullRequestItem {
19
19
  readonly title: string
20
20
  readonly body: string
21
21
  readonly labels: readonly PullRequestLabel[]
22
+ readonly additions: number
23
+ readonly deletions: number
24
+ readonly changedFiles: number
22
25
  readonly state: PullRequestState
23
26
  readonly reviewStatus: "draft" | "approved" | "changes" | "review" | "none"
24
27
  readonly checkStatus: "passing" | "pending" | "failing" | "none"
@@ -3,49 +3,105 @@ import { config } from "../config.js"
3
3
  import type { CheckItem, PullRequestItem } from "../domain.js"
4
4
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
5
 
6
- interface GitHubListPullRequest {
6
+ interface GitHubPullRequestNode {
7
7
  readonly number: number
8
8
  readonly title: string
9
9
  readonly body: string
10
- readonly labels: readonly {
11
- readonly name: string
12
- readonly color?: string | null
13
- }[]
10
+ readonly labels: {
11
+ readonly nodes: readonly {
12
+ readonly name: string
13
+ readonly color?: string | null
14
+ }[]
15
+ }
16
+ readonly additions: number
17
+ readonly deletions: number
18
+ readonly changedFiles: number
14
19
  readonly isDraft: boolean
15
- readonly reviewDecision: string
16
- readonly statusCheckRollup: readonly {
17
- readonly name?: string | null
18
- readonly context?: string | null
19
- readonly status?: string | null
20
- readonly conclusion?: string | null
21
- readonly state?: string | null
22
- }[]
20
+ readonly reviewDecision: string | null
21
+ readonly statusCheckRollup?: {
22
+ readonly contexts: {
23
+ readonly nodes: readonly GraphQLCheckContext[]
24
+ }
25
+ } | null
23
26
  readonly state: string
24
27
  readonly createdAt: string
25
28
  readonly closedAt?: string | null
26
29
  readonly url: string
27
- }
28
-
29
- interface GitHubSearchPullRequest {
30
- readonly number: number
31
30
  readonly repository: {
32
31
  readonly nameWithOwner: string
33
32
  }
34
33
  }
35
34
 
35
+ type GraphQLCheckContext =
36
+ | {
37
+ readonly __typename: "CheckRun"
38
+ readonly name?: string | null
39
+ readonly status?: string | null
40
+ readonly conclusion?: string | null
41
+ }
42
+ | {
43
+ readonly __typename: "StatusContext"
44
+ readonly context?: string | null
45
+ readonly state?: string | null
46
+ }
47
+
48
+ interface GraphQLSearchResponse {
49
+ readonly data: {
50
+ readonly search: {
51
+ readonly nodes: readonly (GitHubPullRequestNode | null)[]
52
+ readonly pageInfo: {
53
+ readonly hasNextPage: boolean
54
+ readonly endCursor: string | null
55
+ }
56
+ }
57
+ }
58
+ }
59
+
36
60
  interface GitHubViewer {
37
61
  readonly login: string
38
62
  }
39
63
 
40
- const searchJsonFields = "repository,number"
41
- const detailJsonFields = "number,title,body,labels,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
64
+ const pullRequestSearchQuery = `
65
+ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
66
+ search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
67
+ nodes {
68
+ ... on PullRequest {
69
+ number
70
+ title
71
+ body
72
+ isDraft
73
+ reviewDecision
74
+ additions
75
+ deletions
76
+ changedFiles
77
+ state
78
+ createdAt
79
+ closedAt
80
+ url
81
+ repository { nameWithOwner }
82
+ labels(first: 20) { nodes { name color } }
83
+ statusCheckRollup {
84
+ contexts(first: 100) {
85
+ nodes {
86
+ __typename
87
+ ... on CheckRun { name status conclusion }
88
+ ... on StatusContext { context state }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ pageInfo { hasNextPage endCursor }
95
+ }
96
+ }
97
+ `
42
98
 
43
99
  const normalizeDate = (value: string | null | undefined) => {
44
100
  if (!value || value.startsWith("0001-01-01")) return null
45
101
  return new Date(value)
46
102
  }
47
103
 
48
- const getReviewStatus = (item: GitHubListPullRequest): PullRequestItem["reviewStatus"] => {
104
+ const getReviewStatus = (item: GitHubPullRequestNode): PullRequestItem["reviewStatus"] => {
49
105
  if (item.isDraft) return "draft"
50
106
  if (item.reviewDecision === "APPROVED") return "approved"
51
107
  if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
@@ -70,8 +126,22 @@ const normalizeCheckConclusion = (raw?: string | null): CheckItem["conclusion"]
70
126
  return null
71
127
  }
72
128
 
73
- const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
74
- if (item.statusCheckRollup.length === 0) {
129
+ const getContextStatus = (context: GraphQLCheckContext): CheckItem["status"] => {
130
+ if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
131
+ if (context.state === "PENDING") return "in_progress"
132
+ return "completed"
133
+ }
134
+
135
+ const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusion"] => {
136
+ if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
137
+ if (context.state === "SUCCESS") return "success"
138
+ if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
139
+ return null
140
+ }
141
+
142
+ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
143
+ const contexts = item.statusCheckRollup?.contexts.nodes ?? []
144
+ if (contexts.length === 0) {
75
145
  return { checkStatus: "none", checkSummary: null, checks: [] }
76
146
  }
77
147
 
@@ -81,51 +151,52 @@ const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "check
81
151
  let failing = false
82
152
  const checks: CheckItem[] = []
83
153
 
84
- for (const check of item.statusCheckRollup) {
85
- const name = check.name ?? check.context ?? "check"
154
+ for (const check of contexts) {
155
+ const name = check.__typename === "CheckRun" ? check.name ?? "check" : check.context ?? "check"
156
+ const status = getContextStatus(check)
157
+ const conclusion = getContextConclusion(check)
86
158
 
87
- checks.push({
88
- name,
89
- status: normalizeCheckStatus(check.status),
90
- conclusion: normalizeCheckConclusion(check.conclusion),
91
- })
159
+ checks.push({ name, status, conclusion })
92
160
 
93
- if (check.status === "COMPLETED") {
161
+ if (status === "completed") {
94
162
  completed += 1
95
163
  } else {
96
164
  pending = true
97
165
  }
98
166
 
99
- if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
167
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
100
168
  successful += 1
101
- } else if (check.conclusion && check.conclusion !== "SUCCESS") {
169
+ } else if (conclusion) {
102
170
  failing = true
103
171
  }
104
172
  }
105
173
 
106
174
  if (pending) {
107
- return { checkStatus: "pending", checkSummary: `checks ${completed}/${item.statusCheckRollup.length}`, checks }
175
+ return { checkStatus: "pending", checkSummary: `checks ${completed}/${contexts.length}`, checks }
108
176
  }
109
177
 
110
178
  if (failing) {
111
- return { checkStatus: "failing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
179
+ return { checkStatus: "failing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
112
180
  }
113
181
 
114
- return { checkStatus: "passing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
182
+ return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
115
183
  }
116
184
 
117
- const parsePullRequest = (repository: string, item: GitHubListPullRequest): PullRequestItem => {
185
+ const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
118
186
  const checkInfo = getCheckInfo(item)
119
187
 
120
188
  return {
121
- repository,
189
+ repository: item.repository.nameWithOwner,
122
190
  number: item.number,
123
191
  title: item.title,
124
192
  body: item.body,
125
- labels: item.labels.map((label) => ({
193
+ labels: item.labels.nodes.map((label) => ({
126
194
  name: label.name,
127
195
  color: label.color ? `#${label.color}` : null,
128
196
  })),
197
+ additions: item.additions,
198
+ deletions: item.deletions,
199
+ changedFiles: item.changedFiles,
129
200
  state: item.state.toLowerCase() === "open" ? "open" : "closed",
130
201
  reviewStatus: getReviewStatus(item),
131
202
  checkStatus: checkInfo.checkStatus,
@@ -137,28 +208,14 @@ const parsePullRequest = (repository: string, item: GitHubListPullRequest): Pull
137
208
  }
138
209
  }
139
210
 
140
- const searchOpenArgs = (author: string) => [
141
- "search",
142
- "prs",
143
- "--author",
144
- author,
145
- "--state",
146
- "open",
147
- "--limit",
148
- String(config.prFetchLimit),
149
- "--sort",
150
- "created",
151
- "--order",
152
- "desc",
153
- "--json",
154
- searchJsonFields,
155
- ] as const
211
+ const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
156
212
 
157
213
  type GitHubError = CommandError | JsonParseError
158
214
 
159
215
  export class GitHubService extends Context.Service<GitHubService, {
160
216
  readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
161
217
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
218
+ readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
162
219
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
163
220
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
164
221
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -170,18 +227,27 @@ export class GitHubService extends Context.Service<GitHubService, {
170
227
  const command = yield* CommandRunner
171
228
 
172
229
  const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
173
- const searchResults = yield* command.runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
174
- const pullRequests = yield* Effect.forEach(
175
- searchResults,
176
- Effect.fn("GitHubService.loadPullRequestDetail")(function*(searchResult) {
177
- const repository = searchResult.repository.nameWithOwner
178
- const pullRequest = yield* command.runJson<GitHubListPullRequest>("gh", [
179
- "pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
180
- ])
181
- return parsePullRequest(repository, pullRequest)
182
- }),
183
- { concurrency: 8 },
184
- )
230
+ const pullRequests: PullRequestItem[] = []
231
+ let cursor: string | null = null
232
+
233
+ while (pullRequests.length < config.prFetchLimit) {
234
+ const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
235
+ const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
236
+ "api", "graphql",
237
+ "-f", `query=${pullRequestSearchQuery}`,
238
+ "-F", `searchQuery=${searchQuery(config.author)}`,
239
+ "-F", `first=${pageSize}`,
240
+ ...(cursor ? ["-F", `after=${cursor}`] : []),
241
+ ])
242
+
243
+ for (const node of response.data.search.nodes) {
244
+ if (node) pullRequests.push(parsePullRequest(node))
245
+ }
246
+
247
+ if (!response.data.search.pageInfo.hasNextPage) break
248
+ cursor = response.data.search.pageInfo.endCursor
249
+ if (!cursor) break
250
+ }
185
251
 
186
252
  return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
187
253
  })
@@ -191,6 +257,11 @@ export class GitHubService extends Context.Service<GitHubService, {
191
257
  return viewer.login
192
258
  })
193
259
 
260
+ const getPullRequestDiff = Effect.fn("GitHubService.getPullRequestDiff")(function*(repository: string, number: number) {
261
+ const result = yield* command.run("gh", ["pr", "diff", String(number), "--repo", repository, "--color", "never"])
262
+ return result.stdout
263
+ })
264
+
194
265
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
195
266
  yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
196
267
  })
@@ -213,6 +284,7 @@ export class GitHubService extends Context.Service<GitHubService, {
213
284
  return GitHubService.of({
214
285
  listOpenPullRequests,
215
286
  getAuthenticatedUser,
287
+ getPullRequestDiff,
216
288
  toggleDraftStatus,
217
289
  listRepoLabels,
218
290
  addPullRequestLabel,