@kitlangton/ghui 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseColor, SyntaxStyle, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useKeyboard, useTerminalDimensions } from "@opentui/react"
3
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
4
  import { Cause, Effect, Schedule } from "effect"
5
5
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
6
6
  import * as Atom from "effect/unstable/reactivity/Atom"
@@ -435,23 +435,104 @@ const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repo
435
435
 
436
436
  const diffStatText = (pullRequest: PullRequestItem) => {
437
437
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
438
- return `+${pullRequest.additions} -${pullRequest.deletions} ${files}`
438
+ return [
439
+ pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
440
+ pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
441
+ files,
442
+ ].filter((part): part is string => part !== null).join(" ")
439
443
  }
440
444
 
441
- const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
445
+ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
446
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
447
+ type Part = { key: string; text: string; color: string }
448
+ const rawParts: Array<Part | null> = [
449
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
450
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
451
+ { key: "files", text: files, color: colors.muted },
452
+ ]
453
+ const parts = rawParts.filter((part): part is Part => part !== null)
454
+
455
+ return (
456
+ <>
457
+ {parts.map((part, index) => (
458
+ <Fragment key={part.key}>
459
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
460
+ <span fg={part.color}>{part.text}</span>
461
+ </Fragment>
462
+ ))}
463
+ </>
464
+ )
465
+ }
466
+
467
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
468
+ if (wrapMode === "none") return 1
469
+ return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
470
+ }
471
+
472
+ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
473
+ let maxLineNumber = 1
474
+ let hasSigns = false
475
+ let oldLine = 0
476
+ let newLine = 0
477
+
478
+ for (const line of lines) {
479
+ const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
480
+ if (hunk) {
481
+ oldLine = Number(hunk[1])
482
+ newLine = Number(hunk[2])
483
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
484
+ continue
485
+ }
486
+
487
+ const firstChar = line[0]
488
+ if (firstChar === "-") {
489
+ hasSigns = true
490
+ maxLineNumber = Math.max(maxLineNumber, oldLine)
491
+ oldLine++
492
+ } else if (firstChar === "+") {
493
+ hasSigns = true
494
+ maxLineNumber = Math.max(maxLineNumber, newLine)
495
+ newLine++
496
+ } else if (firstChar === " ") {
497
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
498
+ oldLine++
499
+ newLine++
500
+ }
501
+ }
502
+
503
+ const digits = Math.floor(Math.log10(maxLineNumber)) + 1
504
+ return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
505
+ }
506
+
507
+ const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
508
+ const lines = patch.split("\n")
509
+ const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
510
+ const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
511
+ const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
512
+ const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
442
513
  let count = 0
443
514
  let inHunk = false
444
- let deletions = 0
445
- let additions = 0
515
+ let deletions: number[] = []
516
+ let additions: number[] = []
446
517
 
447
518
  const flushChangeBlock = () => {
448
- if (deletions === 0 && additions === 0) return
449
- count += view === "split" ? Math.max(deletions, additions) : deletions + additions
450
- deletions = 0
451
- additions = 0
519
+ if (deletions.length === 0 && additions.length === 0) return
520
+ if (view === "split") {
521
+ const rows = Math.max(deletions.length, additions.length)
522
+ for (let index = 0; index < rows; index++) {
523
+ const deletionCount = index < deletions.length ? deletions[index]! : 1
524
+ const additionCount = index < additions.length ? additions[index]! : 1
525
+ count += Math.max(deletionCount, additionCount)
526
+ }
527
+ } else {
528
+ for (const deletion of deletions) count += deletion
529
+ for (const addition of additions) count += addition
530
+ }
531
+ deletions = []
532
+ additions = []
452
533
  }
453
534
 
