@kitlangton/ghui 0.2.0 → 0.2.1

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.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -8,7 +8,7 @@ import { Cause, Effect, Layer, Schedule } from "effect"
8
8
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
9
9
  import * as Atom from "effect/unstable/reactivity/Atom"
10
10
  import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
11
- import { useContext, useEffect, useMemo, useRef, useState } from "react"
11
+ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
12
12
  import { buildAppCommands } from "./appCommands.js"
13
13
  import type { AppCommand } from "./commands.js"
14
14
  import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
@@ -603,6 +603,7 @@ export const App = () => {
603
603
  const [terminalFocused, setTerminalFocused] = useState(true)
604
604
  const [startupLoadComplete, setStartupLoadComplete] = useState(false)
605
605
  const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
606
+ const [detailPreviewScrollTop, setDetailPreviewScrollTop] = useState(0)
606
607
  const usernameResult = useAtomValue(usernameAtom)
607
608
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
608
609
  const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
@@ -644,6 +645,7 @@ export const App = () => {
644
645
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
645
646
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
646
647
  const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
648
+ const detailPreviewScrollTopRef = useRef(0)
647
649
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
648
650
  const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
649
651
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
@@ -1071,6 +1073,10 @@ export const App = () => {
1071
1073
  setDiffPreferredSide(null)
1072
1074
  setDiffCommentRangeStartIndex(null)
1073
1075
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
1076
+ if (detailPreviewScrollTopRef.current !== 0) {
1077
+ detailPreviewScrollTopRef.current = 0
1078
+ setDetailPreviewScrollTop(0)
1079
+ }
1074
1080
  }, [selectedIndex])
1075
1081
 
1076
1082
  useEffect(() => {
@@ -1211,8 +1217,6 @@ export const App = () => {
1211
1217
  title: `${loadingIndicator} Loading pull request details`,
1212
1218
  hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1213
1219
  } : detailPlaceholderContent
1214
- const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true, rightContentWidth, selectedConversationItems, selectedConversationStatus)
1215
-
1216
1220
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1217
1221
 
1218
1222
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
@@ -1334,8 +1338,23 @@ export const App = () => {
1334
1338
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1335
1339
  }
1336
1340
 
1337
- const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1338
- const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1341
+ const syncDetailPreviewScrollState = useCallback(() => {
1342
+ const scrollTop = detailPreviewScrollRef.current?.scrollTop
1343
+ if (scrollTop === undefined) return
1344
+ const nextTop = Math.max(0, Math.floor(scrollTop))
1345
+ if (detailPreviewScrollTopRef.current === nextTop) return
1346
+ detailPreviewScrollTopRef.current = nextTop
1347
+ setDetailPreviewScrollTop(nextTop)
1348
+ }, [])
1349
+
1350
+ const scrollDetailPreviewBy = (y: number) => {
1351
+ detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1352
+ syncDetailPreviewScrollState()
1353
+ }
1354
+ const scrollDetailPreviewTo = (y: number) => {
1355
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1356
+ syncDetailPreviewScrollState()
1357
+ }
1339
1358
 
1340
1359
  const ensureDiffLineVisible = (line: number) => {
1341
1360
  const scroll = diffScrollRef.current
@@ -1354,6 +1373,16 @@ export const App = () => {
1354
1373
  return () => globalThis.clearInterval(interval)
1355
1374
  }, [diffFullView, stackedDiffFiles])
1356
1375
 
1376
+ useLayoutEffect(() => {
1377
+ if (!isWideLayout || detailFullView || diffFullView) return
1378
+ const scroll = detailPreviewScrollRef.current
1379
+ if (!scroll) return
1380
+ const sync = () => { syncDetailPreviewScrollState() }
1381
+ scroll.verticalScrollBar.on("change", sync)
1382
+ syncDetailPreviewScrollState()
1383
+ return () => { scroll.verticalScrollBar.off("change", sync) }
1384
+ }, [isWideLayout, detailFullView, diffFullView, syncDetailPreviewScrollState])
1385
+
1357
1386
  const jumpDiffFile = (delta: 1 | -1) => {
1358
1387
  if (readyDiffFiles.length === 0) return
1359
1388
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
@@ -2284,6 +2313,16 @@ export const App = () => {
2284
2313
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2285
2314
  const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
2286
2315
  const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2316
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows({
2317
+ pullRequest: selectedPullRequest,
2318
+ paneWidth: rightPaneWidth,
2319
+ showChecks: true,
2320
+ contentWidth: rightContentWidth,
2321
+ conversationItems: selectedConversationItems,
2322
+ conversationStatus: selectedConversationStatus,
2323
+ bodyScrollTop: detailPreviewScrollTop,
2324
+ bodyViewportHeight: wideDetailBodyViewportHeight,
2325
+ })
2287
2326
 
2288
2327
  const prListProps = {
2289
2328
  groups: visibleGroups,
@@ -345,35 +345,43 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
345
345
 
346
346
  const conversationDividerBodyRow = (pullRequest: PullRequestItem, contentWidth: number, conversationItems: readonly PullRequestConversationItem[], conversationStatus: DetailConversationStatus) => {
347
347
  if (!pullRequest.detailLoaded) return null
348
- const summaryRows = bodyPreview(pullRequest.body, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
349
- const conversationRows = conversationPreview({
350
- items: conversationItems,
351
- status: conversationStatus,
352
- width: contentWidth,
353
- limit: Math.max(0, DETAIL_BODY_SCROLL_LIMIT - summaryRows.length - 1),
354
- })
355
- return conversationRows.length > 0 ? summaryRows.length : null
348
+ const dividerIndex = detailBodyPreview({ pullRequest, contentWidth, limit: DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus })
349
+ .findIndex((line) => line.divider === true)
350
+ return dividerIndex >= 0 ? dividerIndex : null
356
351
  }
357
352
 
358
- export const getDetailJunctionRows = (
359
- pullRequest: PullRequestItem | null,
360
- paneWidth: number,
353
+ export const getDetailJunctionRows = ({
354
+ pullRequest,
355
+ paneWidth,
361
356
  showChecks = false,
362
- contentWidth = Math.max(1, paneWidth - 2),
363
- conversationItems: readonly PullRequestConversationItem[] = [],
364
- conversationStatus: DetailConversationStatus = "idle",
365
- ): readonly number[] => {
357
+ contentWidth,
358
+ conversationItems = [],
359
+ conversationStatus = "idle",
360
+ bodyScrollTop = 0,
361
+ bodyViewportHeight = Number.POSITIVE_INFINITY,
362
+ }: {
363
+ readonly pullRequest: PullRequestItem | null
364
+ readonly paneWidth: number
365
+ readonly showChecks?: boolean
366
+ readonly contentWidth?: number
367
+ readonly conversationItems?: readonly PullRequestConversationItem[]
368
+ readonly conversationStatus?: DetailConversationStatus
369
+ readonly bodyScrollTop?: number
370
+ readonly bodyViewportHeight?: number
371
+ }): readonly number[] => {
366
372
  if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
373
+ const resolvedContentWidth = contentWidth ?? Math.max(1, paneWidth - 2)
367
374
  const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
368
375
  const detailDividerRow = 1 + titleLines + 1
369
376
  const checks = deduplicateChecks(pullRequest.checks)
370
377
  const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
371
378
  const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
372
- const conversationDivider = conversationDividerBodyRow(pullRequest, contentWidth, conversationItems, conversationStatus)
379
+ const conversationDivider = conversationDividerBodyRow(pullRequest, resolvedContentWidth, conversationItems, conversationStatus)
380
+ const visibleConversationDivider = conversationDivider === null ? null : conversationDivider - Math.max(0, Math.floor(bodyScrollTop))
373
381
  return [
374
382
  detailDividerRow,
375
383
  showChecks && checks.length > 0 ? checksDividerRow : -1,
376
- conversationDivider === null ? -1 : headerHeight + conversationDivider,
384
+ visibleConversationDivider === null || visibleConversationDivider < 0 || visibleConversationDivider >= bodyViewportHeight ? -1 : headerHeight + visibleConversationDivider,
377
385
  ].filter((row) => row >= 0)
378
386
  }
379
387