@kitlangton/ghui 0.1.0 → 0.1.3

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.3",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,10 +28,11 @@
28
28
  ],
29
29
  "publishConfig": {
30
30
  "access": "public",
31
+ "provenance": true,
31
32
  "registry": "https://registry.npmjs.org/"
32
33
  },
33
34
  "bin": {
34
- "ghui": "./bin/ghui"
35
+ "ghui": "bin/ghui.js"
35
36
  },
36
37
  "scripts": {
37
38
  "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,52 @@ 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
+ }
63
+
64
+ interface RetryProgress {
65
+ readonly attempt: number
66
+ readonly max: number
67
+ }
60
68
 
61
- const initialPullRequestState: PullRequestState = {
62
- status: "loading",
63
- data: [],
64
- error: null,
65
- fetchedAt: null,
69
+ interface DetailPlaceholderInput {
70
+ readonly status: LoadStatus
71
+ readonly retryProgress: RetryProgress | null
72
+ readonly loadingIndicator: string
73
+ readonly visibleCount: number
74
+ readonly filterText: string
66
75
  }
67
76
 
68
- const pullRequestStateAtom = Atom.make(initialPullRequestState).pipe(Atom.keepAlive)
77
+ const pullRequestReferencePattern = /(#[0-9]+)/g
78
+ const PR_FETCH_RETRIES = 6
79
+ const DETAIL_PLACEHOLDER_ROWS = 4
80
+ const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
81
+
82
+ const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
83
+ const pullRequestsAtom = githubRuntime.atom(
84
+ GitHubService.use((github) =>
85
+ Effect.gen(function*() {
86
+ yield* Atom.set(retryProgressAtom, null)
87
+ const data = yield* github.listOpenPullRequests().pipe(
88
+ Effect.tapError(() =>
89
+ Atom.update(retryProgressAtom, (current) => ({
90
+ attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
91
+ max: PR_FETCH_RETRIES,
92
+ }))
93
+ ),
94
+ Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
95
+ Effect.tapError(() => Atom.set(retryProgressAtom, null)),
96
+ )
97
+
98
+ yield* Atom.set(retryProgressAtom, null)
99
+ return { data, fetchedAt: new Date() } satisfies PullRequestLoad
100
+ })
101
+ ),
102
+ ).pipe(Atom.keepAlive)
69
103
  const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
70
104
  const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
71
- const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
72
105
  const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
73
106
  const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
74
107
  const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
@@ -76,8 +109,7 @@ const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
76
109
  const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
77
110
  const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
78
111
 
79
- const GROUP_ICONS = ["▸", "◆", "●", "▪", "›", "◈", "▹", "◉", "⬥", "⏵", "⊡", "⬩"] as const
80
- const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
112
+ const GROUP_ICON = "◆"
81
113
 
82
114
  interface LabelModalState {
83
115
  readonly open: boolean
@@ -99,7 +131,25 @@ const initialLabelModalState: LabelModalState = {
99
131
 
100
132
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
101
133
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
102
- const usernameAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
134
+ const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
135
+ const usernameAtom = githubRuntime.atom(
136
+ config.author === "@me"
137
+ ? GitHubService.use((github) => github.getAuthenticatedUser())
138
+ : Effect.succeed(config.author.replace(/^@/, "")),
139
+ ).pipe(Atom.keepAlive)
140
+
141
+ const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
142
+ GitHubService.use((github) => github.listRepoLabels(repository))
143
+ )
144
+ const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
145
+ GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
146
+ )
147
+ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
148
+ GitHubService.use((github) => github.removePullRequestLabel(input.repository, input.number, input.label))
149
+ )
150
+ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
151
+ GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
152
+ )
103
153
 
104
154
  const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
105
155
 
@@ -166,6 +216,12 @@ const fitCell = (text: string, width: number, align: "left" | "right" = "left")
166
216
  return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
167
217
  }
168
218
 
219
+ const centerCell = (text: string, width: number) => {
220
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
221
+ const left = Math.floor((width - trimmed.length) / 2)
222
+ return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
223
+ }
224
+
169
225
  const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
