@kitlangton/ghui 0.1.0 → 0.1.2
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/README.md +9 -3
- package/bin/{ghui → ghui.js} +0 -0
- package/package.json +2 -2
- package/src/App.tsx +320 -156
- package/src/services/CommandRunner.ts +57 -29
- package/src/services/GitHubService.ts +68 -36
package/README.md
CHANGED
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
Terminal UI for browsing and acting on your open GitHub pull requests across repositories.
|
|
6
6
|
|
|
7
|
-
## Install
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @kitlangton/ghui
|
|
11
|
+
```
|
|
8
12
|
|
|
9
13
|
Requires `bun` and an authenticated GitHub CLI (`gh auth login`).
|
|
10
14
|
|
|
15
|
+
## Install Locally
|
|
16
|
+
|
|
11
17
|
Clone, install, and link:
|
|
12
18
|
|
|
13
19
|
```bash
|
|
@@ -27,7 +33,7 @@ ghui
|
|
|
27
33
|
|
|
28
34
|
This package publishes from GitHub Releases using npm Trusted Publishing.
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
The first npm publish has already created the package. Configure npm Trusted Publishing:
|
|
31
37
|
|
|
32
38
|
- Package: `@kitlangton/ghui`
|
|
33
39
|
- Publisher: GitHub Actions
|
|
@@ -35,7 +41,7 @@ If this is the first npm publish, publish once from your machine with `npm publi
|
|
|
35
41
|
- Repository: `ghui`
|
|
36
42
|
- Workflow filename: `publish.yml`
|
|
37
43
|
|
|
38
|
-
After that, publish by creating a GitHub Release whose tag matches `package.json` version, for example `v0.1.
|
|
44
|
+
After that, publish by creating a GitHub Release whose tag matches `package.json` version, for example `v0.1.1`.
|
|
39
45
|
|
|
40
46
|
## Configuration
|
|
41
47
|
|
package/bin/{ghui → ghui.js}
RENAMED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/ghui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Terminal UI for GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"registry": "https://registry.npmjs.org/"
|
|
32
32
|
},
|
|
33
33
|
"bin": {
|
|
34
|
-
"ghui": "
|
|
34
|
+
"ghui": "bin/ghui.js"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"dev": "bun --watch src/index.tsx",
|
package/src/App.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import { useAtom } from "@effect/atom-react"
|
|
2
|
+
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
3
3
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
4
|
+
import { Cause, Effect, Schedule } from "effect"
|
|
5
|
+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
4
6
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
5
|
-
import { Fragment, useEffect, useMemo, useRef } from "react"
|
|
7
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from "react"
|
|
6
8
|
import { config } from "./config.js"
|
|
7
9
|
import type { CheckItem, PullRequestItem, PullRequestLabel } from "./domain.js"
|
|
8
10
|
import { daysOpen, formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
|
|
9
|
-
import {
|
|
11
|
+
import { GitHubService } from "./services/GitHubService.js"
|
|
10
12
|
|
|
11
|
-
const
|
|
13
|
+
const githubRuntime = Atom.runtime(GitHubService.layer)
|
|
12
14
|
|
|
13
15
|
const colors = {
|
|
14
16
|
text: "#ede7da",
|
|
@@ -41,10 +43,8 @@ const colors = {
|
|
|
41
43
|
|
|
42
44
|
type LoadStatus = "loading" | "ready" | "error"
|
|
43
45
|
|
|
44
|
-
interface
|
|
45
|
-
readonly status: LoadStatus
|
|
46
|
+
interface PullRequestLoad {
|
|
46
47
|
readonly data: readonly PullRequestItem[]
|
|
47
|
-
readonly error: string | null
|
|
48
48
|
readonly fetchedAt: Date | null
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -56,19 +56,44 @@ interface PreviewLine {
|
|
|
56
56
|
}>
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
interface DetailPlaceholderContent {
|
|
60
|
+
readonly title: string
|
|
61
|
+
readonly hint: string
|
|
62
|
+
}
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
error: null,
|
|
65
|
-
fetchedAt: null,
|
|
64
|
+
interface RetryProgress {
|
|
65
|
+
readonly attempt: number
|
|
66
|
+
readonly max: number
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const
|
|
69
|
+
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
70
|
+
const PR_FETCH_RETRIES = 6
|
|
71
|
+
const DETAIL_PLACEHOLDER_ROWS = 4
|
|
72
|
+
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
73
|
+
|
|
74
|
+
const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
|
|
75
|
+
const pullRequestsAtom = githubRuntime.atom(
|
|
76
|
+
GitHubService.use((github) =>
|
|
77
|
+
Effect.gen(function*() {
|
|
78
|
+
yield* Atom.set(retryProgressAtom, null)
|
|
79
|
+
const data = yield* github.listOpenPullRequests().pipe(
|
|
80
|
+
Effect.tapError(() =>
|
|
81
|
+
Atom.update(retryProgressAtom, (current) => ({
|
|
82
|
+
attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
|
|
83
|
+
max: PR_FETCH_RETRIES,
|
|
84
|
+
}))
|
|
85
|
+
),
|
|
86
|
+
Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
|
|
87
|
+
Effect.tapError(() => Atom.set(retryProgressAtom, null)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
yield* Atom.set(retryProgressAtom, null)
|
|
91
|
+
return { data, fetchedAt: new Date() } satisfies PullRequestLoad
|
|
92
|
+
})
|
|
93
|
+
),
|
|
94
|
+
).pipe(Atom.keepAlive)
|
|
69
95
|
const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
70
96
|
const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
71
|
-
const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
72
97
|
const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
73
98
|
const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
74
99
|
const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
@@ -76,8 +101,7 @@ const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
|
76
101
|
const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
77
102
|
const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
78
103
|
|
|
79
|
-
const
|
|
80
|
-
const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
104
|
+
const GROUP_ICON = "◆"
|
|
81
105
|
|
|
82
106
|
interface LabelModalState {
|
|
83
107
|
readonly open: boolean
|
|
@@ -99,7 +123,25 @@ const initialLabelModalState: LabelModalState = {
|
|
|
99
123
|
|
|
100
124
|
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
101
125
|
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
102
|
-
const
|
|
126
|
+
const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
127
|
+
const usernameAtom = githubRuntime.atom(
|
|
128
|
+
config.author === "@me"
|
|
129
|
+
? GitHubService.use((github) => github.getAuthenticatedUser())
|
|
130
|
+
: Effect.succeed(config.author.replace(/^@/, "")),
|
|
131
|
+
).pipe(Atom.keepAlive)
|
|
132
|
+
|
|
133
|
+
const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
|
|
134
|
+
GitHubService.use((github) => github.listRepoLabels(repository))
|
|
135
|
+
)
|
|
136
|
+
const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
|
|
137
|
+
GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
|
|
138
|
+
)
|
|
139
|
+
const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
|
|
140
|
+
GitHubService.use((github) => github.removePullRequestLabel(input.repository, input.number, input.label))
|
|
141
|
+
)
|
|
142
|
+
const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
|
|
143
|
+
GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
|
|
144
|
+
)
|
|
103
145
|
|
|
104
146
|
const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
105
147
|
|
|
@@ -166,6 +208,12 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
|
|
|
166
208
|
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
167
209
|
}
|
|
168
210
|
|
|
211
|
+
const centerCell = (text: string, width: number) => {
|
|
212
|
+
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
213
|
+
const left = Math.floor((width - trimmed.length) / 2)
|
|
214
|
+
return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
|
|
215
|
+
}
|
|
216
|
+
|
|
169
217
|
const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
170
218
|
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
171
219
|
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
@@ -245,6 +293,8 @@ const fallbackLabelColor = (name: string) => {
|
|
|
245
293
|
return `hsl(${hue} 55% 35%)`
|
|
246
294
|
}
|
|
247
295
|
|
|
296
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
297
|
+
|
|
248
298
|
const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
|
|
249
299
|
|
|
250
300
|
const labelTextColor = (color: string) => {
|
|
@@ -396,43 +446,101 @@ const SectionTitle = ({ title }: { title: string }) => (
|
|
|
396
446
|
</TextLine>
|
|
397
447
|
)
|
|
398
448
|
|
|
399
|
-
const FooterHints = ({
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
449
|
+
const FooterHints = ({
|
|
450
|
+
filterEditing,
|
|
451
|
+
showFilterClear,
|
|
452
|
+
detailFullView,
|
|
453
|
+
hasSelection,
|
|
454
|
+
hasError,
|
|
455
|
+
isLoading,
|
|
456
|
+
loadingIndicator,
|
|
457
|
+
retryProgress,
|
|
458
|
+
}: {
|
|
459
|
+
filterEditing: boolean
|
|
460
|
+
showFilterClear: boolean
|
|
461
|
+
detailFullView: boolean
|
|
462
|
+
hasSelection: boolean
|
|
463
|
+
hasError: boolean
|
|
464
|
+
isLoading: boolean
|
|
465
|
+
loadingIndicator: string
|
|
466
|
+
retryProgress: RetryProgress | null
|
|
467
|
+
}) => {
|
|
468
|
+
if (filterEditing) {
|
|
469
|
+
return (
|
|
470
|
+
<TextLine>
|
|
471
|
+
<span fg={colors.count}>search</span>
|
|
472
|
+
<span fg={colors.muted}> typing </span>
|
|
473
|
+
<span fg={colors.count}>↑↓</span>
|
|
474
|
+
<span fg={colors.muted}> move </span>
|
|
475
|
+
<span fg={colors.count}>enter</span>
|
|
476
|
+
<span fg={colors.muted}> apply </span>
|
|
407
477
|
<span fg={colors.count}>esc</span>
|
|
478
|
+
<span fg={colors.muted}> cancel </span>
|
|
479
|
+
<span fg={colors.count}>ctrl-u</span>
|
|
408
480
|
<span fg={colors.muted}> clear </span>
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
)
|
|
481
|
+
<span fg={colors.count}>ctrl-w</span>
|
|
482
|
+
<span fg={colors.muted}> word</span>
|
|
483
|
+
</TextLine>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<TextLine>
|
|
489
|
+
<span fg={colors.count}>/</span>
|
|
490
|
+
<span fg={colors.muted}> filter </span>
|
|
491
|
+
{showFilterClear ? (
|
|
492
|
+
<>
|
|
493
|
+
<span fg={colors.count}>esc</span>
|
|
494
|
+
<span fg={colors.muted}> clear </span>
|
|
495
|
+
</>
|
|
496
|
+
) : null}
|
|
497
|
+
{retryProgress ? (
|
|
498
|
+
<>
|
|
499
|
+
<span fg={colors.status.pending}>retry</span>
|
|
500
|
+
<span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
|
|
501
|
+
</>
|
|
502
|
+
) : isLoading ? (
|
|
503
|
+
<>
|
|
504
|
+
<span fg={colors.status.pending}>{loadingIndicator}</span>
|
|
505
|
+
<span fg={colors.muted}> loading </span>
|
|
506
|
+
</>
|
|
507
|
+
) : null}
|
|
508
|
+
<span fg={colors.count}>r</span>
|
|
509
|
+
<span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
|
|
510
|
+
{hasSelection ? (
|
|
511
|
+
<>
|
|
512
|
+
<span fg={colors.count}>↑↓</span>
|
|
513
|
+
<span fg={colors.muted}> move </span>
|
|
514
|
+
</>
|
|
515
|
+
) : null}
|
|
516
|
+
{hasSelection && detailFullView ? (
|
|
517
|
+
<>
|
|
518
|
+
<span fg={colors.count}>esc</span>
|
|
519
|
+
<span fg={colors.muted}> back </span>
|
|
520
|
+
</>
|
|
521
|
+
) : hasSelection ? (
|
|
522
|
+
<>
|
|
523
|
+
<span fg={colors.count}>enter</span>
|
|
524
|
+
<span fg={colors.muted}> expand </span>
|
|
525
|
+
</>
|
|
526
|
+
) : null}
|
|
527
|
+
{hasSelection ? (
|
|
528
|
+
<>
|
|
529
|
+
<span fg={colors.count}>d</span>
|
|
530
|
+
<span fg={colors.muted}> draft </span>
|
|
531
|
+
<span fg={colors.count}>l</span>
|
|
532
|
+
<span fg={colors.muted}> labels </span>
|
|
533
|
+
<span fg={colors.count}>o</span>
|
|
534
|
+
<span fg={colors.muted}> open </span>
|
|
535
|
+
<span fg={colors.count}>y</span>
|
|
536
|
+
<span fg={colors.muted}> copy </span>
|
|
537
|
+
</>
|
|
538
|
+
) : null}
|
|
539
|
+
<span fg={colors.count}>q</span>
|
|
540
|
+
<span fg={colors.muted}> quit</span>
|
|
541
|
+
</TextLine>
|
|
542
|
+
)
|
|
543
|
+
}
|
|
436
544
|
|
|
437
545
|
const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
|
|
438
546
|
<TextLine>
|
|
@@ -742,18 +850,50 @@ const DetailBody = ({
|
|
|
742
850
|
)
|
|
743
851
|
}
|
|
744
852
|
|
|
853
|
+
const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => {
|
|
854
|
+
const innerWidth = Math.max(1, paneWidth - 2)
|
|
855
|
+
const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
|
|
856
|
+
const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
|
|
857
|
+
const cardInnerWidth = Math.max(1, cardWidth - 2)
|
|
858
|
+
const contentLine = (text: string, fg: string, bold = false) => (
|
|
859
|
+
<TextLine>
|
|
860
|
+
<span fg={colors.separator}>{offset}│</span>
|
|
861
|
+
{bold ? (
|
|
862
|
+
<span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
|
|
863
|
+
) : (
|
|
864
|
+
<span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
|
|
865
|
+
)}
|
|
866
|
+
<span fg={colors.separator}>│</span>
|
|
867
|
+
</TextLine>
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<box flexDirection="column">
|
|
872
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
873
|
+
<PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
|
|
874
|
+
{contentLine(content.title, colors.count, true)}
|
|
875
|
+
{contentLine(content.hint, colors.muted)}
|
|
876
|
+
<PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
|
|
877
|
+
</box>
|
|
878
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
879
|
+
</box>
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
|
|
745
883
|
const DetailsPane = ({
|
|
746
884
|
pullRequest,
|
|
747
885
|
contentWidth,
|
|
748
886
|
bodyLines = DETAIL_BODY_LINES,
|
|
749
887
|
paneWidth = contentWidth + 2,
|
|
750
888
|
showChecks = false,
|
|
889
|
+
placeholderContent,
|
|
751
890
|
}: {
|
|
752
891
|
pullRequest: PullRequestItem | null
|
|
753
892
|
contentWidth: number
|
|
754
893
|
bodyLines?: number
|
|
755
894
|
paneWidth?: number
|
|
756
895
|
showChecks?: boolean
|
|
896
|
+
placeholderContent: DetailPlaceholderContent
|
|
757
897
|
}) => {
|
|
758
898
|
const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
|
|
759
899
|
const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
|
|
@@ -764,7 +904,7 @@ const DetailsPane = ({
|
|
|
764
904
|
() => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
|
|
765
905
|
[pullRequest?.body, contentWidth, bodyLines],
|
|
766
906
|
)
|
|
767
|
-
const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
|
|
907
|
+
const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
768
908
|
|
|
769
909
|
return (
|
|
770
910
|
<box flexDirection="column" height={contentHeight}>
|
|
@@ -774,12 +914,14 @@ const DetailsPane = ({
|
|
|
774
914
|
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
|
|
775
915
|
</>
|
|
776
916
|
) : (
|
|
777
|
-
|
|
778
|
-
<
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
917
|
+
<>
|
|
918
|
+
<DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
|
|
919
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
920
|
+
{Array.from({ length: bodyLines }, (_, index) => (
|
|
921
|
+
<BlankRow key={index} />
|
|
922
|
+
))}
|
|
923
|
+
</box>
|
|
924
|
+
</>
|
|
783
925
|
)}
|
|
784
926
|
</box>
|
|
785
927
|
)
|
|
@@ -792,6 +934,7 @@ const LabelModal = ({
|
|
|
792
934
|
modalHeight,
|
|
793
935
|
offsetLeft,
|
|
794
936
|
offsetTop,
|
|
937
|
+
loadingIndicator,
|
|
795
938
|
}: {
|
|
796
939
|
state: LabelModalState
|
|
797
940
|
currentLabels: readonly PullRequestLabel[]
|
|
@@ -799,19 +942,26 @@ const LabelModal = ({
|
|
|
799
942
|
modalHeight: number
|
|
800
943
|
offsetLeft: number
|
|
801
944
|
offsetTop: number
|
|
945
|
+
loadingIndicator: string
|
|
802
946
|
}) => {
|
|
803
|
-
const contentWidth = modalWidth -
|
|
947
|
+
const contentWidth = Math.max(16, modalWidth - 2)
|
|
804
948
|
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
805
949
|
const filtered = state.availableLabels.filter((label) =>
|
|
806
950
|
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
807
951
|
)
|
|
808
|
-
const maxVisible = Math.max(1, modalHeight -
|
|
952
|
+
const maxVisible = Math.max(1, modalHeight - 6)
|
|
809
953
|
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
810
954
|
const scrollStart = Math.min(
|
|
811
955
|
Math.max(0, filtered.length - maxVisible),
|
|
812
956
|
Math.max(0, selectedIndex - maxVisible + 1),
|
|
813
957
|
)
|
|
814
958
|
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
959
|
+
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
960
|
+
const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
|
|
961
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
962
|
+
const queryText = state.query.length > 0 ? state.query : "type to filter labels"
|
|
963
|
+
const queryPrefix = state.query.length > 0 ? "/ " : "/ "
|
|
964
|
+
const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
|
|
815
965
|
|
|
816
966
|
return (
|
|
817
967
|
<box
|
|
@@ -825,34 +975,42 @@ const LabelModal = ({
|
|
|
825
975
|
>
|
|
826
976
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
827
977
|
<TextLine>
|
|
828
|
-
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
829
|
-
|
|
978
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
979
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
980
|
+
<span fg={colors.muted}>{countText}</span>
|
|
830
981
|
</TextLine>
|
|
831
982
|
</box>
|
|
832
983
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
833
984
|
<TextLine>
|
|
834
|
-
<span fg={colors.count}
|
|
985
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
835
986
|
<span fg={state.query.length > 0 ? colors.text : colors.muted}>
|
|
836
|
-
{
|
|
987
|
+
{fitCell(queryText, queryWidth)}
|
|
837
988
|
</span>
|
|
838
989
|
</TextLine>
|
|
839
990
|
</box>
|
|
840
991
|
<Divider width={modalWidth} />
|
|
841
992
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
842
993
|
{state.loading ? (
|
|
843
|
-
<PlainLine text=
|
|
994
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
|
|
844
995
|
) : visibleLabels.length === 0 ? (
|
|
845
|
-
<PlainLine text={state.query.length > 0 ? "No matching labels
|
|
996
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
|
|
846
997
|
) : (
|
|
847
998
|
visibleLabels.map((label, index) => {
|
|
848
999
|
const actualIndex = scrollStart + index
|
|
849
1000
|
const isActive = currentNames.has(label.name.toLowerCase())
|
|
850
1001
|
const isSelected = actualIndex === selectedIndex
|
|
1002
|
+
const status = isActive ? "added" : ""
|
|
1003
|
+
const nameWidth = Math.max(8, contentWidth - 10 - status.length)
|
|
1004
|
+
const gap = Math.max(1, contentWidth - 8 - Math.min(label.name.length, nameWidth) - status.length)
|
|
851
1005
|
return (
|
|
852
1006
|
<box key={label.name} height={1}>
|
|
853
1007
|
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
854
|
-
<span fg={
|
|
855
|
-
<span
|
|
1008
|
+
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "›" : " "}</span>
|
|
1009
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
|
|
1010
|
+
<span bg={labelColor(label)}> </span>
|
|
1011
|
+
<span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
|
|
1012
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
1013
|
+
{status ? <span fg={colors.status.passing}>{status}</span> : null}
|
|
856
1014
|
</TextLine>
|
|
857
1015
|
</box>
|
|
858
1016
|
)
|
|
@@ -863,8 +1021,12 @@ const LabelModal = ({
|
|
|
863
1021
|
<Divider width={modalWidth} />
|
|
864
1022
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
865
1023
|
<TextLine>
|
|
1024
|
+
<span fg={colors.count}>↑↓</span>
|
|
1025
|
+
<span fg={colors.muted}> move </span>
|
|
866
1026
|
<span fg={colors.count}>enter</span>
|
|
867
1027
|
<span fg={colors.muted}> toggle </span>
|
|
1028
|
+
<span fg={colors.count}>/type</span>
|
|
1029
|
+
<span fg={colors.muted}> filter </span>
|
|
868
1030
|
<span fg={colors.count}>esc</span>
|
|
869
1031
|
<span fg={colors.muted}> close</span>
|
|
870
1032
|
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
@@ -876,10 +1038,10 @@ const LabelModal = ({
|
|
|
876
1038
|
|
|
877
1039
|
export const App = () => {
|
|
878
1040
|
const { width, height } = useTerminalDimensions()
|
|
879
|
-
const
|
|
1041
|
+
const pullRequestResult = useAtomValue(pullRequestsAtom)
|
|
1042
|
+
const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
|
|
880
1043
|
const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
|
|
881
1044
|
const [notice, setNotice] = useAtom(noticeAtom)
|
|
882
|
-
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
883
1045
|
const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
|
|
884
1046
|
const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
|
|
885
1047
|
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
@@ -888,9 +1050,15 @@ export const App = () => {
|
|
|
888
1050
|
const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
|
|
889
1051
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
890
1052
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
891
|
-
const [
|
|
892
|
-
const
|
|
893
|
-
const
|
|
1053
|
+
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
1054
|
+
const retryProgress = useAtomValue(retryProgressAtom)
|
|
1055
|
+
const [loadingFrame, setLoadingFrame] = useState(0)
|
|
1056
|
+
const usernameResult = useAtomValue(usernameAtom)
|
|
1057
|
+
const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
|
|
1058
|
+
const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
|
|
1059
|
+
const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
|
|
1060
|
+
const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
|
|
1061
|
+
const groupIcon = GROUP_ICON
|
|
894
1062
|
const contentWidth = Math.max(60, width ?? 100)
|
|
895
1063
|
const isWideLayout = (width ?? 100) >= 100
|
|
896
1064
|
const splitGap = 1
|
|
@@ -925,65 +1093,28 @@ export const App = () => {
|
|
|
925
1093
|
}
|
|
926
1094
|
}, [])
|
|
927
1095
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
void getAuthenticatedUser()
|
|
938
|
-
.then((login) => {
|
|
939
|
-
if (!cancelled) setUsername(login)
|
|
940
|
-
})
|
|
941
|
-
.catch(() => {
|
|
942
|
-
if (!cancelled) setUsername(null)
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
return () => {
|
|
946
|
-
cancelled = true
|
|
947
|
-
}
|
|
948
|
-
}, [setUsername])
|
|
1096
|
+
const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
|
|
1097
|
+
const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
|
|
1098
|
+
const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
|
|
1099
|
+
? "loading"
|
|
1100
|
+
: AsyncResult.isFailure(pullRequestResult)
|
|
1101
|
+
? "error"
|
|
1102
|
+
: "ready"
|
|
1103
|
+
const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
|
|
1104
|
+
const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
|
|
949
1105
|
|
|
950
1106
|
useEffect(() => {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
}))
|
|
958
|
-
|
|
959
|
-
loadOpenPullRequests()
|
|
960
|
-
.then((pullRequests) => {
|
|
961
|
-
if (cancelled) return
|
|
962
|
-
setPullRequestState({
|
|
963
|
-
status: "ready",
|
|
964
|
-
data: pullRequests,
|
|
965
|
-
error: null,
|
|
966
|
-
fetchedAt: new Date(),
|
|
967
|
-
})
|
|
968
|
-
})
|
|
969
|
-
.catch((error) => {
|
|
970
|
-
if (cancelled) return
|
|
971
|
-
setPullRequestState((current) => ({
|
|
972
|
-
...current,
|
|
973
|
-
status: "error",
|
|
974
|
-
error: error instanceof Error ? error.message : String(error),
|
|
975
|
-
}))
|
|
976
|
-
})
|
|
977
|
-
|
|
978
|
-
return () => {
|
|
979
|
-
cancelled = true
|
|
980
|
-
}
|
|
981
|
-
}, [refreshNonce])
|
|
1107
|
+
if (pullRequestStatus !== "loading") return
|
|
1108
|
+
const interval = globalThis.setInterval(() => {
|
|
1109
|
+
setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
|
|
1110
|
+
}, 120)
|
|
1111
|
+
return () => globalThis.clearInterval(interval)
|
|
1112
|
+
}, [pullRequestStatus])
|
|
982
1113
|
|
|
983
1114
|
const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
|
|
984
1115
|
const visibleFilterText = filterMode ? filterDraft : filterQuery
|
|
985
1116
|
|
|
986
|
-
const filteredPullRequests =
|
|
1117
|
+
const filteredPullRequests = pullRequests.filter((pullRequest) => {
|
|
987
1118
|
const query = effectiveFilterQuery
|
|
988
1119
|
if (query.length === 0) return true
|
|
989
1120
|
return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
|
|
@@ -1009,9 +1140,9 @@ export const App = () => {
|
|
|
1009
1140
|
}
|
|
1010
1141
|
return 0
|
|
1011
1142
|
}
|
|
1012
|
-
const summaryRight =
|
|
1013
|
-
? `updated ${formatShortDate(
|
|
1014
|
-
:
|
|
1143
|
+
const summaryRight = pullRequestLoad?.fetchedAt
|
|
1144
|
+
? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
|
|
1145
|
+
: pullRequestStatus === "loading"
|
|
1015
1146
|
? "loading pull requests..."
|
|
1016
1147
|
: ""
|
|
1017
1148
|
const headerLeft = username ? `GHUI ${username}` : "GHUI"
|
|
@@ -1022,13 +1153,13 @@ export const App = () => {
|
|
|
1022
1153
|
if (index >= 0) setSelectedIndex(index)
|
|
1023
1154
|
}
|
|
1024
1155
|
const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
}))
|
|
1156
|
+
const pullRequest = pullRequests.find((item) => item.url === url)
|
|
1157
|
+
if (!pullRequest) return
|
|
1158
|
+
setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
|
|
1029
1159
|
}
|
|
1030
1160
|
const refreshPullRequests = (message?: string) => {
|
|
1031
|
-
|
|
1161
|
+
setPullRequestOverrides({})
|
|
1162
|
+
refreshPullRequestsAtom()
|
|
1032
1163
|
if (message) flashNotice(message)
|
|
1033
1164
|
}
|
|
1034
1165
|
|
|
@@ -1040,6 +1171,31 @@ export const App = () => {
|
|
|
1040
1171
|
}, [visiblePullRequests.length])
|
|
1041
1172
|
|
|
1042
1173
|
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
1174
|
+
const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
|
|
1175
|
+
const detailPlaceholderContent: DetailPlaceholderContent = pullRequestStatus === "loading"
|
|
1176
|
+
? {
|
|
1177
|
+
title: `${loadingIndicator} Loading pull requests`,
|
|
1178
|
+
hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
|
|
1179
|
+
}
|
|
1180
|
+
: pullRequestStatus === "error"
|
|
1181
|
+
? {
|
|
1182
|
+
title: "Could not load pull requests",
|
|
1183
|
+
hint: "Press r to retry",
|
|
1184
|
+
}
|
|
1185
|
+
: visiblePullRequests.length === 0 && visibleFilterText.length > 0
|
|
1186
|
+
? {
|
|
1187
|
+
title: "No matching pull requests",
|
|
1188
|
+
hint: "Press esc to clear the filter",
|
|
1189
|
+
}
|
|
1190
|
+
: visiblePullRequests.length === 0
|
|
1191
|
+
? {
|
|
1192
|
+
title: "No open pull requests",
|
|
1193
|
+
hint: "Press r to refresh",
|
|
1194
|
+
}
|
|
1195
|
+
: {
|
|
1196
|
+
title: "Select a pull request",
|
|
1197
|
+
hint: "Use up/down to move",
|
|
1198
|
+
}
|
|
1043
1199
|
const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
|
|
1044
1200
|
const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
|
|
1045
1201
|
const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
|
|
@@ -1047,7 +1203,9 @@ export const App = () => {
|
|
|
1047
1203
|
const checksRows = checksRowCount(detailChecks)
|
|
1048
1204
|
// checks heading (1) + grid rows + divider
|
|
1049
1205
|
const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
|
|
1050
|
-
const detailJunctions =
|
|
1206
|
+
const detailJunctions = selectedPullRequest
|
|
1207
|
+
? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
|
|
1208
|
+
: [DETAIL_PLACEHOLDER_ROWS]
|
|
1051
1209
|
|
|
1052
1210
|
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
1053
1211
|
|
|
@@ -1068,7 +1226,7 @@ export const App = () => {
|
|
|
1068
1226
|
}
|
|
1069
1227
|
|
|
1070
1228
|
setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
|
|
1071
|
-
void
|
|
1229
|
+
void loadRepoLabels(repository)
|
|
1072
1230
|
.then((labels) => {
|
|
1073
1231
|
setLabelCache((current) => ({ ...current, [repository]: labels }))
|
|
1074
1232
|
setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
|
|
@@ -1095,7 +1253,7 @@ export const App = () => {
|
|
|
1095
1253
|
...pr,
|
|
1096
1254
|
labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
|
|
1097
1255
|
}))
|
|
1098
|
-
void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1256
|
+
void removePullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
|
|
1099
1257
|
.then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
|
|
1100
1258
|
.catch((error) => {
|
|
1101
1259
|
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
@@ -1106,7 +1264,7 @@ export const App = () => {
|
|
|
1106
1264
|
...pr,
|
|
1107
1265
|
labels: [...pr.labels, { name: label.name, color: label.color }],
|
|
1108
1266
|
}))
|
|
1109
|
-
void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1267
|
+
void addPullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
|
|
1110
1268
|
.then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
|
|
1111
1269
|
.catch((error) => {
|
|
1112
1270
|
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
@@ -1348,14 +1506,6 @@ export const App = () => {
|
|
|
1348
1506
|
openLabelModal()
|
|
1349
1507
|
return
|
|
1350
1508
|
}
|
|
1351
|
-
if (key.name === "p" || key.name === "P") {
|
|
1352
|
-
setGroupIconIndex((current) => {
|
|
1353
|
-
const next = (current + 1) % GROUP_ICONS.length
|
|
1354
|
-
flashNotice(`icon: ${GROUP_ICONS[next]}`)
|
|
1355
|
-
return next
|
|
1356
|
-
})
|
|
1357
|
-
return
|
|
1358
|
-
}
|
|
1359
1509
|
if (key.name === "o" && selectedPullRequest) {
|
|
1360
1510
|
void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
|
|
1361
1511
|
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
@@ -1368,7 +1518,7 @@ export const App = () => {
|
|
|
1368
1518
|
...pullRequest,
|
|
1369
1519
|
reviewStatus: nextReviewStatus,
|
|
1370
1520
|
}))
|
|
1371
|
-
void
|
|
1521
|
+
void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
|
|
1372
1522
|
.then(() => {
|
|
1373
1523
|
flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
|
|
1374
1524
|
})
|
|
@@ -1395,8 +1545,8 @@ export const App = () => {
|
|
|
1395
1545
|
const prListProps = {
|
|
1396
1546
|
groups: visibleGroups,
|
|
1397
1547
|
selectedUrl: selectedPullRequest?.url ?? null,
|
|
1398
|
-
status:
|
|
1399
|
-
error:
|
|
1548
|
+
status: pullRequestStatus,
|
|
1549
|
+
error: pullRequestError,
|
|
1400
1550
|
filterText: visibleFilterText,
|
|
1401
1551
|
showFilterBar: filterMode || filterQuery.length > 0,
|
|
1402
1552
|
isFilterEditing: filterMode,
|
|
@@ -1428,6 +1578,7 @@ export const App = () => {
|
|
|
1428
1578
|
bodyLines={fullscreenBodyLines}
|
|
1429
1579
|
paneWidth={contentWidth}
|
|
1430
1580
|
showChecks
|
|
1581
|
+
placeholderContent={detailPlaceholderContent}
|
|
1431
1582
|
/>
|
|
1432
1583
|
</scrollbox>
|
|
1433
1584
|
</box>
|
|
@@ -1448,9 +1599,7 @@ export const App = () => {
|
|
|
1448
1599
|
</scrollbox>
|
|
1449
1600
|
</>
|
|
1450
1601
|
) : (
|
|
1451
|
-
<
|
|
1452
|
-
<PlainLine text="Select a pull request with up/down." fg={colors.muted} />
|
|
1453
|
-
</box>
|
|
1602
|
+
<DetailPlaceholder content={detailPlaceholderContent} paneWidth={rightPaneWidth} />
|
|
1454
1603
|
)}
|
|
1455
1604
|
</box>
|
|
1456
1605
|
</box>
|
|
@@ -1462,12 +1611,13 @@ export const App = () => {
|
|
|
1462
1611
|
contentWidth={fullscreenContentWidth}
|
|
1463
1612
|
bodyLines={fullscreenBodyLines}
|
|
1464
1613
|
paneWidth={contentWidth}
|
|
1614
|
+
placeholderContent={detailPlaceholderContent}
|
|
1465
1615
|
/>
|
|
1466
1616
|
</scrollbox>
|
|
1467
1617
|
</box>
|
|
1468
1618
|
) : (
|
|
1469
1619
|
<>
|
|
1470
|
-
<DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
|
|
1620
|
+
<DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
|
|
1471
1621
|
<Divider width={contentWidth} />
|
|
1472
1622
|
<box flexGrow={1} flexDirection="column">
|
|
1473
1623
|
<scrollbox flexGrow={1}>
|
|
@@ -1485,7 +1635,20 @@ export const App = () => {
|
|
|
1485
1635
|
<Divider width={contentWidth} />
|
|
1486
1636
|
)}
|
|
1487
1637
|
<box paddingLeft={1} paddingRight={1}>
|
|
1488
|
-
{footerNotice ?
|
|
1638
|
+
{footerNotice ? (
|
|
1639
|
+
<PlainLine text={footerNotice} fg={colors.count} />
|
|
1640
|
+
) : (
|
|
1641
|
+
<FooterHints
|
|
1642
|
+
filterEditing={filterMode}
|
|
1643
|
+
showFilterClear={filterMode || filterQuery.length > 0}
|
|
1644
|
+
detailFullView={detailFullView}
|
|
1645
|
+
hasSelection={selectedPullRequest !== null}
|
|
1646
|
+
hasError={pullRequestStatus === "error"}
|
|
1647
|
+
isLoading={pullRequestStatus === "loading"}
|
|
1648
|
+
loadingIndicator={loadingIndicator}
|
|
1649
|
+
retryProgress={retryProgress}
|
|
1650
|
+
/>
|
|
1651
|
+
)}
|
|
1489
1652
|
</box>
|
|
1490
1653
|
{labelModal.open ? (
|
|
1491
1654
|
<LabelModal
|
|
@@ -1495,6 +1658,7 @@ export const App = () => {
|
|
|
1495
1658
|
modalHeight={labelModalHeight}
|
|
1496
1659
|
offsetLeft={labelModalLeft}
|
|
1497
1660
|
offsetTop={labelModalTop}
|
|
1661
|
+
loadingIndicator={loadingIndicator}
|
|
1498
1662
|
/>
|
|
1499
1663
|
) : null}
|
|
1500
1664
|
</box>
|
|
@@ -1,43 +1,71 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
2
|
+
|
|
1
3
|
export interface CommandResult {
|
|
2
4
|
readonly stdout: string
|
|
3
5
|
readonly stderr: string
|
|
4
6
|
readonly exitCode: number
|
|
5
7
|
}
|
|
6
8
|
|
|
9
|
+
export class CommandError extends Schema.TaggedErrorClass<CommandError>()("CommandError", {
|
|
10
|
+
command: Schema.String,
|
|
11
|
+
args: Schema.Array(Schema.String),
|
|
12
|
+
detail: Schema.String,
|
|
13
|
+
cause: Schema.Defect,
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
export class JsonParseError extends Schema.TaggedErrorClass<JsonParseError>()("JsonParseError", {
|
|
17
|
+
command: Schema.String,
|
|
18
|
+
args: Schema.Array(Schema.String),
|
|
19
|
+
stdout: Schema.String,
|
|
20
|
+
cause: Schema.Defect,
|
|
21
|
+
}) {}
|
|
22
|
+
|
|
7
23
|
const readStream = async (stream: ReadableStream | null | undefined) => {
|
|
8
24
|
if (!stream) return ""
|
|
9
25
|
return Bun.readableStreamToText(stream)
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
export class CommandRunner extends Context.Service<CommandRunner, {
|
|
29
|
+
readonly run: (command: string, args: readonly string[]) => Effect.Effect<CommandResult, CommandError>
|
|
30
|
+
readonly runJson: <A>(command: string, args: readonly string[]) => Effect.Effect<A, CommandError | JsonParseError>
|
|
31
|
+
}>()("ghui/CommandRunner") {
|
|
32
|
+
static readonly layer = Layer.effect(
|
|
33
|
+
CommandRunner,
|
|
34
|
+
Effect.gen(function*() {
|
|
35
|
+
const runProcess = Effect.fn("CommandRunner.runProcess")((command: string, args: readonly string[]) =>
|
|
36
|
+
Effect.tryPromise({
|
|
37
|
+
async try() {
|
|
38
|
+
const proc = Bun.spawn({
|
|
39
|
+
cmd: [command, ...args],
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
})
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
|
|
45
|
+
return { stdout, stderr, exitCode }
|
|
46
|
+
},
|
|
47
|
+
catch: (cause) => new CommandError({ command, args: [...args], detail: `Failed to run ${command}`, cause }),
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[]) {
|
|
52
|
+
const result = yield* runProcess(command, args)
|
|
53
|
+
if (result.exitCode !== 0) {
|
|
54
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
|
|
55
|
+
return yield* new CommandError({ command, args: [...args], detail, cause: detail })
|
|
56
|
+
}
|
|
57
|
+
return result
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const runJson = Effect.fn("CommandRunner.runJson")(function*<A>(command: string, args: readonly string[]) {
|
|
61
|
+
const result = yield* run(command, args)
|
|
62
|
+
return yield* Effect.try({
|
|
63
|
+
try: () => JSON.parse(result.stdout) as A,
|
|
64
|
+
catch: (cause) => new JsonParseError({ command, args: [...args], stdout: result.stdout, cause }),
|
|
65
|
+
})
|
|
66
|
+
})
|
|
35
67
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return JSON.parse(result.stdout) as A
|
|
40
|
-
} catch (error) {
|
|
41
|
-
throw new Error(`Could not parse JSON from ${command}: ${String(error)}`)
|
|
42
|
-
}
|
|
68
|
+
return CommandRunner.of({ run, runJson })
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
43
71
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect"
|
|
1
2
|
import { config } from "../config.js"
|
|
2
|
-
import type { CheckItem, PullRequestItem
|
|
3
|
-
import {
|
|
3
|
+
import type { CheckItem, PullRequestItem } from "../domain.js"
|
|
4
|
+
import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
|
|
4
5
|
|
|
5
6
|
interface GitHubListPullRequest {
|
|
6
7
|
readonly number: number
|
|
@@ -153,41 +154,72 @@ const searchOpenArgs = (author: string) => [
|
|
|
153
154
|
searchJsonFields,
|
|
154
155
|
] as const
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
157
|
+
type GitHubError = CommandError | JsonParseError
|
|
158
|
+
|
|
159
|
+
export class GitHubService extends Context.Service<GitHubService, {
|
|
160
|
+
readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
161
|
+
readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
|
|
162
|
+
readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
|
|
163
|
+
readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
|
|
164
|
+
readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
|
|
165
|
+
readonly removePullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
|
|
166
|
+
}>()("ghui/GitHubService") {
|
|
167
|
+
static readonly layerNoDeps = Layer.effect(
|
|
168
|
+
GitHubService,
|
|
169
|
+
Effect.gen(function*() {
|
|
170
|
+
const command = yield* CommandRunner
|
|
171
|
+
|
|
172
|
+
const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
|
|
173
|
+
const searchResults = yield* command.runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
|
|
174
|
+
const pullRequests = yield* Effect.forEach(
|
|
175
|
+
searchResults,
|
|
176
|
+
Effect.fn("GitHubService.loadPullRequestDetail")(function*(searchResult) {
|
|
177
|
+
const repository = searchResult.repository.nameWithOwner
|
|
178
|
+
const pullRequest = yield* command.runJson<GitHubListPullRequest>("gh", [
|
|
179
|
+
"pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
|
|
180
|
+
])
|
|
181
|
+
return parsePullRequest(repository, pullRequest)
|
|
182
|
+
}),
|
|
183
|
+
{ concurrency: 8 },
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
|
|
190
|
+
const viewer = yield* command.runJson<GitHubViewer>("gh", ["api", "user"])
|
|
191
|
+
return viewer.login
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
|
|
195
|
+
yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
|
|
199
|
+
const labels = yield* command.runJson<readonly { name: string; color: string }[]>("gh", [
|
|
200
|
+
"label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
|
|
201
|
+
])
|
|
202
|
+
return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const addPullRequestLabel = Effect.fn("GitHubService.addPullRequestLabel")(function*(repository: string, number: number, label: string) {
|
|
206
|
+
yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const removePullRequestLabel = Effect.fn("GitHubService.removePullRequestLabel")(function*(repository: string, number: number, label: string) {
|
|
210
|
+
yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return GitHubService.of({
|
|
214
|
+
listOpenPullRequests,
|
|
215
|
+
getAuthenticatedUser,
|
|
216
|
+
toggleDraftStatus,
|
|
217
|
+
listRepoLabels,
|
|
218
|
+
addPullRequestLabel,
|
|
219
|
+
removePullRequestLabel,
|
|
220
|
+
})
|
|
165
221
|
}),
|
|
166
222
|
)
|
|
167
223
|
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export const getAuthenticatedUser = async () => {
|
|
172
|
-
const viewer = await runJson<GitHubViewer>("gh", ["api", "user"])
|
|
173
|
-
return viewer.login
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export const toggleDraftStatus = async (repository: string, number: number, isDraft: boolean) => {
|
|
177
|
-
await run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export const listRepoLabels = async (repository: string): Promise<readonly PullRequestLabel[]> => {
|
|
181
|
-
const labels = await runJson<readonly { name: string; color: string }[]>("gh", [
|
|
182
|
-
"label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
|
|
183
|
-
])
|
|
184
|
-
return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export const addPullRequestLabel = async (repository: string, number: number, label: string) => {
|
|
188
|
-
await run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export const removePullRequestLabel = async (repository: string, number: number, label: string) => {
|
|
192
|
-
await run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
|
|
224
|
+
static readonly layer = GitHubService.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
|
|
193
225
|
}
|