@kitlangton/ghui 0.1.0 → 0.1.2

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
@@ -4,10 +4,16 @@
4
4
 
5
5
  Terminal UI for browsing and acting on your open GitHub pull requests across repositories.
6
6
 
7
- ## Install Locally
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g @kitlangton/ghui
11
+ ```
8
12
 
9
13
  Requires `bun` and an authenticated GitHub CLI (`gh auth login`).
10
14
 
15
+ ## Install Locally
16
+
11
17
  Clone, install, and link:
12
18
 
13
19
  ```bash
@@ -27,7 +33,7 @@ ghui
27
33
 
28
34
  This package publishes from GitHub Releases using npm Trusted Publishing.
29
35
 
30
- If this is the first npm publish, publish once from your machine with `npm publish --access public`. Then configure npm Trusted Publishing:
36
+ The first npm publish has already created the package. Configure npm Trusted Publishing:
31
37
 
32
38
  - Package: `@kitlangton/ghui`
33
39
  - Publisher: GitHub Actions
@@ -35,7 +41,7 @@ If this is the first npm publish, publish once from your machine with `npm publi
35
41
  - Repository: `ghui`
36
42
  - Workflow filename: `publish.yml`
37
43
 
38
- After that, publish by creating a GitHub Release whose tag matches `package.json` version, for example `v0.1.0`.
44
+ After that, publish by creating a GitHub Release whose tag matches `package.json` version, for example `v0.1.1`.
39
45
 
40
46
  ## Configuration
41
47
 
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "registry": "https://registry.npmjs.org/"
32
32
  },
33
33
  "bin": {
34
- "ghui": "./bin/ghui"
34
+ "ghui": "bin/ghui.js"
35
35
  },
36
36
  "scripts": {
37
37
  "dev": "bun --watch src/index.tsx",
package/src/App.tsx CHANGED
@@ -1,14 +1,16 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import { useAtom } from "@effect/atom-react"
2
+ import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
3
  import { useKeyboard, useTerminalDimensions } from "@opentui/react"
4
+ import { Cause, Effect, Schedule } from "effect"
5
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
4
6
  import * as Atom from "effect/unstable/reactivity/Atom"
5
- import { Fragment, useEffect, useMemo, useRef } from "react"
7
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react"
6
8
  import { config } from "./config.js"
7
9
  import type { CheckItem, PullRequestItem, PullRequestLabel } from "./domain.js"
8
10
  import { daysOpen, formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
9
- import { addPullRequestLabel, getAuthenticatedUser, listOpenPullRequests as loadOpenPullRequests, listRepoLabels, removePullRequestLabel, toggleDraftStatus } from "./services/GitHubService.js"
11
+ import { GitHubService } from "./services/GitHubService.js"
10
12
 
11
- const toggleDraft = (repository: string, number: number, isDraft: boolean) => toggleDraftStatus(repository, number, isDraft)
13
+ const githubRuntime = Atom.runtime(GitHubService.layer)
12
14
 
13
15
  const colors = {
14
16
  text: "#ede7da",
@@ -41,10 +43,8 @@ const colors = {
41
43
 
42
44
  type LoadStatus = "loading" | "ready" | "error"
43
45
 
44
- interface PullRequestState {
45
- readonly status: LoadStatus
46
+ interface PullRequestLoad {
46
47
  readonly data: readonly PullRequestItem[]
47
- readonly error: string | null
48
48
  readonly fetchedAt: Date | null
49
49
  }
50
50
 
@@ -56,19 +56,44 @@ interface PreviewLine {
56
56
  }>
57
57
  }
58
58
 
59
- const pullRequestReferencePattern = /(#[0-9]+)/g
59
+ interface DetailPlaceholderContent {
60
+ readonly title: string
61
+ readonly hint: string
62
+ }
60
63
 
61
- const initialPullRequestState: PullRequestState = {
62
- status: "loading",
63
- data: [],
64
- error: null,
65
- fetchedAt: null,
64
+ interface RetryProgress {
65
+ readonly attempt: number
66
+ readonly max: number
66
67
  }
67
68
 
68
- const pullRequestStateAtom = Atom.make(initialPullRequestState).pipe(Atom.keepAlive)
69
+ const pullRequestReferencePattern = /(#[0-9]+)/g
70
+ const PR_FETCH_RETRIES = 6
71
+ const DETAIL_PLACEHOLDER_ROWS = 4
72
+ const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
73
+
74
+ const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
75
+ const pullRequestsAtom = githubRuntime.atom(
76
+ GitHubService.use((github) =>
77
+ Effect.gen(function*() {
78
+ yield* Atom.set(retryProgressAtom, null)
79
+ const data = yield* github.listOpenPullRequests().pipe(
80
+ Effect.tapError(() =>
81
+ Atom.update(retryProgressAtom, (current) => ({
82
+ attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
83
+ max: PR_FETCH_RETRIES,
84
+ }))
85
+ ),
86
+ Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
87
+ Effect.tapError(() => Atom.set(retryProgressAtom, null)),
88
+ )
89
+
90
+ yield* Atom.set(retryProgressAtom, null)
91
+ return { data, fetchedAt: new Date() } satisfies PullRequestLoad
92
+ })
93
+ ),
94
+ ).pipe(Atom.keepAlive)
69
95
  const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
70
96
  const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
71
- const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
72
97
  const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
73
98
  const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
74
99
  const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
@@ -76,8 +101,7 @@ const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
76
101
  const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
77
102
  const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
78
103
 
79
- const GROUP_ICONS = ["▸", "◆", "●", "▪", "›", "◈", "▹", "◉", "⬥", "⏵", "⊡", "⬩"] as const
80
- const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
104
+ const GROUP_ICON = "◆"
81
105
 
82
106
  interface LabelModalState {
83
107
  readonly open: boolean
@@ -99,7 +123,25 @@ const initialLabelModalState: LabelModalState = {
99
123
 
100
124
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
101
125
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
102
- const usernameAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
126
+ const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
127
+ const usernameAtom = githubRuntime.atom(
128
+ config.author === "@me"
129
+ ? GitHubService.use((github) => github.getAuthenticatedUser())
130
+ : Effect.succeed(config.author.replace(/^@/, "")),
131
+ ).pipe(Atom.keepAlive)
132
+
133
+ const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
134
+ GitHubService.use((github) => github.listRepoLabels(repository))
135
+ )
136
+ const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
137
+ GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
138
+ )
139
+ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
140
+ GitHubService.use((github) => github.removePullRequestLabel(input.repository, input.number, input.label))
141
+ )
142
+ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
143
+ GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
144
+ )
103
145
 
104
146
  const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
105
147
 
@@ -166,6 +208,12 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
166
208
  return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
167
209
  }
168
210
 
211
+ const centerCell = (text: string, width: number) => {
212
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
213
+ const left = Math.floor((width - trimmed.length) / 2)
214
+ return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
215
+ }
216
+
169
217
  const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
170
218
  if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
171
219
  return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
@@ -245,6 +293,8 @@ const fallbackLabelColor = (name: string) => {
245
293
  return `hsl(${hue} 55% 35%)`
246
294
  }
247
295
 
296
+ const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
297
+
248
298
  const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
249
299
 
250
300
  const labelTextColor = (color: string) => {
@@ -396,43 +446,101 @@ const SectionTitle = ({ title }: { title: string }) => (
396
446
  </TextLine>
397
447
  )
398
448
 
399
- const FooterHints = ({ showFilterClear, detailFullView }: { showFilterClear: boolean; detailFullView: boolean }) => (
400
- <TextLine>
401
- <span fg={colors.count}>↑↓</span>
402
- <span fg={colors.muted}> move </span>
403
- <span fg={colors.count}>/</span>
404
- <span fg={colors.muted}> filter </span>
405
- {showFilterClear ? (
406
- <>
449
+ const FooterHints = ({
450
+ filterEditing,
451
+ showFilterClear,
452
+ detailFullView,
453
+ hasSelection,
454
+ hasError,
455
+ isLoading,
456
+ loadingIndicator,
457
+ retryProgress,
458
+ }: {
459
+ filterEditing: boolean
460
+ showFilterClear: boolean
461
+ detailFullView: boolean
462
+ hasSelection: boolean
463
+ hasError: boolean
464
+ isLoading: boolean
465
+ loadingIndicator: string
466
+ retryProgress: RetryProgress | null
467
+ }) => {
468
+ if (filterEditing) {
469
+ return (
470
+ <TextLine>
471
+ <span fg={colors.count}>search</span>
472
+ <span fg={colors.muted}> typing </span>
473
+ <span fg={colors.count}>↑↓</span>
474
+ <span fg={colors.muted}> move </span>
475
+ <span fg={colors.count}>enter</span>
476
+ <span fg={colors.muted}> apply </span>
407
477
  <span fg={colors.count}>esc</span>
478
+ <span fg={colors.muted}> cancel </span>
479
+ <span fg={colors.count}>ctrl-u</span>
408
480
  <span fg={colors.muted}> clear </span>
409
- </>
410
- ) : null}
411
- {detailFullView ? (
412
- <>
413
- <span fg={colors.count}>esc</span>
414
- <span fg={colors.muted}> back </span>
415
- </>
416
- ) : (
417
- <>
418
- <span fg={colors.count}>enter</span>
419
- <span fg={colors.muted}> expand </span>
420
- </>
421
- )}
422
- <span fg={colors.count}>r</span>
423
- <span fg={colors.muted}> ref </span>
424
- <span fg={colors.count}>d</span>
425
- <span fg={colors.muted}> draft </span>
426
- <span fg={colors.count}>l</span>
427
- <span fg={colors.muted}> labels </span>
428
- <span fg={colors.count}>o</span>
429
- <span fg={colors.muted}> open </span>
430
- <span fg={colors.count}>y</span>
431
- <span fg={colors.muted}> copy </span>
432
- <span fg={colors.count}>q</span>
433
- <span fg={colors.muted}> quit</span>
434
- </TextLine>
435
- )
481
+ <span fg={colors.count}>ctrl-w</span>
482
+ <span fg={colors.muted}> word</span>
483
+ </TextLine>
484
+ )
485
+ }
486
+
487
+ return (
488
+ <TextLine>
489
+ <span fg={colors.count}>/</span>
490
+ <span fg={colors.muted}> filter </span>
491
+ {showFilterClear ? (
492
+ <>
493
+ <span fg={colors.count}>esc</span>
494
+ <span fg={colors.muted}> clear </span>
495
+ </>
496
+ ) : null}
497
+ {retryProgress ? (
498
+ <>
499
+ <span fg={colors.status.pending}>retry</span>
500
+ <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
501
+ </>
502
+ ) : isLoading ? (
503
+ <>
504
+ <span fg={colors.status.pending}>{loadingIndicator}</span>
505
+ <span fg={colors.muted}> loading </span>
506
+ </>
507
+ ) : null}
508
+ <span fg={colors.count}>r</span>
509
+ <span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
510
+ {hasSelection ? (
511
+ <>
512
+ <span fg={colors.count}>↑↓</span>
513
+ <span fg={colors.muted}> move </span>
514
+ </>
515
+ ) : null}
516
+ {hasSelection && detailFullView ? (
517
+ <>
518
+ <span fg={colors.count}>esc</span>
519
+ <span fg={colors.muted}> back </span>
520
+ </>
521
+ ) : hasSelection ? (
522
+ <>
523
+ <span fg={colors.count}>enter</span>
524
+ <span fg={colors.muted}> expand </span>
525
+ </>
526
+ ) : null}
527
+ {hasSelection ? (
528
+ <>
529
+ <span fg={colors.count}>d</span>
530
+ <span fg={colors.muted}> draft </span>
531
+ <span fg={colors.count}>l</span>
532
+ <span fg={colors.muted}> labels </span>
533
+ <span fg={colors.count}>o</span>
534
+ <span fg={colors.muted}> open </span>
535
+ <span fg={colors.count}>y</span>
536
+ <span fg={colors.muted}> copy </span>
537
+ </>
538
+ ) : null}
539
+ <span fg={colors.count}>q</span>
540
+ <span fg={colors.muted}> quit</span>
541
+ </TextLine>
542
+ )
543
+ }
436
544
 
437
545
  const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
438
546
  <TextLine>
@@ -742,18 +850,50 @@ const DetailBody = ({
742
850
  )
743
851
  }
744
852
 
853
+ const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => {
854
+ const innerWidth = Math.max(1, paneWidth - 2)
855
+ const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
856
+ const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
857
+ const cardInnerWidth = Math.max(1, cardWidth - 2)
858
+ const contentLine = (text: string, fg: string, bold = false) => (
859
+ <TextLine>
860
+ <span fg={colors.separator}>{offset}│</span>
861
+ {bold ? (
862
+ <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
863
+ ) : (
864
+ <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
865
+ )}
866
+ <span fg={colors.separator}>│</span>
867
+ </TextLine>
868
+ )
869
+
870
+ return (
871
+ <box flexDirection="column">
872
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
873
+ <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
874
+ {contentLine(content.title, colors.count, true)}
875
+ {contentLine(content.hint, colors.muted)}
876
+ <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
877
+ </box>
878
+ <box height={1}><Divider width={paneWidth} /></box>
879
+ </box>
880
+ )
881
+ }
882
+
745
883
  const DetailsPane = ({
746
884
  pullRequest,
747
885
  contentWidth,
748
886
  bodyLines = DETAIL_BODY_LINES,
749
887
  paneWidth = contentWidth + 2,
750
888
  showChecks = false,
889
+ placeholderContent,
751
890
  }: {
752
891
  pullRequest: PullRequestItem | null
753
892
  contentWidth: number
754
893
  bodyLines?: number
755
894
  paneWidth?: number
756
895
  showChecks?: boolean
896
+ placeholderContent: DetailPlaceholderContent
757
897
  }) => {
758
898
  const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
759
899
  const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
@@ -764,7 +904,7 @@ const DetailsPane = ({
764
904
  () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
765
905
  [pullRequest?.body, contentWidth, bodyLines],
766
906
  )
767
- const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
907
+ const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
768
908
 
769
909
  return (
770
910
  <box flexDirection="column" height={contentHeight}>
@@ -774,12 +914,14 @@ const DetailsPane = ({
774
914
  <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
775
915
  </>
776
916
  ) : (
777
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
778
- <PlainLine text="Select a pull request with up/down." fg={colors.muted} />
779
- {Array.from({ length: DETAIL_BODY_LINES + 2 }, (_, index) => (
780
- <BlankRow key={index} />
781
- ))}
782
- </box>
917
+ <>
918
+ <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
919
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
920
+ {Array.from({ length: bodyLines }, (_, index) => (
921
+ <BlankRow key={index} />
922
+ ))}
923
+ </box>
924
+ </>
783
925
  )}
784
926
  </box>
785
927
  )
@@ -792,6 +934,7 @@ const LabelModal = ({
792
934
  modalHeight,
793
935
  offsetLeft,
794
936
  offsetTop,
937
+ loadingIndicator,
795
938
  }: {
796
939
  state: LabelModalState
797
940
  currentLabels: readonly PullRequestLabel[]
@@ -799,19 +942,26 @@ const LabelModal = ({
799
942
  modalHeight: number
800
943
  offsetLeft: number
801
944
  offsetTop: number
945
+ loadingIndicator: string
802
946
  }) => {
803
- const contentWidth = modalWidth - 4
947
+ const contentWidth = Math.max(16, modalWidth - 2)
804
948
  const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
805
949
  const filtered = state.availableLabels.filter((label) =>
806
950
  state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
807
951
  )
808
- const maxVisible = Math.max(1, modalHeight - 5)
952
+ const maxVisible = Math.max(1, modalHeight - 6)
809
953
  const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
810
954
  const scrollStart = Math.min(
811
955
  Math.max(0, filtered.length - maxVisible),
812
956
  Math.max(0, selectedIndex - maxVisible + 1),
813
957
  )
814
958
  const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
959
+ const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
960
+ const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
961
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
962
+ const queryText = state.query.length > 0 ? state.query : "type to filter labels"
963
+ const queryPrefix = state.query.length > 0 ? "/ " : "/ "
964
+ const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
815
965
 
816
966
  return (
817
967
  <box
@@ -825,34 +975,42 @@ const LabelModal = ({
825
975
  >
826
976
  <box height={1} paddingLeft={1} paddingRight={1}>
827
977
  <TextLine>
828
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>Labels</span>
829
- {state.repository ? <span fg={colors.muted}> {state.repository}</span> : null}
978
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
979
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
980
+ <span fg={colors.muted}>{countText}</span>
830
981
  </TextLine>
831
982
  </box>
832
983
  <box height={1} paddingLeft={1} paddingRight={1}>
833
984
  <TextLine>
834
- <span fg={colors.count}>&gt; </span>
985
+ <span fg={colors.count}>{queryPrefix}</span>
835
986
  <span fg={state.query.length > 0 ? colors.text : colors.muted}>
836
- {state.query.length > 0 ? state.query : "type to filter..."}
987
+ {fitCell(queryText, queryWidth)}
837
988
  </span>
838
989
  </TextLine>
839
990
  </box>
840
991
  <Divider width={modalWidth} />
841
992
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
842
993
  {state.loading ? (
843
- <PlainLine text="Loading labels..." fg={colors.muted} />
994
+ <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
844
995
  ) : visibleLabels.length === 0 ? (
845
- <PlainLine text={state.query.length > 0 ? "No matching labels." : "No labels found."} fg={colors.muted} />
996
+ <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
846
997
  ) : (
847
998
  visibleLabels.map((label, index) => {
848
999
  const actualIndex = scrollStart + index
849
1000
  const isActive = currentNames.has(label.name.toLowerCase())
850
1001
  const isSelected = actualIndex === selectedIndex
1002
+ const status = isActive ? "added" : ""
1003
+ const nameWidth = Math.max(8, contentWidth - 10 - status.length)
1004
+ const gap = Math.max(1, contentWidth - 8 - Math.min(label.name.length, nameWidth) - status.length)
851
1005
  return (
852
1006
  <box key={label.name} height={1}>
853
1007
  <TextLine bg={isSelected ? colors.selectedBg : undefined}>
854
- <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "" : " "}</span>
855
- <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {fitCell(label.name, Math.min(label.name.length, contentWidth - 6))} </span>
1008
+ <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "" : " "}</span>
1009
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
1010
+ <span bg={labelColor(label)}> </span>
1011
+ <span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
1012
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
1013
+ {status ? <span fg={colors.status.passing}>{status}</span> : null}
856
1014
  </TextLine>
857
1015
  </box>
858
1016
  )
@@ -863,8 +1021,12 @@ const LabelModal = ({
863
1021
  <Divider width={modalWidth} />
864
1022
  <box height={1} paddingLeft={1} paddingRight={1}>
865
1023
  <TextLine>
1024
+ <span fg={colors.count}>↑↓</span>
1025
+ <span fg={colors.muted}> move </span>
866
1026
  <span fg={colors.count}>enter</span>
867
1027
  <span fg={colors.muted}> toggle </span>
1028
+ <span fg={colors.count}>/type</span>
1029
+ <span fg={colors.muted}> filter </span>
868
1030
  <span fg={colors.count}>esc</span>
869
1031
  <span fg={colors.muted}> close</span>
870
1032
  {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
@@ -876,10 +1038,10 @@ const LabelModal = ({
876
1038
 
877
1039
  export const App = () => {
878
1040
  const { width, height } = useTerminalDimensions()
879
- const [pullRequestState, setPullRequestState] = useAtom(pullRequestStateAtom)
1041
+ const pullRequestResult = useAtomValue(pullRequestsAtom)
1042
+ const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
880
1043
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
881
1044
  const [notice, setNotice] = useAtom(noticeAtom)
882
- const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
883
1045
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
884
1046
  const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
885
1047
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
@@ -888,9 +1050,15 @@ export const App = () => {
888
1050
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
889
1051
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
890
1052
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
891
- const [username, setUsername] = useAtom(usernameAtom)
892
- const [groupIconIndex, setGroupIconIndex] = useAtom(groupIconIndexAtom)
893
- const groupIcon = GROUP_ICONS[groupIconIndex % GROUP_ICONS.length]!
1053
+ const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
1054
+ const retryProgress = useAtomValue(retryProgressAtom)
1055
+ const [loadingFrame, setLoadingFrame] = useState(0)
1056
+ const usernameResult = useAtomValue(usernameAtom)
1057
+ const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
1058
+ const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1059
+ const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1060
+ const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1061
+ const groupIcon = GROUP_ICON
894
1062
  const contentWidth = Math.max(60, width ?? 100)
895
1063
  const isWideLayout = (width ?? 100) >= 100
896
1064
  const splitGap = 1
@@ -925,65 +1093,28 @@ export const App = () => {
925
1093
  }
926
1094
  }, [])
927
1095
 
928
- useEffect(() => {
929
- let cancelled = false
930
- if (config.author !== "@me") {
931
- setUsername(config.author.replace(/^@/, ""))
932
- return () => {
933
- cancelled = true
934
- }
935
- }
936
-
937
- void getAuthenticatedUser()
938
- .then((login) => {
939
- if (!cancelled) setUsername(login)
940
- })
941
- .catch(() => {
942
- if (!cancelled) setUsername(null)
943
- })
944
-
945
- return () => {
946
- cancelled = true
947
- }
948
- }, [setUsername])
1096
+ const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
1097
+ const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
1098
+ const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
1099
+ ? "loading"
1100
+ : AsyncResult.isFailure(pullRequestResult)
1101
+ ? "error"
1102
+ : "ready"
1103
+ const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
1104
+ const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
949
1105
 
950
1106
  useEffect(() => {
951
- let cancelled = false
952
-
953
- setPullRequestState((current) => ({
954
- ...current,
955
- status: current.fetchedAt === null ? "loading" : "ready",
956
- error: null,
957
- }))
958
-
959
- loadOpenPullRequests()
960
- .then((pullRequests) => {
961
- if (cancelled) return
962
- setPullRequestState({
963
- status: "ready",
964
- data: pullRequests,
965
- error: null,
966
- fetchedAt: new Date(),
967
- })
968
- })
969
- .catch((error) => {
970
- if (cancelled) return
971
- setPullRequestState((current) => ({
972
- ...current,
973
- status: "error",
974
- error: error instanceof Error ? error.message : String(error),
975
- }))
976
- })
977
-
978
- return () => {
979
- cancelled = true
980
- }
981
- }, [refreshNonce])
1107
+ if (pullRequestStatus !== "loading") return
1108
+ const interval = globalThis.setInterval(() => {
1109
+ setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1110
+ }, 120)
1111
+ return () => globalThis.clearInterval(interval)
1112
+ }, [pullRequestStatus])
982
1113
 
983
1114
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
984
1115
  const visibleFilterText = filterMode ? filterDraft : filterQuery
985
1116
 
986
- const filteredPullRequests = pullRequestState.data.filter((pullRequest) => {
1117
+ const filteredPullRequests = pullRequests.filter((pullRequest) => {
987
1118
  const query = effectiveFilterQuery
988
1119
  if (query.length === 0) return true
989
1120
  return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
@@ -1009,9 +1140,9 @@ export const App = () => {
1009
1140
  }
1010
1141
  return 0
1011
1142
  }
1012
- const summaryRight = pullRequestState.fetchedAt
1013
- ? `updated ${formatShortDate(pullRequestState.fetchedAt)} ${formatTimestamp(pullRequestState.fetchedAt)}`
1014
- : pullRequestState.status === "loading"
1143
+ const summaryRight = pullRequestLoad?.fetchedAt
1144
+ ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
1145
+ : pullRequestStatus === "loading"
1015
1146
  ? "loading pull requests..."
1016
1147
  : ""
1017
1148
  const headerLeft = username ? `GHUI ${username}` : "GHUI"
@@ -1022,13 +1153,13 @@ export const App = () => {
1022
1153
  if (index >= 0) setSelectedIndex(index)
1023
1154
  }
1024
1155
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
1025
- setPullRequestState((current) => ({
1026
- ...current,
1027
- data: current.data.map((pullRequest) => (pullRequest.url === url ? transform(pullRequest) : pullRequest)),
1028
- }))
1156
+ const pullRequest = pullRequests.find((item) => item.url === url)
1157
+ if (!pullRequest) return
1158
+ setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
1029
1159
  }
1030
1160
  const refreshPullRequests = (message?: string) => {
1031
- setRefreshNonce((current) => current + 1)
1161
+ setPullRequestOverrides({})
1162
+ refreshPullRequestsAtom()
1032
1163
  if (message) flashNotice(message)
1033
1164
  }
1034
1165
 
@@ -1040,6 +1171,31 @@ export const App = () => {
1040
1171
  }, [visiblePullRequests.length])
1041
1172
 
1042
1173
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1174
+ const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1175
+ const detailPlaceholderContent: DetailPlaceholderContent = pullRequestStatus === "loading"
1176
+ ? {
1177
+ title: `${loadingIndicator} Loading pull requests`,
1178
+ hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
1179
+ }
1180
+ : pullRequestStatus === "error"
1181
+ ? {
1182
+ title: "Could not load pull requests",
1183
+ hint: "Press r to retry",
1184
+ }
1185
+ : visiblePullRequests.length === 0 && visibleFilterText.length > 0
1186
+ ? {
1187
+ title: "No matching pull requests",
1188
+ hint: "Press esc to clear the filter",
1189
+ }
1190
+ : visiblePullRequests.length === 0
1191
+ ? {
1192
+ title: "No open pull requests",
1193
+ hint: "Press r to refresh",
1194
+ }
1195
+ : {
1196
+ title: "Select a pull request",
1197
+ hint: "Use up/down to move",
1198
+ }
1043
1199
  const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
1044
1200
  const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
1045
1201
  const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
@@ -1047,7 +1203,9 @@ export const App = () => {
1047
1203
  const checksRows = checksRowCount(detailChecks)
1048
1204
  // checks heading (1) + grid rows + divider
1049
1205
  const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
1050
- const detailJunctions = detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1206
+ const detailJunctions = selectedPullRequest
1207
+ ? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1208
+ : [DETAIL_PLACEHOLDER_ROWS]
1051
1209
 
1052
1210
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1053
1211
 
@@ -1068,7 +1226,7 @@ export const App = () => {
1068
1226
  }
1069
1227
 
1070
1228
  setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1071
- void listRepoLabels(repository)
1229
+ void loadRepoLabels(repository)
1072
1230
  .then((labels) => {
1073
1231
  setLabelCache((current) => ({ ...current, [repository]: labels }))
1074
1232
  setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
@@ -1095,7 +1253,7 @@ export const App = () => {
1095
1253
  ...pr,
1096
1254
  labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
1097
1255
  }))
1098
- void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1256
+ void removePullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1099
1257
  .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
1100
1258
  .catch((error) => {
1101
1259
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
@@ -1106,7 +1264,7 @@ export const App = () => {
1106
1264
  ...pr,
1107
1265
  labels: [...pr.labels, { name: label.name, color: label.color }],
1108
1266
  }))
1109
- void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1267
+ void addPullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1110
1268
  .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
1111
1269
  .catch((error) => {
1112
1270
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
@@ -1348,14 +1506,6 @@ export const App = () => {
1348
1506
  openLabelModal()
1349
1507
  return
1350
1508
  }
1351
- if (key.name === "p" || key.name === "P") {
1352
- setGroupIconIndex((current) => {
1353
- const next = (current + 1) % GROUP_ICONS.length
1354
- flashNotice(`icon: ${GROUP_ICONS[next]}`)
1355
- return next
1356
- })
1357
- return
1358
- }
1359
1509
  if (key.name === "o" && selectedPullRequest) {
1360
1510
  void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1361
1511
  flashNotice(`Opened #${selectedPullRequest.number} in browser`)
