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