170
226
  if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
171
227
  return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
@@ -245,6 +301,8 @@ const fallbackLabelColor = (name: string) => {
245
301
  return `hsl(${hue} 55% 35%)`
246
302
  }
247
303
 
304
+ const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
305
+
248
306
  const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
249
307
 
250
308
  const labelTextColor = (color: string) => {
@@ -258,6 +316,47 @@ const labelTextColor = (color: string) => {
258
316
  return "#f8fafc"
259
317
  }
260
318
 
319
+ const getDetailPlaceholderContent = ({
320
+ status,
321
+ retryProgress,
322
+ loadingIndicator,
323
+ visibleCount,
324
+ filterText,
325
+ }: DetailPlaceholderInput): DetailPlaceholderContent => {
326
+ if (status === "loading") {
327
+ return {
328
+ title: `${loadingIndicator} Loading pull requests`,
329
+ hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
330
+ }
331
+ }
332
+
333
+ if (status === "error") {
334
+ return {
335
+ title: "Could not load pull requests",
336
+ hint: "Press r to retry",
337
+ }
338
+ }
339
+
340
+ if (visibleCount === 0 && filterText.length > 0) {
341
+ return {
342
+ title: "No matching pull requests",
343
+ hint: "Press esc to clear the filter",
344
+ }
345
+ }
346
+
347
+ if (visibleCount === 0) {
348
+ return {
349
+ title: "No open pull requests",
350
+ hint: "Press r to refresh",
351
+ }
352
+ }
353
+
354
+ return {
355
+ title: "Select a pull request",
356
+ hint: "Use up/down to move",
357
+ }
358
+ }
359
+
261
360
  const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
262
361
  const sourceLines = body.replace(/\r/g, "").split("\n")
263
362
  const preview: Array<PreviewLine> = []
@@ -396,43 +495,101 @@ const SectionTitle = ({ title }: { title: string }) => (
396
495
  </TextLine>
397
496
  )
398
497
 
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
- <>
498
+ const FooterHints = ({
499
+ filterEditing,
500
+ showFilterClear,
501
+ detailFullView,
502
+ hasSelection,
503
+ hasError,
504
+ isLoading,
505
+ loadingIndicator,
506
+ retryProgress,
507
+ }: {
508
+ filterEditing: boolean
509
+ showFilterClear: boolean
510
+ detailFullView: boolean
511
+ hasSelection: boolean
512
+ hasError: boolean
513
+ isLoading: boolean
514
+ loadingIndicator: string
515
+ retryProgress: RetryProgress | null
516
+ }) => {
517
+ if (filterEditing) {
518
+ return (
519
+ <TextLine>
520
+ <span fg={colors.count}>search</span>
521
+ <span fg={colors.muted}> typing </span>
522
+ <span fg={colors.count}>↑↓</span>
523
+ <span fg={colors.muted}> move </span>
524
+ <span fg={colors.count}>enter</span>
525
+ <span fg={colors.muted}> apply </span>
407
526
  <span fg={colors.count}>esc</span>
527
+ <span fg={colors.muted}> cancel </span>
528
+ <span fg={colors.count}>ctrl-u</span>
408
529
  <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
- )
530
+ <span fg={colors.count}>ctrl-w</span>
531
+ <span fg={colors.muted}> word</span>
532
+ </TextLine>
533
+ )
534
+ }
535
+
536
+ return (
537
+ <TextLine>
538
+ <span fg={colors.count}>/</span>
539
+ <span fg={colors.muted}> filter </span>
540
+ {showFilterClear ? (
541
+ <>
542
+ <span fg={colors.count}>esc</span>
543
+ <span fg={colors.muted}> clear </span>
544
+ </>
545
+ ) : null}
546
+ {retryProgress ? (
547
+ <>
548
+ <span fg={colors.status.pending}>retry</span>
549
+ <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
550
+ </>
551
+ ) : isLoading ? (
552
+ <>
553
+ <span fg={colors.status.pending}>{loadingIndicator}</span>
554
+ <span fg={colors.muted}> loading </span>
555
+ </>
556
+ ) : null}
557
+ <span fg={colors.count}>r</span>
558
+ <span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
559
+ {hasSelection ? (
560
+ <>
561
+ <span fg={colors.count}>↑↓</span>
562
+ <span fg={colors.muted}> move </span>
563
+ </>
564
+ ) : null}
565
+ {hasSelection && detailFullView ? (
566
+ <>
567
+ <span fg={colors.count}>esc</span>
568
+ <span fg={colors.muted}> back </span>
569
+ </>
570
+ ) : hasSelection ? (
571
+ <>
572
+ <span fg={colors.count}>enter</span>
573
+ <span fg={colors.muted}> expand </span>
574
+ </>
575
+ ) : null}
576
+ {hasSelection ? (
577
+ <>
578
+ <span fg={colors.count}>d</span>
579
+ <span fg={colors.muted}> draft </span>
580
+ <span fg={colors.count}>l</span>
581
+ <span fg={colors.muted}> labels </span>
582
+ <span fg={colors.count}>o</span>
583
+ <span fg={colors.muted}> open </span>
584
+ <span fg={colors.count}>y</span>
585
+ <span fg={colors.muted}> copy </span>
586
+ </>
587
+ ) : null}
588
+ <span fg={colors.count}>q</span>
589
+ <span fg={colors.muted}> quit</span>
590
+ </TextLine>
591
+ )
592
+ }
436
593
 
