@kitlangton/ghui 0.1.15 → 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 +151 -12
- package/src/domain.ts +1 -1
- package/src/services/GitHubService.ts +6 -0
- package/src/themeStore.ts +43 -0
- package/src/ui/FooterHints.tsx +8 -0
- package/src/ui/PullRequestList.tsx +11 -5
- package/src/ui/colors.ts +2 -0
- package/src/ui/modals.tsx +100 -12
- 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
|
@@ -11,17 +11,19 @@ import { formatShortDate, formatTimestamp } from "./date.js"
|
|
|
11
11
|
import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
|
|
12
12
|
import { Observability } from "./observability.js"
|
|
13
13
|
import { GitHubService } from "./services/GitHubService.js"
|
|
14
|
+
import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
|
|
14
15
|
import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
|
|
15
16
|
import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
|
|
16
17
|
import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
|
|
17
18
|
import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
|
|
18
19
|
import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
|
|
19
|
-
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"
|
|
20
21
|
import { groupBy, reviewLabel } from "./ui/pullRequests.js"
|
|
21
22
|
import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
|
|
22
23
|
import { PullRequestList } from "./ui/PullRequestList.js"
|
|
23
24
|
|
|
24
25
|
const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
|
|
26
|
+
const initialThemeId = await Effect.runPromise(loadStoredThemeId)
|
|
25
27
|
|
|
26
28
|
type LoadStatus = "loading" | "ready" | "error"
|
|
27
29
|
|
|
@@ -80,11 +82,13 @@ const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
|
|
|
80
82
|
const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
|
|
81
83
|
|
|
82
84
|
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
85
|
+
const closeModalAtom = Atom.make(initialCloseModalState).pipe(Atom.keepAlive)
|
|
83
86
|
const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
|
|
84
|
-
const themeIdAtom = Atom.make<ThemeId>(
|
|
87
|
+
const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
|
|
85
88
|
const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
|
|
86
89
|
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
87
90
|
const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
91
|
+
const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
88
92
|
const usernameAtom = githubRuntime.atom(
|
|
89
93
|
config.author === "@me"
|
|
90
94
|
? GitHubService.use((github) => github.getAuthenticatedUser())
|
|
@@ -115,6 +119,9 @@ const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: stri
|
|
|
115
119
|
const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
|
|
116
120
|
GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
|
|
117
121
|
)
|
|
122
|
+
const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
|
|
123
|
+
GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
|
|
124
|
+
)
|
|
118
125
|
|
|
119
126
|
const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
120
127
|
|
|
@@ -260,6 +267,7 @@ export const App = () => {
|
|
|
260
267
|
const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
|
|
261
268
|
const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
|
|
262
269
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
270
|
+
const [closeModal, setCloseModal] = useAtom(closeModalAtom)
|
|
263
271
|
const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
|
|
264
272
|
const [themeId, setThemeId] = useAtom(themeIdAtom)
|
|
265
273
|
const [themeModal, setThemeModal] = useAtom(themeModalAtom)
|
|
@@ -270,8 +278,11 @@ export const App = () => {
|
|
|
270
278
|
themeModalRef.current = themeModal
|
|
271
279
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
272
280
|
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
281
|
+
const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
|
|
273
282
|
const retryProgress = useAtomValue(retryProgressAtom)
|
|
274
283
|
const [loadingFrame, setLoadingFrame] = useState(0)
|
|
284
|
+
const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
|
|
285
|
+
const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
|
|
275
286
|
const [terminalFocused, setTerminalFocused] = useState(true)
|
|
276
287
|
const usernameResult = useAtomValue(usernameAtom)
|
|
277
288
|
const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
|
|
@@ -282,6 +293,7 @@ export const App = () => {
|
|
|
282
293
|
const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
|
|
283
294
|
const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
|
|
284
295
|
const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
|
|
296
|
+
const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
|
|
285
297
|
const terminalWidth = width ?? 100
|
|
286
298
|
const terminalHeight = height ?? 24
|
|
287
299
|
const contentWidth = Math.max(1, terminalWidth)
|
|
@@ -299,6 +311,7 @@ export const App = () => {
|
|
|
299
311
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
300
312
|
const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
301
313
|
const detailHydrationRef = useRef<number | null>(null)
|
|
314
|
+
const refreshGenerationRef = useRef(0)
|
|
302
315
|
const lastPullRequestRefreshAtRef = useRef(0)
|
|
303
316
|
const terminalFocusedRef = useRef(true)
|
|
304
317
|
const terminalWasBlurredRef = useRef(false)
|
|
@@ -336,10 +349,19 @@ export const App = () => {
|
|
|
336
349
|
}, [])
|
|
337
350
|
|
|
338
351
|
const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
|
|
339
|
-
const pullRequests = useMemo(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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])
|
|
343
365
|
const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
|
|
344
366
|
? "loading"
|
|
345
367
|
: AsyncResult.isFailure(pullRequestResult)
|
|
@@ -397,8 +419,14 @@ export const App = () => {
|
|
|
397
419
|
setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
|
|
398
420
|
}
|
|
399
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
|
+
}
|
|
400
429
|
refreshPullRequestsAtom()
|
|
401
|
-
if (message) flashNotice(message)
|
|
402
430
|
}
|
|
403
431
|
refreshPullRequestsRef.current = refreshPullRequests
|
|
404
432
|
maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
|
|
@@ -415,6 +443,21 @@ export const App = () => {
|
|
|
415
443
|
}
|
|
416
444
|
}, [pullRequestLoad?.fetchedAt])
|
|
417
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
|
+
|
|
418
461
|
useEffect(() => {
|
|
419
462
|
const handleFocus = () => {
|
|
420
463
|
terminalFocusedRef.current = true
|
|
@@ -462,8 +505,9 @@ export const App = () => {
|
|
|
462
505
|
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
463
506
|
const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
|
|
464
507
|
const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
|
|
465
|
-
const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => !pullRequest.detailLoaded)
|
|
466
|
-
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"
|
|
467
511
|
const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
|
|
468
512
|
|
|
469
513
|
useEffect(() => {
|
|
@@ -478,9 +522,11 @@ export const App = () => {
|
|
|
478
522
|
const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
|
|
479
523
|
if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
|
|
480
524
|
if (detailHydrationRef.current === fetchedAt) return
|
|
481
|
-
if (!pullRequests.some((pullRequest) => !pullRequest.detailLoaded)) return
|
|
525
|
+
if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
|
|
482
526
|
detailHydrationRef.current = fetchedAt
|
|
527
|
+
const generation = refreshGenerationRef.current
|
|
483
528
|
void loadPullRequestDetails().then((details) => {
|
|
529
|
+
if (generation !== refreshGenerationRef.current) return
|
|
484
530
|
setPullRequestOverrides((current) => {
|
|
485
531
|
const next = { ...current }
|
|
486
532
|
for (const detail of details) {
|
|
@@ -558,8 +604,53 @@ export const App = () => {
|
|
|
558
604
|
.catch((error) => flashNotice(errorMessage(error)))
|
|
559
605
|
}
|
|
560
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
|
+
|
|
561
651
|
const openThemeModal = () => {
|
|
562
652
|
setLabelModal(initialLabelModalState)
|
|
653
|
+
setCloseModal(initialCloseModalState)
|
|
563
654
|
setMergeModal(initialMergeModalState)
|
|
564
655
|
setThemeModal({
|
|
565
656
|
open: true,
|
|
@@ -574,6 +665,7 @@ export const App = () => {
|
|
|
574
665
|
if (!confirm) {
|
|
575
666
|
setThemeId(themeModal.initialThemeId)
|
|
576
667
|
} else if (selectedTheme) {
|
|
668
|
+
void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
|
|
577
669
|
flashNotice(`Theme: ${selectedTheme.name}`)
|
|
578
670
|
}
|
|
579
671
|
setThemeModal(initialThemeModalState)
|
|
@@ -619,6 +711,7 @@ export const App = () => {
|
|
|
619
711
|
|
|
620
712
|
const openLabelModal = () => {
|
|
621
713
|
if (!selectedPullRequest) return
|
|
714
|
+
setCloseModal(initialCloseModalState)
|
|
622
715
|
setMergeModal(initialMergeModalState)
|
|
623
716
|
setThemeModal(initialThemeModalState)
|
|
624
717
|
const repository = selectedPullRequest.repository
|
|
@@ -649,6 +742,7 @@ export const App = () => {
|
|
|
649
742
|
|
|
650
743
|
const openMergeModal = () => {
|
|
651
744
|
if (!selectedPullRequest) return
|
|
745
|
+
setCloseModal(initialCloseModalState)
|
|
652
746
|
setThemeModal(initialThemeModalState)
|
|
653
747
|
const repository = selectedPullRequest.repository
|
|
654
748
|
const number = selectedPullRequest.number
|
|
@@ -699,6 +793,16 @@ export const App = () => {
|
|
|
699
793
|
setMergeModal((current) => ({ ...current, running: true, error: null }))
|
|
700
794
|
void mergePullRequest({ repository, number, action: option.action })
|
|
701
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
|
+
}
|
|
702
806
|
setMergeModal(initialMergeModalState)
|
|
703
807
|
if (option.refreshOnSuccess) {
|
|
704
808
|
refreshPullRequests(`${option.pastTense} #${number}`)
|
|
@@ -755,6 +859,10 @@ export const App = () => {
|
|
|
755
859
|
closeThemeModal(false)
|
|
756
860
|
return
|
|
757
861
|
}
|
|
862
|
+
if (closeModal.open) {
|
|
863
|
+
setCloseModal(initialCloseModalState)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
758
866
|
if (mergeModal.open) {
|
|
759
867
|
setMergeModal(initialMergeModalState)
|
|
760
868
|
return
|
|
@@ -808,6 +916,18 @@ export const App = () => {
|
|
|
808
916
|
return
|
|
809
917
|
}
|
|
810
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
|
+
|
|
811
931
|
if (mergeModal.open) {
|
|
812
932
|
const options = availableMergeActions(mergeModal.info)
|
|
813
933
|
if (key.name === "escape") {
|
|
@@ -1107,7 +1227,7 @@ export const App = () => {
|
|
|
1107
1227
|
return
|
|
1108
1228
|
}
|
|
1109
1229
|
if (key.name === "r") {
|
|
1110
|
-
refreshPullRequests("
|
|
1230
|
+
refreshPullRequests("Refreshed")
|
|
1111
1231
|
return
|
|
1112
1232
|
}
|
|
1113
1233
|
if (
|
|
@@ -1200,6 +1320,10 @@ export const App = () => {
|
|
|
1200
1320
|
openDiffView()
|
|
1201
1321
|
return
|
|
1202
1322
|
}
|
|
1323
|
+
if (key.name === "x" && selectedPullRequest?.state === "open") {
|
|
1324
|
+
openCloseModal()
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1203
1327
|
if (key.name === "l" && selectedPullRequest) {
|
|
1204
1328
|
openLabelModal()
|
|
1205
1329
|
return
|
|
@@ -1275,6 +1399,10 @@ export const App = () => {
|
|
|
1275
1399
|
const labelModalHeight = Math.min(20, terminalHeight - 4)
|
|
1276
1400
|
const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
|
|
1277
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)
|
|
1278
1406
|
const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
|
|
1279
1407
|
const mergeModalHeight = Math.min(16, terminalHeight - 4)
|
|
1280
1408
|
const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
|
|
@@ -1388,8 +1516,9 @@ export const App = () => {
|
|
|
1388
1516
|
detailFullView={detailFullView}
|
|
1389
1517
|
diffFullView={diffFullView}
|
|
1390
1518
|
hasSelection={selectedPullRequest !== null}
|
|
1519
|
+
canCloseSelection={selectedPullRequest?.state === "open"}
|
|
1391
1520
|
hasError={pullRequestStatus === "error"}
|
|
1392
|
-
isLoading={pullRequestStatus === "loading"}
|
|
1521
|
+
isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running}
|
|
1393
1522
|
loadingIndicator={loadingIndicator}
|
|
1394
1523
|
retryProgress={retryProgress}
|
|
1395
1524
|
/>
|
|
@@ -1406,6 +1535,16 @@ export const App = () => {
|
|
|
1406
1535
|
loadingIndicator={loadingIndicator}
|
|
1407
1536
|
/>
|
|
1408
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}
|
|
1409
1548
|
{mergeModal.open ? (
|
|
1410
1549
|
<MergeModal
|
|
1411
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,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { isThemeId, type ThemeId } from "./ui/colors.js"
|
|
6
|
+
|
|
7
|
+
interface StoredConfig {
|
|
8
|
+
readonly theme?: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const configDirectory = () => {
|
|
12
|
+
if (process.env.GHUI_CONFIG_DIR) return process.env.GHUI_CONFIG_DIR
|
|
13
|
+
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "ghui")
|
|
14
|
+
if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "ghui")
|
|
15
|
+
return join(homedir(), ".config", "ghui")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const configPath = () => join(configDirectory(), "config.json")
|
|
19
|
+
|
|
20
|
+
const parseConfig = (text: string): StoredConfig => {
|
|
21
|
+
const value = JSON.parse(text) as unknown
|
|
22
|
+
return value && typeof value === "object" ? value : {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
|
|
26
|
+
const file = Bun.file(configPath())
|
|
27
|
+
if (!(await file.exists())) return "ghui" satisfies ThemeId
|
|
28
|
+
|
|
29
|
+
const config = parseConfig(await file.text())
|
|
30
|
+
return isThemeId(config.theme) ? config.theme : "ghui"
|
|
31
|
+
}), () => Effect.succeed("ghui" satisfies ThemeId))
|
|
32
|
+
|
|
33
|
+
export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
|
|
34
|
+
const path = configPath()
|
|
35
|
+
const file = Bun.file(path)
|
|
36
|
+
const config = await file.exists()
|
|
37
|
+
? parseConfig(await file.text())
|
|
38
|
+
: {}
|
|
39
|
+
if (config.theme === theme) return
|
|
40
|
+
|
|
41
|
+
await mkdir(dirname(path), { recursive: true })
|
|
42
|
+
await Bun.write(path, `${JSON.stringify({ ...config, theme }, null, "\t")}\n`)
|
|
43
|
+
})
|
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/colors.ts
CHANGED
|
@@ -588,6 +588,8 @@ export const colors: ColorPalette = { ...ghuiColors }
|
|
|
588
588
|
|
|
589
589
|
export const getThemeDefinition = (id: ThemeId) => themeDefinitions.find((theme) => theme.id === id) ?? themeDefinitions[0]!
|
|
590
590
|
|
|
591
|
+
export const isThemeId = (value: unknown): value is ThemeId => typeof value === "string" && themeDefinitions.some((theme) => theme.id === value)
|
|
592
|
+
|
|
591
593
|
export const filterThemeDefinitions = (query: string) => {
|
|
592
594
|
const normalized = query.trim().toLowerCase()
|
|
593
595
|
if (normalized.length === 0) return themeDefinitions
|
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,
|
|
@@ -281,7 +368,7 @@ export const ThemeModal = ({
|
|
|
281
368
|
const contentWidth = Math.max(14, innerWidth - 2)
|
|
282
369
|
const rowWidth = innerWidth
|
|
283
370
|
const filteredThemes = filterThemeDefinitions(state.query)
|
|
284
|
-
const maxVisible = Math.max(1, modalHeight -
|
|
371
|
+
const maxVisible = Math.max(1, modalHeight - 7)
|
|
285
372
|
const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
|
|
286
373
|
const selectedIndex = Math.max(0, activeIndex)
|
|
287
374
|
const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
|
|
@@ -293,14 +380,14 @@ export const ThemeModal = ({
|
|
|
293
380
|
const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
|
|
294
381
|
const title = "Themes"
|
|
295
382
|
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
296
|
-
const
|
|
383
|
+
const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
|
|
297
384
|
const queryPrefix = "/ "
|
|
298
|
-
const
|
|
385
|
+
const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
|
|
299
386
|
const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
|
|
300
387
|
const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
|
|
301
388
|
|
|
302
389
|
return (
|
|
303
|
-
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[
|
|
390
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
304
391
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
305
392
|
<TextLine>
|
|
306
393
|
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
@@ -309,13 +396,14 @@ export const ThemeModal = ({
|
|
|
309
396
|
</TextLine>
|
|
310
397
|
</box>
|
|
311
398
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
399
|
+
{state.filterMode ? (
|
|
400
|
+
<TextLine>
|
|
401
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
402
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
|
|
403
|
+
</TextLine>
|
|
404
|
+
) : (
|
|
405
|
+
<PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
|
|
406
|
+
)}
|
|
319
407
|
</box>
|
|
320
408
|
<Divider width={innerWidth} />
|
|
321
409
|
<box height={maxVisible} flexDirection="column">
|
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 "✓"
|