@kitlangton/ghui 0.1.16 → 0.1.17
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 +20 -25
- package/package.json +1 -1
- package/src/App.tsx +147 -11
- package/src/domain.ts +1 -1
- package/src/services/GitHubService.ts +6 -0
- package/src/ui/FooterHints.tsx +8 -0
- package/src/ui/PullRequestList.tsx +11 -5
- package/src/ui/modals.tsx +88 -1
- package/src/ui/pullRequests.ts +2 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# ghui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Terminal UI for keeping up with your open GitHub pull requests across repositories.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`ghui` gives you one keyboard-driven place to review PR details, inspect diffs, manage labels, toggle draft state, merge, open PRs in GitHub, and copy PR metadata without leaving the terminal.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,38 +10,29 @@ Terminal UI for browsing and acting on your open GitHub pull requests across rep
|
|
|
10
10
|
npm install -g @kitlangton/ghui
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
## Install Locally
|
|
13
|
+
Requirements:
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
git clone https://github.com/kitlangton/ghui.git
|
|
21
|
-
cd ghui
|
|
22
|
-
bun install
|
|
23
|
-
bun link
|
|
24
|
-
```
|
|
15
|
+
- Bun runtime installed
|
|
16
|
+
- GitHub CLI installed and authenticated with `gh auth login`
|
|
25
17
|
|
|
26
|
-
Run from anywhere:
|
|
18
|
+
Run it from anywhere:
|
|
27
19
|
|
|
28
20
|
```bash
|
|
29
21
|
ghui
|
|
30
22
|
```
|
|
31
23
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
This package publishes from GitHub Releases using npm Trusted Publishing.
|
|
24
|
+
<img width="1420" height="856" alt="image" src="https://github.com/user-attachments/assets/5e560a4a-5887-4baa-a6d4-e1f4f0410c70" />
|
|
35
25
|
|
|
36
|
-
|
|
26
|
+
## Local Development
|
|
37
27
|
|
|
38
|
-
|
|
39
|
-
- Publisher: GitHub Actions
|
|
40
|
-
- Owner: `kitlangton`
|
|
41
|
-
- Repository: `ghui`
|
|
42
|
-
- Workflow filename: `publish.yml`
|
|
28
|
+
Clone, install, and link:
|
|
43
29
|
|
|
44
|
-
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/kitlangton/ghui.git
|
|
32
|
+
cd ghui
|
|
33
|
+
bun install
|
|
34
|
+
bun link
|
|
35
|
+
```
|
|
45
36
|
|
|
46
37
|
## Configuration
|
|
47
38
|
|
|
@@ -66,7 +57,11 @@ You can also copy `.env.example` to `.env` and edit the values locally.
|
|
|
66
57
|
- `enter`: expand details
|
|
67
58
|
- `esc`: return from expanded details or close modal
|
|
68
59
|
- `r`: refresh
|
|
69
|
-
- `d`:
|
|
60
|
+
- `d`: view diff
|
|
61
|
+
- `s`: toggle draft or ready-for-review state
|
|
62
|
+
- `m`: merge
|
|
63
|
+
- `x`: close with confirmation
|
|
64
|
+
- `t`: choose theme
|
|
70
65
|
- `l`: manage labels
|
|
71
66
|
- `o`: open PR in browser
|
|
72
67
|
- `y`: copy PR metadata
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "
|
|
|
17
17
|
import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
|
|
18
18
|
import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
|
|
19
19
|
import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
|
|
20
|
-
import { initialLabelModalState, initialMergeModalState, initialThemeModalState, LabelModal, MergeModal, ThemeModal } from "./ui/modals.js"
|
|
20
|
+
import { CloseModal, initialCloseModalState, initialLabelModalState, initialMergeModalState, initialThemeModalState, LabelModal, MergeModal, ThemeModal } from "./ui/modals.js"
|
|
21
21
|
import { groupBy, reviewLabel } from "./ui/pullRequests.js"
|
|
22
22
|
import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
|
|
23
23
|
import { PullRequestList } from "./ui/PullRequestList.js"
|
|
@@ -82,11 +82,13 @@ const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
|
|
|
82
82
|
const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
|
|
83
83
|
|
|
84
84
|
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
85
|
+
const closeModalAtom = Atom.make(initialCloseModalState).pipe(Atom.keepAlive)
|
|
85
86
|
const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
|
|
86
87
|
const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
|
|
87
88
|
const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
|
|
88
89
|
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
89
90
|
const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
91
|
+
const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
90
92
|
const usernameAtom = githubRuntime.atom(
|
|
91
93
|
config.author === "@me"
|
|
92
94
|
? GitHubService.use((github) => github.getAuthenticatedUser())
|
|
@@ -117,6 +119,9 @@ const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: stri
|
|
|
117
119
|
const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
|
|
118
120
|
GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
|
|
119
121
|
)
|
|
122
|
+
const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
|
|
123
|
+
GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
|
|
124
|
+
)
|
|
120
125
|
|
|
121
126
|
const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
122
127
|
|
|
@@ -262,6 +267,7 @@ export const App = () => {
|
|
|
262
267
|
const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
|
|
263
268
|
const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
|
|
264
269
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
270
|
+
const [closeModal, setCloseModal] = useAtom(closeModalAtom)
|
|
265
271
|
const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
|
|
266
272
|
const [themeId, setThemeId] = useAtom(themeIdAtom)
|
|
267
273
|
const [themeModal, setThemeModal] = useAtom(themeModalAtom)
|
|
@@ -272,8 +278,11 @@ export const App = () => {
|
|
|
272
278
|
themeModalRef.current = themeModal
|
|
273
279
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
274
280
|
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
281
|
+
const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
|
|
275
282
|
const retryProgress = useAtomValue(retryProgressAtom)
|
|
276
283
|
const [loadingFrame, setLoadingFrame] = useState(0)
|
|
284
|
+
const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
|
|
285
|
+
const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
|
|
277
286
|
const [terminalFocused, setTerminalFocused] = useState(true)
|
|
278
287
|
const usernameResult = useAtomValue(usernameAtom)
|
|
279
288
|
const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
|
|
@@ -284,6 +293,7 @@ export const App = () => {
|
|
|
284
293
|
const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
|
|
285
294
|
const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
|
|
286
295
|
const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
|
|
296
|
+
const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
|
|
287
297
|
const terminalWidth = width ?? 100
|
|
288
298
|
const terminalHeight = height ?? 24
|
|
289
299
|
const contentWidth = Math.max(1, terminalWidth)
|
|
@@ -301,6 +311,7 @@ export const App = () => {
|
|
|
301
311
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
302
312
|
const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
303
313
|
const detailHydrationRef = useRef<number | null>(null)
|
|
314
|
+
const refreshGenerationRef = useRef(0)
|
|
304
315
|
const lastPullRequestRefreshAtRef = useRef(0)
|
|
305
316
|
const terminalFocusedRef = useRef(true)
|
|
306
317
|
const terminalWasBlurredRef = useRef(false)
|
|
@@ -338,10 +349,19 @@ export const App = () => {
|
|
|
338
349
|
}, [])
|
|
339
350
|
|
|
340
351
|
const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
|
|
341
|
-
const pullRequests = useMemo(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
352
|
+
const pullRequests = useMemo(() => {
|
|
353
|
+
const source = pullRequestLoad?.data ?? []
|
|
354
|
+
const seenUrls = new Set<string>()
|
|
355
|
+
const openPullRequests = source.map((pullRequest) => {
|
|
356
|
+
seenUrls.add(pullRequest.url)
|
|
357
|
+
return recentlyCompletedPullRequests[pullRequest.url] ?? pullRequestOverrides[pullRequest.url] ?? pullRequest
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
return [
|
|
361
|
+
...openPullRequests,
|
|
362
|
+
...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
|
|
363
|
+
]
|
|
364
|
+
}, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
|
|
345
365
|
const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
|
|
346
366
|
? "loading"
|
|
347
367
|
: AsyncResult.isFailure(pullRequestResult)
|
|
@@ -399,8 +419,14 @@ export const App = () => {
|
|
|
399
419
|
setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
|
|
400
420
|
}
|
|
401
421
|
const refreshPullRequests = (message?: string) => {
|
|
422
|
+
refreshGenerationRef.current += 1
|
|
423
|
+
setPullRequestOverrides({})
|
|
424
|
+
if (message) {
|
|
425
|
+
setNotice(null)
|
|
426
|
+
setRefreshCompletionMessage(message)
|
|
427
|
+
setRefreshStartedAt(lastPullRequestRefreshAtRef.current)
|
|
428
|
+
}
|
|
402
429
|
refreshPullRequestsAtom()
|
|
403
|
-
if (message) flashNotice(message)
|
|
404
430
|
}
|
|
405
431
|
refreshPullRequestsRef.current = refreshPullRequests
|
|
406
432
|
maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
|
|
@@ -417,6 +443,21 @@ export const App = () => {
|
|
|
417
443
|
}
|
|
418
444
|
}, [pullRequestLoad?.fetchedAt])
|
|
419
445
|
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
if (!refreshCompletionMessage || refreshStartedAt === null) return
|
|
448
|
+
const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
|
|
449
|
+
const isHydratingDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
|
|
450
|
+
if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
|
|
451
|
+
flashNotice(`✓ ${refreshCompletionMessage}`)
|
|
452
|
+
setRefreshCompletionMessage(null)
|
|
453
|
+
setRefreshStartedAt(null)
|
|
454
|
+
} else if (pullRequestStatus === "error") {
|
|
455
|
+
flashNotice("Refresh failed")
|
|
456
|
+
setRefreshCompletionMessage(null)
|
|
457
|
+
setRefreshStartedAt(null)
|
|
458
|
+
}
|
|
459
|
+
}, [refreshCompletionMessage, refreshStartedAt, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests])
|
|
460
|
+
|
|
420
461
|
useEffect(() => {
|
|
421
462
|
const handleFocus = () => {
|
|
422
463
|
terminalFocusedRef.current = true
|
|
@@ -464,8 +505,9 @@ export const App = () => {
|
|
|
464
505
|
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
465
506
|
const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
|
|
466
507
|
const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
|
|
467
|
-
const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => !pullRequest.detailLoaded)
|
|
468
|
-
const
|
|
508
|
+
const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
|
|
509
|
+
const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
|
|
510
|
+
const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
|
|
469
511
|
const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
|
|
470
512
|
|
|
471
513
|
useEffect(() => {
|
|
@@ -480,9 +522,11 @@ export const App = () => {
|
|
|
480
522
|
const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
|
|
481
523
|
if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
|
|
482
524
|
if (detailHydrationRef.current === fetchedAt) return
|
|
483
|
-
if (!pullRequests.some((pullRequest) => !pullRequest.detailLoaded)) return
|
|
525
|
+
if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
|
|
484
526
|
detailHydrationRef.current = fetchedAt
|
|
527
|
+
const generation = refreshGenerationRef.current
|
|
485
528
|
void loadPullRequestDetails().then((details) => {
|
|
529
|
+
if (generation !== refreshGenerationRef.current) return
|
|
486
530
|
setPullRequestOverrides((current) => {
|
|
487
531
|
const next = { ...current }
|
|
488
532
|
for (const detail of details) {
|
|
@@ -560,8 +604,53 @@ export const App = () => {
|
|
|
560
604
|
.catch((error) => flashNotice(errorMessage(error)))
|
|
561
605
|
}
|
|
562
606
|
|
|
607
|
+
const openCloseModal = () => {
|
|
608
|
+
if (!selectedPullRequest || selectedPullRequest.state !== "open") return
|
|
609
|
+
setLabelModal(initialLabelModalState)
|
|
610
|
+
setMergeModal(initialMergeModalState)
|
|
611
|
+
setThemeModal(initialThemeModalState)
|
|
612
|
+
setCloseModal({
|
|
613
|
+
open: true,
|
|
614
|
+
repository: selectedPullRequest.repository,
|
|
615
|
+
number: selectedPullRequest.number,
|
|
616
|
+
title: selectedPullRequest.title,
|
|
617
|
+
url: selectedPullRequest.url,
|
|
618
|
+
running: false,
|
|
619
|
+
error: null,
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const confirmClosePullRequest = () => {
|
|
624
|
+
if (!closeModal.repository || closeModal.number === null || !closeModal.url || closeModal.running) return
|
|
625
|
+
const { repository, number, url } = closeModal
|
|
626
|
+
const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.url === url)
|
|
627
|
+
const previousPullRequest = targetPullRequest ?? null
|
|
628
|
+
|
|
629
|
+
setCloseModal((current) => ({ ...current, running: true, error: null }))
|
|
630
|
+
void closePullRequest({ repository, number })
|
|
631
|
+
.then(() => {
|
|
632
|
+
if (previousPullRequest) {
|
|
633
|
+
setRecentlyCompletedPullRequests((current) => ({
|
|
634
|
+
...current,
|
|
635
|
+
[previousPullRequest.url]: {
|
|
636
|
+
...previousPullRequest,
|
|
637
|
+
state: "closed",
|
|
638
|
+
autoMergeEnabled: false,
|
|
639
|
+
},
|
|
640
|
+
}))
|
|
641
|
+
}
|
|
642
|
+
setCloseModal(initialCloseModalState)
|
|
643
|
+
refreshPullRequests(`Closed #${number}`)
|
|
644
|
+
})
|
|
645
|
+
.catch((error) => {
|
|
646
|
+
setCloseModal((current) => ({ ...current, running: false, error: errorMessage(error) }))
|
|
647
|
+
flashNotice(errorMessage(error))
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
563
651
|
const openThemeModal = () => {
|
|
564
652
|
setLabelModal(initialLabelModalState)
|
|
653
|
+
setCloseModal(initialCloseModalState)
|
|
565
654
|
setMergeModal(initialMergeModalState)
|
|
566
655
|
setThemeModal({
|
|
567
656
|
open: true,
|
|
@@ -622,6 +711,7 @@ export const App = () => {
|
|
|
622
711
|
|
|
623
712
|
const openLabelModal = () => {
|
|
624
713
|
if (!selectedPullRequest) return
|
|
714
|
+
setCloseModal(initialCloseModalState)
|
|
625
715
|
setMergeModal(initialMergeModalState)
|
|
626
716
|
setThemeModal(initialThemeModalState)
|
|
627
717
|
const repository = selectedPullRequest.repository
|
|
@@ -652,6 +742,7 @@ export const App = () => {
|
|
|
652
742
|
|
|
653
743
|
const openMergeModal = () => {
|
|
654
744
|
if (!selectedPullRequest) return
|
|
745
|
+
setCloseModal(initialCloseModalState)
|
|
655
746
|
setThemeModal(initialThemeModalState)
|
|
656
747
|
const repository = selectedPullRequest.repository
|
|
657
748
|
const number = selectedPullRequest.number
|
|
@@ -702,6 +793,16 @@ export const App = () => {
|
|
|
702
793
|
setMergeModal((current) => ({ ...current, running: true, error: null }))
|
|
703
794
|
void mergePullRequest({ repository, number, action: option.action })
|
|
704
795
|
.then(() => {
|
|
796
|
+
if (option.refreshOnSuccess && previousPullRequest) {
|
|
797
|
+
setRecentlyCompletedPullRequests((current) => ({
|
|
798
|
+
...current,
|
|
799
|
+
[previousPullRequest.url]: {
|
|
800
|
+
...previousPullRequest,
|
|
801
|
+
state: "merged",
|
|
802
|
+
autoMergeEnabled: false,
|
|
803
|
+
},
|
|
804
|
+
}))
|
|
805
|
+
}
|
|
705
806
|
setMergeModal(initialMergeModalState)
|
|
706
807
|
if (option.refreshOnSuccess) {
|
|
707
808
|
refreshPullRequests(`${option.pastTense} #${number}`)
|
|
@@ -758,6 +859,10 @@ export const App = () => {
|
|
|
758
859
|
closeThemeModal(false)
|
|
759
860
|
return
|
|
760
861
|
}
|
|
862
|
+
if (closeModal.open) {
|
|
863
|
+
setCloseModal(initialCloseModalState)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
761
866
|
if (mergeModal.open) {
|
|
762
867
|
setMergeModal(initialMergeModalState)
|
|
763
868
|
return
|
|
@@ -811,6 +916,18 @@ export const App = () => {
|
|
|
811
916
|
return
|
|
812
917
|
}
|
|
813
918
|
|
|
919
|
+
if (closeModal.open) {
|
|
920
|
+
if (key.name === "escape") {
|
|
921
|
+
setCloseModal(initialCloseModalState)
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
if (key.name === "return" || key.name === "enter") {
|
|
925
|
+
confirmClosePullRequest()
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
814
931
|
if (mergeModal.open) {
|
|
815
932
|
const options = availableMergeActions(mergeModal.info)
|
|
816
933
|
if (key.name === "escape") {
|
|
@@ -1110,7 +1227,7 @@ export const App = () => {
|
|
|
1110
1227
|
return
|
|
1111
1228
|
}
|
|
1112
1229
|
if (key.name === "r") {
|
|
1113
|
-
refreshPullRequests("
|
|
1230
|
+
refreshPullRequests("Refreshed")
|
|
1114
1231
|
return
|
|
1115
1232
|
}
|
|
1116
1233
|
if (
|
|
@@ -1203,6 +1320,10 @@ export const App = () => {
|
|
|
1203
1320
|
openDiffView()
|
|
1204
1321
|
return
|
|
1205
1322
|
}
|
|
1323
|
+
if (key.name === "x" && selectedPullRequest?.state === "open") {
|
|
1324
|
+
openCloseModal()
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1206
1327
|
if (key.name === "l" && selectedPullRequest) {
|
|
1207
1328
|
openLabelModal()
|
|
1208
1329
|
return
|
|
@@ -1278,6 +1399,10 @@ export const App = () => {
|
|
|
1278
1399
|
const labelModalHeight = Math.min(20, terminalHeight - 4)
|
|
1279
1400
|
const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
|
|
1280
1401
|
const labelModalTop = Math.floor((terminalHeight - labelModalHeight) / 2)
|
|
1402
|
+
const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
|
|
1403
|
+
const closeModalHeight = Math.min(12, terminalHeight - 4)
|
|
1404
|
+
const closeModalLeft = Math.floor((contentWidth - closeModalWidth) / 2)
|
|
1405
|
+
const closeModalTop = Math.floor((terminalHeight - closeModalHeight) / 2)
|
|
1281
1406
|
const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
|
|
1282
1407
|
const mergeModalHeight = Math.min(16, terminalHeight - 4)
|
|
1283
1408
|
const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
|
|
@@ -1391,8 +1516,9 @@ export const App = () => {
|
|
|
1391
1516
|
detailFullView={detailFullView}
|
|
1392
1517
|
diffFullView={diffFullView}
|
|
1393
1518
|
hasSelection={selectedPullRequest !== null}
|
|
1519
|
+
canCloseSelection={selectedPullRequest?.state === "open"}
|
|
1394
1520
|
hasError={pullRequestStatus === "error"}
|
|
1395
|
-
isLoading={pullRequestStatus === "loading"}
|
|
1521
|
+
isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running}
|
|
1396
1522
|
loadingIndicator={loadingIndicator}
|
|
1397
1523
|
retryProgress={retryProgress}
|
|
1398
1524
|
/>
|
|
@@ -1409,6 +1535,16 @@ export const App = () => {
|
|
|
1409
1535
|
loadingIndicator={loadingIndicator}
|
|
1410
1536
|
/>
|
|
1411
1537
|
) : null}
|
|
1538
|
+
{closeModal.open ? (
|
|
1539
|
+
<CloseModal
|
|
1540
|
+
state={closeModal}
|
|
1541
|
+
modalWidth={closeModalWidth}
|
|
1542
|
+
modalHeight={closeModalHeight}
|
|
1543
|
+
offsetLeft={closeModalLeft}
|
|
1544
|
+
offsetTop={closeModalTop}
|
|
1545
|
+
loadingIndicator={loadingIndicator}
|
|
1546
|
+
/>
|
|
1547
|
+
) : null}
|
|
1412
1548
|
{mergeModal.open ? (
|
|
1413
1549
|
<MergeModal
|
|
1414
1550
|
state={mergeModal}
|
package/src/domain.ts
CHANGED
|
@@ -301,6 +301,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
301
301
|
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
|
|
302
302
|
readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
|
|
303
303
|
readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
|
|
304
|
+
readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
|
|
304
305
|
readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
|
|
305
306
|
readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
|
|
306
307
|
readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
|
|
@@ -399,6 +400,10 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
399
400
|
yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
|
|
400
401
|
})
|
|
401
402
|
|
|
403
|
+
const closePullRequest = Effect.fn("GitHubService.closePullRequest")(function*(repository: string, number: number) {
|
|
404
|
+
yield* command.run("gh", ["pr", "close", String(number), "--repo", repository])
|
|
405
|
+
})
|
|
406
|
+
|
|
402
407
|
const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
|
|
403
408
|
yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
404
409
|
})
|
|
@@ -425,6 +430,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
425
430
|
getPullRequestDiff,
|
|
426
431
|
getPullRequestMergeInfo,
|
|
427
432
|
mergePullRequest,
|
|
433
|
+
closePullRequest,
|
|
428
434
|
toggleDraftStatus,
|
|
429
435
|
listRepoLabels,
|
|
430
436
|
addPullRequestLabel,
|
package/src/ui/FooterHints.tsx
CHANGED
|
@@ -12,6 +12,7 @@ export const FooterHints = ({
|
|
|
12
12
|
detailFullView,
|
|
13
13
|
diffFullView,
|
|
14
14
|
hasSelection,
|
|
15
|
+
canCloseSelection,
|
|
15
16
|
hasError,
|
|
16
17
|
isLoading,
|
|
17
18
|
loadingIndicator,
|
|
@@ -22,6 +23,7 @@ export const FooterHints = ({
|
|
|
22
23
|
detailFullView: boolean
|
|
23
24
|
diffFullView: boolean
|
|
24
25
|
hasSelection: boolean
|
|
26
|
+
canCloseSelection: boolean
|
|
25
27
|
hasError: boolean
|
|
26
28
|
isLoading: boolean
|
|
27
29
|
loadingIndicator: string
|
|
@@ -117,6 +119,12 @@ export const FooterHints = ({
|
|
|
117
119
|
<span fg={colors.muted}> labels </span>
|
|
118
120
|
<span fg={colors.count}>m</span>
|
|
119
121
|
<span fg={colors.muted}> merge </span>
|
|
122
|
+
{canCloseSelection ? (
|
|
123
|
+
<>
|
|
124
|
+
<span fg={colors.count}>x</span>
|
|
125
|
+
<span fg={colors.muted}> close </span>
|
|
126
|
+
</>
|
|
127
|
+
) : null}
|
|
120
128
|
<span fg={colors.count}>o</span>
|
|
121
129
|
<span fg={colors.muted}> open </span>
|
|
122
130
|
<span fg={colors.count}>y</span>
|
|
@@ -45,22 +45,28 @@ const PullRequestRow = ({
|
|
|
45
45
|
numWidth: number
|
|
46
46
|
onSelect: () => void
|
|
47
47
|
}) => {
|
|
48
|
-
const
|
|
48
|
+
const isClosed = pullRequest.state === "closed"
|
|
49
|
+
const isMerged = pullRequest.state === "merged"
|
|
50
|
+
const isFinal = isClosed || isMerged
|
|
51
|
+
const checkText = isMerged ? "merged" : isClosed ? "closed" : checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
49
52
|
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
50
53
|
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
51
54
|
const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
|
|
52
55
|
const fillerWidth = Math.max(0, contentWidth - rowWidth)
|
|
53
|
-
const indicatorColor = pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
|
|
56
|
+
const indicatorColor = isMerged ? colors.status.passing : isClosed ? colors.muted : pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
|
|
57
|
+
const rowTextColor = selected ? colors.selectedText : isFinal ? colors.muted : colors.text
|
|
58
|
+
const numberColor = selected ? colors.accent : isFinal ? colors.muted : colors.count
|
|
59
|
+
const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
|
|
54
60
|
|
|
55
61
|
return (
|
|
56
62
|
<box height={1} onMouseDown={onSelect}>
|
|
57
|
-
<TextLine fg={
|
|
63
|
+
<TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
|
|
58
64
|
<span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
59
65
|
<span> </span>
|
|
60
|
-
<span fg={
|
|
66
|
+
<span fg={numberColor}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
|
|
61
67
|
<span> </span>
|
|
62
68
|
<span>{fitCell(pullRequest.title, titleWidth)}</span>
|
|
63
|
-
<span fg={
|
|
69
|
+
<span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
64
70
|
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
65
71
|
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
66
72
|
</TextLine>
|
package/src/ui/modals.tsx
CHANGED
|
@@ -25,6 +25,16 @@ export interface MergeModalState {
|
|
|
25
25
|
readonly error: string | null
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface CloseModalState {
|
|
29
|
+
readonly open: boolean
|
|
30
|
+
readonly repository: string | null
|
|
31
|
+
readonly number: number | null
|
|
32
|
+
readonly title: string
|
|
33
|
+
readonly url: string | null
|
|
34
|
+
readonly running: boolean
|
|
35
|
+
readonly error: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
28
38
|
export interface ThemeModalState {
|
|
29
39
|
readonly open: boolean
|
|
30
40
|
readonly query: string
|
|
@@ -52,6 +62,16 @@ export const initialMergeModalState: MergeModalState = {
|
|
|
52
62
|
error: null,
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
export const initialCloseModalState: CloseModalState = {
|
|
66
|
+
open: false,
|
|
67
|
+
repository: null,
|
|
68
|
+
number: null,
|
|
69
|
+
title: "",
|
|
70
|
+
url: null,
|
|
71
|
+
running: false,
|
|
72
|
+
error: null,
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
export const initialThemeModalState: ThemeModalState = {
|
|
56
76
|
open: false,
|
|
57
77
|
query: "",
|
|
@@ -191,7 +211,7 @@ export const MergeModal = ({
|
|
|
191
211
|
const options = availableMergeActions(state.info)
|
|
192
212
|
const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
|
|
193
213
|
const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
|
|
194
|
-
const rightText = state.running ?
|
|
214
|
+
const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
|
|
195
215
|
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
196
216
|
const repo = state.info?.repository ?? state.repository
|
|
197
217
|
const statusLine = state.info
|
|
@@ -262,6 +282,73 @@ export const MergeModal = ({
|
|
|
262
282
|
)
|
|
263
283
|
}
|
|
264
284
|
|
|
285
|
+
export const CloseModal = ({
|
|
286
|
+
state,
|
|
287
|
+
modalWidth,
|
|
288
|
+
modalHeight,
|
|
289
|
+
offsetLeft,
|
|
290
|
+
offsetTop,
|
|
291
|
+
loadingIndicator,
|
|
292
|
+
}: {
|
|
293
|
+
state: CloseModalState
|
|
294
|
+
modalWidth: number
|
|
295
|
+
modalHeight: number
|
|
296
|
+
offsetLeft: number
|
|
297
|
+
offsetTop: number
|
|
298
|
+
loadingIndicator: string
|
|
299
|
+
}) => {
|
|
300
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
301
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
302
|
+
const title = state.number ? `Close #${state.number}` : "Close pull request"
|
|
303
|
+
const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
|
|
304
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
305
|
+
const repo = state.repository ? shortRepoName(state.repository) : ""
|
|
306
|
+
const titleLines = [
|
|
307
|
+
fitCell(repo, contentWidth),
|
|
308
|
+
fitCell(state.title, contentWidth),
|
|
309
|
+
]
|
|
310
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
311
|
+
const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
|
|
312
|
+
const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
316
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
317
|
+
<TextLine>
|
|
318
|
+
<span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
|
|
319
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
320
|
+
<span fg={state.running ? colors.status.pending : colors.muted}>{rightText}</span>
|
|
321
|
+
</TextLine>
|
|
322
|
+
</box>
|
|
323
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
324
|
+
<PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />
|
|
325
|
+
</box>
|
|
326
|
+
<Divider width={innerWidth} />
|
|
327
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
328
|
+
{state.error ? (
|
|
329
|
+
<PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
|
|
330
|
+
) : (
|
|
331
|
+
<>
|
|
332
|
+
{Array.from({ length: topRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
333
|
+
<PlainLine text={titleLines[0]!} fg={colors.muted} />
|
|
334
|
+
<PlainLine text={titleLines[1]!} fg={colors.text} bold />
|
|
335
|
+
{Array.from({ length: bottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
336
|
+
</>
|
|
337
|
+
)}
|
|
338
|
+
</box>
|
|
339
|
+
<Divider width={innerWidth} />
|
|
340
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
341
|
+
<TextLine>
|
|
342
|
+
<span fg={colors.count}>enter</span>
|
|
343
|
+
<span fg={colors.muted}> close </span>
|
|
344
|
+
<span fg={colors.count}>esc</span>
|
|
345
|
+
<span fg={colors.muted}> cancel</span>
|
|
346
|
+
</TextLine>
|
|
347
|
+
</box>
|
|
348
|
+
</ModalFrame>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
265
352
|
export const ThemeModal = ({
|
|
266
353
|
state,
|
|
267
354
|
activeThemeId,
|
package/src/ui/pullRequests.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSum
|
|
|
18
18
|
export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
|
|
19
19
|
|
|
20
20
|
export const reviewIcon = (pullRequest: PullRequestItem) => {
|
|
21
|
+
if (pullRequest.state === "merged") return "✓"
|
|
22
|
+
if (pullRequest.state === "closed") return "×"
|
|
21
23
|
if (pullRequest.autoMergeEnabled) return "↻"
|
|
22
24
|
if (pullRequest.reviewStatus === "draft") return "◌"
|
|
23
25
|
if (pullRequest.reviewStatus === "approved") return "✓"
|