@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 +1 -1
- package/src/App.tsx +580 -17
- package/src/domain.ts +3 -0
- package/src/services/GitHubService.ts +14 -1
package/package.json
CHANGED
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
|
|
204
|
-
const titleWidth = Math.max(8,
|
|
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
|
|
1070
|
-
const
|
|
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={
|
|
1075
|
-
<span
|
|
1432
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "✓" : " "}</span>
|
|
1433
|
+
<span> </span>
|
|
1076
1434
|
<span bg={labelColor(label)}> </span>
|
|
1077
|
-
<span
|
|
1078
|
-
<span fg={colors.
|
|
1079
|
-
{
|
|
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}
|
|
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
|
|
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
|
|
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,
|