437
594
  const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
438
595
  <TextLine>
@@ -742,18 +899,67 @@ const DetailBody = ({
742
899
  )
743
900
  }
744
901
 
902
+ const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
903
+ const innerWidth = Math.max(1, width - 2)
904
+ const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
905
+ const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
906
+ const cardInnerWidth = Math.max(1, cardWidth - 2)
907
+ const contentLine = (text: string, fg: string, bold = false) => (
908
+ <TextLine>
909
+ <span fg={colors.separator}>{offset}│</span>
910
+ {bold ? (
911
+ <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
912
+ ) : (
913
+ <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
914
+ )}
915
+ <span fg={colors.separator}>│</span>
916
+ </TextLine>
917
+ )
918
+
919
+ return (
920
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
921
+ <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
922
+ {contentLine(content.title, colors.count, true)}
923
+ {contentLine(content.hint, colors.muted)}
924
+ <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
925
+ </box>
926
+ )
927
+ }
928
+
929
+ const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
930
+ <box flexDirection="column">
931
+ <StatusCard content={content} width={paneWidth} />
932
+ <box height={1}><Divider width={paneWidth} /></box>
933
+ </box>
934
+ )
935
+
936
+ const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
937
+ const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
938
+ const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
939
+
940
+ return (
941
+ <box height={height} flexDirection="column">
942
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
943
+ <StatusCard content={content} width={width} />
944
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
945
+ </box>
946
+ )
947
+ }
948
+
745
949
  const DetailsPane = ({
746
950
  pullRequest,
747
951
  contentWidth,
748
952
  bodyLines = DETAIL_BODY_LINES,
749
953
  paneWidth = contentWidth + 2,
750
954
  showChecks = false,
955
+ placeholderContent,
751
956
  }: {
752
957
  pullRequest: PullRequestItem | null
753
958
  contentWidth: number
754
959
  bodyLines?: number
755
960
  paneWidth?: number
756
961
  showChecks?: boolean
962
+ placeholderContent: DetailPlaceholderContent
757
963
  }) => {
758
964
  const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
759
965
  const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
@@ -764,7 +970,7 @@ const DetailsPane = ({
764
970
  () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
765
971
  [pullRequest?.body, contentWidth, bodyLines],
766
972
  )
767
- const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
973
+ const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
768
974
 
769
975
  return (
770
976
  <box flexDirection="column" height={contentHeight}>
@@ -774,12 +980,14 @@ const DetailsPane = ({
774
980
  <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
775
981
  </>
776
982
  ) : (
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>
983
+ <>
984
+ <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
985
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
986
+ {Array.from({ length: bodyLines }, (_, index) => (
987
+ <BlankRow key={index} />
988
+ ))}
989
+ </box>
990
+ </>
783
991
  )}
784
992
  </box>
785
993
  )
@@ -792,6 +1000,7 @@ const LabelModal = ({
792
1000
  modalHeight,
793
1001
  offsetLeft,
794
1002
  offsetTop,
1003
+ loadingIndicator,
795
1004
  }: {
796
1005
  state: LabelModalState
797
1006
  currentLabels: readonly PullRequestLabel[]
@@ -799,19 +1008,26 @@ const LabelModal = ({
799
1008
  modalHeight: number
800
1009
  offsetLeft: number
801
1010
  offsetTop: number
1011
+ loadingIndicator: string
802
1012
  }) => {
803
- const contentWidth = modalWidth - 4
1013
+ const contentWidth = Math.max(16, modalWidth - 2)
804
1014
  const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
805
1015
  const filtered = state.availableLabels.filter((label) =>
806
1016
  state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
807
1017
  )
808
- const maxVisible = Math.max(1, modalHeight - 5)
1018
+ const maxVisible = Math.max(1, modalHeight - 6)
809
1019
  const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
810
1020
  const scrollStart = Math.min(
811
1021
  Math.max(0, filtered.length - maxVisible),
812
1022
  Math.max(0, selectedIndex - maxVisible + 1),
813
1023
  )
814
1024
  const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
1025
+ const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
1026
+ const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
1027
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
1028
+ const queryText = state.query.length > 0 ? state.query : "type to filter labels"
1029
+ const queryPrefix = state.query.length > 0 ? "/ " : "/ "
1030
+ const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
815
1031
 
816
1032
  return (
817
1033
  <box
@@ -825,34 +1041,42 @@ const LabelModal = ({
825
1041
  >
826
1042
  <box height={1} paddingLeft={1} paddingRight={1}>
827
1043
  <TextLine>
828
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>Labels</span>
829
- {state.repository ? <span fg={colors.muted}> {state.repository}</span> : null}
1044
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
1045
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1046
+ <span fg={colors.muted}>{countText}</span>
830
1047
  </TextLine>
831
1048
  </box>
832
1049
  <box height={1} paddingLeft={1} paddingRight={1}>
833
1050
  <TextLine>
834
- <span fg={colors.count}>&gt; </span>
1051
+ <span fg={colors.count}>{queryPrefix}</span>
835
1052
  <span fg={state.query.length > 0 ? colors.text : colors.muted}>
836
- {state.query.length > 0 ? state.query : "type to filter..."}
1053
+ {fitCell(queryText, queryWidth)}
837
1054
  </span>
838
1055
  </TextLine>
839
1056
  </box>
840
1057
  <Divider width={modalWidth} />
841
1058
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
842
1059
  {state.loading ? (
843
- <PlainLine text="Loading labels..." fg={colors.muted} />
1060
+ <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
844
1061
  ) : visibleLabels.length === 0 ? (
845
- <PlainLine text={state.query.length > 0 ? "No matching labels." : "No labels found."} fg={colors.muted} />
1062
+ <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
846
1063
  ) : (
847
1064
  visibleLabels.map((label, index) => {
848
1065
  const actualIndex = scrollStart + index
849
1066
  const isActive = currentNames.has(label.name.toLowerCase())
850
1067
  const isSelected = actualIndex === selectedIndex
1068
+ const status = isActive ? "added" : ""
1069
+ const nameWidth = Math.max(8, contentWidth - 10 - status.length)
1070
+ const gap = Math.max(1, contentWidth - 8 - Math.min(label.name.length, nameWidth) - status.length)
851
1071
  return (
852
1072
  <box key={label.name} height={1}>
853
1073
  <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>
1074
+ <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "" : " "}</span>
1075
+ <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? " ✓ " : " "}</span>
1076
+ <span bg={labelColor(label)}> </span>
1077
+ <span fg={isSelected ? colors.selectedText : colors.text}> {fitCell(label.name, nameWidth)}</span>
1078
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
1079
+ {status ? <span fg={colors.status.passing}>{status}</span> : null}
856
1080
  </TextLine>
857
1081
  </box>
858
1082
  )
@@ -863,8 +1087,12 @@ const LabelModal = ({
863
1087
  <Divider width={modalWidth} />
864
1088
  <box height={1} paddingLeft={1} paddingRight={1}>
865
1089
  <TextLine>
1090
+ <span fg={colors.count}>↑↓</span>
1091
+ <span fg={colors.muted}> move </span>
866
1092
  <span fg={colors.count}>enter</span>
867
1093
  <span fg={colors.muted}> toggle </span>
1094
+ <span fg={colors.count}>/type</span>
1095
+ <span fg={colors.muted}> filter </span>
868
1096
  <span fg={colors.count}>esc</span>
869
1097
  <span fg={colors.muted}> close</span>
870
1098
  {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
@@ -876,10 +1104,10 @@ const LabelModal = ({
876
1104
 
877
1105
  export const App = () => {
878
1106
  const { width, height } = useTerminalDimensions()
879
- const [pullRequestState, setPullRequestState] = useAtom(pullRequestStateAtom)
1107
+ const pullRequestResult = useAtomValue(pullRequestsAtom)
1108
+ const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
880
1109
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
881
1110
  const [notice, setNotice] = useAtom(noticeAtom)
882
- const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
883
1111
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
884
1112
  const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
885
1113
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
@@ -888,9 +1116,15 @@ export const App = () => {
888
1116
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
889
1117
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
890
1118
  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]!
1119
+ const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
1120
+ const retryProgress = useAtomValue(retryProgressAtom)
1121
+ const [loadingFrame, setLoadingFrame] = useState(0)
1122
+ const usernameResult = useAtomValue(usernameAtom)
1123
+ const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
1124
+ const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1125
+ const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1126
+ const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1127
+ const groupIcon = GROUP_ICON
894
1128
  const contentWidth = Math.max(60, width ?? 100)
895
1129
  const isWideLayout = (width ?? 100) >= 100
896
1130
  const splitGap = 1
@@ -925,65 +1159,29 @@ export const App = () => {
925
1159
  }
926
1160
  }, [])
927
1161
 
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])
1162
+ const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
1163
+ const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
1164
+ const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
1165
+ ? "loading"
1166
+ : AsyncResult.isFailure(pullRequestResult)
1167
+ ? "error"
1168
+ : "ready"
1169
+ const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
1170
+ const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
1171
+ const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
949
1172
 
950
1173
  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])
1174
+ if (pullRequestStatus !== "loading") return
1175
+ const interval = globalThis.setInterval(() => {
1176
+ setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1177
+ }, 120)
1178
+ return () => globalThis.clearInterval(interval)
1179
+ }, [pullRequestStatus])
982
1180
 
983
1181
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
984
1182
  const visibleFilterText = filterMode ? filterDraft : filterQuery
985
1183
 
986
- const filteredPullRequests = pullRequestState.data.filter((pullRequest) => {
1184
+ const filteredPullRequests = pullRequests.filter((pullRequest) => {
987
1185
  const query = effectiveFilterQuery
988
1186
  if (query.length === 0) return true
989
1187
  return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
@@ -1009,9 +1207,9 @@ export const App = () => {
1009
1207
  }
1010
1208
  return 0
1011
1209
  }
1012
- const summaryRight = pullRequestState.fetchedAt
1013
- ? `updated ${formatShortDate(pullRequestState.fetchedAt)} ${formatTimestamp(pullRequestState.fetchedAt)}`
1014
- : pullRequestState.status === "loading"
1210
+ const summaryRight = pullRequestLoad?.fetchedAt
1211
+ ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
1212
+ : pullRequestStatus === "loading"
1015
1213
  ? "loading pull requests..."
1016
1214
  : ""
1017
1215
  const headerLeft = username ? `GHUI ${username}` : "GHUI"
@@ -1022,13 +1220,13 @@ export const App = () => {
1022
1220
  if (index >= 0) setSelectedIndex(index)
1023
1221
  }
1024
1222
  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
- }))
1223
+ const pullRequest = pullRequests.find((item) => item.url === url)
1224
+ if (!pullRequest) return
1225
+ setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
1029
1226
  }
1030
1227
  const refreshPullRequests = (message?: string) => {
1031
- setRefreshNonce((current) => current + 1)
1228
+ setPullRequestOverrides({})
1229
+ refreshPullRequestsAtom()
1032
1230
  if (message) flashNotice(message)
1033
1231
  }
1034
1232
 
@@ -1040,6 +1238,14 @@ export const App = () => {
1040
1238
  }, [visiblePullRequests.length])
1041
1239
 
1042
1240
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1241
+ const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1242
+ const detailPlaceholderContent = getDetailPlaceholderContent({
1243
+ status: pullRequestStatus,
1244
+ retryProgress,
1245
+ loadingIndicator,
1246
+ visibleCount: visiblePullRequests.length,
1247
+ filterText: visibleFilterText,
1248
+ })
1043
1249
  const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
1044
1250
  const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
1045
1251
  const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
@@ -1047,7 +1253,9 @@ export const App = () => {
1047
1253
  const checksRows = checksRowCount(detailChecks)
1048
1254
  // checks heading (1) + grid rows + divider
1049
1255
  const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
1050
- const detailJunctions = detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1256
+ const detailJunctions = selectedPullRequest
1257
+ ? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1258
+ : [DETAIL_PLACEHOLDER_ROWS]
1051
1259
 
1052
1260
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1053
1261
 
@@ -1068,7 +1276,7 @@ export const App = () => {
1068
1276
  }
1069
1277
 
1070
1278
  setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1071
- void listRepoLabels(repository)
1279
+ void loadRepoLabels(repository)
1072
1280
  .then((labels) => {
1073
1281
  setLabelCache((current) => ({ ...current, [repository]: labels }))
1074
1282
  setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
@@ -1095,7 +1303,7 @@ export const App = () => {
1095
1303
  ...pr,
1096
1304
  labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
1097
1305
  }))
1098
- void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1306
+ void removePullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1099
1307
  .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
1100
1308
  .catch((error) => {
1101
1309
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
@@ -1106,7 +1314,7 @@ export const App = () => {
1106
1314
  ...pr,
1107
1315
  labels: [...pr.labels, { name: label.name, color: label.color }],
1108
1316
  }))
1109
- void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
1317
+ void addPullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1110
1318
  .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
1111
1319
  .catch((error) => {
1112
1320
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
@@ -1348,14 +1556,6 @@ export const App = () => {
1348
1556
  openLabelModal()
1349
1557
  return
1350
1558
  }
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
1559
  if (key.name === "o" && selectedPullRequest) {
1360
1560
  void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
1361
1561
  flashNotice(`Opened #${selectedPullRequest.number} in browser`)
@@ -1368,7 +1568,7 @@ export const App = () => {
1368
1568
  ...pullRequest,
1369
1569
  reviewStatus: nextReviewStatus,
1370
1570
  }))
1371
- void toggleDraft(selectedPullRequest.repository, selectedPullRequest.number, selectedPullRequest.reviewStatus === "draft")
1571
+ void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1372
1572
  .then(() => {
1373
1573
  flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1374
1574
  })
@@ -1395,8 +1595,8 @@ export const App = () => {
1395
1595
  const prListProps = {
1396
1596
  groups: visibleGroups,
1397
1597
  selectedUrl: selectedPullRequest?.url ?? null,
1398
- status: pullRequestState.status,
1399
- error: pullRequestState.error,
1598
+ status: pullRequestStatus,
1599
+ error: pullRequestError,
1400
1600
  filterText: visibleFilterText,
1401
1601
  showFilterBar: filterMode || filterQuery.length > 0,
1402
1602
  isFilterEditing: filterMode,
@@ -1414,12 +1614,14 @@ export const App = () => {
1414
1614
  <box paddingLeft={1} paddingRight={1} flexDirection="column">
1415
1615
  <PlainLine text={headerLine} fg={colors.muted} bold />
1416
1616
  </box>
1417
- {isWideLayout && !detailFullView ? (
1617
+ {isWideLayout && !detailFullView && !isInitialLoading ? (
1418
1618
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
1419
1619
  ) : (
1420
1620
  <Divider width={contentWidth} />
1421
1621
  )}
1422
- {isWideLayout && detailFullView ? (
1622
+ {isInitialLoading ? (
1623
+ <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
1624
+ ) : isWideLayout && detailFullView ? (
1423
1625
  <box flexGrow={1} flexDirection="column">
1424
1626
  <scrollbox flexGrow={1}>
1425
1627
  <DetailsPane
@@ -1428,6 +1630,7 @@ export const App = () => {
1428
1630
  bodyLines={fullscreenBodyLines}
1429
1631
  paneWidth={contentWidth}
1430
1632
  showChecks
1633
+ placeholderContent={detailPlaceholderContent}
1431
1634
  />
1432
1635
  </scrollbox>
1433
1636
  </box>
@@ -1448,9 +1651,7 @@ export const App = () => {
1448
1651
  </scrollbox>
1449
1652
  </>
1450
1653
  ) : (
1451
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1452
- <PlainLine text="Select a pull request with up/down." fg={colors.muted} />
1453
- </box>
1654
+ <DetailPlaceholder content={detailPlaceholderContent} paneWidth={rightPaneWidth} />
1454
1655
  )}
1455
1656
  </box>
1456
1657
  </box>
@@ -1462,12 +1663,13 @@ export const App = () => {
1462
1663
  contentWidth={fullscreenContentWidth}
1463
1664
  bodyLines={fullscreenBodyLines}
1464
1665
  paneWidth={contentWidth}
1666
+ placeholderContent={detailPlaceholderContent}
1465
1667
  />
1466
1668
  </scrollbox>
1467
1669
  </box>
1468
1670
  ) : (
1469
1671
  <>
1470
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
1672
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
1471
1673
  <Divider width={contentWidth} />
1472
1674
  <box flexGrow={1} flexDirection="column">
1473
1675
  <scrollbox flexGrow={1}>
@@ -1479,13 +1681,26 @@ export const App = () => {
1479
1681
  </>
1480
1682
  )}
1481
1683
 
1482
- {isWideLayout && !detailFullView ? (
1684
+ {isWideLayout && !detailFullView && !isInitialLoading ? (
1483
1685
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
1484
1686
  ) : (
1485
1687
  <Divider width={contentWidth} />
1486
1688
  )}
1487
1689
  <box paddingLeft={1} paddingRight={1}>
1488
- {footerNotice ? <PlainLine text={footerNotice} fg={colors.count} /> : <FooterHints showFilterClear={filterMode || filterQuery.length > 0} detailFullView={detailFullView} />}
1690
+ {footerNotice ? (
1691
+ <PlainLine text={footerNotice} fg={colors.count} />
1692
+ ) : (
1693
+ <FooterHints
1694
+ filterEditing={filterMode}
1695
+ showFilterClear={filterMode || filterQuery.length > 0}
1696
+ detailFullView={detailFullView}
1697
+ hasSelection={selectedPullRequest !== null}
1698
+ hasError={pullRequestStatus === "error"}
1699
+ isLoading={pullRequestStatus === "loading"}
1700
+ loadingIndicator={loadingIndicator}
1701
+ retryProgress={retryProgress}
1702
+ />
1703
+ )}
1489
1704
  </box>
1490
1705
  {labelModal.open ? (
1491
1706
  <LabelModal
@@ -1495,6 +1710,7 @@ export const App = () => {
1495
1710
  modalHeight={labelModalHeight}
1496
1711
  offsetLeft={labelModalLeft}
1497
1712
  offsetTop={labelModalTop}
1713
+ loadingIndicator={loadingIndicator}
1498
1714
  />
1499
1715
  ) : null}
1500
1716
  </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
  }