@kitlangton/ghui 0.1.2 → 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.2",
3
+ "version": "0.1.4",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  ],
29
29
  "publishConfig": {
30
30
  "access": "public",
31
+ "provenance": true,
31
32
  "registry": "https://registry.npmjs.org/"
32
33
  },
33
34
  "bin": {
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,25 @@ 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
+
80
+ interface DetailPlaceholderInput {
81
+ readonly status: LoadStatus
82
+ readonly retryProgress: RetryProgress | null
83
+ readonly loadingIndicator: string
84
+ readonly visibleCount: number
85
+ readonly filterText: string
86
+ }
87
+
69
88
  const pullRequestReferencePattern = /(#[0-9]+)/g
70
89
  const PR_FETCH_RETRIES = 6
71
90
  const DETAIL_PLACEHOLDER_ROWS = 4
@@ -100,6 +119,11 @@ const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
100
119
  const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
101
120
  const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
102
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)
103
127
 
104
128
  const GROUP_ICON = "◆"
105
129
 
@@ -142,6 +166,9 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
142
166
  const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
143
167
  GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
144
168
  )
169
+ const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
170
+ GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
171
+ )
145
172
 
146
173
  const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
147
174
 
@@ -192,8 +219,8 @@ const getRowLayout = (contentWidth: number, numberWidth = 6) => {
192
219
  const reviewWidth = 1
193
220
  const checkWidth = 6
194
221
  const ageWidth = 4
195
- const leftWidth = Math.max(24, contentWidth - reviewWidth - checkWidth - ageWidth - 2) // -2 for spaces between columns
196
- 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)
197
224
  return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
198
225
  }
199
226
 
@@ -208,6 +235,8 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
208
235
  return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
209
236
  }
210
237
 
