@kitlangton/ghui 0.1.0 → 0.1.3
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 +3 -2
- package/src/App.tsx +375 -159
- 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.3",
|
|
4
4
|
"description": "Terminal UI for GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
],
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public",
|
|
31
|
+
"provenance": true,
|
|
31
32
|
"registry": "https://registry.npmjs.org/"
|
|
32
33
|
},
|
|
33
34
|
"bin": {
|
|
34
|
-
"ghui": "
|
|
35
|
+
"ghui": "bin/ghui.js"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
37
38
|
"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,52 @@ interface PreviewLine {
|
|
|
56
56
|
}>
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
interface DetailPlaceholderContent {
|
|
60
|
+
readonly title: string
|
|
61
|
+
readonly hint: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface RetryProgress {
|
|
65
|
+
readonly attempt: number
|
|
66
|
+
readonly max: number
|
|
67
|
+
}
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
status:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
interface DetailPlaceholderInput {
|
|
70
|
+
readonly status: LoadStatus
|
|
71
|
+
readonly retryProgress: RetryProgress | null
|
|
72
|
+
readonly loadingIndicator: string
|
|
73
|
+
readonly visibleCount: number
|
|
74
|
+
readonly filterText: string
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
const
|
|
77
|
+
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
78
|
+
const PR_FETCH_RETRIES = 6
|
|
79
|
+
const DETAIL_PLACEHOLDER_ROWS = 4
|
|
80
|
+
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
81
|
+
|
|
82
|
+
const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
|
|
83
|
+
const pullRequestsAtom = githubRuntime.atom(
|
|
84
|
+
GitHubService.use((github) =>
|
|
85
|
+
Effect.gen(function*() {
|
|
86
|
+
yield* Atom.set(retryProgressAtom, null)
|
|
87
|
+
const data = yield* github.listOpenPullRequests().pipe(
|
|
88
|
+
Effect.tapError(() =>
|
|
89
|
+
Atom.update(retryProgressAtom, (current) => ({
|
|
90
|
+
attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
|
|
91
|
+
max: PR_FETCH_RETRIES,
|
|
92
|
+
}))
|
|
93
|
+
),
|
|
94
|
+
Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
|
|
95
|
+
Effect.tapError(() => Atom.set(retryProgressAtom, null)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
yield* Atom.set(retryProgressAtom, null)
|
|
99
|
+
return { data, fetchedAt: new Date() } satisfies PullRequestLoad
|
|
100
|
+
})
|
|
101
|
+
),
|
|
102
|
+
).pipe(Atom.keepAlive)
|
|
69
103
|
const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
70
104
|
const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
71
|
-
const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
72
105
|
const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
73
106
|
const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
74
107
|
const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
@@ -76,8 +109,7 @@ const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
|
76
109
|
const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
77
110
|
const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
78
111
|
|
|
79
|
-
const
|
|
80
|
-
const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
112
|
+
const GROUP_ICON = "◆"
|
|
81
113
|
|
|
82
114
|
interface LabelModalState {
|
|
83
115
|
readonly open: boolean
|
|
@@ -99,7 +131,25 @@ const initialLabelModalState: LabelModalState = {
|
|
|
99
131
|
|
|
100
132
|
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
101
133
|
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
102
|
-
const
|
|
134
|
+
const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
135
|
+
const usernameAtom = githubRuntime.atom(
|
|
136
|
+
config.author === "@me"
|
|
137
|
+
? GitHubService.use((github) => github.getAuthenticatedUser())
|
|
138
|
+
: Effect.succeed(config.author.replace(/^@/, "")),
|
|
139
|
+
).pipe(Atom.keepAlive)
|
|
140
|
+
|
|
141
|
+
const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
|
|
142
|
+
GitHubService.use((github) => github.listRepoLabels(repository))
|
|
143
|
+
)
|
|
144
|
+
const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
|
|
145
|
+
GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
|
|
146
|
+
)
|
|
147
|
+
const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
|
|
148
|
+
GitHubService.use((github) => github.removePullRequestLabel(input.repository, input.number, input.label))
|
|
149
|
+
)
|
|
150
|
+
const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
|
|
151
|
+
GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
|
|
152
|
+
)
|
|
103
153
|
|
|
104
154
|
const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
105
155
|
|
|
@@ -166,6 +216,12 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
|
|
|
166
216
|
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
167
217
|
}
|
|
168
218
|
|
|
219
|
+
const centerCell = (text: string, width: number) => {
|
|
220
|
+
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
221
|
+
const left = Math.floor((width - trimmed.length) / 2)
|
|
222
|
+
return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
|
|
223
|
+
}
|
|
224
|
+
|
|
169
225
|
const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
170
226
|
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
171
227
|
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
@@ -245,6 +301,8 @@ const fallbackLabelColor = (name: string) => {
|
|
|
245
301
|
return `hsl(${hue} 55% 35%)`
|
|
246
302
|
}
|
|
247
303
|
|
|
304
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
305
|
+
|
|
248
306
|
const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
|
|
249
307
|
|
|
250
308
|
const labelTextColor = (color: string) => {
|
|
@@ -258,6 +316,47 @@ const labelTextColor = (color: string) => {
|
|
|
258
316
|
return "#f8fafc"
|
|
259
317
|
}
|
|
260
318
|
|
|
319
|
+
const getDetailPlaceholderContent = ({
|
|
320
|
+
status,
|
|
321
|
+
retryProgress,
|
|
322
|
+
loadingIndicator,
|
|
323
|
+
visibleCount,
|
|
324
|
+
filterText,
|
|
325
|
+
}: DetailPlaceholderInput): DetailPlaceholderContent => {
|
|
326
|
+
if (status === "loading") {
|
|
327
|
+
return {
|
|
328
|
+
title: `${loadingIndicator} Loading pull requests`,
|
|
329
|
+
hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (status === "error") {
|
|
334
|
+
return {
|
|
335
|
+
title: "Could not load pull requests",
|
|
336
|
+
hint: "Press r to retry",
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (visibleCount === 0 && filterText.length > 0) {
|
|
341
|
+
return {
|
|
342
|
+
title: "No matching pull requests",
|
|
343
|
+
hint: "Press esc to clear the filter",
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (visibleCount === 0) {
|
|
348
|
+
return {
|
|
349
|
+
title: "No open pull requests",
|
|
350
|
+
hint: "Press r to refresh",
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
title: "Select a pull request",
|
|
356
|
+
hint: "Use up/down to move",
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
261
360
|
const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
|
|
262
361
|
const sourceLines = body.replace(/\r/g, "").split("\n")
|
|
263
362
|
const preview: Array<PreviewLine> = []
|
|
@@ -396,43 +495,101 @@ const SectionTitle = ({ title }: { title: string }) => (
|
|
|
396
495
|
</TextLine>
|
|
397
496
|
)
|
|
398
497
|
|
|
399
|
-
const FooterHints = ({
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
498
|
+
const FooterHints = ({
|
|
499
|
+
filterEditing,
|
|
500
|
+
showFilterClear,
|
|
501
|
+
detailFullView,
|
|
502
|
+
hasSelection,
|
|
503
|
+
hasError,
|
|
504
|
+
isLoading,
|
|
505
|
+
loadingIndicator,
|
|
506
|
+
retryProgress,
|
|
507
|
+
}: {
|
|
508
|
+
filterEditing: boolean
|
|
509
|
+
showFilterClear: boolean
|
|
510
|
+
detailFullView: boolean
|
|
511
|
+
hasSelection: boolean
|
|
512
|
+
hasError: boolean
|
|
513
|
+
isLoading: boolean
|
|
514
|
+
loadingIndicator: string
|
|
515
|
+
retryProgress: RetryProgress | null
|
|
516
|
+
}) => {
|
|
517
|
+
if (filterEditing) {
|
|
518
|
+
return (
|
|
519
|
+
<TextLine>
|
|
520
|
+
<span fg={colors.count}>search</span>
|
|
521
|
+
<span fg={colors.muted}> typing </span>
|
|
522
|
+
<span fg={colors.count}>↑↓</span>
|
|
523
|
+
<span fg={colors.muted}> move </span>
|
|
524
|
+
<span fg={colors.count}>enter</span>
|
|
525
|
+
<span fg={colors.muted}> apply </span>
|
|
407
526
|
<span fg={colors.count}>esc</span>
|
|
527
|
+
<span fg={colors.muted}> cancel </span>
|
|
528
|
+
<span fg={colors.count}>ctrl-u</span>
|
|
408
529
|
<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
|
-
)
|
|
530
|
+
<span fg={colors.count}>ctrl-w</span>
|
|
531
|
+
<span fg={colors.muted}> word</span>
|
|
532
|
+
</TextLine>
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<TextLine>
|
|
538
|
+
<span fg={colors.count}>/</span>
|
|
539
|
+
<span fg={colors.muted}> filter </span>
|
|
540
|
+
{showFilterClear ? (
|
|
541
|
+
<>
|
|
542
|
+
<span fg={colors.count}>esc</span>
|
|
543
|
+
<span fg={colors.muted}> clear </span>
|
|
544
|
+
</>
|
|
545
|
+
) : null}
|
|
546
|
+
{retryProgress ? (
|
|
547
|
+
<>
|
|
548
|
+
<span fg={colors.status.pending}>retry</span>
|
|
549
|
+
<span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
|
|
550
|
+
</>
|
|
551
|
+
) : isLoading ? (
|
|
552
|
+
<>
|
|
553
|
+
<span fg={colors.status.pending}>{loadingIndicator}</span>
|
|
554
|
+
<span fg={colors.muted}> loading </span>
|
|
555
|
+
</>
|
|
556
|
+
) : null}
|
|
557
|
+
<span fg={colors.count}>r</span>
|
|
558
|
+
<span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
|
|
559
|
+
{hasSelection ? (
|
|
560
|
+
<>
|
|
561
|
+
<span fg={colors.count}>↑↓</span>
|
|
562
|
+
<span fg={colors.muted}> move </span>
|
|
563
|
+
</>
|
|
564
|
+
) : null}
|
|
565
|
+
{hasSelection && detailFullView ? (
|
|
566
|
+
<>
|
|
567
|
+
<span fg={colors.count}>esc</span>
|
|
568
|
+
<span fg={colors.muted}> back </span>
|
|
569
|
+
</>
|
|
570
|
+
) : hasSelection ? (
|
|
571
|
+
<>
|
|
572
|
+
<span fg={colors.count}>enter</span>
|
|
573
|
+
<span fg={colors.muted}> expand </span>
|
|
574
|
+
</>
|
|
575
|
+
) : null}
|
|
576
|
+
{hasSelection ? (
|
|
577
|
+
<>
|
|
578
|
+
<span fg={colors.count}>d</span>
|
|
579
|
+
<span fg={colors.muted}> draft </span>
|
|
580
|
+
<span fg={colors.count}>l</span>
|
|
581
|
+
<span fg={colors.muted}> labels </span>
|
|
582
|
+
<span fg={colors.count}>o</span>
|
|
583
|
+
<span fg={colors.muted}> open </span>
|
|
584
|
+
<span fg={colors.count}>y</span>
|
|
585
|
+
<span fg={colors.muted}> copy </span>
|
|
586
|
+
</>
|
|
587
|
+
) : null}
|
|
588
|
+
<span fg={colors.count}>q</span>
|
|
589
|
+
<span fg={colors.muted}> quit</span>
|
|
590
|
+
</TextLine>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
436
593
|
|
|
437
594
|
const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
|
|
438
595
|
<TextLine>
|
|
@@ -742,18 +899,67 @@ const DetailBody = ({
|
|
|
742
899
|
)
|
|
743
900
|
}
|
|
744
901
|
|
|
902
|
+
const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
|
|
903
|
+
const innerWidth = Math.max(1, width - 2)
|
|
904
|
+
const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
|
|
905
|
+
const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
|
|
906
|
+
const cardInnerWidth = Math.max(1, cardWidth - 2)
|
|
907
|
+
const contentLine = (text: string, fg: string, bold = false) => (
|
|
908
|
+
<TextLine>
|
|
909
|
+
<span fg={colors.separator}>{offset}│</span>
|
|
910
|
+
{bold ? (
|
|
911
|
+
<span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
|
|
912
|
+
) : (
|
|
913
|
+
<span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
|
|
914
|
+
)}
|
|
915
|
+
<span fg={colors.separator}>│</span>
|
|
916
|
+
</TextLine>
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
return (
|
|
920
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
921
|
+
<PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
|
|
922
|
+
{contentLine(content.title, colors.count, true)}
|
|
923
|
+
{contentLine(content.hint, colors.muted)}
|
|
924
|
+
<PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
|
|
925
|
+
</box>
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
|
|
930
|
+
<box flexDirection="column">
|
|
931
|
+
<StatusCard content={content} width={paneWidth} />
|
|
932
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
933
|
+
</box>
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
|
|
937
|
+
const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
|
|
938
|
+
const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
|
|
939
|
+
|
|
940
|
+
return (
|
|
941
|
+
<box height={height} flexDirection="column">
|
|
942
|
+
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
943
|
+
<StatusCard content={content} width={width} />
|
|
944
|
+
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
945
|
+
</box>
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
|
|
745
949
|
const DetailsPane = ({
|
|
746
950
|
pullRequest,
|
|
747
951
|
contentWidth,
|
|
748
952
|
bodyLines = DETAIL_BODY_LINES,
|
|
749
953
|
paneWidth = contentWidth + 2,
|
|
750
954
|
showChecks = false,
|
|
955
|
+
placeholderContent,
|
|
751
956
|
}: {
|
|
752
957
|
pullRequest: PullRequestItem | null
|
|
753
958
|
contentWidth: number
|
|
754
959
|
bodyLines?: number
|
|
755
960
|
paneWidth?: number
|
|
756
961
|
showChecks?: boolean
|
|
962
|
+
placeholderContent: DetailPlaceholderContent
|
|
757
963
|
}) => {
|
|
758
964
|
const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
|
|
759
965
|
const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
|
|
@@ -764,7 +970,7 @@ const DetailsPane = ({
|
|
|
764
970
|
() => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
|
|
765
971
|
[pullRequest?.body, contentWidth, bodyLines],
|
|
766
972
|
)
|
|
767
|
-
const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
|
|
973
|
+
const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
768
974
|
|
|
769
975
|
return (
|
|
770
976
|
<box flexDirection="column" height={contentHeight}>
|
|
@@ -774,12 +980,14 @@ const DetailsPane = ({
|
|
|
774
980
|
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
|
|
775
981
|
</>
|
|
776
982
|
) : (
|
|
777
|
-
|
|
778
|
-
<
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
983
|
+
<>
|
|
984
|
+
<DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
|
|
985
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
986
|
+
{Array.from({ length: bodyLines }, (_, index) => (
|
|
987
|
+
<BlankRow key={index} />
|
|
988
|
+
))}
|
|
989
|
+
</box>
|
|
990
|
+
</>
|
|
783
991
|
)}
|
|
784
992
|
</box>
|
|
785
993
|
)
|
|
@@ -792,6 +1000,7 @@ const LabelModal = ({
|
|
|
792
1000
|
modalHeight,
|
|
793
1001
|
offsetLeft,
|
|
794
1002
|
offsetTop,
|
|
1003
|
+
loadingIndicator,
|
|
795
1004
|
}: {
|
|
796
1005
|
state: LabelModalState
|
|
797
1006
|
currentLabels: readonly PullRequestLabel[]
|
|
@@ -799,19 +1008,26 @@ const LabelModal = ({
|
|
|
799
1008
|
modalHeight: number
|
|
800
1009
|
offsetLeft: number
|
|
801
1010
|
offsetTop: number
|
|
1011
|
+
loadingIndicator: string
|
|
802
1012
|
}) => {
|
|
803
|
-
const contentWidth = modalWidth -
|
|
1013
|
+
const contentWidth = Math.max(16, modalWidth - 2)
|
|
804
1014
|
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
805
1015
|
const filtered = state.availableLabels.filter((label) =>
|
|
806
1016
|
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
807
1017
|
)
|
|
808
|
-
const maxVisible = Math.max(1, modalHeight -
|
|
1018
|
+
const maxVisible = Math.max(1, modalHeight - 6)
|
|
809
1019
|
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
810
1020
|
const scrollStart = Math.min(
|
|
811
1021
|
Math.max(0, filtered.length - maxVisible),
|
|
812
1022
|
Math.max(0, selectedIndex - maxVisible + 1),
|
|
813
1023
|
)
|
|
814
1024
|
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
1025
|
+
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
1026
|
+
const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
|
|
1027
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
1028
|
+
const queryText = state.query.length > 0 ? state.query : "type to filter labels"
|
|
1029
|
+
const queryPrefix = state.query.length > 0 ? "/ " : "/ "
|
|
1030
|
+
const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
|
|
815
1031
|
|
|
816
1032
|
return (
|
|
817
1033
|
<box
|
|
@@ -825,34 +1041,42 @@ const LabelModal = ({
|
|
|
825
1041
|
>
|
|
826
1042
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
827
1043
|
<TextLine>
|
|
828
|
-
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
829
|
-
|
|
1044
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
1045
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
1046
|
+
<span fg={colors.muted}>{countText}</span>
|
|
830
1047
|
</TextLine>
|
|
831
1048
|
</box>
|
|
832
1049
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
833
1050
|
<TextLine>
|
|
834
|
-
<span fg={colors.count}
|
|
1051
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
835
1052
|
<span fg={state.query.length > 0 ? colors.text : colors.muted}>
|
|
836
|
-
{
|
|
1053
|
+
{fitCell(queryText, queryWidth)}
|
|
837
1054
|
</span>
|
|
838
1055
|
</TextLine>
|
|
839
1056
|
</box>
|
|
840
1057
|
<Divider width={modalWidth} />
|
|
841
1058
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
842
1059
|
{state.loading ? (
|
|
843
|
-
<PlainLine text=
|
|
1060
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
|
|
844
1061
|
) : visibleLabels.length === 0 ? (
|
|
845
|
-
<PlainLine text={state.query.length > 0 ? "No matching labels
|
|
1062
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
|
|
846
1063
|
) : (
|
|
847
1064
|
visibleLabels.map((label, index) => {
|
|
848
1065
|
const actualIndex = scrollStart + index
|
|
849
1066
|
const isActive = currentNames.has(label.name.toLowerCase())
|
|
850
1067
|
const isSelected = actualIndex === selectedIndex
|
|
1068
|
+
const status = isActive ? "added" : ""
|
|
1069
|
+
const nameWidth = Math.max(8, contentWidth - 10 - status.length)
|
|
1070
|
+
const gap = Math.max(1, contentWidth - 8 - Math.min(label.name.length, nameWidth) - status.length)
|
|
851
1071
|
return (
|
|
852
1072
|
<box key={label.name} height={1}>
|
|
853
1073
|
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
854
|
-
<span fg={
|
|
855
|
-
<span
|
|
1074
|
+
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "›" : " "}</span>
|
|
1075
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
|
|
1076
|
+
<span bg={labelColor(label)}> </span>
|
|
1077
|
+
<span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
|
|
1078
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
1079
|
+
{status ? <span fg={colors.status.passing}>{status}</span> : null}
|
|
856
1080
|
</TextLine>
|
|
857
1081
|
</box>
|
|
858
1082
|
)
|
|
@@ -863,8 +1087,12 @@ const LabelModal = ({
|
|
|
863
1087
|
<Divider width={modalWidth} />
|
|
864
1088
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
865
1089
|
<TextLine>
|
|
1090
|
+
<span fg={colors.count}>↑↓</span>
|
|
1091
|
+
<span fg={colors.muted}> move </span>
|
|
866
1092
|
<span fg={colors.count}>enter</span>
|
|
867
1093
|
<span fg={colors.muted}> toggle </span>
|
|
1094
|
+
<span fg={colors.count}>/type</span>
|
|
1095
|
+
<span fg={colors.muted}> filter </span>
|
|
868
1096
|
<span fg={colors.count}>esc</span>
|
|
869
1097
|
<span fg={colors.muted}> close</span>
|
|
870
1098
|
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
@@ -876,10 +1104,10 @@ const LabelModal = ({
|
|
|
876
1104
|
|
|
877
1105
|
export const App = () => {
|
|
878
1106
|
const { width, height } = useTerminalDimensions()
|
|
879
|
-
const
|
|
1107
|
+
const pullRequestResult = useAtomValue(pullRequestsAtom)
|
|
1108
|
+
const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
|
|
880
1109
|
const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
|
|
881
1110
|
const [notice, setNotice] = useAtom(noticeAtom)
|
|
882
|
-
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
883
1111
|
const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
|
|
884
1112
|
const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
|
|
885
1113
|
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
@@ -888,9 +1116,15 @@ export const App = () => {
|
|
|
888
1116
|
const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
|
|
889
1117
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
890
1118
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
891
|
-
const [
|
|
892
|
-
const
|
|
893
|
-
const
|
|
1119
|
+
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
1120
|
+
const retryProgress = useAtomValue(retryProgressAtom)
|
|
1121
|
+
const [loadingFrame, setLoadingFrame] = useState(0)
|
|
1122
|
+
const usernameResult = useAtomValue(usernameAtom)
|
|
1123
|
+
const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
|
|
1124
|
+
const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
|
|
1125
|
+
const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
|
|
1126
|
+
const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
|
|
1127
|
+
const groupIcon = GROUP_ICON
|
|
894
1128
|
const contentWidth = Math.max(60, width ?? 100)
|
|
895
1129
|
const isWideLayout = (width ?? 100) >= 100
|
|
896
1130
|
const splitGap = 1
|
|
@@ -925,65 +1159,29 @@ export const App = () => {
|
|
|
925
1159
|
}
|
|
926
1160
|
}, [])
|
|
927
1161
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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])
|
|
1162
|
+
const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
|
|
1163
|
+
const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
|
|
1164
|
+
const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
|
|
1165
|
+
? "loading"
|
|
1166
|
+
: AsyncResult.isFailure(pullRequestResult)
|
|
1167
|
+
? "error"
|
|
1168
|
+
: "ready"
|
|
1169
|
+
const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
|
|
1170
|
+
const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
|
|
1171
|
+
const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
|
|
949
1172
|
|
|
950
1173
|
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])
|
|
1174
|
+
if (pullRequestStatus !== "loading") return
|
|
1175
|
+
const interval = globalThis.setInterval(() => {
|
|
1176
|
+
setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
|
|
1177
|
+
}, 120)
|
|
1178
|
+
return () => globalThis.clearInterval(interval)
|
|
1179
|
+
}, [pullRequestStatus])
|
|
982
1180
|
|
|
983
1181
|
const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
|
|
984
1182
|
const visibleFilterText = filterMode ? filterDraft : filterQuery
|
|
985
1183
|
|
|
986
|
-
const filteredPullRequests =
|
|
1184
|
+
const filteredPullRequests = pullRequests.filter((pullRequest) => {
|
|
987
1185
|
const query = effectiveFilterQuery
|
|
988
1186
|
if (query.length === 0) return true
|
|
989
1187
|
return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
|
|
@@ -1009,9 +1207,9 @@ export const App = () => {
|
|
|
1009
1207
|
}
|
|
1010
1208
|
return 0
|
|
1011
1209
|
}
|
|
1012
|
-
const summaryRight =
|
|
1013
|
-
? `updated ${formatShortDate(
|
|
1014
|
-
:
|
|
1210
|
+
const summaryRight = pullRequestLoad?.fetchedAt
|
|
1211
|
+
? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
|
|
1212
|
+
: pullRequestStatus === "loading"
|
|
1015
1213
|
? "loading pull requests..."
|
|
1016
1214
|
: ""
|
|
1017
1215
|
const headerLeft = username ? `GHUI ${username}` : "GHUI"
|
|
@@ -1022,13 +1220,13 @@ export const App = () => {
|
|
|
1022
1220
|
if (index >= 0) setSelectedIndex(index)
|
|
1023
1221
|
}
|
|
1024
1222
|
const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
}))
|
|
1223
|
+
const pullRequest = pullRequests.find((item) => item.url === url)
|
|
1224
|
+
if (!pullRequest) return
|
|
1225
|
+
setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
|
|
1029
1226
|
}
|
|
1030
1227
|
const refreshPullRequests = (message?: string) => {
|
|
1031
|
-
|
|
1228
|
+
setPullRequestOverrides({})
|
|
1229
|
+
refreshPullRequestsAtom()
|
|
1032
1230
|
if (message) flashNotice(message)
|
|
1033
1231
|
}
|
|
1034
1232
|
|
|
@@ -1040,6 +1238,14 @@ export const App = () => {
|
|
|
1040
1238
|
}, [visiblePullRequests.length])
|
|
1041
1239
|
|
|
1042
1240
|
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
1241
|
+
const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
|
|
1242
|
+
const detailPlaceholderContent = getDetailPlaceholderContent({
|
|
1243
|
+
status: pullRequestStatus,
|
|
1244
|
+
retryProgress,
|
|
1245
|
+
loadingIndicator,
|
|
1246
|
+
visibleCount: visiblePullRequests.length,
|
|
1247
|
+
filterText: visibleFilterText,
|
|
1248
|
+
})
|
|
1043
1249
|
const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
|
|
1044
1250
|
const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
|
|
1045
1251
|
const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
|
|
@@ -1047,7 +1253,9 @@ export const App = () => {
|
|
|
1047
1253
|
const checksRows = checksRowCount(detailChecks)
|
|
1048
1254
|
// checks heading (1) + grid rows + divider
|
|
1049
1255
|
const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
|
|
1050
|
-
const detailJunctions =
|
|
1256
|
+
const detailJunctions = selectedPullRequest
|
|
1257
|
+
? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
|
|
1258
|
+
: [DETAIL_PLACEHOLDER_ROWS]
|
|
1051
1259
|
|
|
1052
1260
|
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
1053
1261
|
|
|
@@ -1068,7 +1276,7 @@ export const App = () => {
|
|
|
1068
1276
|
}
|
|
1069
1277
|
|
|
1070
1278
|
setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
|
|
1071
|
-
void
|
|
1279
|
+
void loadRepoLabels(repository)
|
|
1072
1280
|
.then((labels) => {
|
|
1073
1281
|
setLabelCache((current) => ({ ...current, [repository]: labels }))
|
|
1074
1282
|
setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
|
|
@@ -1095,7 +1303,7 @@ export const App = () => {
|
|
|
1095
1303
|
...pr,
|
|
1096
1304
|
labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
|
|
1097
1305
|
}))
|
|
1098
|
-
void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1306
|
+
void removePullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
|
|
1099
1307
|
.then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
|
|
1100
1308
|
.catch((error) => {
|
|
1101
1309
|
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
@@ -1106,7 +1314,7 @@ export const App = () => {
|
|
|
1106
1314
|
...pr,
|
|
1107
1315
|
labels: [...pr.labels, { name: label.name, color: label.color }],
|
|
1108
1316
|
}))
|
|
1109
|
-
void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1317
|
+
void addPullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
|
|
1110
1318
|
.then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
|
|
1111
1319
|
.catch((error) => {
|
|
1112
1320
|
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
@@ -1348,14 +1556,6 @@ export const App = () => {
|
|
|
1348
1556
|
openLabelModal()
|
|
1349
1557
|
return
|
|
1350
1558
|
}
|
|
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
1559
|
if (key.name === "o" && selectedPullRequest) {
|
|
1360
1560
|
void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
|
|
1361
1561
|
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
@@ -1368,7 +1568,7 @@ export const App = () => {
|
|
|
1368
1568
|
...pullRequest,
|
|
1369
1569
|
reviewStatus: nextReviewStatus,
|
|
1370
1570
|
}))
|
|
1371
|
-
void
|
|
1571
|
+
void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
|
|
1372
1572
|
.then(() => {
|
|
1373
1573
|
flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
|
|
1374
1574
|
})
|
|
@@ -1395,8 +1595,8 @@ export const App = () => {
|
|
|
1395
1595
|
const prListProps = {
|
|
1396
1596
|
groups: visibleGroups,
|
|
1397
1597
|
selectedUrl: selectedPullRequest?.url ?? null,
|
|
1398
|
-
status:
|
|
1399
|
-
error:
|
|
1598
|
+
status: pullRequestStatus,
|
|
1599
|
+
error: pullRequestError,
|
|
1400
1600
|
filterText: visibleFilterText,
|
|
1401
1601
|
showFilterBar: filterMode || filterQuery.length > 0,
|
|
1402
1602
|
isFilterEditing: filterMode,
|
|
@@ -1414,12 +1614,14 @@ export const App = () => {
|
|
|
1414
1614
|
<box paddingLeft={1} paddingRight={1} flexDirection="column">
|
|
1415
1615
|
<PlainLine text={headerLine} fg={colors.muted} bold />
|
|
1416
1616
|
</box>
|
|
1417
|
-
{isWideLayout && !detailFullView ? (
|
|
1617
|
+
{isWideLayout && !detailFullView && !isInitialLoading ? (
|
|
1418
1618
|
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
|
|
1419
1619
|
) : (
|
|
1420
1620
|
<Divider width={contentWidth} />
|
|
1421
1621
|
)}
|
|
1422
|
-
{
|
|
1622
|
+
{isInitialLoading ? (
|
|
1623
|
+
<LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
|
|
1624
|
+
) : isWideLayout && detailFullView ? (
|
|
1423
1625
|
<box flexGrow={1} flexDirection="column">
|
|
1424
1626
|
<scrollbox flexGrow={1}>
|
|
1425
1627
|
<DetailsPane
|
|
@@ -1428,6 +1630,7 @@ export const App = () => {
|
|
|
1428
1630
|
bodyLines={fullscreenBodyLines}
|
|
1429
1631
|
paneWidth={contentWidth}
|
|
1430
1632
|
showChecks
|
|
1633
|
+
placeholderContent={detailPlaceholderContent}
|
|
1431
1634
|
/>
|
|
1432
1635
|
</scrollbox>
|
|
1433
1636
|
</box>
|
|
@@ -1448,9 +1651,7 @@ export const App = () => {
|
|
|
1448
1651
|
</scrollbox>
|
|
1449
1652
|
</>
|
|
1450
1653
|
) : (
|
|
1451
|
-
<
|
|
1452
|
-
<PlainLine text="Select a pull request with up/down." fg={colors.muted} />
|
|
1453
|
-
</box>
|
|
1654
|
+
<DetailPlaceholder content={detailPlaceholderContent} paneWidth={rightPaneWidth} />
|
|
1454
1655
|
)}
|
|
1455
1656
|
</box>
|
|
1456
1657
|
</box>
|
|
@@ -1462,12 +1663,13 @@ export const App = () => {
|
|
|
1462
1663
|
contentWidth={fullscreenContentWidth}
|
|
1463
1664
|
bodyLines={fullscreenBodyLines}
|
|
1464
1665
|
paneWidth={contentWidth}
|
|
1666
|
+
placeholderContent={detailPlaceholderContent}
|
|
1465
1667
|
/>
|
|
1466
1668
|
</scrollbox>
|
|
1467
1669
|
</box>
|
|
1468
1670
|
) : (
|
|
1469
1671
|
<>
|
|
1470
|
-
<DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
|
|
1672
|
+
<DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
|
|
1471
1673
|
<Divider width={contentWidth} />
|
|
1472
1674
|
<box flexGrow={1} flexDirection="column">
|
|
1473
1675
|
<scrollbox flexGrow={1}>
|
|
@@ -1479,13 +1681,26 @@ export const App = () => {
|
|
|
1479
1681
|
</>
|
|
1480
1682
|
)}
|
|
1481
1683
|
|
|
1482
|
-
{isWideLayout && !detailFullView ? (
|
|
1684
|
+
{isWideLayout && !detailFullView && !isInitialLoading ? (
|
|
1483
1685
|
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
|
|
1484
1686
|
) : (
|
|
1485
1687
|
<Divider width={contentWidth} />
|
|
1486
1688
|
)}
|
|
1487
1689
|
<box paddingLeft={1} paddingRight={1}>
|
|
1488
|
-
{footerNotice ?
|
|
1690
|
+
{footerNotice ? (
|
|
1691
|
+
<PlainLine text={footerNotice} fg={colors.count} />
|
|
1692
|
+
) : (
|
|
1693
|
+
<FooterHints
|
|
1694
|
+
filterEditing={filterMode}
|
|
1695
|
+
showFilterClear={filterMode || filterQuery.length > 0}
|
|
1696
|
+
detailFullView={detailFullView}
|
|
1697
|
+
hasSelection={selectedPullRequest !== null}
|
|
1698
|
+
hasError={pullRequestStatus === "error"}
|
|
1699
|
+
isLoading={pullRequestStatus === "loading"}
|
|
1700
|
+
loadingIndicator={loadingIndicator}
|
|
1701
|
+
retryProgress={retryProgress}
|
|
1702
|
+
/>
|
|
1703
|
+
)}
|
|
1489
1704
|
</box>
|
|
1490
1705
|
{labelModal.open ? (
|
|
1491
1706
|
<LabelModal
|
|
@@ -1495,6 +1710,7 @@ export const App = () => {
|
|
|
1495
1710
|
modalHeight={labelModalHeight}
|
|
1496
1711
|
offsetLeft={labelModalLeft}
|
|
1497
1712
|
offsetTop={labelModalTop}
|
|
1713
|
+
loadingIndicator={loadingIndicator}
|
|
1498
1714
|
/>
|
|
1499
1715
|
) : null}
|
|
1500
1716
|
</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
|
}
|