454
- for (const line of patch.split("\n")) {
535
+ for (const line of lines) {
455
536
  if (line.startsWith("@@")) {
456
537
  flushChangeBlock()
457
538
  inHunk = true
@@ -464,18 +545,18 @@ const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
464
545
  if (firstChar === "\\") continue
465
546
 
466
547
  if (firstChar === "-") {
467
- deletions++
548
+ deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
468
549
  continue
469
550
  }
470
551
 
471
552
  if (firstChar === "+") {
472
- additions++
553
+ additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
473
554
  continue
474
555
  }
475
556
 
476
557
  if (firstChar === " ") {
477
558
  flushChangeBlock()
478
- count++
559
+ count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
479
560
  }
480
561
  }
481
562
 
@@ -1036,7 +1117,7 @@ const DetailHeader = ({
1036
1117
  const review = reviewLabel(pullRequest)
1037
1118
  const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
1038
1119
  const statusParts = [review, checks].filter((part): part is string => Boolean(part))
1039
- const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
1120
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
1040
1121
  const leftWidth = 1 + number.length + 1 + repo.length
1041
1122
  const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
1042
1123
 
@@ -1048,7 +1129,7 @@ const DetailHeader = ({
1048
1129
  {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
1049
1130
  {review && checks ? <span fg={colors.muted}> </span> : null}
1050
1131
  {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
1051
- {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
1132
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
1052
1133
  <span fg={colors.muted}>{opened}</span>
1053
1134
  </TextLine>
1054
1135
  )
@@ -1070,10 +1151,7 @@ const DetailHeader = ({
1070
1151
  {showStats ? (
1071
1152
  <>
1072
1153
  <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
1073
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1074
- <span fg={colors.muted}> </span>
1075
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1076
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1154
+ <DiffStats pullRequest={pullRequest} />
1077
1155
  </>
1078
1156
  ) : null}
1079
1157
  </TextLine>
@@ -1241,6 +1319,14 @@ const PullRequestDiffPane = ({
1241
1319
  loadingIndicator: string
1242
1320
  scrollRef: React.Ref<ScrollBoxRenderable>
1243
1321
  }) => {
1322
+ const readyFiles = diffState?.status === "ready" ? diffState.files : []
1323
+ const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
1324
+ const file = readyFiles[safeIndex] ?? null
1325
+ const diffHeight = useMemo(
1326
+ () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
1327
+ [file?.patch, view, wrapMode, paneWidth],
1328
+ )
1329
+
1244
1330
  if (!pullRequest) {
1245
1331
  return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
1246
1332
  }
@@ -1258,10 +1344,7 @@ const PullRequestDiffPane = ({
1258
1344
  <span fg={colors.count}>#{pullRequest.number}</span>
1259
1345
  <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1260
1346
  <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1261
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1262
- <span fg={colors.muted}> </span>
1263
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1264
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1347
+ <DiffStats pullRequest={pullRequest} />
1265
1348
  </TextLine>
1266
1349
  </box>
1267
1350
  <Divider width={paneWidth} />
@@ -1282,15 +1365,12 @@ const PullRequestDiffPane = ({
1282
1365
  )
1283
1366
  }
1284
1367
 
1285
- if (diffState.files.length === 0) {
1368
+ if (readyFiles.length === 0 || !file) {
1286
1369
  return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
1287
1370
  }
1288
1371
 
1289
- const safeIndex = Math.max(0, Math.min(fileIndex, diffState.files.length - 1))
1290
- const file = diffState.files[safeIndex]!
1291
- const fileCounter = `${safeIndex + 1}/${diffState.files.length}`
1372
+ const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
1292
1373
  const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
1293
- const diffHeight = patchRenderableLineCount(file.patch, view)
1294
1374
 
1295
1375
  return (
1296
1376
  <box height={height} flexDirection="column">
@@ -1299,10 +1379,7 @@ const PullRequestDiffPane = ({
1299
1379
  <span fg={colors.count}>#{pullRequest.number}</span>
1300
1380
  <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1301
1381
  <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1302
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1303
- <span fg={colors.muted}> </span>
1304
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1305
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1382
+ <DiffStats pullRequest={pullRequest} />
1306
1383
  </TextLine>
1307
1384
  </box>
1308
1385
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -1451,6 +1528,7 @@ const LabelModal = ({
1451
1528
  }
1452
1529
 
1453
1530
  export const App = () => {
1531
+ const renderer = useRenderer()
1454
1532
  const { width, height } = useTerminalDimensions()
1455
1533
  const pullRequestResult = useAtomValue(pullRequestsAtom)
1456
1534
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
@@ -1743,10 +1821,8 @@ export const App = () => {
1743
1821
  setLabelModal(initialLabelModalState)
1744
1822
  return
1745
1823
  }
1746
- if (key.name === "q") {
1747
- process.exit(0)
1748
- }
1749
- process.exit(0)
1824
+ renderer.destroy()
1825
+ return
1750
1826
  }
1751
1827
 
1752
1828
  // Label modal takes priority over everything else
package/src/index.tsx CHANGED
@@ -5,7 +5,14 @@ import { RegistryProvider } from "@effect/atom-react"
5
5
  import { createRoot } from "@opentui/react"
6
6
  import { App } from "./App.js"
7
7
 
8
- const renderer = await createCliRenderer({ exitOnCtrlC: false })
8
+ process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
9
+
10
+ const renderer = await createCliRenderer({
11
+ exitOnCtrlC: false,
12
+ screenMode: "alternate-screen",
13
+ externalOutputMode: "passthrough",
14
+ onDestroy: () => process.exit(0),
15
+ })
9
16
 
10
17
  createRoot(renderer).render(
11
18
  <RegistryProvider>