@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # ghui
2
2
 
3
- <img width="1420" height="856" alt="image" src="https://github.com/user-attachments/assets/5e560a4a-5887-4baa-a6d4-e1f4f0410c70" />
3
+ Terminal UI for keeping up with your open GitHub pull requests across repositories.
4
4
 
5
- Terminal UI for browsing and acting on your open GitHub pull requests across repositories.
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
- Requires `bun` and an authenticated GitHub CLI (`gh auth login`).
14
-
15
- ## Install Locally
13
+ Requirements:
16
14
 
17
- Clone, install, and link:
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
- ## Publish
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
- The first npm publish has already created the package. Configure npm Trusted Publishing:
26
+ ## Local Development
37
27
 
38
- - Package: `@kitlangton/ghui`
39
- - Publisher: GitHub Actions
40
- - Owner: `kitlangton`
41
- - Repository: `ghui`
42
- - Workflow filename: `publish.yml`
28
+ Clone, install, and link:
43
29
 
44
- After that, publish by creating a GitHub Release whose tag matches `package.json` version, for example `v0.1.1`.
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`: toggle draft
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
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>("ghui").pipe(Atom.keepAlive)
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
- () => pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? [],
341
- [pullRequestLoad?.data, pullRequestOverrides],
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 hasActiveLoadingIndicator = pullRequestStatus === "loading" || isHydratingPullRequestDetails || labelModal.loading || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
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("Refreshing pull requests...")
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
@@ -1,4 +1,4 @@
1
- export type PullRequestState = "open" | "closed"
1
+ export type PullRequestState = "open" | "closed" | "merged"
2
2
 
3
3
  export type CheckConclusion = "success" | "failure" | "neutral" | "skipped" | "cancelled" | "timed_out"
4
4
 
@@ -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
+ })
@@ -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 checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
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={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
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={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
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={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
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 ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
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 - 8)
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 queryText = state.query.length > 0 ? state.query : "type to filter themes"
383
+ const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
297
384
  const queryPrefix = "/ "
298
- const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
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={[3, modalHeight - 4]}>
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
- <PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
313
- </box>
314
- <box height={1} paddingLeft={1} paddingRight={1}>
315
- <TextLine>
316
- <span fg={colors.count}>{queryPrefix}</span>
317
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
318
- </TextLine>
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">
@@ -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 "✓"