@@ -1368,7 +1518,7 @@ export const App = () => {
1368
1518
  ...pullRequest,
1369
1519
  reviewStatus: nextReviewStatus,
1370
1520
  }))
1371
- void toggleDraft(selectedPullRequest.repository, selectedPullRequest.number, selectedPullRequest.reviewStatus === "draft")
1521
+ void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1372
1522
  .then(() => {
1373
1523
  flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1374
1524
  })
@@ -1395,8 +1545,8 @@ export const App = () => {
1395
1545
  const prListProps = {
1396
1546
  groups: visibleGroups,
1397
1547
  selectedUrl: selectedPullRequest?.url ?? null,
1398
- status: pullRequestState.status,
1399
- error: pullRequestState.error,
1548
+ status: pullRequestStatus,
1549
+ error: pullRequestError,
1400
1550
  filterText: visibleFilterText,
1401
1551
  showFilterBar: filterMode || filterQuery.length > 0,
1402
1552
  isFilterEditing: filterMode,
@@ -1428,6 +1578,7 @@ export const App = () => {
1428
1578
  bodyLines={fullscreenBodyLines}
1429
1579
  paneWidth={contentWidth}
1430
1580
  showChecks
1581
+ placeholderContent={detailPlaceholderContent}
1431
1582
  />
1432
1583
  </scrollbox>
1433
1584
  </box>
@@ -1448,9 +1599,7 @@ export const App = () => {
1448
1599
  </scrollbox>
1449
1600
  </>
1450
1601
  ) : (
1451
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1452
- <PlainLine text="Select a pull request with up/down." fg={colors.muted} />
1453
- </box>
1602
+ <DetailPlaceholder content={detailPlaceholderContent} paneWidth={rightPaneWidth} />
1454
1603
  )}
1455
1604
  </box>
1456
1605
  </box>
@@ -1462,12 +1611,13 @@ export const App = () => {
1462
1611
  contentWidth={fullscreenContentWidth}
1463
1612
  bodyLines={fullscreenBodyLines}
1464
1613
  paneWidth={contentWidth}
1614
+ placeholderContent={detailPlaceholderContent}
1465
1615
  />
1466
1616
  </scrollbox>
1467
1617
  </box>
1468
1618
  ) : (
1469
1619
  <>
1470
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
1620
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
1471
1621
  <Divider width={contentWidth} />
1472
1622
  <box flexGrow={1} flexDirection="column">
1473
1623
  <scrollbox flexGrow={1}>
@@ -1485,7 +1635,20 @@ export const App = () => {
1485
1635
  <Divider width={contentWidth} />
1486
1636
  )}
1487
1637
  <box paddingLeft={1} paddingRight={1}>
1488
- {footerNotice ? <PlainLine text={footerNotice} fg={colors.count} /> : <FooterHints showFilterClear={filterMode || filterQuery.length > 0} detailFullView={detailFullView} />}
1638
+ {footerNotice ? (
1639
+ <PlainLine text={footerNotice} fg={colors.count} />
1640
+ ) : (
1641
+ <FooterHints
1642
+ filterEditing={filterMode}
1643
+ showFilterClear={filterMode || filterQuery.length > 0}
1644
+ detailFullView={detailFullView}
1645
+ hasSelection={selectedPullRequest !== null}
1646
+ hasError={pullRequestStatus === "error"}
1647
+ isLoading={pullRequestStatus === "loading"}
1648
+ loadingIndicator={loadingIndicator}
1649
+ retryProgress={retryProgress}
1650
+ />
1651
+ )}
1489
1652
  </box>
1490
1653
  {labelModal.open ? (
1491
1654
  <LabelModal
@@ -1495,6 +1658,7 @@ export const App = () => {
1495
1658
  modalHeight={labelModalHeight}
1496
1659
  offsetLeft={labelModalLeft}
1497
1660
  offsetTop={labelModalTop}
1661
+ loadingIndicator={loadingIndicator}
1498
1662
  />
1499
1663
  ) : null}
1500
1664
  </box>
@@ -1,43 +1,71 @@
1
+ import { Context, Effect, Layer, Schema } from "effect"
2
+
1
3
  export interface CommandResult {
2
4
  readonly stdout: string
3
5
  readonly stderr: string
4
6
  readonly exitCode: number
5
7
  }
6
8
 
9
+ export class CommandError extends Schema.TaggedErrorClass<CommandError>()("CommandError", {
10
+ command: Schema.String,
11
+ args: Schema.Array(Schema.String),
12
+ detail: Schema.String,
13
+ cause: Schema.Defect,
14
+ }) {}
15
+
16
+ export class JsonParseError extends Schema.TaggedErrorClass<JsonParseError>()("JsonParseError", {
17
+ command: Schema.String,
18
+ args: Schema.Array(Schema.String),
19
+ stdout: Schema.String,
20
+ cause: Schema.Defect,
21
+ }) {}
22
+
7
23
  const readStream = async (stream: ReadableStream | null | undefined) => {
8
24
  if (!stream) return ""
9
25
  return Bun.readableStreamToText(stream)
10
26
  }
11
27
 
12
- const runProcess = async (command: string, args: readonly string[]): Promise<CommandResult> => {
13
- try {
14
- const proc = Bun.spawn({
15
- cmd: [command, ...args],
16
- stdout: "pipe",
17
- stderr: "pipe",
18
- })
19
-
20
- const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
21
- return { stdout, stderr, exitCode }
22
- } catch (error) {
23
- throw new Error(`Failed to run ${command}: ${String(error)}`)
24
- }
25
- }
28
+ export class CommandRunner extends Context.Service<CommandRunner, {
29
+ readonly run: (command: string, args: readonly string[]) => Effect.Effect<CommandResult, CommandError>
30
+ readonly runJson: <A>(command: string, args: readonly string[]) => Effect.Effect<A, CommandError | JsonParseError>
31
+ }>()("ghui/CommandRunner") {
32
+ static readonly layer = Layer.effect(
33
+ CommandRunner,
34
+ Effect.gen(function*() {
35
+ const runProcess = Effect.fn("CommandRunner.runProcess")((command: string, args: readonly string[]) =>
36
+ Effect.tryPromise({
37
+ async try() {
38
+ const proc = Bun.spawn({
39
+ cmd: [command, ...args],
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ })
26
43
 
27
- export const run = async (command: string, args: readonly string[]) => {
28
- const result = await runProcess(command, args)
29
- if (result.exitCode !== 0) {
30
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
31
- throw new Error(`${command} ${args.join(" ")} failed: ${detail}`)
32
- }
33
- return result
34
- }
44
+ const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
45
+ return { stdout, stderr, exitCode }
46
+ },
47
+ catch: (cause) => new CommandError({ command, args: [...args], detail: `Failed to run ${command}`, cause }),
48
+ })
49
+ )
50
+
51
+ const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[]) {
52
+ const result = yield* runProcess(command, args)
53
+ if (result.exitCode !== 0) {
54
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
55
+ return yield* new CommandError({ command, args: [...args], detail, cause: detail })
56
+ }
57
+ return result
58
+ })
59
+
60
+ const runJson = Effect.fn("CommandRunner.runJson")(function*<A>(command: string, args: readonly string[]) {
61
+ const result = yield* run(command, args)
62
+ return yield* Effect.try({
63
+ try: () => JSON.parse(result.stdout) as A,
64
+ catch: (cause) => new JsonParseError({ command, args: [...args], stdout: result.stdout, cause }),
65
+ })
66
+ })
35
67
 
36
- export const runJson = async <A>(command: string, args: readonly string[]) => {
37
- const result = await run(command, args)
38
- try {
39
- return JSON.parse(result.stdout) as A
40
- } catch (error) {
41
- throw new Error(`Could not parse JSON from ${command}: ${String(error)}`)
42
- }
68
+ return CommandRunner.of({ run, runJson })
69
+ }),
70
+ )
43
71
  }
@@ -1,6 +1,7 @@
1
+ import { Context, Effect, Layer } from "effect"
1
2
  import { config } from "../config.js"
2
- import type { CheckItem, PullRequestItem, PullRequestLabel } from "../domain.js"
3
- import { run, runJson } from "./CommandRunner.js"
3
+ import type { CheckItem, PullRequestItem } from "../domain.js"
4
+ import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
4
5
 
5
6
  interface GitHubListPullRequest {
6
7
  readonly number: number
@@ -153,41 +154,72 @@ const searchOpenArgs = (author: string) => [
153
154
  searchJsonFields,
154
155
  ] as const
155
156
 
156
- export const listOpenPullRequests = async (): Promise<readonly PullRequestItem[]> => {
157
- const searchResults = await runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
158
- const pullRequests = await Promise.all(
159
- searchResults.map(async (searchResult) => {
160
- const repository = searchResult.repository.nameWithOwner
161
- const pullRequest = await runJson<GitHubListPullRequest>("gh", [
162
- "pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
163
- ])
164
- return parsePullRequest(repository, pullRequest)
157
+ type GitHubError = CommandError | JsonParseError
158
+
159
+ export class GitHubService extends Context.Service<GitHubService, {
160
+ readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
161
+ readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
162
+ readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
163
+ readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
164
+ readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
165
+ readonly removePullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
166
+ }>()("ghui/GitHubService") {
167
+ static readonly layerNoDeps = Layer.effect(
168
+ GitHubService,
169
+ Effect.gen(function*() {
170
+ const command = yield* CommandRunner
171
+
172
+ const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
173
+ const searchResults = yield* command.runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
174
+ const pullRequests = yield* Effect.forEach(
175
+ searchResults,
176
+ Effect.fn("GitHubService.loadPullRequestDetail")(function*(searchResult) {
177
+ const repository = searchResult.repository.nameWithOwner
178
+ const pullRequest = yield* command.runJson<GitHubListPullRequest>("gh", [
179
+ "pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
180
+ ])
181
+ return parsePullRequest(repository, pullRequest)
182
+ }),
183
+ { concurrency: 8 },
184
+ )
185
+
186
+ return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
187
+ })
188
+
189
+ const getAuthenticatedUser = Effect.fn("GitHubService.getAuthenticatedUser")(function*() {
190
+ const viewer = yield* command.runJson<GitHubViewer>("gh", ["api", "user"])
191
+ return viewer.login
192
+ })
193
+
194
+ const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
195
+ yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
196
+ })
197
+
198
+ const listRepoLabels = Effect.fn("GitHubService.listRepoLabels")(function*(repository: string) {
199
+ const labels = yield* command.runJson<readonly { name: string; color: string }[]>("gh", [
200
+ "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
201
+ ])
202
+ return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
203
+ })
204
+
205
+ const addPullRequestLabel = Effect.fn("GitHubService.addPullRequestLabel")(function*(repository: string, number: number, label: string) {
206
+ yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
207
+ })
208
+
209
+ const removePullRequestLabel = Effect.fn("GitHubService.removePullRequestLabel")(function*(repository: string, number: number, label: string) {
210
+ yield* command.run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
211
+ })
212
+
213
+ return GitHubService.of({
214
+ listOpenPullRequests,
215
+ getAuthenticatedUser,
216
+ toggleDraftStatus,
217
+ listRepoLabels,
218
+ addPullRequestLabel,
219
+ removePullRequestLabel,
220
+ })
165
221
  }),
166
222
  )
167
223
 
168
- return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
169
- }
170
-
171
- export const getAuthenticatedUser = async () => {
172
- const viewer = await runJson<GitHubViewer>("gh", ["api", "user"])
173
- return viewer.login
174
- }
175
-
176
- export const toggleDraftStatus = async (repository: string, number: number, isDraft: boolean) => {
177
- await run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
178
- }
179
-
180
- export const listRepoLabels = async (repository: string): Promise<readonly PullRequestLabel[]> => {
181
- const labels = await runJson<readonly { name: string; color: string }[]>("gh", [
182
- "label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
183
- ])
184
- return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
185
- }
186
-
187
- export const addPullRequestLabel = async (repository: string, number: number, label: string) => {
188
- await run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
189
- }
190
-
191
- export const removePullRequestLabel = async (repository: string, number: number, label: string) => {
192
- await run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
224
+ static readonly layer = GitHubService.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
193
225
  }