238
+ const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
239
+
211
240
  const centerCell = (text: string, width: number) => {
212
241
  const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
213
242
  const left = Math.floor((width - trimmed.length) / 2)
@@ -308,6 +337,195 @@ const labelTextColor = (color: string) => {
308
337
  return "#f8fafc"
309
338
  }
310
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
+
488
+ const getDetailPlaceholderContent = ({
489
+ status,
490
+ retryProgress,
491
+ loadingIndicator,
492
+ visibleCount,
493
+ filterText,
494
+ }: DetailPlaceholderInput): DetailPlaceholderContent => {
495
+ if (status === "loading") {
496
+ return {
497
+ title: `${loadingIndicator} Loading pull requests`,
498
+ hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
499
+ }
500
+ }
501
+
502
+ if (status === "error") {
503
+ return {
504
+ title: "Could not load pull requests",
505
+ hint: "Press r to retry",
506
+ }
507
+ }
508
+
509
+ if (visibleCount === 0 && filterText.length > 0) {
510
+ return {
511
+ title: "No matching pull requests",
512
+ hint: "Press esc to clear the filter",
513
+ }
514
+ }
515
+
516
+ if (visibleCount === 0) {
517
+ return {
518
+ title: "No open pull requests",
519
+ hint: "Press r to refresh",
520
+ }
521
+ }
522
+
523
+ return {
524
+ title: "Select a pull request",
525
+ hint: "Use up/down to move",
526
+ }
527
+ }
528
+
311
529
  const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
312
530
  const sourceLines = body.replace(/\r/g, "").split("\n")
313
531
  const preview: Array<PreviewLine> = []
@@ -450,6 +668,7 @@ const FooterHints = ({
450
668
  filterEditing,
451
669
  showFilterClear,
452
670
  detailFullView,
671
+ diffFullView,
453
672
  hasSelection,
454
673
  hasError,
455
674
  isLoading,
@@ -459,6 +678,7 @@ const FooterHints = ({
459
678
  filterEditing: boolean
460
679
  showFilterClear: boolean
461
680
  detailFullView: boolean
681
+ diffFullView: boolean
462
682
  hasSelection: boolean
463
683
  hasError: boolean
464
684
  isLoading: boolean
@@ -484,6 +704,52 @@ const FooterHints = ({
484
704
  )
485
705
  }
486
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
+
487
753
  return (
488
754
  <TextLine>
489
755
  <span fg={colors.count}>/</span>
@@ -528,6 +794,8 @@ const FooterHints = ({
528
794
  <>
529
795
  <span fg={colors.count}>d</span>
530
796
  <span fg={colors.muted}> draft </span>
797
+ <span fg={colors.count}>p</span>
798
+ <span fg={colors.muted}> diff </span>
531
799
  <span fg={colors.count}>l</span>
532
800
  <span fg={colors.muted}> labels </span>
533
801
  <span fg={colors.count}>o</span>
@@ -565,6 +833,8 @@ const PullRequestRow = ({
565
833
  const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
566
834
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
567
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)
568
838
 
569
839
  return (
570
840
  <box height={1} onMouseDown={onSelect}>
@@ -576,6 +846,7 @@ const PullRequestRow = ({
576
846
  <span>{fitCell(pullRequest.title, titleWidth)}</span>
577
847
  <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
578
848
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
849
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
579
850
  </TextLine>
580
851
  </box>
581
852
  )
@@ -758,6 +1029,12 @@ const DetailHeader = ({
758
1029
  const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
759
1030
  const unique = deduplicateChecks(pullRequest.checks)
760
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)
761
1038
 
762
1039
  return (
763
1040
  <>
@@ -800,6 +1077,15 @@ const DetailHeader = ({
800
1077
  <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
801
1078
  </Fragment>
802
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}
803
1089
  </TextLine>
804
1090
  </box>
805
1091
  <box height={1}><Divider width={paneWidth} /></box>
@@ -850,8 +1136,8 @@ const DetailBody = ({
850
1136
  )
851
1137
  }
852
1138
 
853
- const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => {
854
- const innerWidth = Math.max(1, paneWidth - 2)
1139
+ const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
1140
+ const innerWidth = Math.max(1, width - 2)
855
1141
  const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
856
1142
  const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
857
1143
  const cardInnerWidth = Math.max(1, cardWidth - 2)
@@ -868,14 +1154,31 @@ const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderC
868
1154
  )
869
1155
 
870
1156
  return (
871
- <box flexDirection="column">
872
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
873
- <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
874
- {contentLine(content.title, colors.count, true)}
875
- {contentLine(content.hint, colors.muted)}
876
- <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
877
- </box>
878
- <box height={1}><Divider width={paneWidth} /></box>
1157
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1158
+ <PlainLine text={`${offset}┌${"".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
1159
+ {contentLine(content.title, colors.count, true)}
1160
+ {contentLine(content.hint, colors.muted)}
1161
+ <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
1162
+ </box>
1163
+ )
1164
+ }
1165
+
1166
+ const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
1167
+ <box flexDirection="column">
1168
+ <StatusCard content={content} width={paneWidth} />
1169
+ <box height={1}><Divider width={paneWidth} /></box>
1170
+ </box>
1171
+ )
1172
+
1173
+ const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
1174
+ const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
1175
+ const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
1176
+
1177
+ return (
1178
+ <box height={height} flexDirection="column">
1179
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
1180
+ <StatusCard content={content} width={width} />
1181
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
879
1182
  </box>
880
1183
  )
881
1184
  }
@@ -927,6 +1230,127 @@ const DetailsPane = ({
927
1230
  )
928
1231
  }
929
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
+
930
1354
  const LabelModal = ({
931
1355
  state,
932
1356
  currentLabels,
@@ -1000,17 +1424,17 @@ const LabelModal = ({
1000
1424
  const isActive = currentNames.has(label.name.toLowerCase())
1001
1425
  const isSelected = actualIndex === selectedIndex
1002
1426
  const status = isActive ? "added" : ""
1003
- const nameWidth = Math.max(8, contentWidth - 10 - status.length)
1004
- 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)
1005
1429
  return (
1006
1430
  <box key={label.name} height={1}>
1007
1431
  <TextLine bg={isSelected ? colors.selectedBg : undefined}>
1008
- <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "" : " "}</span>
1009
- <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
1432
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "" : " "}</span>
1433
+ <span> </span>
1010
1434
  <span bg={labelColor(label)}> </span>
1011
- <span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
1012
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
1013
- {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}
1014
1438
  </TextLine>
1015
1439
  </box>
1016
1440
  )
@@ -1025,7 +1449,7 @@ const LabelModal = ({
1025
1449
  <span fg={colors.muted}> move </span>
1026
1450
  <span fg={colors.count}>enter</span>
1027
1451
  <span fg={colors.muted}> toggle </span>
1028
- <span fg={colors.count}>/type</span>
1452
+ <span fg={colors.count}>/</span>
1029
1453
  <span fg={colors.muted}> filter </span>
1030
1454
  <span fg={colors.count}>esc</span>
1031
1455
  <span fg={colors.muted}> close</span>
@@ -1048,6 +1472,11 @@ export const App = () => {
1048
1472
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
1049
1473
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
1050
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)
1051
1480
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
1052
1481
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
1053
1482
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
@@ -1058,6 +1487,7 @@ export const App = () => {
1058
1487
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1059
1488
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1060
1489
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1490
+ const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
1061
1491
  const groupIcon = GROUP_ICON
1062
1492
  const contentWidth = Math.max(60, width ?? 100)
1063
1493
  const isWideLayout = (width ?? 100) >= 100
@@ -1072,6 +1502,8 @@ export const App = () => {
1072
1502
  const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
1073
1503
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1074
1504
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1505
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1506
+ const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1075
1507
  const headerFooterWidth = Math.max(24, contentWidth - 2)
1076
1508
 
1077
1509
  const flashNotice = (message: string) => {
@@ -1100,6 +1532,7 @@ export const App = () => {
1100
1532
  : AsyncResult.isFailure(pullRequestResult)
1101
1533
  ? "error"
1102
1534
  : "ready"
1535
+ const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
1103
1536
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
1104
1537
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
1105
1538
 
@@ -1170,32 +1603,21 @@ export const App = () => {
1170
1603
  })
1171
1604
  }, [visiblePullRequests.length])
1172
1605
 
1606
+ useEffect(() => {
1607
+ setDiffFileIndex(0)
1608
+ }, [selectedIndex])
1609
+
1173
1610
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1611
+ const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
1612
+ const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
1174
1613
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1175
- const detailPlaceholderContent: DetailPlaceholderContent = pullRequestStatus === "loading"
1176
- ? {
1177
- title: `${loadingIndicator} Loading pull requests`,
1178
- hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
1179
- }
1180
- : pullRequestStatus === "error"
1181
- ? {
1182
- title: "Could not load pull requests",
1183
- hint: "Press r to retry",
1184
- }
1185
- : visiblePullRequests.length === 0 && visibleFilterText.length > 0
1186
- ? {
1187
- title: "No matching pull requests",
1188
- hint: "Press esc to clear the filter",
1189
- }
1190
- : visiblePullRequests.length === 0
1191
- ? {
1192
- title: "No open pull requests",
1193
- hint: "Press r to refresh",
1194
- }
1195
- : {
1196
- title: "Select a pull request",
1197
- hint: "Use up/down to move",
1198
- }
1614
+ const detailPlaceholderContent = getDetailPlaceholderContent({
1615
+ status: pullRequestStatus,
1616
+ retryProgress,
1617
+ loadingIndicator,
1618
+ visibleCount: visiblePullRequests.length,
1619
+ filterText: visibleFilterText,
1620
+ })
1199
1621
  const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
1200
1622
  const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
1201
1623
  const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
@@ -1209,6 +1631,38 @@ export const App = () => {
1209
1631
 
1210
1632
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1211
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
+
1212
1666
  const openLabelModal = () => {
1213
1667
  if (!selectedPullRequest) return
1214
1668
  const repository = selectedPullRequest.repository
@@ -1335,6 +1789,100 @@ export const App = () => {
1335
1789
  return
1336
1790
  }
1337
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
+
1338
1886
  // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1339
1887
  if (detailFullView) {
1340
1888
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
@@ -1342,19 +1890,66 @@ export const App = () => {
1342
1890
  setDetailScrollOffset(0)
1343
1891
  return
1344
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
+ }
1345
1936
  if (key.name === "up" || key.name === "k") {
1937
+ detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1346
1938
  setDetailScrollOffset((current) => Math.max(0, current - 1))
1347
1939
  return
1348
1940
  }
1349
1941
  if (key.name === "down" || key.name === "j") {
1942
+ detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
1350
1943
  setDetailScrollOffset((current) => current + 1)
1351
1944
  return
1352
1945
  }
1353
1946
  if (key.ctrl && key.name === "u") {
1947
+ detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1354
1948
  setDetailScrollOffset((current) => Math.max(0, current - halfPage))
1355
1949
  return
1356
1950
  }
1357
1951
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
1952
+ detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1358
1953
  setDetailScrollOffset((current) => current + halfPage)
1359
1954
  return
1360
1955
  }
@@ -1473,7 +2068,7 @@ export const App = () => {
1473
2068
  return
1474
2069
  }
1475
2070
  // Vim-style navigation: gg to go to top, G to go to bottom
1476
- if (key.name === "G" || key.name === "g" && key.shift) {
2071
+ if (isShiftG(key)) {
1477
2072
  setSelectedIndex((_current) => {
1478
2073
  if (visiblePullRequests.length === 0) return 0
1479
2074
  return visiblePullRequests.length - 1
@@ -1502,6 +2097,10 @@ export const App = () => {
1502
2097
  setDetailScrollOffset(0)
1503
2098
  return
1504
2099
  }
2100
+ if (key.name === "p" && selectedPullRequest) {
2101
+ openDiffView()
2102
+ return
2103
+ }
1505
2104
  if (key.name === "l" && selectedPullRequest) {
1506
2105
  openLabelModal()
1507
2106
  return
@@ -1554,7 +2153,8 @@ export const App = () => {
1554
2153
  onSelectPullRequest: selectPullRequestByUrl,
1555
2154
  } as const
1556
2155
 
1557
- 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)
1558
2158
  const labelModalHeight = Math.min(20, (height ?? 24) - 4)
1559
2159
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1560
2160
  const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
@@ -1564,14 +2164,28 @@ export const App = () => {
1564
2164
  <box paddingLeft={1} paddingRight={1} flexDirection="column">
1565
2165
  <PlainLine text={headerLine} fg={colors.muted} bold />
1566
2166
  </box>
1567
- {isWideLayout && !detailFullView ? (
2167
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1568
2168
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
1569
2169
  ) : (
1570
2170
  <Divider width={contentWidth} />
1571
2171
  )}
1572
- {isWideLayout && detailFullView ? (
2172
+ {isInitialLoading ? (
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
+ />
2186
+ ) : isWideLayout && detailFullView ? (
1573
2187
  <box flexGrow={1} flexDirection="column">
1574
- <scrollbox flexGrow={1}>
2188
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1575
2189
  <DetailsPane
1576
2190
  pullRequest={selectedPullRequest}
1577
2191
  contentWidth={fullscreenContentWidth}
@@ -1605,7 +2219,7 @@ export const App = () => {
1605
2219
  </box>
1606
2220
  ) : detailFullView ? (
1607
2221
  <box flexGrow={1} flexDirection="column">
1608
- <scrollbox flexGrow={1}>
2222
+ <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1609
2223
  <DetailsPane
1610
2224
  pullRequest={selectedPullRequest}
1611
2225
  contentWidth={fullscreenContentWidth}
@@ -1629,7 +2243,7 @@ export const App = () => {
1629
2243
  </>
1630
2244
  )}
1631
2245
 
1632
- {isWideLayout && !detailFullView ? (
2246
+ {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
1633
2247
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
1634
2248
  ) : (
1635
2249
  <Divider width={contentWidth} />
@@ -1642,6 +2256,7 @@ export const App = () => {
1642
2256
  filterEditing={filterMode}
1643
2257
  showFilterClear={filterMode || filterQuery.length > 0}
1644
2258
  detailFullView={detailFullView}
2259
+ diffFullView={diffFullView}
1645
2260
  hasSelection={selectedPullRequest !== null}
1646
2261
  hasError={pullRequestStatus === "error"}
1647
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,