@kitlangton/ghui 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -13,7 +13,7 @@ import { Observability } from "./observability.js"
13
13
  import { GitHubService } from "./services/GitHubService.js"
14
14
  import { colors } from "./ui/colors.js"
15
15
  import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
16
- import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailJunctionRows, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
16
+ import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
17
17
  import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
18
18
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
19
19
  import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal } from "./ui/modals.js"
@@ -39,6 +39,9 @@ interface DetailPlaceholderInput {
39
39
  }
40
40
 
41
41
  const PR_FETCH_RETRIES = 6
42
+ const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
43
+ const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
44
+ const AUTO_REFRESH_JITTER_MS = 10_000
42
45
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
43
46
 
44
47
  const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
@@ -218,6 +221,7 @@ export const App = () => {
218
221
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
219
222
  const retryProgress = useAtomValue(retryProgressAtom)
220
223
  const [loadingFrame, setLoadingFrame] = useState(0)
224
+ const [terminalFocused, setTerminalFocused] = useState(true)
221
225
  const usernameResult = useAtomValue(usernameAtom)
222
226
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
223
227
  const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
@@ -242,6 +246,12 @@ export const App = () => {
242
246
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
243
247
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
244
248
  const detailHydrationRef = useRef<number | null>(null)
249
+ const lastPullRequestRefreshAtRef = useRef(0)
250
+ const terminalFocusedRef = useRef(true)
251
+ const terminalWasBlurredRef = useRef(false)
252
+ const pullRequestStatusRef = useRef<LoadStatus>("loading")
253
+ const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
254
+ const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
245
255
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
246
256
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
247
257
  const headerFooterWidth = Math.max(24, contentWidth - 2)
@@ -281,6 +291,7 @@ export const App = () => {
281
291
  const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
282
292
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
283
293
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
294
+ pullRequestStatusRef.current = pullRequestStatus
284
295
 
285
296
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
286
297
  const visibleFilterText = filterMode ? filterDraft : filterQuery
@@ -332,6 +343,53 @@ export const App = () => {
332
343
  refreshPullRequestsAtom()
333
344
  if (message) flashNotice(message)
334
345
  }
346
+ refreshPullRequestsRef.current = refreshPullRequests
347
+ maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
348
+ if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
349
+ const lastRefreshAt = lastPullRequestRefreshAtRef.current
350
+ if (lastRefreshAt > 0 && Date.now() - lastRefreshAt < minimumAgeMs) return
351
+ refreshPullRequestsRef.current()
352
+ }
353
+
354
+ useEffect(() => {
355
+ const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
356
+ if (fetchedAt !== undefined) {
357
+ lastPullRequestRefreshAtRef.current = fetchedAt
358
+ }
359
+ }, [pullRequestLoad?.fetchedAt])
360
+
361
+ useEffect(() => {
362
+ const handleFocus = () => {
363
+ terminalFocusedRef.current = true
364
+ setTerminalFocused(true)
365
+ if (terminalWasBlurredRef.current) {
366
+ maybeRefreshPullRequestsRef.current(FOCUS_RETURN_REFRESH_MIN_MS)
367
+ }
368
+ }
369
+ const handleBlur = () => {
370
+ terminalWasBlurredRef.current = true
371
+ terminalFocusedRef.current = false
372
+ setTerminalFocused(false)
373
+ }
374
+
375
+ renderer.on("focus", handleFocus)
376
+ renderer.on("blur", handleBlur)
377
+ return () => {
378
+ renderer.off("focus", handleFocus)
379
+ renderer.off("blur", handleBlur)
380
+ }
381
+ }, [renderer])
382
+
383
+ useEffect(() => {
384
+ if (!terminalFocused) return
385
+ const lastRefreshAt = lastPullRequestRefreshAtRef.current || Date.now()
386
+ const ageMs = Date.now() - lastRefreshAt
387
+ const delayMs = Math.max(0, FOCUSED_IDLE_REFRESH_MS - ageMs) + Math.floor(Math.random() * AUTO_REFRESH_JITTER_MS)
388
+ const timeout = globalThis.setTimeout(() => {
389
+ maybeRefreshPullRequestsRef.current(FOCUSED_IDLE_REFRESH_MS)
390
+ }, delayMs)
391
+ return () => globalThis.clearTimeout(timeout)
392
+ }, [terminalFocused, pullRequestLoad?.fetchedAt])
335
393
 
336
394
  useEffect(() => {
337
395
  setSelectedIndex((current) => {
@@ -966,7 +1024,7 @@ export const App = () => {
966
1024
  setDetailScrollOffset(0)
967
1025
  return
968
1026
  }
969
- if (key.name === "p" && selectedPullRequest) {
1027
+ if ((key.name === "d" || key.name === "p") && selectedPullRequest) {
970
1028
  openDiffView()
971
1029
  return
972
1030
  }
@@ -983,7 +1041,7 @@ export const App = () => {
983
1041
  flashNotice(`Opened #${selectedPullRequest.number} in browser`)
984
1042
  return
985
1043
  }
986
- if ((key.name === "d" || key.name === "D") && selectedPullRequest) {
1044
+ if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
987
1045
  const previousPullRequest = selectedPullRequest
988
1046
  const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
989
1047
  updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
@@ -1013,6 +1071,22 @@ export const App = () => {
1013
1071
 
1014
1072
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
1015
1073
  const fullscreenBodyLines = Math.max(8, (height ?? 24) - 8)
1074
+ const wideFullscreenDetailScrollable = getDetailsPaneHeight({
1075
+ pullRequest: selectedPullRequest,
1076
+ contentWidth: fullscreenContentWidth,
1077
+ bodyLines: fullscreenBodyLines,
1078
+ paneWidth: contentWidth,
1079
+ showChecks: true,
1080
+ }) > wideBodyHeight
1081
+ const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
1082
+ pullRequest: selectedPullRequest,
1083
+ contentWidth: fullscreenContentWidth,
1084
+ bodyLines: fullscreenBodyLines,
1085
+ paneWidth: contentWidth,
1086
+ }) > wideBodyHeight
1087
+ const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
1088
+ const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
1089
+ const wideDetailBodyScrollable = getDetailBodyHeight(selectedPullRequest, rightContentWidth, wideDetailLines) > wideDetailBodyViewportHeight
1016
1090
 
1017
1091
  const prListProps = {
1018
1092
  groups: visibleGroups,
@@ -1061,7 +1135,7 @@ export const App = () => {
1061
1135
  />
1062
1136
  ) : isWideLayout && detailFullView ? (
1063
1137
  <box flexGrow={1} flexDirection="column">
1064
- <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1138
+ <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
1065
1139
  <DetailsPane
1066
1140
  pullRequest={selectedPullRequest}
1067
1141
  contentWidth={fullscreenContentWidth}
@@ -1085,7 +1159,7 @@ export const App = () => {
1085
1159
  {selectedPullRequest ? (
1086
1160
  <>
1087
1161
  <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1088
- <scrollbox flexGrow={1}>
1162
+ <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
1089
1163
  <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} />
1090
1164
  </scrollbox>
1091
1165
  </>
@@ -1096,7 +1170,7 @@ export const App = () => {
1096
1170
  </box>
1097
1171
  ) : detailFullView ? (
1098
1172
  <box flexGrow={1} flexDirection="column">
1099
- <scrollbox ref={detailScrollRef} focused flexGrow={1}>
1173
+ <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
1100
1174
  <DetailsPane
1101
1175
  pullRequest={selectedPullRequest}
1102
1176
  contentWidth={fullscreenContentWidth}
package/src/index.tsx CHANGED
@@ -7,13 +7,21 @@ import { App } from "./App.js"
7
7
 
8
8
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
9
9
 
10
+ const FOCUS_REPORTING_ENABLE = "\x1b[?1004h"
11
+ const FOCUS_REPORTING_DISABLE = "\x1b[?1004l"
12
+
10
13
  const renderer = await createCliRenderer({
11
14
  exitOnCtrlC: false,
12
15
  screenMode: "alternate-screen",
13
16
  externalOutputMode: "passthrough",
14
- onDestroy: () => process.exit(0),
17
+ onDestroy: () => {
18
+ process.stdout.write(FOCUS_REPORTING_DISABLE)
19
+ process.exit(0)
20
+ },
15
21
  })
16
22
 
23
+ process.stdout.write(FOCUS_REPORTING_ENABLE)
24
+
17
25
  createRoot(renderer).render(
18
26
  <RegistryProvider>
19
27
  <App />
@@ -266,6 +266,36 @@ export const getDetailJunctionRows = (pullRequest: PullRequestItem | null, paneW
266
266
  return showChecks && checks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
267
267
  }
268
268
 
269
+ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
270
+ if (!pullRequest) return DETAIL_PLACEHOLDER_ROWS + 1
271
+ const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
272
+ const checks = deduplicateChecks(pullRequest.checks)
273
+ const checksHeight = showChecks && checks.length > 0 ? checksRowCount(checks) + 2 : 0
274
+ return titleLines + 3 + checksHeight
275
+ }
276
+
277
+ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
278
+ if (!pullRequest) return bodyLines
279
+ if (!pullRequest.detailLoaded) return bodyLines
280
+ return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
281
+ }
282
+
283
+ export const getDetailsPaneHeight = ({
284
+ pullRequest,
285
+ contentWidth,
286
+ bodyLines = DETAIL_BODY_LINES,
287
+ paneWidth = contentWidth + 2,
288
+ showChecks = false,
289
+ }: {
290
+ pullRequest: PullRequestItem | null
291
+ contentWidth: number
292
+ bodyLines?: number
293
+ paneWidth?: number
294
+ showChecks?: boolean
295
+ }) => pullRequest
296
+ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
297
+ : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
298
+
269
299
  export const DetailHeader = ({
270
300
  pullRequest,
271
301
  contentWidth,
@@ -465,16 +495,7 @@ export const DetailsPane = ({
465
495
  placeholderContent: DetailPlaceholderContent
466
496
  loadingIndicator: string
467
497
  }) => {
468
- const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
469
- const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
470
- const checkRows = checksRowCount(uniqueChecks)
471
- const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
472
- const previewLines = useMemo(
473
- () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
474
- [pullRequest?.body, contentWidth, bodyLines],
475
- )
476
- const bodyHeight = pullRequest && !pullRequest.detailLoaded ? bodyLines : previewLines.length
477
- const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + bodyHeight : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
498
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines, paneWidth, showChecks })
478
499
 
479
500
  return (
480
501
  <box flexDirection="column" height={contentHeight}>
@@ -104,33 +104,16 @@ export const FooterHints = ({
104
104
  </>
105
105
  ) : null}
106
106
  <span fg={colors.count}>r</span>
107
- <span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
108
- {hasSelection ? (
109
- <>
110
- <span fg={colors.count}>↑↓</span>
111
- <span fg={colors.muted}> move </span>
112
- </>
113
- ) : null}
114
- {hasSelection && detailFullView ? (
115
- <>
116
- <span fg={colors.count}>esc</span>
117
- <span fg={colors.muted}> back </span>
118
- </>
119
- ) : hasSelection ? (
120
- <>
121
- <span fg={colors.count}>enter</span>
122
- <span fg={colors.muted}> expand </span>
123
- </>
124
- ) : null}
107
+ <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
125
108
  {hasSelection ? (
126
109
  <>
110
+ <span fg={colors.count}>s</span>
111
+ <span fg={colors.muted}> state </span>
127
112
  <span fg={colors.count}>d</span>
128
- <span fg={colors.muted}> draft </span>
129
- <span fg={colors.count}>p</span>
130
113
  <span fg={colors.muted}> diff </span>
131
114
  <span fg={colors.count}>l</span>
132
115
  <span fg={colors.muted}> labels </span>
133
- <span fg={colors.count}>M</span>
116
+ <span fg={colors.count}>m</span>
134
117
  <span fg={colors.muted}> merge </span>
135
118
  <span fg={colors.count}>o</span>
136
119
  <span fg={colors.muted}> open </span>
package/src/ui/diff.ts CHANGED
@@ -89,6 +89,41 @@ const patchFileName = (patch: string) => {
89
89
  return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
90
90
  }
91
91
 
92
+ const hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
93
+
94
+ const formatHunkRange = (start: string, count: number) => `${start},${count}`
95
+
96
+ const normalizeHunkLineCounts = (patch: string) => {
97
+ const lines = patch.split("\n")
98
+ const normalized = [...lines]
99
+
100
+ for (let index = 0; index < lines.length; index++) {
101
+ const match = lines[index]!.match(hunkHeaderPattern)
102
+ if (!match) continue
103
+
104
+ let oldCount = 0
105
+ let newCount = 0
106
+ for (let lineIndex = index + 1; lineIndex < lines.length; lineIndex++) {
107
+ const line = lines[lineIndex]!
108
+ if (line.startsWith("@@ ") || line.startsWith("diff --git ")) break
109
+
110
+ const prefix = line[0]
111
+ if (prefix === " ") {
112
+ oldCount += 1
113
+ newCount += 1
114
+ } else if (prefix === "-") {
115
+ oldCount += 1
116
+ } else if (prefix === "+") {
117
+ newCount += 1
118
+ }
119
+ }
120
+
121
+ normalized[index] = `@@ -${formatHunkRange(match[1]!, oldCount)} +${formatHunkRange(match[3]!, newCount)} @@${match[5]!}`
122
+ }
123
+
124
+ return normalized.join("\n")
125
+ }
126
+
92
127
  export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
93
128
  const trimmed = patch.trimEnd()
94
129
  if (trimmed.length === 0) return []
@@ -101,7 +136,7 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
101
136
  return matches.map((match, index) => {
102
137
  const start = match.index ?? 0
103
138
  const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
104
- const filePatch = trimmed.slice(start, end).trimEnd()
139
+ const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
105
140
  const name = patchFileName(filePatch)
106
141
  return { name, filetype: filetypeForPath(name), patch: filePatch }
107
142
  })