@kitlangton/ghui 0.1.3 → 0.1.4

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.4",
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,52 @@ 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}>j/k</span>
713
+ <span fg={colors.muted}> scroll </span>
714
+ <span fg={colors.count}>gg/G</span>
715
+ <span fg={colors.muted}> top/bot </span>
716
+ <span fg={colors.count}>v</span>
717
+ <span fg={colors.muted}> view </span>
718
+ <span fg={colors.count}>w</span>
719
+ <span fg={colors.muted}> wrap </span>
720
+ <span fg={colors.count}>[]</span>
721
+ <span fg={colors.muted}> files </span>
722
+ <span fg={colors.count}>r</span>
723
+ <span fg={colors.muted}> reload </span>
724
+ <span fg={colors.count}>o</span>
725
+ <span fg={colors.muted}> open </span>
726
+ <span fg={colors.count}>q</span>
727
+ <span fg={colors.muted}> quit</span>
728
+ </TextLine>
729
+ )
730
+ }
731
+
732
+ if (detailFullView) {
733
+ return (
734
+ <TextLine>
735
+ <span fg={colors.count}>esc</span>
736
+ <span fg={colors.muted}> back </span>
737
+ <span fg={colors.count}>j/k</span>
738
+ <span fg={colors.muted}> scroll </span>
739
+ <span fg={colors.count}>gg/G</span>
740
+ <span fg={colors.muted}> top/bot </span>
741
+ <span fg={colors.count}>ctrl-d/u</span>
742
+ <span fg={colors.muted}> page </span>
743
+ <span fg={colors.count}>o</span>
744
+ <span fg={colors.muted}> open </span>
745
+ <span fg={colors.count}>y</span>
746
+ <span fg={colors.muted}> copy </span>
747
+ <span fg={colors.count}>q</span>
748
+ <span fg={colors.muted}> quit</span>
749
+ </TextLine>
750
+ )
751
+ }
752
+
536
753
  return (
537
754
  <TextLine>
538
755
  <span fg={colors.count}>/</span>
@@ -577,6 +794,8 @@ const FooterHints = ({
577
794
  <>
578
795
  <span fg={colors.count}>d</span>
579
796
  <span fg={colors.muted}> draft </span>
797
+ <span fg={colors.count}>p</span>
798
+ <span fg={colors.muted}> diff </span>
580
799
  <span fg={colors.count}>l</span>
581
800
  <span fg={colors.muted}> labels </span>
582
801
  <span fg={colors.count}>o</span>
@@ -614,6 +833,8 @@ const PullRequestRow = ({
614
833
  const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
615
834
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
616
835
  const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
836
+ const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
837
+ const fillerWidth = Math.max(0, contentWidth - rowWidth)
617
838
 
618
839
  return (
619
840
  <box height={1} onMouseDown={onSelect}>
@@ -625,6 +846,7 @@ const PullRequestRow = ({
625
846
  <span>{fitCell(pullRequest.title, titleWidth)}</span>
626
847
  <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
627
848
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
849
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
628
850
  </TextLine>
629
851
  </box>
630
852
  )
@@ -807,6 +1029,12 @@ const DetailHeader = ({
807
1029
  const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
808
1030
  const unique = deduplicateChecks(pullRequest.checks)
809
1031
  const checkRows = checksRowCount(unique)
1032
+ const statsText = diffStatText(pullRequest)
1033
+ const labelsWidth = labels.length > 0
1034
+ ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
1035
+ : "no labels".length
1036
+ const showStats = contentWidth - labelsWidth - statsText.length >= 2
1037
+ const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
810
1038
 
811
1039
  return (
812
1040
  <>
@@ -849,6 +1077,15 @@ const DetailHeader = ({
849
1077
  <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
850
1078
  </Fragment>
851
1079
  )) : <span fg={colors.muted}>no labels</span>}
1080
+ {showStats ? (
1081
+ <>
1082
+ <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
1083
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1084
+ <span fg={colors.muted}> </span>
1085
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1086
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1087
+ </>
1088
+ ) : null}
852
1089
  </TextLine>
853
1090
  </box>
854
1091
  <box height={1}><Divider width={paneWidth} /></box>
@@ -993,6 +1230,127 @@ const DetailsPane = ({
993
1230
  )
994
1231
  }
995
1232
 
1233
+ const PullRequestDiffPane = ({
1234
+ pullRequest,
1235
+ diffState,
1236
+ fileIndex,
1237
+ view,
1238
+ wrapMode,
1239
+ paneWidth,
1240
+ height,
1241
+ loadingIndicator,
1242
+ scrollRef,
1243
+ }: {
1244
+ pullRequest: PullRequestItem | null
1245
+ diffState: PullRequestDiffState | undefined
1246
+ fileIndex: number
1247
+ view: "unified" | "split"
1248
+ wrapMode: "none" | "word"
1249
+ paneWidth: number
1250
+ height: number
1251
+ loadingIndicator: string
1252
+ scrollRef: React.Ref<ScrollBoxRenderable>
1253
+ }) => {
1254
+ if (!pullRequest) {
1255
+ return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
1256
+ }
1257
+
1258
+ const stats = diffStatText(pullRequest)
1259
+ const headerWidth = Math.max(24, paneWidth - 2)
1260
+ const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
1261
+ const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
1262
+
1263
+ if (!diffState || diffState.status === "loading") {
1264
+ return (
1265
+ <box height={height} flexDirection="column">
1266
+ <box height={1} paddingLeft={1} paddingRight={1}>
1267
+ <TextLine>
1268
+ <span fg={colors.count}>#{pullRequest.number}</span>
1269
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1270
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1271
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1272
+ <span fg={colors.muted}> </span>
1273
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1274
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1275
+ </TextLine>
1276
+ </box>
1277
+ <Divider width={paneWidth} />
1278
+ <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
1279
+ </box>
1280
+ )
1281
+ }
1282
+
1283
+ if (diffState.status === "error") {
1284
+ return (
1285
+ <box height={height} flexDirection="column">
1286
+ <box height={1} paddingLeft={1} paddingRight={1}>
1287
+ <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
1288
+ </box>
1289
+ <Divider width={paneWidth} />
1290
+ <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
1291
+ </box>
1292
+ )
1293
+ }
1294
+
1295
+ if (diffState.files.length === 0) {
1296
+ return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
1297
+ }
1298
+
1299
+ const safeIndex = Math.max(0, Math.min(fileIndex, diffState.files.length - 1))
1300
+ const file = diffState.files[safeIndex]!
1301
+ const fileCounter = `${safeIndex + 1}/${diffState.files.length}`
1302
+ const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
1303
+ const diffHeight = patchRenderableLineCount(file.patch, view)
1304
+
1305
+ return (
1306
+ <box height={height} flexDirection="column">
1307
+ <box height={1} paddingLeft={1} paddingRight={1}>
1308
+ <TextLine>
1309
+ <span fg={colors.count}>#{pullRequest.number}</span>
1310
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1311
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1312
+ <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1313
+ <span fg={colors.muted}> </span>
1314
+ <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1315
+ <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1316
+ </TextLine>
1317
+ </box>
1318
+ <box height={1} paddingLeft={1} paddingRight={1}>
1319
+ <TextLine>
1320
+ <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
1321
+ <span fg={colors.muted}> {fileCounter}</span>
1322
+ </TextLine>
1323
+ </box>
1324
+ <Divider width={paneWidth} />
1325
+ <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
1326
+ <diff
1327
+ key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
1328
+ diff={file.patch}
1329
+ view={view}
1330
+ syncScroll
1331
+ filetype={file.filetype ?? "text"}
1332
+ syntaxStyle={diffSyntaxStyle}
1333
+ showLineNumbers
1334
+ wrapMode={wrapMode}
1335
+ addedBg="#17351f"
1336
+ removedBg="#3a1e22"
1337
+ contextBg="transparent"
1338
+ addedSignColor={colors.status.passing}
1339
+ removedSignColor={colors.status.failing}
1340
+ lineNumberFg={colors.muted}
1341
+ lineNumberBg="#151515"
1342
+ addedLineNumberBg="#12301a"
1343
+ removedLineNumberBg="#35171b"
1344
+ selectionBg={colors.selectedBg}
1345
+ selectionFg={colors.selectedText}
1346
+ height={diffHeight}
1347
+ style={{ flexShrink: 0 }}
1348
+ />
1349
+ </scrollbox>
1350
+ </box>
1351
+ )
1352
+ }
1353
+
996
1354
  const LabelModal = ({
997
1355
  state,
998
1356
  currentLabels,
@@ -1066,17 +1424,17 @@ const LabelModal = ({
1066
1424
  const isActive = currentNames.has(label.name.toLowerCase())
1067
1425
  const isSelected = actualIndex === selectedIndex
1068
1426
  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)
1427
+ const statusText = status.length > 0 ? ` ${status}` : ""
1428
+ const nameWidth = Math.max(1, contentWidth - 5 - statusText.length)
1071
1429
  return (
1072
1430
  <box key={label.name} height={1}>
1073
1431
  <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>
1432
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "" : " "}</span>
1433
+ <span> </span>
1076
1434
  <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}
1435
+ <span> </span>
1436
+ <span fg={isSelected ? colors.selectedText : colors.text}>{trimCell(label.name, nameWidth)}</span>
1437
+ {statusText ? <span fg={colors.status.passing}>{statusText}</span> : null}
1080
1438
  </TextLine>
1081
1439
  </box>
1082
1440
  )
@@ -1091,7 +1449,7 @@ const LabelModal = ({
1091
1449
  <span fg={colors.muted}> move </span>
1092
1450
  <span fg={colors.count}>enter</span>
1093
1451
  <span fg={colors.muted}> toggle </span>
1094
- <span fg={colors.count}>/type</span>
1452
+ <span fg={colors.count}>/</span>
1095
1453
  <span fg={colors.muted}> filter </span>
1096
1454
  <span fg={colors.count}>esc</span>
1097
1455
  <span fg={colors.muted}> close</span>
@@ -1114,6 +1472,11 @@ export const App = () => {
1114
1472
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
1115
1473
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
1116
1474
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
1475
+ const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
1476
+ const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
1477
+ const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
1478
+ const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
1479
+ const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
1117
1480
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
1118
1481
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
1119
1482
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
@@ -1124,6 +1487,7 @@ export const App = () => {
1124
1487
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1125
1488
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1126
1489
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1490
+ const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
1127
1491
  const groupIcon = GROUP_ICON
1128
1492
  const contentWidth = Math.max(60, width ?? 100)
1129
1493
  const isWideLayout = (width ?? 100) >= 100
@@ -1138,6 +1502,8 @@ export const App = () => {
1138
1502
  const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
1139
1503
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1140
1504
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1505
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1506
+ const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1141
1507
  const headerFooterWidth = Math.max(24, contentWidth - 2)
1142
1508
 
1143
1509
  const flashNotice = (message: string) => {
@@ -1237,7 +1603,13 @@ export const App = () => {
1237
1603
  })
1238
1604
  }, [visiblePullRequests.length])
1239
1605
 
1606
+ useEffect(() => {
1607
+ setDiffFileIndex(0)
1608
+ }, [selectedIndex])
1609
+
1240
1610
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1611
+ const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
1612
+ const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
1241
1613
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1242
1614
  const detailPlaceholderContent = getDetailPlaceholderContent({
1243
1615
  status: pullRequestStatus,
@@ -1259,6 +1631,38 @@ export const App = () => {
1259
1631
 
1260
1632
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1261
1633
 
1634
+ const loadPullRequestDiff = (pullRequest: PullRequestItem, force = false) => {
1635
+ const key = pullRequestDiffKey(pullRequest)
1636
+ const existing = pullRequestDiffCache[key]
1637
+ if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
1638
+
1639
+ setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
1640
+ void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
1641
+ .then((patch) => {
1642
+ setPullRequestDiffCache((current) => ({
1643
+ ...current,
1644
+ [key]: { status: "ready", patch, files: splitPatchFiles(patch) },
1645
+ }))
1646
+ })
1647
+ .catch((error) => {
1648
+ setPullRequestDiffCache((current) => ({
1649
+ ...current,
1650
+ [key]: { status: "error", error: errorMessage(error) },
1651
+ }))
1652
+ flashNotice(errorMessage(error))
1653
+ })
1654
+ }
1655
+
1656
+ const openDiffView = () => {
1657
+ if (!selectedPullRequest) return
1658
+ setDiffFullView(true)
1659
+ setDetailFullView(false)
1660
+ setDiffFileIndex(0)
1661
+ setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1662
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1663
+ loadPullRequestDiff(selectedPullRequest)
1664
+ }
1665
+
1262
1666
  const openLabelModal = () => {
1263
1667
  if (!selectedPullRequest) return
1264
1668
  const repository = selectedPullRequest.repository
@@ -1385,6 +1789,100 @@ export const App = () => {
1385
1789
  return
1386
1790
  }
1387
1791
 
1792
+ if (diffFullView) {
1793
+ if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1794
+ setDiffFullView(false)
1795
+ return
1796
+ }
1797
+ if (key.name === "home") {
1798
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1799
+ return
1800
+ }
1801
+ if (key.name === "end") {
1802
+ diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1803
+ return
1804
+ }
1805
+ if (key.name === "pageup") {
1806
+ diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1807
+ return
1808
+ }
1809
+ if (key.name === "pagedown") {
1810
+ diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1811
+ return
1812
+ }
1813
+ if (isShiftG(key)) {
1814
+ diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1815
+ setPendingG(false)
1816
+ if (pendingGTimeoutRef.current !== null) {
1817
+ clearTimeout(pendingGTimeoutRef.current)
1818
+ pendingGTimeoutRef.current = null
1819
+ }
1820
+ return
1821
+ }
1822
+ if (key.name === "g") {
1823
+ if (pendingG) {
1824
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1825
+ setPendingG(false)
1826
+ if (pendingGTimeoutRef.current !== null) {
1827
+ clearTimeout(pendingGTimeoutRef.current)
1828
+ pendingGTimeoutRef.current = null
1829
+ }
1830
+ } else {
1831
+ setPendingG(true)
1832
+ pendingGTimeoutRef.current = setTimeout(() => {
1833
+ setPendingG(false)
1834
+ pendingGTimeoutRef.current = null
1835
+ }, 500)
1836
+ }
1837
+ return
1838
+ }
1839
+ if (key.name === "up" || key.name === "k") {
1840
+ diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
1841
+ return
1842
+ }
1843
+ if (key.name === "down" || key.name === "j") {
1844
+ diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
1845
+ return
1846
+ }
1847
+ if (key.ctrl && key.name === "u") {
1848
+ diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1849
+ return
1850
+ }
1851
+ if (key.ctrl && (key.name === "d" || key.name === "v")) {
1852
+ diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1853
+ return
1854
+ }
1855
+ if (key.name === "v") {
1856
+ setDiffRenderView((current) => current === "unified" ? "split" : "unified")
1857
+ return
1858
+ }
1859
+ if (key.name === "w") {
1860
+ setDiffWrapMode((current) => current === "none" ? "word" : "none")
1861
+ return
1862
+ }
1863
+ if (key.name === "r" && selectedPullRequest) {
1864
+ loadPullRequestDiff(selectedPullRequest, true)
1865
+ flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1866
+ return
1867
+ }
1868
+ if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
1869
+ setDiffFileIndex((current) => Math.min(Math.max(0, selectedDiffState.files.length - 1), current + 1))
1870
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1871
+ return
1872
+ }
1873
+ if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
1874
+ setDiffFileIndex((current) => Math.max(0, current - 1))
1875
+ diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1876
+ return
1877
+ }
1878
+ if (key.name === "o" && selectedPullRequest) {
1879
+ void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1880
+ flashNotice(`Opened #${selectedPullRequest.number} in browser`)
1881
+ return
1882
+ }
1883
+ return
1884
+ }
1885
+
1388
1886
  // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1389
1887
  if (detailFullView) {
1390
1888
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
@@ -1392,19 +1890,66 @@ export const App = () => {
1392
1890
  setDetailScrollOffset(0)
1393
1891
  return
1394
1892
  }
1893
+ if (key.name === "home") {
1894
+ detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1895
+ setDetailScrollOffset(0)
1896
+ return
1897
+ }
1898
+ if (key.name === "end" || isShiftG(key)) {
1899
+ detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1900
+ setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
1901
+ setPendingG(false)
1902
+ if (pendingGTimeoutRef.current !== null) {
1903
+ clearTimeout(pendingGTimeoutRef.current)
1904
+ pendingGTimeoutRef.current = null
1905
+ }
1906
+ return
1907
+ }
1908
+ if (key.name === "pageup") {
1909
+ detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1910
+ setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1911
+ return
1912
+ }
1913
+ if (key.name === "pagedown") {
1914
+ detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1915
+ setDetailScrollOffset((current) => current + halfPage)
1916
+ return
1917
+ }
1918
+ if (key.name === "g") {
1919
+ if (pendingG) {
1920
+ detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1921
+ setDetailScrollOffset(0)
1922
+ setPendingG(false)
1923
+ if (pendingGTimeoutRef.current !== null) {
1924
+ clearTimeout(pendingGTimeoutRef.current)
1925
+ pendingGTimeoutRef.current = null
1926
+ }
1927
+ } else {
1928
+ setPendingG(true)
1929
+ pendingGTimeoutRef.current = setTimeout(() => {
1930
+ setPendingG(false)
1931
+ pendingGTimeoutRef.current = null
1932
+ }, 500)
1933
+ }
1934
+ return
1935
+ }
1395
1936
  if (key.name === "up" || key.name === "k") {
1937
+ detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1396
1938
  setDetailScrollOffset((current) => Math.max(0, current - 1))
1397
1939
  return
1398
1940
  }
1399
1941
  if (key.name === "down" || key.name === "j") {
1942
+ detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
1400
1943
  setDetailScrollOffset((current) => current + 1)
1401
1944
  return
1402
1945
  }
1403
1946
  if (key.ctrl && key.name === "u") {
1947
+ detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1404
1948
  setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1405
1949
  return
1406
1950
  }
1407
1951
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
1952
+ detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1408
1953
  setDetailScrollOffset((current) => current + halfPage)
1409
1954
  return
1410
1955
  }
@@ -1523,7 +2068,7 @@ export const App = () => {
1523
2068
  return
1524
2069
  }
1525
2070
  // Vim-style navigation: gg to go to top, G to go to bottom
1526
- if (key.name === "G" || key.name === "g" && key.shift) {
2071
+ if (isShiftG(key)) {
1527
2072
  setSelectedIndex((_current) => {
1528
2073
  if (visiblePullRequests.length === 0) return 0
1529
2074
  return visiblePullRequests.length - 1
@@ -1552,6 +2097,10 @@ export const App = () => {
1552
2097
  setDetailScrollOffset(0)
1553
2098
  return
1554
2099
  }
2100
+ if (key.name === "p" && selectedPullRequest) {
2101
+ openDiffView()
2102
+ return
2103
+ }
1555
2104
  if (key.name === "l" && selectedPullRequest) {
1556
2105
  openLabelModal()
1557
2106
  return
@@ -1604,7 +2153,8 @@ export const App = () => {
1604
2153
  onSelectPullRequest: selectPullRequestByUrl,
1605
2154
  } as const
1606
2155
 
1607
- const labelModalWidth = Math.min(40, contentWidth - 4)
2156
+ const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
2157
+ const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1608
2158
  const labelModalHeight = Math.min(20, (height ?? 24) - 4)
1609
2159
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1610
2160
  const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
@@ -1614,16 +2164,28 @@ export const App = () => {
1614
2164
  <box paddingLeft={1} paddingRight={1} flexDirection="column">
1615
2165
  <PlainLine text={headerLine} fg={colors.muted} bold />
1616
2166
  </box>
1617
- {isWideLayout && !detailFullView && !isInitialLoading ? (
2167
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1618
2168
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
1619
2169
  ) : (
1620
2170
  <Divider width={contentWidth} />
1621
2171
  )}
1622
2172
  {isInitialLoading ? (
1623
2173
  <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
2174
+ ) : diffFullView ? (
2175
+ <PullRequestDiffPane
2176
+ pullRequest={selectedPullRequest}
2177
+ diffState={selectedDiffState}
2178
+ fileIndex={diffFileIndex}
2179
+ view={effectiveDiffRenderView}
2180
+ wrapMode={diffWrapMode}
2181
+ paneWidth={contentWidth}
2182
+ height={wideBodyHeight}
2183
+ loadingIndicator={loadingIndicator}
2184
+ scrollRef={diffScrollRef}
2185
+ />
1624
2186
  ) : isWideLayout && detailFullView ? (
1625
2187
  <box flexGrow={1} flexDirection="column">
1626
- <scrollbox flexGrow={1}>
2188
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1627
2189
  <DetailsPane
1628
2190
  pullRequest={selectedPullRequest}
1629
2191
  contentWidth={fullscreenContentWidth}
@@ -1657,7 +2219,7 @@ export const App = () => {
1657
2219
  </box>
1658
2220
  ) : detailFullView ? (
1659
2221
  <box flexGrow={1} flexDirection="column">
1660
- <scrollbox flexGrow={1}>
2222
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1661
2223
  <DetailsPane
1662
2224
  pullRequest={selectedPullRequest}
1663
2225
  contentWidth={fullscreenContentWidth}
@@ -1681,7 +2243,7 @@ export const App = () => {
1681
2243
  </>
1682
2244
  )}
1683
2245
 
1684
- {isWideLayout && !detailFullView && !isInitialLoading ? (
2246
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1685
2247
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
1686
2248
  ) : (
1687
2249
  <Divider width={contentWidth} />
@@ -1694,6 +2256,7 @@ export const App = () => {
1694
2256
  filterEditing={filterMode}
1695
2257
  showFilterClear={filterMode || filterQuery.length > 0}
1696
2258
  detailFullView={detailFullView}
2259
+ diffFullView={diffFullView}
1697
2260
  hasSelection={selectedPullRequest !== null}
1698
2261
  hasError={pullRequestStatus === "error"}
1699
2262
  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"
@@ -11,6 +11,9 @@ interface GitHubListPullRequest {
11
11
  readonly name: string
12
12
  readonly color?: string | null
13
13
  }[]
14
+ readonly additions: number
15
+ readonly deletions: number
16
+ readonly changedFiles: number
14
17
  readonly isDraft: boolean
15
18
  readonly reviewDecision: string
16
19
  readonly statusCheckRollup: readonly {
@@ -38,7 +41,7 @@ interface GitHubViewer {
38
41
  }
39
42
 
40
43
  const searchJsonFields = "repository,number"
41
- const detailJsonFields = "number,title,body,labels,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
44
+ const detailJsonFields = "number,title,body,labels,additions,deletions,changedFiles,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
42
45
 
43
46
  const normalizeDate = (value: string | null | undefined) => {
44
47
  if (!value || value.startsWith("0001-01-01")) return null
@@ -126,6 +129,9 @@ const parsePullRequest = (repository: string, item: GitHubListPullRequest): Pull
126
129
  name: label.name,
127
130
  color: label.color ? `#${label.color}` : null,
128
131
  })),
132
+ additions: item.additions,
133
+ deletions: item.deletions,
134
+ changedFiles: item.changedFiles,
129
135
  state: item.state.toLowerCase() === "open" ? "open" : "closed",
130
136
  reviewStatus: getReviewStatus(item),
131
137
  checkStatus: checkInfo.checkStatus,
@@ -159,6 +165,7 @@ type GitHubError = CommandError | JsonParseError
159
165
  export class GitHubService extends Context.Service<GitHubService, {
160
166
  readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
161
167
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
168
+ readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
162
169
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
163
170
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
164
171
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -191,6 +198,11 @@ export class GitHubService extends Context.Service<GitHubService, {
191
198
  return viewer.login
192
199
  })
193
200
 
201
+ const getPullRequestDiff = Effect.fn("GitHubService.getPullRequestDiff")(function*(repository: string, number: number) {
202
+ const result = yield* command.run("gh", ["pr", "diff", String(number), "--repo", repository, "--color", "never"])
203
+ return result.stdout
204
+ })
205
+
194
206
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
195
207
  yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
196
208
  })
@@ -213,6 +225,7 @@ export class GitHubService extends Context.Service<GitHubService, {
213
225
  return GitHubService.of({
214
226
  listOpenPullRequests,
215
227
  getAuthenticatedUser,
228
+ getPullRequestDiff,
216
229
  toggleDraftStatus,
217
230
  listRepoLabels,
218
231
  addPullRequestLabel,