@kitlangton/ghui 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/App.tsx +667 -52
- package/src/domain.ts +3 -0
- package/src/services/GitHubService.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/ghui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Terminal UI for GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public",
|
|
31
|
+
"provenance": true,
|
|
31
32
|
"registry": "https://registry.npmjs.org/"
|
|
32
33
|
},
|
|
33
34
|
"bin": {
|
package/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core"
|
|
1
|
+
import { parseColor, SyntaxStyle, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
3
3
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
4
4
|
import { Cause, Effect, Schedule } from "effect"
|
|
@@ -66,6 +66,25 @@ interface RetryProgress {
|
|
|
66
66
|
readonly max: number
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
interface DiffFilePatch {
|
|
70
|
+
readonly name: string
|
|
71
|
+
readonly filetype: string | undefined
|
|
72
|
+
readonly patch: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type PullRequestDiffState =
|
|
76
|
+
| { readonly status: "loading" }
|
|
77
|
+
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
78
|
+
| { readonly status: "error"; readonly error: string }
|
|
79
|
+
|
|
80
|
+
interface DetailPlaceholderInput {
|
|
81
|
+
readonly status: LoadStatus
|
|
82
|
+
readonly retryProgress: RetryProgress | null
|
|
83
|
+
readonly loadingIndicator: string
|
|
84
|
+
readonly visibleCount: number
|
|
85
|
+
readonly filterText: string
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
70
89
|
const PR_FETCH_RETRIES = 6
|
|
71
90
|
const DETAIL_PLACEHOLDER_ROWS = 4
|
|
@@ -100,6 +119,11 @@ const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
|
100
119
|
const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
101
120
|
const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
102
121
|
const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
122
|
+
const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
123
|
+
const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
124
|
+
const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
|
|
125
|
+
const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
|
|
126
|
+
const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
|
|
103
127
|
|
|
104
128
|
const GROUP_ICON = "◆"
|
|
105
129
|
|
|
@@ -142,6 +166,9 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
|
|
|
142
166
|
const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
|
|
143
167
|
GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
|
|
144
168
|
)
|
|
169
|
+
const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
|
|
170
|
+
GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
|
|
171
|
+
)
|
|
145
172
|
|
|
146
173
|
const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
147
174
|
|
|
@@ -192,8 +219,8 @@ const getRowLayout = (contentWidth: number, numberWidth = 6) => {
|
|
|
192
219
|
const reviewWidth = 1
|
|
193
220
|
const checkWidth = 6
|
|
194
221
|
const ageWidth = 4
|
|
195
|
-
const
|
|
196
|
-
const titleWidth = Math.max(8,
|
|
222
|
+
const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
|
|
223
|
+
const titleWidth = Math.max(8, contentWidth - fixedWidth)
|
|
197
224
|
return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
|
|
198
225
|
}
|
|
199
226
|
|
|
@@ -208,6 +235,8 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
|
|
|
208
235
|
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
209
236
|
}
|
|
210
237
|
|
|
238
|
+
const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
239
|
+
|
|
211
240
|
const centerCell = (text: string, width: number) => {
|
|
212
241
|
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
213
242
|
const left = Math.floor((width - trimmed.length) / 2)
|
|
@@ -308,6 +337,195 @@ const labelTextColor = (color: string) => {
|
|
|
308
337
|
return "#f8fafc"
|
|
309
338
|
}
|
|
310
339
|
|
|
340
|
+
const diffSyntaxStyle = SyntaxStyle.fromStyles({
|
|
341
|
+
keyword: { fg: parseColor("#f4a51c"), bold: true },
|
|
342
|
+
"keyword.import": { fg: parseColor("#f4a51c"), bold: true },
|
|
343
|
+
string: { fg: parseColor("#d7c5a1") },
|
|
344
|
+
comment: { fg: parseColor(colors.muted), italic: true },
|
|
345
|
+
number: { fg: parseColor("#93c5fd") },
|
|
346
|
+
boolean: { fg: parseColor("#93c5fd") },
|
|
347
|
+
constant: { fg: parseColor("#93c5fd") },
|
|
348
|
+
function: { fg: parseColor("#7dd3a3") },
|
|
349
|
+
"function.call": { fg: parseColor("#7dd3a3") },
|
|
350
|
+
constructor: { fg: parseColor("#f59e0b") },
|
|
351
|
+
type: { fg: parseColor("#f59e0b") },
|
|
352
|
+
operator: { fg: parseColor("#f87171") },
|
|
353
|
+
variable: { fg: parseColor(colors.text) },
|
|
354
|
+
property: { fg: parseColor("#93c5fd") },
|
|
355
|
+
bracket: { fg: parseColor(colors.text) },
|
|
356
|
+
punctuation: { fg: parseColor(colors.text) },
|
|
357
|
+
default: { fg: parseColor(colors.text) },
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const extensionFiletypes: Record<string, string> = {
|
|
361
|
+
c: "c",
|
|
362
|
+
cc: "cpp",
|
|
363
|
+
cpp: "cpp",
|
|
364
|
+
cs: "csharp",
|
|
365
|
+
css: "css",
|
|
366
|
+
go: "go",
|
|
367
|
+
h: "c",
|
|
368
|
+
hpp: "cpp",
|
|
369
|
+
html: "html",
|
|
370
|
+
java: "java",
|
|
371
|
+
js: "javascript",
|
|
372
|
+
jsx: "javascript",
|
|
373
|
+
json: "json",
|
|
374
|
+
kt: "kotlin",
|
|
375
|
+
md: "markdown",
|
|
376
|
+
mjs: "javascript",
|
|
377
|
+
py: "python",
|
|
378
|
+
rs: "rust",
|
|
379
|
+
rb: "ruby",
|
|
380
|
+
sh: "bash",
|
|
381
|
+
svelte: "svelte",
|
|
382
|
+
toml: "toml",
|
|
383
|
+
ts: "typescript",
|
|
384
|
+
tsx: "typescript",
|
|
385
|
+
txt: "text",
|
|
386
|
+
vue: "vue",
|
|
387
|
+
yaml: "yaml",
|
|
388
|
+
yml: "yaml",
|
|
389
|
+
zig: "zig",
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const filetypeForPath = (path: string) => {
|
|
393
|
+
const basename = path.split("/").at(-1) ?? path
|
|
394
|
+
if (basename === "Dockerfile") return "dockerfile"
|
|
395
|
+
const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
|
|
396
|
+
return extension ? extensionFiletypes[extension] : undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
|
|
400
|
+
|
|
401
|
+
const patchFileName = (patch: string) => {
|
|
402
|
+
const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
|
|
403
|
+
if (diffLine) {
|
|
404
|
+
const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
|
|
405
|
+
if (match) {
|
|
406
|
+
const next = unquoteDiffPath(match[2]!)
|
|
407
|
+
if (next !== "/dev/null") return next
|
|
408
|
+
return unquoteDiffPath(match[1]!)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
|
|
413
|
+
return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
417
|
+
const trimmed = patch.trimEnd()
|
|
418
|
+
if (trimmed.length === 0) return []
|
|
419
|
+
|
|
420
|
+
const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
|
|
421
|
+
if (matches.length === 0) {
|
|
422
|
+
return [{ name: "diff", filetype: undefined, patch: trimmed }]
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return matches.map((match, index) => {
|
|
426
|
+
const start = match.index ?? 0
|
|
427
|
+
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
428
|
+
const filePatch = trimmed.slice(start, end).trimEnd()
|
|
429
|
+
const name = patchFileName(filePatch)
|
|
430
|
+
return { name, filetype: filetypeForPath(name), patch: filePatch }
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
435
|
+
|
|
436
|
+
const diffStatText = (pullRequest: PullRequestItem) => {
|
|
437
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
438
|
+
return `+${pullRequest.additions} -${pullRequest.deletions} ${files}`
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
|
|
442
|
+
let count = 0
|
|
443
|
+
let inHunk = false
|
|
444
|
+
let deletions = 0
|
|
445
|
+
let additions = 0
|
|
446
|
+
|
|
447
|
+
const flushChangeBlock = () => {
|
|
448
|
+
if (deletions === 0 && additions === 0) return
|
|
449
|
+
count += view === "split" ? Math.max(deletions, additions) : deletions + additions
|
|
450
|
+
deletions = 0
|
|
451
|
+
additions = 0
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const line of patch.split("\n")) {
|
|
455
|
+
if (line.startsWith("@@")) {
|
|
456
|
+
flushChangeBlock()
|
|
457
|
+
inHunk = true
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!inHunk) continue
|
|
462
|
+
|
|
463
|
+
const firstChar = line[0]
|
|
464
|
+
if (firstChar === "\\") continue
|
|
465
|
+
|
|
466
|
+
if (firstChar === "-") {
|
|
467
|
+
deletions++
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (firstChar === "+") {
|
|
472
|
+
additions++
|
|
473
|
+
continue
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (firstChar === " ") {
|
|
477
|
+
flushChangeBlock()
|
|
478
|
+
count++
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
flushChangeBlock()
|
|
483
|
+
return Math.max(1, count)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
|
|
487
|
+
|
|
488
|
+
const getDetailPlaceholderContent = ({
|
|
489
|
+
status,
|
|
490
|
+
retryProgress,
|
|
491
|
+
loadingIndicator,
|
|
492
|
+
visibleCount,
|
|
493
|
+
filterText,
|
|
494
|
+
}: DetailPlaceholderInput): DetailPlaceholderContent => {
|
|
495
|
+
if (status === "loading") {
|
|
496
|
+
return {
|
|
497
|
+
title: `${loadingIndicator} Loading pull requests`,
|
|
498
|
+
hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (status === "error") {
|
|
503
|
+
return {
|
|
504
|
+
title: "Could not load pull requests",
|
|
505
|
+
hint: "Press r to retry",
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (visibleCount === 0 && filterText.length > 0) {
|
|
510
|
+
return {
|
|
511
|
+
title: "No matching pull requests",
|
|
512
|
+
hint: "Press esc to clear the filter",
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (visibleCount === 0) {
|
|
517
|
+
return {
|
|
518
|
+
title: "No open pull requests",
|
|
519
|
+
hint: "Press r to refresh",
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
title: "Select a pull request",
|
|
525
|
+
hint: "Use up/down to move",
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
311
529
|
const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
|
|
312
530
|
const sourceLines = body.replace(/\r/g, "").split("\n")
|
|
313
531
|
const preview: Array<PreviewLine> = []
|
|
@@ -450,6 +668,7 @@ const FooterHints = ({
|
|
|
450
668
|
filterEditing,
|
|
451
669
|
showFilterClear,
|
|
452
670
|
detailFullView,
|
|
671
|
+
diffFullView,
|
|
453
672
|
hasSelection,
|
|
454
673
|
hasError,
|
|
455
674
|
isLoading,
|
|
@@ -459,6 +678,7 @@ const FooterHints = ({
|
|
|
459
678
|
filterEditing: boolean
|
|
460
679
|
showFilterClear: boolean
|
|
461
680
|
detailFullView: boolean
|
|
681
|
+
diffFullView: boolean
|
|
462
682
|
hasSelection: boolean
|
|
463
683
|
hasError: boolean
|
|
464
684
|
isLoading: boolean
|
|
@@ -484,6 +704,52 @@ const FooterHints = ({
|
|
|
484
704
|
)
|
|
485
705
|
}
|
|
486
706
|
|
|
707
|
+
if (diffFullView) {
|
|
708
|
+
return (
|
|
709
|
+
<TextLine>
|
|
710
|
+
<span fg={colors.count}>esc</span>
|
|
711
|
+
<span fg={colors.muted}> back </span>
|
|
712
|
+
<span fg={colors.count}>j/k</span>
|
|
713
|
+
<span fg={colors.muted}> scroll </span>
|
|
714
|
+
<span fg={colors.count}>gg/G</span>
|
|
715
|
+
<span fg={colors.muted}> top/bot </span>
|
|
716
|
+
<span fg={colors.count}>v</span>
|
|
717
|
+
<span fg={colors.muted}> view </span>
|
|
718
|
+
<span fg={colors.count}>w</span>
|
|
719
|
+
<span fg={colors.muted}> wrap </span>
|
|
720
|
+
<span fg={colors.count}>[]</span>
|
|
721
|
+
<span fg={colors.muted}> files </span>
|
|
722
|
+
<span fg={colors.count}>r</span>
|
|
723
|
+
<span fg={colors.muted}> reload </span>
|
|
724
|
+
<span fg={colors.count}>o</span>
|
|
725
|
+
<span fg={colors.muted}> open </span>
|
|
726
|
+
<span fg={colors.count}>q</span>
|
|
727
|
+
<span fg={colors.muted}> quit</span>
|
|
728
|
+
</TextLine>
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (detailFullView) {
|
|
733
|
+
return (
|
|
734
|
+
<TextLine>
|
|
735
|
+
<span fg={colors.count}>esc</span>
|
|
736
|
+
<span fg={colors.muted}> back </span>
|
|
737
|
+
<span fg={colors.count}>j/k</span>
|
|
738
|
+
<span fg={colors.muted}> scroll </span>
|
|
739
|
+
<span fg={colors.count}>gg/G</span>
|
|
740
|
+
<span fg={colors.muted}> top/bot </span>
|
|
741
|
+
<span fg={colors.count}>ctrl-d/u</span>
|
|
742
|
+
<span fg={colors.muted}> page </span>
|
|
743
|
+
<span fg={colors.count}>o</span>
|
|
744
|
+
<span fg={colors.muted}> open </span>
|
|
745
|
+
<span fg={colors.count}>y</span>
|
|
746
|
+
<span fg={colors.muted}> copy </span>
|
|
747
|
+
<span fg={colors.count}>q</span>
|
|
748
|
+
<span fg={colors.muted}> quit</span>
|
|
749
|
+
</TextLine>
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
|
|
487
753
|
return (
|
|
488
754
|
<TextLine>
|
|
489
755
|
<span fg={colors.count}>/</span>
|
|
@@ -528,6 +794,8 @@ const FooterHints = ({
|
|
|
528
794
|
<>
|
|
529
795
|
<span fg={colors.count}>d</span>
|
|
530
796
|
<span fg={colors.muted}> draft </span>
|
|
797
|
+
<span fg={colors.count}>p</span>
|
|
798
|
+
<span fg={colors.muted}> diff </span>
|
|
531
799
|
<span fg={colors.count}>l</span>
|
|
532
800
|
<span fg={colors.muted}> labels </span>
|
|
533
801
|
<span fg={colors.count}>o</span>
|
|
@@ -565,6 +833,8 @@ const PullRequestRow = ({
|
|
|
565
833
|
const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
566
834
|
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
567
835
|
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
836
|
+
const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
|
|
837
|
+
const fillerWidth = Math.max(0, contentWidth - rowWidth)
|
|
568
838
|
|
|
569
839
|
return (
|
|
570
840
|
<box height={1} onMouseDown={onSelect}>
|
|
@@ -576,6 +846,7 @@ const PullRequestRow = ({
|
|
|
576
846
|
<span>{fitCell(pullRequest.title, titleWidth)}</span>
|
|
577
847
|
<span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
578
848
|
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
849
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
579
850
|
</TextLine>
|
|
580
851
|
</box>
|
|
581
852
|
)
|
|
@@ -758,6 +1029,12 @@ const DetailHeader = ({
|
|
|
758
1029
|
const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
|
|
759
1030
|
const unique = deduplicateChecks(pullRequest.checks)
|
|
760
1031
|
const checkRows = checksRowCount(unique)
|
|
1032
|
+
const statsText = diffStatText(pullRequest)
|
|
1033
|
+
const labelsWidth = labels.length > 0
|
|
1034
|
+
? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
|
|
1035
|
+
: "no labels".length
|
|
1036
|
+
const showStats = contentWidth - labelsWidth - statsText.length >= 2
|
|
1037
|
+
const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
|
|
761
1038
|
|
|
762
1039
|
return (
|
|
763
1040
|
<>
|
|
@@ -800,6 +1077,15 @@ const DetailHeader = ({
|
|
|
800
1077
|
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
|
|
801
1078
|
</Fragment>
|
|
802
1079
|
)) : <span fg={colors.muted}>no labels</span>}
|
|
1080
|
+
{showStats ? (
|
|
1081
|
+
<>
|
|
1082
|
+
<span fg={colors.muted}>{" ".repeat(statsGap)}</span>
|
|
1083
|
+
<span fg={colors.status.passing}>+{pullRequest.additions}</span>
|
|
1084
|
+
<span fg={colors.muted}> </span>
|
|
1085
|
+
<span fg={colors.status.failing}>-{pullRequest.deletions}</span>
|
|
1086
|
+
<span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
|
|
1087
|
+
</>
|
|
1088
|
+
) : null}
|
|
803
1089
|
</TextLine>
|
|
804
1090
|
</box>
|
|
805
1091
|
<box height={1}><Divider width={paneWidth} /></box>
|
|
@@ -850,8 +1136,8 @@ const DetailBody = ({
|
|
|
850
1136
|
)
|
|
851
1137
|
}
|
|
852
1138
|
|
|
853
|
-
const
|
|
854
|
-
const innerWidth = Math.max(1,
|
|
1139
|
+
const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
|
|
1140
|
+
const innerWidth = Math.max(1, width - 2)
|
|
855
1141
|
const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
|
|
856
1142
|
const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
|
|
857
1143
|
const cardInnerWidth = Math.max(1, cardWidth - 2)
|
|
@@ -868,14 +1154,31 @@ const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderC
|
|
|
868
1154
|
)
|
|
869
1155
|
|
|
870
1156
|
return (
|
|
871
|
-
<box flexDirection="column">
|
|
872
|
-
<
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1157
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
1158
|
+
<PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
|
|
1159
|
+
{contentLine(content.title, colors.count, true)}
|
|
1160
|
+
{contentLine(content.hint, colors.muted)}
|
|
1161
|
+
<PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
|
|
1162
|
+
</box>
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
|
|
1167
|
+
<box flexDirection="column">
|
|
1168
|
+
<StatusCard content={content} width={paneWidth} />
|
|
1169
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
1170
|
+
</box>
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
|
|
1174
|
+
const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
|
|
1175
|
+
const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
|
|
1176
|
+
|
|
1177
|
+
return (
|
|
1178
|
+
<box height={height} flexDirection="column">
|
|
1179
|
+
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
1180
|
+
<StatusCard content={content} width={width} />
|
|
1181
|
+
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
879
1182
|
</box>
|
|
880
1183
|
)
|
|
881
1184
|
}
|
|
@@ -927,6 +1230,127 @@ const DetailsPane = ({
|
|
|
927
1230
|
)
|
|
928
1231
|
}
|
|
929
1232
|
|
|
1233
|
+
const PullRequestDiffPane = ({
|
|
1234
|
+
pullRequest,
|
|
1235
|
+
diffState,
|
|
1236
|
+
fileIndex,
|
|
1237
|
+
view,
|
|
1238
|
+
wrapMode,
|
|
1239
|
+
paneWidth,
|
|
1240
|
+
height,
|
|
1241
|
+
loadingIndicator,
|
|
1242
|
+
scrollRef,
|
|
1243
|
+
}: {
|
|
1244
|
+
pullRequest: PullRequestItem | null
|
|
1245
|
+
diffState: PullRequestDiffState | undefined
|
|
1246
|
+
fileIndex: number
|
|
1247
|
+
view: "unified" | "split"
|
|
1248
|
+
wrapMode: "none" | "word"
|
|
1249
|
+
paneWidth: number
|
|
1250
|
+
height: number
|
|
1251
|
+
loadingIndicator: string
|
|
1252
|
+
scrollRef: React.Ref<ScrollBoxRenderable>
|
|
1253
|
+
}) => {
|
|
1254
|
+
if (!pullRequest) {
|
|
1255
|
+
return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const stats = diffStatText(pullRequest)
|
|
1259
|
+
const headerWidth = Math.max(24, paneWidth - 2)
|
|
1260
|
+
const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
|
|
1261
|
+
const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
|
|
1262
|
+
|
|
1263
|
+
if (!diffState || diffState.status === "loading") {
|
|
1264
|
+
return (
|
|
1265
|
+
<box height={height} flexDirection="column">
|
|
1266
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
1267
|
+
<TextLine>
|
|
1268
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
1269
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
1270
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
1271
|
+
<span fg={colors.status.passing}>+{pullRequest.additions}</span>
|
|
1272
|
+
<span fg={colors.muted}> </span>
|
|
1273
|
+
<span fg={colors.status.failing}>-{pullRequest.deletions}</span>
|
|
1274
|
+
<span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
|
|
1275
|
+
</TextLine>
|
|
1276
|
+
</box>
|
|
1277
|
+
<Divider width={paneWidth} />
|
|
1278
|
+
<LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
|
|
1279
|
+
</box>
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (diffState.status === "error") {
|
|
1284
|
+
return (
|
|
1285
|
+
<box height={height} flexDirection="column">
|
|
1286
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
1287
|
+
<PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
|
|
1288
|
+
</box>
|
|
1289
|
+
<Divider width={paneWidth} />
|
|
1290
|
+
<StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
|
|
1291
|
+
</box>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
if (diffState.files.length === 0) {
|
|
1296
|
+
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const safeIndex = Math.max(0, Math.min(fileIndex, diffState.files.length - 1))
|
|
1300
|
+
const file = diffState.files[safeIndex]!
|
|
1301
|
+
const fileCounter = `${safeIndex + 1}/${diffState.files.length}`
|
|
1302
|
+
const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
|
|
1303
|
+
const diffHeight = patchRenderableLineCount(file.patch, view)
|
|
1304
|
+
|
|
1305
|
+
return (
|
|
1306
|
+
<box height={height} flexDirection="column">
|
|
1307
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
1308
|
+
<TextLine>
|
|
1309
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
1310
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
1311
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
1312
|
+
<span fg={colors.status.passing}>+{pullRequest.additions}</span>
|
|
1313
|
+
<span fg={colors.muted}> </span>
|
|
1314
|
+
<span fg={colors.status.failing}>-{pullRequest.deletions}</span>
|
|
1315
|
+
<span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
|
|
1316
|
+
</TextLine>
|
|
1317
|
+
</box>
|
|
1318
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
1319
|
+
<TextLine>
|
|
1320
|
+
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
1321
|
+
<span fg={colors.muted}> {fileCounter}</span>
|
|
1322
|
+
</TextLine>
|
|
1323
|
+
</box>
|
|
1324
|
+
<Divider width={paneWidth} />
|
|
1325
|
+
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
|
|
1326
|
+
<diff
|
|
1327
|
+
key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
|
|
1328
|
+
diff={file.patch}
|
|
1329
|
+
view={view}
|
|
1330
|
+
syncScroll
|
|
1331
|
+
filetype={file.filetype ?? "text"}
|
|
1332
|
+
syntaxStyle={diffSyntaxStyle}
|
|
1333
|
+
showLineNumbers
|
|
1334
|
+
wrapMode={wrapMode}
|
|
1335
|
+
addedBg="#17351f"
|
|
1336
|
+
removedBg="#3a1e22"
|
|
1337
|
+
contextBg="transparent"
|
|
1338
|
+
addedSignColor={colors.status.passing}
|
|
1339
|
+
removedSignColor={colors.status.failing}
|
|
1340
|
+
lineNumberFg={colors.muted}
|
|
1341
|
+
lineNumberBg="#151515"
|
|
1342
|
+
addedLineNumberBg="#12301a"
|
|
1343
|
+
removedLineNumberBg="#35171b"
|
|
1344
|
+
selectionBg={colors.selectedBg}
|
|
1345
|
+
selectionFg={colors.selectedText}
|
|
1346
|
+
height={diffHeight}
|
|
1347
|
+
style={{ flexShrink: 0 }}
|
|
1348
|
+
/>
|
|
1349
|
+
</scrollbox>
|
|
1350
|
+
</box>
|
|
1351
|
+
)
|
|
1352
|
+
}
|
|
1353
|
+
|
|
930
1354
|
const LabelModal = ({
|
|
931
1355
|
state,
|
|
932
1356
|
currentLabels,
|
|
@@ -1000,17 +1424,17 @@ const LabelModal = ({
|
|
|
1000
1424
|
const isActive = currentNames.has(label.name.toLowerCase())
|
|
1001
1425
|
const isSelected = actualIndex === selectedIndex
|
|
1002
1426
|
const status = isActive ? "added" : ""
|
|
1003
|
-
const
|
|
1004
|
-
const
|
|
1427
|
+
const statusText = status.length > 0 ? ` ${status}` : ""
|
|
1428
|
+
const nameWidth = Math.max(1, contentWidth - 5 - statusText.length)
|
|
1005
1429
|
return (
|
|
1006
1430
|
<box key={label.name} height={1}>
|
|
1007
1431
|
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
1008
|
-
<span fg={
|
|
1009
|
-
<span
|
|
1432
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "✓" : " "}</span>
|
|
1433
|
+
<span> </span>
|
|
1010
1434
|
<span bg={labelColor(label)}> </span>
|
|
1011
|
-
<span
|
|
1012
|
-
<span fg={colors.
|
|
1013
|
-
{
|
|
1435
|
+
<span> </span>
|
|
1436
|
+
<span fg={isSelected ? colors.selectedText : colors.text}>{trimCell(label.name, nameWidth)}</span>
|
|
1437
|
+
{statusText ? <span fg={colors.status.passing}>{statusText}</span> : null}
|
|
1014
1438
|
</TextLine>
|
|
1015
1439
|
</box>
|
|
1016
1440
|
)
|
|
@@ -1025,7 +1449,7 @@ const LabelModal = ({
|
|
|
1025
1449
|
<span fg={colors.muted}> move </span>
|
|
1026
1450
|
<span fg={colors.count}>enter</span>
|
|
1027
1451
|
<span fg={colors.muted}> toggle </span>
|
|
1028
|
-
<span fg={colors.count}
|
|
1452
|
+
<span fg={colors.count}>/</span>
|
|
1029
1453
|
<span fg={colors.muted}> filter </span>
|
|
1030
1454
|
<span fg={colors.count}>esc</span>
|
|
1031
1455
|
<span fg={colors.muted}> close</span>
|
|
@@ -1048,6 +1472,11 @@ export const App = () => {
|
|
|
1048
1472
|
const [pendingG, setPendingG] = useAtom(pendingGAtom)
|
|
1049
1473
|
const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
|
|
1050
1474
|
const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
|
|
1475
|
+
const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
|
|
1476
|
+
const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
|
|
1477
|
+
const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
|
|
1478
|
+
const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
|
|
1479
|
+
const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
|
|
1051
1480
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
1052
1481
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
1053
1482
|
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
@@ -1058,6 +1487,7 @@ export const App = () => {
|
|
|
1058
1487
|
const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
|
|
1059
1488
|
const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
|
|
1060
1489
|
const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
|
|
1490
|
+
const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
|
|
1061
1491
|
const groupIcon = GROUP_ICON
|
|
1062
1492
|
const contentWidth = Math.max(60, width ?? 100)
|
|
1063
1493
|
const isWideLayout = (width ?? 100) >= 100
|
|
@@ -1072,6 +1502,8 @@ export const App = () => {
|
|
|
1072
1502
|
const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
|
|
1073
1503
|
const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
1074
1504
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
1505
|
+
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
1506
|
+
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
1075
1507
|
const headerFooterWidth = Math.max(24, contentWidth - 2)
|
|
1076
1508
|
|
|
1077
1509
|
const flashNotice = (message: string) => {
|
|
@@ -1100,6 +1532,7 @@ export const App = () => {
|
|
|
1100
1532
|
: AsyncResult.isFailure(pullRequestResult)
|
|
1101
1533
|
? "error"
|
|
1102
1534
|
: "ready"
|
|
1535
|
+
const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
|
|
1103
1536
|
const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
|
|
1104
1537
|
const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
|
|
1105
1538
|
|
|
@@ -1170,32 +1603,21 @@ export const App = () => {
|
|
|
1170
1603
|
})
|
|
1171
1604
|
}, [visiblePullRequests.length])
|
|
1172
1605
|
|
|
1606
|
+
useEffect(() => {
|
|
1607
|
+
setDiffFileIndex(0)
|
|
1608
|
+
}, [selectedIndex])
|
|
1609
|
+
|
|
1173
1610
|
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
1611
|
+
const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
|
|
1612
|
+
const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
|
|
1174
1613
|
const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
|
|
1175
|
-
const detailPlaceholderContent
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
:
|
|
1181
|
-
|
|
1182
|
-
title: "Could not load pull requests",
|
|
1183
|
-
hint: "Press r to retry",
|
|
1184
|
-
}
|
|
1185
|
-
: visiblePullRequests.length === 0 && visibleFilterText.length > 0
|
|
1186
|
-
? {
|
|
1187
|
-
title: "No matching pull requests",
|
|
1188
|
-
hint: "Press esc to clear the filter",
|
|
1189
|
-
}
|
|
1190
|
-
: visiblePullRequests.length === 0
|
|
1191
|
-
? {
|
|
1192
|
-
title: "No open pull requests",
|
|
1193
|
-
hint: "Press r to refresh",
|
|
1194
|
-
}
|
|
1195
|
-
: {
|
|
1196
|
-
title: "Select a pull request",
|
|
1197
|
-
hint: "Use up/down to move",
|
|
1198
|
-
}
|
|
1614
|
+
const detailPlaceholderContent = getDetailPlaceholderContent({
|
|
1615
|
+
status: pullRequestStatus,
|
|
1616
|
+
retryProgress,
|
|
1617
|
+
loadingIndicator,
|
|
1618
|
+
visibleCount: visiblePullRequests.length,
|
|
1619
|
+
filterText: visibleFilterText,
|
|
1620
|
+
})
|
|
1199
1621
|
const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
|
|
1200
1622
|
const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
|
|
1201
1623
|
const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
|
|
@@ -1209,6 +1631,38 @@ export const App = () => {
|
|
|
1209
1631
|
|
|
1210
1632
|
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
1211
1633
|
|
|
1634
|
+
const loadPullRequestDiff = (pullRequest: PullRequestItem, force = false) => {
|
|
1635
|
+
const key = pullRequestDiffKey(pullRequest)
|
|
1636
|
+
const existing = pullRequestDiffCache[key]
|
|
1637
|
+
if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
|
|
1638
|
+
|
|
1639
|
+
setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
|
|
1640
|
+
void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
|
|
1641
|
+
.then((patch) => {
|
|
1642
|
+
setPullRequestDiffCache((current) => ({
|
|
1643
|
+
...current,
|
|
1644
|
+
[key]: { status: "ready", patch, files: splitPatchFiles(patch) },
|
|
1645
|
+
}))
|
|
1646
|
+
})
|
|
1647
|
+
.catch((error) => {
|
|
1648
|
+
setPullRequestDiffCache((current) => ({
|
|
1649
|
+
...current,
|
|
1650
|
+
[key]: { status: "error", error: errorMessage(error) },
|
|
1651
|
+
}))
|
|
1652
|
+
flashNotice(errorMessage(error))
|
|
1653
|
+
})
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const openDiffView = () => {
|
|
1657
|
+
if (!selectedPullRequest) return
|
|
1658
|
+
setDiffFullView(true)
|
|
1659
|
+
setDetailFullView(false)
|
|
1660
|
+
setDiffFileIndex(0)
|
|
1661
|
+
setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
|
|
1662
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1663
|
+
loadPullRequestDiff(selectedPullRequest)
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1212
1666
|
const openLabelModal = () => {
|
|
1213
1667
|
if (!selectedPullRequest) return
|
|
1214
1668
|
const repository = selectedPullRequest.repository
|
|
@@ -1335,6 +1789,100 @@ export const App = () => {
|
|
|
1335
1789
|
return
|
|
1336
1790
|
}
|
|
1337
1791
|
|
|
1792
|
+
if (diffFullView) {
|
|
1793
|
+
if (key.name === "escape" || key.name === "return" || key.name === "enter") {
|
|
1794
|
+
setDiffFullView(false)
|
|
1795
|
+
return
|
|
1796
|
+
}
|
|
1797
|
+
if (key.name === "home") {
|
|
1798
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1799
|
+
return
|
|
1800
|
+
}
|
|
1801
|
+
if (key.name === "end") {
|
|
1802
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1805
|
+
if (key.name === "pageup") {
|
|
1806
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
|
|
1807
|
+
return
|
|
1808
|
+
}
|
|
1809
|
+
if (key.name === "pagedown") {
|
|
1810
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
|
|
1811
|
+
return
|
|
1812
|
+
}
|
|
1813
|
+
if (isShiftG(key)) {
|
|
1814
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
|
|
1815
|
+
setPendingG(false)
|
|
1816
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
1817
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
1818
|
+
pendingGTimeoutRef.current = null
|
|
1819
|
+
}
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1822
|
+
if (key.name === "g") {
|
|
1823
|
+
if (pendingG) {
|
|
1824
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1825
|
+
setPendingG(false)
|
|
1826
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
1827
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
1828
|
+
pendingGTimeoutRef.current = null
|
|
1829
|
+
}
|
|
1830
|
+
} else {
|
|
1831
|
+
setPendingG(true)
|
|
1832
|
+
pendingGTimeoutRef.current = setTimeout(() => {
|
|
1833
|
+
setPendingG(false)
|
|
1834
|
+
pendingGTimeoutRef.current = null
|
|
1835
|
+
}, 500)
|
|
1836
|
+
}
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
if (key.name === "up" || key.name === "k") {
|
|
1840
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
|
|
1841
|
+
return
|
|
1842
|
+
}
|
|
1843
|
+
if (key.name === "down" || key.name === "j") {
|
|
1844
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
|
|
1845
|
+
return
|
|
1846
|
+
}
|
|
1847
|
+
if (key.ctrl && key.name === "u") {
|
|
1848
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
|
|
1849
|
+
return
|
|
1850
|
+
}
|
|
1851
|
+
if (key.ctrl && (key.name === "d" || key.name === "v")) {
|
|
1852
|
+
diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
|
|
1853
|
+
return
|
|
1854
|
+
}
|
|
1855
|
+
if (key.name === "v") {
|
|
1856
|
+
setDiffRenderView((current) => current === "unified" ? "split" : "unified")
|
|
1857
|
+
return
|
|
1858
|
+
}
|
|
1859
|
+
if (key.name === "w") {
|
|
1860
|
+
setDiffWrapMode((current) => current === "none" ? "word" : "none")
|
|
1861
|
+
return
|
|
1862
|
+
}
|
|
1863
|
+
if (key.name === "r" && selectedPullRequest) {
|
|
1864
|
+
loadPullRequestDiff(selectedPullRequest, true)
|
|
1865
|
+
flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
|
|
1869
|
+
setDiffFileIndex((current) => Math.min(Math.max(0, selectedDiffState.files.length - 1), current + 1))
|
|
1870
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1871
|
+
return
|
|
1872
|
+
}
|
|
1873
|
+
if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
|
|
1874
|
+
setDiffFileIndex((current) => Math.max(0, current - 1))
|
|
1875
|
+
diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1876
|
+
return
|
|
1877
|
+
}
|
|
1878
|
+
if (key.name === "o" && selectedPullRequest) {
|
|
1879
|
+
void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
|
|
1880
|
+
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
1881
|
+
return
|
|
1882
|
+
}
|
|
1883
|
+
return
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1338
1886
|
// Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
|
|
1339
1887
|
if (detailFullView) {
|
|
1340
1888
|
if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
|
|
@@ -1342,19 +1890,66 @@ export const App = () => {
|
|
|
1342
1890
|
setDetailScrollOffset(0)
|
|
1343
1891
|
return
|
|
1344
1892
|
}
|
|
1893
|
+
if (key.name === "home") {
|
|
1894
|
+
detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1895
|
+
setDetailScrollOffset(0)
|
|
1896
|
+
return
|
|
1897
|
+
}
|
|
1898
|
+
if (key.name === "end" || isShiftG(key)) {
|
|
1899
|
+
detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
|
|
1900
|
+
setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
|
|
1901
|
+
setPendingG(false)
|
|
1902
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
1903
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
1904
|
+
pendingGTimeoutRef.current = null
|
|
1905
|
+
}
|
|
1906
|
+
return
|
|
1907
|
+
}
|
|
1908
|
+
if (key.name === "pageup") {
|
|
1909
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
|
|
1910
|
+
setDetailScrollOffset((current) => Math.max(0, current - halfPage))
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
if (key.name === "pagedown") {
|
|
1914
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
|
|
1915
|
+
setDetailScrollOffset((current) => current + halfPage)
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
if (key.name === "g") {
|
|
1919
|
+
if (pendingG) {
|
|
1920
|
+
detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
|
|
1921
|
+
setDetailScrollOffset(0)
|
|
1922
|
+
setPendingG(false)
|
|
1923
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
1924
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
1925
|
+
pendingGTimeoutRef.current = null
|
|
1926
|
+
}
|
|
1927
|
+
} else {
|
|
1928
|
+
setPendingG(true)
|
|
1929
|
+
pendingGTimeoutRef.current = setTimeout(() => {
|
|
1930
|
+
setPendingG(false)
|
|
1931
|
+
pendingGTimeoutRef.current = null
|
|
1932
|
+
}, 500)
|
|
1933
|
+
}
|
|
1934
|
+
return
|
|
1935
|
+
}
|
|
1345
1936
|
if (key.name === "up" || key.name === "k") {
|
|
1937
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
|
|
1346
1938
|
setDetailScrollOffset((current) => Math.max(0, current - 1))
|
|
1347
1939
|
return
|
|
1348
1940
|
}
|
|
1349
1941
|
if (key.name === "down" || key.name === "j") {
|
|
1942
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
|
|
1350
1943
|
setDetailScrollOffset((current) => current + 1)
|
|
1351
1944
|
return
|
|
1352
1945
|
}
|
|
1353
1946
|
if (key.ctrl && key.name === "u") {
|
|
1947
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
|
|
1354
1948
|
setDetailScrollOffset((current) => Math.max(0, current - halfPage))
|
|
1355
1949
|
return
|
|
1356
1950
|
}
|
|
1357
1951
|
if (key.ctrl && (key.name === "d" || key.name === "v")) {
|
|
1952
|
+
detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
|
|
1358
1953
|
setDetailScrollOffset((current) => current + halfPage)
|
|
1359
1954
|
return
|
|
1360
1955
|
}
|
|
@@ -1473,7 +2068,7 @@ export const App = () => {
|
|
|
1473
2068
|
return
|
|
1474
2069
|
}
|
|
1475
2070
|
// Vim-style navigation: gg to go to top, G to go to bottom
|
|
1476
|
-
if (key
|
|
2071
|
+
if (isShiftG(key)) {
|
|
1477
2072
|
setSelectedIndex((_current) => {
|
|
1478
2073
|
if (visiblePullRequests.length === 0) return 0
|
|
1479
2074
|
return visiblePullRequests.length - 1
|
|
@@ -1502,6 +2097,10 @@ export const App = () => {
|
|
|
1502
2097
|
setDetailScrollOffset(0)
|
|
1503
2098
|
return
|
|
1504
2099
|
}
|
|
2100
|
+
if (key.name === "p" && selectedPullRequest) {
|
|
2101
|
+
openDiffView()
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
1505
2104
|
if (key.name === "l" && selectedPullRequest) {
|
|
1506
2105
|
openLabelModal()
|
|
1507
2106
|
return
|
|
@@ -1554,7 +2153,8 @@ export const App = () => {
|
|
|
1554
2153
|
onSelectPullRequest: selectPullRequestByUrl,
|
|
1555
2154
|
} as const
|
|
1556
2155
|
|
|
1557
|
-
const
|
|
2156
|
+
const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
|
|
2157
|
+
const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
|
|
1558
2158
|
const labelModalHeight = Math.min(20, (height ?? 24) - 4)
|
|
1559
2159
|
const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
|
|
1560
2160
|
const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
|
|
@@ -1564,14 +2164,28 @@ export const App = () => {
|
|
|
1564
2164
|
<box paddingLeft={1} paddingRight={1} flexDirection="column">
|
|
1565
2165
|
<PlainLine text={headerLine} fg={colors.muted} bold />
|
|
1566
2166
|
</box>
|
|
1567
|
-
{isWideLayout && !detailFullView ? (
|
|
2167
|
+
{isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
|
|
1568
2168
|
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
|
|
1569
2169
|
) : (
|
|
1570
2170
|
<Divider width={contentWidth} />
|
|
1571
2171
|
)}
|
|
1572
|
-
{
|
|
2172
|
+
{isInitialLoading ? (
|
|
2173
|
+
<LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
|
|
2174
|
+
) : diffFullView ? (
|
|
2175
|
+
<PullRequestDiffPane
|
|
2176
|
+
pullRequest={selectedPullRequest}
|
|
2177
|
+
diffState={selectedDiffState}
|
|
2178
|
+
fileIndex={diffFileIndex}
|
|
2179
|
+
view={effectiveDiffRenderView}
|
|
2180
|
+
wrapMode={diffWrapMode}
|
|
2181
|
+
paneWidth={contentWidth}
|
|
2182
|
+
height={wideBodyHeight}
|
|
2183
|
+
loadingIndicator={loadingIndicator}
|
|
2184
|
+
scrollRef={diffScrollRef}
|
|
2185
|
+
/>
|
|
2186
|
+
) : isWideLayout && detailFullView ? (
|
|
1573
2187
|
<box flexGrow={1} flexDirection="column">
|
|
1574
|
-
<scrollbox flexGrow={1}>
|
|
2188
|
+
<scrollbox ref={detailScrollRef} focused flexGrow={1}>
|
|
1575
2189
|
<DetailsPane
|
|
1576
2190
|
pullRequest={selectedPullRequest}
|
|
1577
2191
|
contentWidth={fullscreenContentWidth}
|
|
@@ -1605,7 +2219,7 @@ export const App = () => {
|
|
|
1605
2219
|
</box>
|
|
1606
2220
|
) : detailFullView ? (
|
|
1607
2221
|
<box flexGrow={1} flexDirection="column">
|
|
1608
|
-
<scrollbox flexGrow={1}>
|
|
2222
|
+
<scrollbox ref={detailScrollRef} focused flexGrow={1}>
|
|
1609
2223
|
<DetailsPane
|
|
1610
2224
|
pullRequest={selectedPullRequest}
|
|
1611
2225
|
contentWidth={fullscreenContentWidth}
|
|
@@ -1629,7 +2243,7 @@ export const App = () => {
|
|
|
1629
2243
|
</>
|
|
1630
2244
|
)}
|
|
1631
2245
|
|
|
1632
|
-
{isWideLayout && !detailFullView ? (
|
|
2246
|
+
{isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
|
|
1633
2247
|
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
|
|
1634
2248
|
) : (
|
|
1635
2249
|
<Divider width={contentWidth} />
|
|
@@ -1642,6 +2256,7 @@ export const App = () => {
|
|
|
1642
2256
|
filterEditing={filterMode}
|
|
1643
2257
|
showFilterClear={filterMode || filterQuery.length > 0}
|
|
1644
2258
|
detailFullView={detailFullView}
|
|
2259
|
+
diffFullView={diffFullView}
|
|
1645
2260
|
hasSelection={selectedPullRequest !== null}
|
|
1646
2261
|
hasError={pullRequestStatus === "error"}
|
|
1647
2262
|
isLoading={pullRequestStatus === "loading"}
|
package/src/domain.ts
CHANGED
|
@@ -19,6 +19,9 @@ export interface PullRequestItem {
|
|
|
19
19
|
readonly title: string
|
|
20
20
|
readonly body: string
|
|
21
21
|
readonly labels: readonly PullRequestLabel[]
|
|
22
|
+
readonly additions: number
|
|
23
|
+
readonly deletions: number
|
|
24
|
+
readonly changedFiles: number
|
|
22
25
|
readonly state: PullRequestState
|
|
23
26
|
readonly reviewStatus: "draft" | "approved" | "changes" | "review" | "none"
|
|
24
27
|
readonly checkStatus: "passing" | "pending" | "failing" | "none"
|
|
@@ -11,6 +11,9 @@ interface GitHubListPullRequest {
|
|
|
11
11
|
readonly name: string
|
|
12
12
|
readonly color?: string | null
|
|
13
13
|
}[]
|
|
14
|
+
readonly additions: number
|
|
15
|
+
readonly deletions: number
|
|
16
|
+
readonly changedFiles: number
|
|
14
17
|
readonly isDraft: boolean
|
|
15
18
|
readonly reviewDecision: string
|
|
16
19
|
readonly statusCheckRollup: readonly {
|
|
@@ -38,7 +41,7 @@ interface GitHubViewer {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
const searchJsonFields = "repository,number"
|
|
41
|
-
const detailJsonFields = "number,title,body,labels,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
|
|
44
|
+
const detailJsonFields = "number,title,body,labels,additions,deletions,changedFiles,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
|
|
42
45
|
|
|
43
46
|
const normalizeDate = (value: string | null | undefined) => {
|
|
44
47
|
if (!value || value.startsWith("0001-01-01")) return null
|
|
@@ -126,6 +129,9 @@ const parsePullRequest = (repository: string, item: GitHubListPullRequest): Pull
|
|
|
126
129
|
name: label.name,
|
|
127
130
|
color: label.color ? `#${label.color}` : null,
|
|
128
131
|
})),
|
|
132
|
+
additions: item.additions,
|
|
133
|
+
deletions: item.deletions,
|
|
134
|
+
changedFiles: item.changedFiles,
|
|
129
135
|
state: item.state.toLowerCase() === "open" ? "open" : "closed",
|
|
130
136
|
reviewStatus: getReviewStatus(item),
|
|
131
137
|
checkStatus: checkInfo.checkStatus,
|
|
@@ -159,6 +165,7 @@ type GitHubError = CommandError | JsonParseError
|
|
|
159
165
|
export class GitHubService extends Context.Service<GitHubService, {
|
|
160
166
|
readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
161
167
|
readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
|
|
168
|
+
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
|
|
162
169
|
readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
|
|
163
170
|
readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
|
|
164
171
|
readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
|
|
@@ -191,6 +198,11 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
191
198
|
return viewer.login
|
|
192
199
|
})
|
|
193
200
|
|
|
201
|
+
const getPullRequestDiff = Effect.fn("GitHubService.getPullRequestDiff")(function*(repository: string, number: number) {
|
|
202
|
+
const result = yield* command.run("gh", ["pr", "diff", String(number), "--repo", repository, "--color", "never"])
|
|
203
|
+
return result.stdout
|
|
204
|
+
})
|
|
205
|
+
|
|
194
206
|
const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
|
|
195
207
|
yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
196
208
|
})
|
|
@@ -213,6 +225,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
213
225
|
return GitHubService.of({
|
|
214
226
|
listOpenPullRequests,
|
|
215
227
|
getAuthenticatedUser,
|
|
228
|
+
getPullRequestDiff,
|
|
216
229
|
toggleDraftStatus,
|
|
217
230
|
listRepoLabels,
|
|
218
231
|
addPullRequestLabel,
|