@kitlangton/ghui 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -709,10 +709,6 @@ const FooterHints = ({
709
709
  <TextLine>
710
710
  <span fg={colors.count}>esc</span>
711
711
  <span fg={colors.muted}> back </span>
712
- <span fg={colors.count}>j/k</span>
713
- <span fg={colors.muted}> scroll </span>
714
- <span fg={colors.count}>gg/G</span>
715
- <span fg={colors.muted}> top/bot </span>
716
712
  <span fg={colors.count}>v</span>
717
713
  <span fg={colors.muted}> view </span>
718
714
  <span fg={colors.count}>w</span>
@@ -734,12 +730,6 @@ const FooterHints = ({
734
730
  <TextLine>
735
731
  <span fg={colors.count}>esc</span>
736
732
  <span fg={colors.muted}> back </span>
737
- <span fg={colors.count}>j/k</span>
738
- <span fg={colors.muted}> scroll </span>
739
- <span fg={colors.count}>gg/G</span>
740
- <span fg={colors.muted}> top/bot </span>
741
- <span fg={colors.count}>ctrl-d/u</span>
742
- <span fg={colors.muted}> page </span>
743
733
  <span fg={colors.count}>o</span>
744
734
  <span fg={colors.muted}> open </span>
745
735
  <span fg={colors.count}>y</span>
@@ -1502,6 +1492,7 @@ export const App = () => {
1502
1492
  const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
1503
1493
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1504
1494
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1495
+ const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1505
1496
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1506
1497
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1507
1498
  const headerFooterWidth = Math.max(24, contentWidth - 2)
@@ -1523,6 +1514,9 @@ export const App = () => {
1523
1514
  if (pendingGTimeoutRef.current !== null) {
1524
1515
  clearTimeout(pendingGTimeoutRef.current)
1525
1516
  }
1517
+ if (diffPrefetchTimeoutRef.current !== null) {
1518
+ clearTimeout(diffPrefetchTimeoutRef.current)
1519
+ }
1526
1520
  }, [])
1527
1521
 
1528
1522
  const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
@@ -1653,6 +1647,22 @@ export const App = () => {
1653
1647
  })
1654
1648
  }
1655
1649
 
1650
+ useEffect(() => {
1651
+ if (!selectedPullRequest || diffFullView) return
1652
+ if (diffPrefetchTimeoutRef.current !== null) {
1653
+ clearTimeout(diffPrefetchTimeoutRef.current)
1654
+ }
1655
+ diffPrefetchTimeoutRef.current = setTimeout(() => {
1656
+ loadPullRequestDiff(selectedPullRequest)
1657
+ }, 250)
1658
+ return () => {
1659
+ if (diffPrefetchTimeoutRef.current !== null) {
1660
+ clearTimeout(diffPrefetchTimeoutRef.current)
1661
+ diffPrefetchTimeoutRef.current = null
1662
+ }
1663
+ }
1664
+ }, [selectedIndex, selectedPullRequest?.url, diffFullView])
1665
+
1656
1666
  const openDiffView = () => {
1657
1667
  if (!selectedPullRequest) return
1658
1668
  setDiffFullView(true)
@@ -1883,7 +1893,7 @@ export const App = () => {
1883
1893
  return
1884
1894
  }
1885
1895
 
1886
- // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1896
+ // Fullscreen detail mode handles its own navigation keys.
1887
1897
  if (detailFullView) {
1888
1898
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1889
1899
  setDetailFullView(false)
@@ -3,52 +3,105 @@ import { config } from "../config.js"
3
3
  import type { CheckItem, PullRequestItem } from "../domain.js"
4
4
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
5
 
6
- interface GitHubListPullRequest {
6
+ interface GitHubPullRequestNode {
7
7
  readonly number: number
8
8
  readonly title: string
9
9
  readonly body: string
10
- readonly labels: readonly {
11
- readonly name: string
12
- readonly color?: string | null
13
- }[]
10
+ readonly labels: {
11
+ readonly nodes: readonly {
12
+ readonly name: string
13
+ readonly color?: string | null
14
+ }[]
15
+ }
14
16
  readonly additions: number
15
17
  readonly deletions: number
16
18
  readonly changedFiles: number
17
19
  readonly isDraft: boolean
18
- readonly reviewDecision: string
19
- readonly statusCheckRollup: readonly {
20
- readonly name?: string | null
21
- readonly context?: string | null
22
- readonly status?: string | null
23
- readonly conclusion?: string | null
24
- readonly state?: string | null
25
- }[]
20
+ readonly reviewDecision: string | null
21
+ readonly statusCheckRollup?: {
22
+ readonly contexts: {
23
+ readonly nodes: readonly GraphQLCheckContext[]
24
+ }
25
+ } | null
26
26
  readonly state: string
27
27
  readonly createdAt: string
28
28
  readonly closedAt?: string | null
29
29
  readonly url: string
30
- }
31
-
32
- interface GitHubSearchPullRequest {
33
- readonly number: number
34
30
  readonly repository: {
35
31
  readonly nameWithOwner: string
36
32
  }
37
33
  }
38
34
 
35
+ type GraphQLCheckContext =
36
+ | {
37
+ readonly __typename: "CheckRun"
38
+ readonly name?: string | null
39
+ readonly status?: string | null
40
+ readonly conclusion?: string | null
41
+ }
42
+ | {
43
+ readonly __typename: "StatusContext"
44
+ readonly context?: string | null
45
+ readonly state?: string | null
46
+ }
47
+
48
+ interface GraphQLSearchResponse {
49
+ readonly data: {
50
+ readonly search: {
51
+ readonly nodes: readonly (GitHubPullRequestNode | null)[]
52
+ readonly pageInfo: {
53
+ readonly hasNextPage: boolean
54
+ readonly endCursor: string | null
55
+ }
56
+ }
57
+ }
58
+ }
59
+
39
60
  interface GitHubViewer {
40
61
  readonly login: string
41
62
  }
42
63
 
43
- const searchJsonFields = "repository,number"
44
- const detailJsonFields = "number,title,body,labels,additions,deletions,changedFiles,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
64
+ const pullRequestSearchQuery = `
65
+ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
66
+ search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
67
+ nodes {
68
+ ... on PullRequest {
69
+ number
70
+ title
71
+ body
72
+ isDraft
73
+ reviewDecision
74
+ additions
75
+ deletions
76
+ changedFiles
77
+ state
78
+ createdAt
79
+ closedAt
80
+ url
81
+ repository { nameWithOwner }
82
+ labels(first: 20) { nodes { name color } }
83
+ statusCheckRollup {
84
+ contexts(first: 100) {
85
+ nodes {
86
+ __typename
87
+ ... on CheckRun { name status conclusion }
88
+ ... on StatusContext { context state }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ pageInfo { hasNextPage endCursor }
95
+ }
96
+ }
97
+ `
45
98
 
46
99
  const normalizeDate = (value: string | null | undefined) => {
47
100
  if (!value || value.startsWith("0001-01-01")) return null
48
101
  return new Date(value)
49
102
  }
50
103
 
51
- const getReviewStatus = (item: GitHubListPullRequest): PullRequestItem["reviewStatus"] => {
104
+ const getReviewStatus = (item: GitHubPullRequestNode): PullRequestItem["reviewStatus"] => {
52
105
  if (item.isDraft) return "draft"
53
106
  if (item.reviewDecision === "APPROVED") return "approved"
54
107
  if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
@@ -73,8 +126,22 @@ const normalizeCheckConclusion = (raw?: string | null): CheckItem["conclusion"]
73
126
  return null
74
127
  }
75
128
 
76
- const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
77
- if (item.statusCheckRollup.length === 0) {
129
+ const getContextStatus = (context: GraphQLCheckContext): CheckItem["status"] => {
130
+ if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
131
+ if (context.state === "PENDING") return "in_progress"
132
+ return "completed"
133
+ }
134
+
135
+ const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusion"] => {
136
+ if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
137
+ if (context.state === "SUCCESS") return "success"
138
+ if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
139
+ return null
140
+ }
141
+
142
+ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
143
+ const contexts = item.statusCheckRollup?.contexts.nodes ?? []
144
+ if (contexts.length === 0) {
78
145
  return { checkStatus: "none", checkSummary: null, checks: [] }
79
146
  }
80
147
 
@@ -84,48 +151,46 @@ const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "check
84
151
  let failing = false
85
152
  const checks: CheckItem[] = []
86
153
 
87
- for (const check of item.statusCheckRollup) {
88
- const name = check.name ?? check.context ?? "check"
154
+ for (const check of contexts) {
155
+ const name = check.__typename === "CheckRun" ? check.name ?? "check" : check.context ?? "check"
156
+ const status = getContextStatus(check)
157
+ const conclusion = getContextConclusion(check)
89
158
 
90
- checks.push({
91
- name,
92
- status: normalizeCheckStatus(check.status),
93
- conclusion: normalizeCheckConclusion(check.conclusion),
94
- })
159
+ checks.push({ name, status, conclusion })
95
160
 
96
- if (check.status === "COMPLETED") {
161
+ if (status === "completed") {
97
162
  completed += 1
98
163
  } else {
99
164
  pending = true
100
165
  }
101
166
 
102
- if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
167
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
103
168
  successful += 1
104
- } else if (check.conclusion && check.conclusion !== "SUCCESS") {
169
+ } else if (conclusion) {
105
170
  failing = true
106
171
  }
107
172
  }
108
173
 
109
174
  if (pending) {
110
- return { checkStatus: "pending", checkSummary: `checks ${completed}/${item.statusCheckRollup.length}`, checks }
175
+ return { checkStatus: "pending", checkSummary: `checks ${completed}/${contexts.length}`, checks }
111
176
  }
112
177
 
113
178
  if (failing) {
114
- return { checkStatus: "failing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
179
+ return { checkStatus: "failing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
115
180
  }
116
181
 
117
- return { checkStatus: "passing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
182
+ return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
118
183
  }
119
184
 
120
- const parsePullRequest = (repository: string, item: GitHubListPullRequest): PullRequestItem => {
185
+ const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
121
186
  const checkInfo = getCheckInfo(item)
122
187
 
123
188
  return {
124
- repository,
189
+ repository: item.repository.nameWithOwner,
125
190
  number: item.number,
126
191
  title: item.title,
127
192
  body: item.body,
128
- labels: item.labels.map((label) => ({
193
+ labels: item.labels.nodes.map((label) => ({
129
194
  name: label.name,
130
195
  color: label.color ? `#${label.color}` : null,
131
196
  })),
@@ -143,22 +208,7 @@ const parsePullRequest = (repository: string, item: GitHubListPullRequest): Pull
143
208
  }
144
209
  }
145
210
 
146
- const searchOpenArgs = (author: string) => [
147
- "search",
148
- "prs",
149
- "--author",
150
- author,
151
- "--state",
152
- "open",
153
- "--limit",
154
- String(config.prFetchLimit),
155
- "--sort",
156
- "created",
157
- "--order",
158
- "desc",
159
- "--json",
160
- searchJsonFields,
161
- ] as const
211
+ const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
162
212
 
163
213
  type GitHubError = CommandError | JsonParseError
164
214
 
@@ -177,18 +227,27 @@ export class GitHubService extends Context.Service<GitHubService, {
177
227
  const command = yield* CommandRunner
178
228
 
179
229
  const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
180
- const searchResults = yield* command.runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
181
- const pullRequests = yield* Effect.forEach(
182
- searchResults,
183
- Effect.fn("GitHubService.loadPullRequestDetail")(function*(searchResult) {
184
- const repository = searchResult.repository.nameWithOwner
185
- const pullRequest = yield* command.runJson<GitHubListPullRequest>("gh", [
186
- "pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
187
- ])
188
- return parsePullRequest(repository, pullRequest)
189
- }),
190
- { concurrency: 8 },
191
- )
230
+ const pullRequests: PullRequestItem[] = []
231
+ let cursor: string | null = null
232
+
233
+ while (pullRequests.length < config.prFetchLimit) {
234
+ const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
235
+ const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
236
+ "api", "graphql",
237
+ "-f", `query=${pullRequestSearchQuery}`,
238
+ "-F", `searchQuery=${searchQuery(config.author)}`,
239
+ "-F", `first=${pageSize}`,
240
+ ...(cursor ? ["-F", `after=${cursor}`] : []),
241
+ ])
242
+
243
+ for (const node of response.data.search.nodes) {
244
+ if (node) pullRequests.push(parsePullRequest(node))
245
+ }
246
+
247
+ if (!response.data.search.pageInfo.hasNextPage) break
248
+ cursor = response.data.search.pageInfo.endCursor
249
+ if (!cursor) break
250
+ }
192
251
 
193
252
  return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
194
253
  })