@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 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.16",
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
@@ -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
- () => pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? [],
343
- [pullRequestLoad?.data, pullRequestOverrides],
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 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"
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("Refreshing pull requests...")
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
@@ -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,
@@ -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/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,
@@ -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 "✓"