@kitlangton/ghui 0.1.18 → 0.1.19

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.18",
3
+ "version": "0.1.19",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -6,7 +6,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
6
6
  import * as Atom from "effect/unstable/reactivity/Atom"
7
7
  import { useEffect, useMemo, useRef, useState } from "react"
8
8
  import { config } from "./config.js"
9
- import { pullRequestQueueLabels, pullRequestQueueModes, type CreatePullRequestCommentInput, type DiffCommentSide, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestQueueMode, type PullRequestReviewComment } from "./domain.js"
9
+ import { pullRequestQueueLabels, pullRequestQueueModes, type CreatePullRequestCommentInput, type DiffCommentSide, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestQueueMode, type PullRequestReviewComment } from "./domain.js"
10
10
  import { formatShortDate, formatTimestamp } from "./date.js"
11
11
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
12
12
  import { Observability } from "./observability.js"
@@ -14,9 +14,9 @@ import { GitHubService } from "./services/GitHubService.js"
14
14
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
15
15
  import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
16
16
  import { backspace as editorBackspace, deleteForward as editorDeleteForward, deleteToLineEnd, deleteToLineStart, deleteWordBackward, deleteWordForward, insertText, moveLeft as editorMoveLeft, moveLineEnd, moveLineStart, moveRight as editorMoveRight, moveVertically, moveWordBackward, moveWordForward, type CommentEditorValue } from "./ui/commentEditor.js"
17
- import { buildStackedDiffFiles, diffCommentAnchorKey, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffCommentAnchor } from "./ui/diff.js"
17
+ import { buildStackedDiffFiles, diffCommentAnchorKey, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
18
18
  import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
19
- import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
19
+ import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
20
20
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
21
21
  import { CloseModal, CommentModal, CommentThreadModal, initialCloseModalState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialThemeModalState, LabelModal, MergeModal, Modal, ThemeModal, type CloseModalState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type ThemeModalState } from "./ui/modals.js"
22
22
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
@@ -26,7 +26,6 @@ import { PullRequestList } from "./ui/PullRequestList.js"
26
26
  const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
27
27
  const initialThemeId = await Effect.runPromise(loadStoredThemeId)
28
28
 
29
- type LoadStatus = "loading" | "ready" | "error"
30
29
 
31
30
  interface PullRequestLoad {
32
31
  readonly queueMode: PullRequestQueueMode
@@ -37,7 +36,7 @@ interface PullRequestLoad {
37
36
 
38
37
  interface DetailPlaceholderInput {
39
38
  readonly status: LoadStatus
40
- readonly retryProgress: RetryProgress | null
39
+ readonly retryProgress: RetryProgress
41
40
  readonly loadingIndicator: string
42
41
  readonly visibleCount: number
43
42
  readonly filterText: string
@@ -59,7 +58,7 @@ type DiffRenderableRuntimeSides = {
59
58
 
60
59
  interface AppliedDiffLineColor {
61
60
  readonly anchor: StackedDiffCommentAnchor
62
- readonly view: "unified" | "split"
61
+ readonly view: DiffView
63
62
  }
64
63
 
65
64
  interface AppliedDiffLineColorState {
@@ -71,6 +70,7 @@ const PR_FETCH_RETRIES = 6
71
70
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
72
71
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
73
72
  const AUTO_REFRESH_JITTER_MS = 10_000
73
+ const DIFF_STICKY_HEADER_LINES = 2
74
74
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
75
75
 
76
76
  const mergeCachedDetails = (fresh: readonly PullRequestItem[], cached: readonly PullRequestItem[] | undefined) => {
@@ -94,7 +94,7 @@ const mergeCachedDetails = (fresh: readonly PullRequestItem[], cached: readonly
94
94
  })
95
95
  }
96
96
 
97
- const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
97
+ const retryProgressAtom = Atom.make<RetryProgress>(initialRetryProgress).pipe(Atom.keepAlive)
98
98
  const queueModeAtom = Atom.make<PullRequestQueueMode>("authored").pipe(Atom.keepAlive)
99
99
  const queueLoadCacheAtom = Atom.make<Partial<Record<PullRequestQueueMode, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
100
100
  const queueSelectionAtom = Atom.make<Partial<Record<PullRequestQueueMode, number>>>({}).pipe(Atom.keepAlive)
@@ -102,19 +102,19 @@ const pullRequestsAtom = githubRuntime.atom(
102
102
  GitHubService.use((github) =>
103
103
  Effect.gen(function*() {
104
104
  const queueMode = yield* Atom.get(queueModeAtom)
105
- yield* Atom.set(retryProgressAtom, null)
105
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
106
106
  const data = yield* github.listOpenPullRequests(queueMode).pipe(
107
107
  Effect.tapError(() =>
108
- Atom.update(retryProgressAtom, (current) => ({
109
- attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
108
+ Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
109
+ attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 1, PR_FETCH_RETRIES),
110
110
  max: PR_FETCH_RETRIES,
111
111
  }))
112
112
  ),
113
113
  Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
114
- Effect.tapError(() => Atom.set(retryProgressAtom, null)),
114
+ Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)),
115
115
  )
116
116
 
117
- yield* Atom.set(retryProgressAtom, null)
117
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
118
118
  const cache = yield* Atom.get(queueLoadCacheAtom)
119
119
  const load = {
120
120
  queueMode,
@@ -137,8 +137,9 @@ const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
137
137
  const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
138
138
  const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
139
139
  const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
140
- const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
141
- const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
140
+ const diffScrollTopAtom = Atom.make(0).pipe(Atom.keepAlive)
141
+ const diffRenderViewAtom = Atom.make<DiffView>("split").pipe(Atom.keepAlive)
142
+ const diffWrapModeAtom = Atom.make<DiffWrapMode>("none").pipe(Atom.keepAlive)
142
143
  const diffCommentModeAtom = Atom.make(false).pipe(Atom.keepAlive)
143
144
  const diffCommentAnchorIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
144
145
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
@@ -320,7 +321,31 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
320
321
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
321
322
  }
322
323
 
323
- const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: "unified" | "split") => {
324
+ const mixHexColor = (color: string, base: string, amount: number) => {
325
+ const parse = (hex: string) => {
326
+ const normalized = hex.replace(/^#/, "")
327
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return null
328
+ return {
329
+ r: Number.parseInt(normalized.slice(0, 2), 16),
330
+ g: Number.parseInt(normalized.slice(2, 4), 16),
331
+ b: Number.parseInt(normalized.slice(4, 6), 16),
332
+ }
333
+ }
334
+ const left = parse(color)
335
+ const right = parse(base)
336
+ if (!left || !right) return color
337
+ const channel = (key: "r" | "g" | "b") => Math.round(left[key] * amount + right[key] * (1 - amount)).toString(16).padStart(2, "0")
338
+ return `#${channel("r")}${channel("g")}${channel("b")}`
339
+ }
340
+
341
+ const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
342
+ const accent = kind === "thread"
343
+ ? colors.status.pending
344
+ : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
345
+ return mixHexColor(accent, originalDiffLineColor(anchor).gutter, 0.45)
346
+ }
347
+
348
+ const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
324
349
  const withSides = diff as unknown as DiffRenderableRuntimeSides
325
350
  if (view === "split") {
326
351
  const target = anchor.side === "LEFT" ? withSides.leftSide : withSides.rightSide
@@ -345,7 +370,7 @@ const getDetailPlaceholderContent = ({
345
370
  if (status === "loading") {
346
371
  return {
347
372
  title: `${loadingIndicator} Loading pull requests`,
348
- hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
373
+ hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
349
374
  }
350
375
  }
351
376
 
@@ -394,6 +419,7 @@ export const App = () => {
394
419
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
395
420
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
396
421
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
422
+ const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
397
423
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
398
424
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
399
425
  const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
@@ -410,22 +436,21 @@ export const App = () => {
410
436
  const commentModalActive = Modal.$is("Comment")(activeModal)
411
437
  const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
412
438
  const themeModalActive = Modal.$is("Theme")(activeModal)
413
- const labelModal: LabelModalState = labelModalActive ? activeModal.state : initialLabelModalState
414
- const closeModal: CloseModalState = closeModalActive ? activeModal.state : initialCloseModalState
415
- const mergeModal: MergeModalState = mergeModalActive ? activeModal.state : initialMergeModalState
416
- const commentModal: CommentModalState = commentModalActive ? activeModal.state : initialCommentModalState
417
- const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal.state : initialCommentThreadModalState
418
- const themeModal: ThemeModalState = themeModalActive ? activeModal.state : initialThemeModalState
439
+ const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
440
+ const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
441
+ const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
442
+ const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
443
+ const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
444
+ const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
419
445
  const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
420
446
  (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
421
- const ctor = Modal[tag] as (args: { state: ModalState<Tag> }) => Modal
447
+ const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
422
448
  if (typeof next === "function") {
423
449
  const updater = next as (prev: ModalState<Tag>) => ModalState<Tag>
424
450
  if (current._tag !== tag) return current
425
- const prev = (current as unknown as { readonly state: ModalState<Tag> }).state
426
- return ctor({ state: updater(prev) })
451
+ return ctor(updater(current as unknown as ModalState<Tag>))
427
452
  }
428
- return ctor({ state: next })
453
+ return ctor(next)
429
454
  })
430
455
  const setLabelModal = makeModalSetter("Label")
431
456
  const setCloseModal = makeModalSetter("Close")
@@ -566,14 +591,9 @@ export const App = () => {
566
591
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
567
592
  const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
568
593
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
569
- const readyDiffFiles = selectedDiffState?.status === "ready" ? selectedDiffState.files : []
594
+ const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
570
595
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
571
596
  const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
572
- const selectedDiffCommentCount = useMemo(() => selectedDiffKey
573
- ? Object.entries(diffCommentThreads)
574
- .filter(([key, comments]) => key.startsWith(`${selectedDiffKey}:`) && comments.length > 0)
575
- .reduce((count, [, comments]) => count + comments.length, 0)
576
- : 0, [diffCommentThreads, selectedDiffKey])
577
597
  const diffCommentAnchors = useMemo(
578
598
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
579
599
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
@@ -732,6 +752,7 @@ export const App = () => {
732
752
 
733
753
  useEffect(() => {
734
754
  setDiffFileIndex(0)
755
+ setDiffScrollTop(0)
735
756
  setDiffCommentAnchorIndex(0)
736
757
  }, [selectedIndex])
737
758
 
@@ -773,12 +794,12 @@ export const App = () => {
773
794
  if (selectedDiffKey) {
774
795
  for (const anchor of diffCommentAnchors) {
775
796
  if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
776
- applyLineColor(anchor, colors.status.pending)
797
+ applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
777
798
  }
778
799
  }
779
800
  }
780
801
  if (diffCommentMode && selectedDiffCommentAnchor) {
781
- applyLineColor(selectedDiffCommentAnchor, selectedDiffCommentAnchor.side === "RIGHT" ? colors.status.passing : colors.status.failing, true)
802
+ applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
782
803
  if (suppressNextDiffCommentScrollRef.current) {
783
804
  suppressNextDiffCommentScrollRef.current = false
784
805
  } else {
@@ -791,7 +812,7 @@ export const App = () => {
791
812
  }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
792
813
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
793
814
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
794
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
815
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
795
816
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
796
817
 
797
818
  useEffect(() => {
@@ -887,20 +908,20 @@ export const App = () => {
887
908
  const key = pullRequestDiffKey(pullRequest)
888
909
  const existing = pullRequestDiffCache[key]
889
910
  if (includeComments) loadPullRequestComments(pullRequest, force)
890
- if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
911
+ if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
891
912
 
892
- setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
913
+ setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
893
914
  void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
894
915
  .then((patch) => {
895
916
  setPullRequestDiffCache((current) => ({
896
917
  ...current,
897
- [key]: { status: "ready", patch, files: splitPatchFiles(patch) },
918
+ [key]: PullRequestDiffState.Ready({ patch, files: splitPatchFiles(patch) }),
898
919
  }))
899
920
  })
900
921
  .catch((error) => {
901
922
  setPullRequestDiffCache((current) => ({
902
923
  ...current,
903
- [key]: { status: "error", error: errorMessage(error) },
924
+ [key]: PullRequestDiffState.Error({ error: errorMessage(error) }),
904
925
  }))
905
926
  flashNotice(errorMessage(error))
906
927
  })
@@ -930,6 +951,7 @@ export const App = () => {
930
951
  setDetailFullView(false)
931
952
  setDiffCommentMode(false)
932
953
  setDiffFileIndex(0)
954
+ setDiffScrollTop(0)
933
955
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
934
956
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
935
957
  loadPullRequestDiff(selectedPullRequest, { includeComments: true })
@@ -943,35 +965,75 @@ export const App = () => {
943
965
  const scrollToDiffFile = (index: number) => {
944
966
  const stackedFile = stackedDiffFiles[index]
945
967
  diffScrollRef.current?.scrollTo({ x: 0, y: stackedFile?.headerLine ?? 0 })
968
+ syncDiffScrollState()
946
969
  }
947
970
 
948
- const syncDiffFileIndexToScroll = () => {
971
+ const syncDiffScrollState = () => {
949
972
  const scrollTop = diffScrollRef.current?.scrollTop
950
973
  if (scrollTop === undefined || stackedDiffFiles.length === 0) return
951
- const nextIndex = stackedDiffFiles.reduce((current, file) => file.headerLine <= scrollTop + 1 ? file.index : current, 0)
974
+ setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
975
+ const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
952
976
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
953
977
  }
954
978
 
955
979
  const scrollDiffBy = (y: number) => {
956
980
  diffScrollRef.current?.scrollBy({ x: 0, y })
957
- syncDiffFileIndexToScroll()
981
+ syncDiffScrollState()
958
982
  }
959
983
 
960
984
  const scrollDiffTo = (y: number) => {
961
985
  diffScrollRef.current?.scrollTo({ x: 0, y })
962
- if (y <= 0) setDiffFileIndex(0)
963
- else if (y === Number.MAX_SAFE_INTEGER) setDiffFileIndex(Math.max(0, readyDiffFiles.length - 1))
964
- else syncDiffFileIndexToScroll()
986
+ syncDiffScrollState()
987
+ }
988
+
989
+ const clearPendingGTimeout = () => {
990
+ if (pendingGTimeoutRef.current !== null) {
991
+ clearTimeout(pendingGTimeoutRef.current)
992
+ pendingGTimeoutRef.current = null
993
+ }
994
+ }
995
+
996
+ const handleVimGoto = (key: { readonly name: string; readonly shift?: boolean }, gotoStart: () => void, gotoEnd: () => void): boolean => {
997
+ if (isShiftG(key)) {
998
+ gotoEnd()
999
+ setPendingG(false)
1000
+ clearPendingGTimeout()
1001
+ return true
1002
+ }
1003
+ if (key.name === "g") {
1004
+ if (pendingG) {
1005
+ gotoStart()
1006
+ setPendingG(false)
1007
+ clearPendingGTimeout()
1008
+ } else {
1009
+ setPendingG(true)
1010
+ pendingGTimeoutRef.current = setTimeout(() => {
1011
+ setPendingG(false)
1012
+ pendingGTimeoutRef.current = null
1013
+ }, 500)
1014
+ }
1015
+ return true
1016
+ }
1017
+ return false
965
1018
  }
966
1019
 
967
1020
  const ensureDiffLineVisible = (line: number) => {
968
1021
  const scroll = diffScrollRef.current
969
1022
  if (!scroll) return
970
1023
  const viewportHeight = Math.max(1, wideBodyHeight - (selectedDiffCommentThread.length > 0 ? 6 : 3))
971
- const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line)
972
- if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
1024
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line, DIFF_STICKY_HEADER_LINES)
1025
+ if (nextTop !== scroll.scrollTop) {
1026
+ scroll.scrollTo({ x: 0, y: nextTop })
1027
+ syncDiffScrollState()
1028
+ }
973
1029
  }
974
1030
 
1031
+ useEffect(() => {
1032
+ if (!diffFullView) return
1033
+ const interval = globalThis.setInterval(syncDiffScrollState, 80)
1034
+ return () => globalThis.clearInterval(interval)
1035
+ }, [diffFullView, stackedDiffFiles])
1036
+
975
1037
  const jumpDiffFile = (delta: 1 | -1) => {
976
1038
  if (readyDiffFiles.length === 0) return
977
1039
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
@@ -987,7 +1049,7 @@ export const App = () => {
987
1049
  const enterDiffCommentMode = () => {
988
1050
  const scrollTop = diffScrollRef.current?.scrollTop ?? 0
989
1051
  suppressNextDiffCommentScrollRef.current = true
990
- setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop))
1052
+ setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop + DIFF_STICKY_HEADER_LINES))
991
1053
  setDiffCommentMode(true)
992
1054
  }
993
1055
 
@@ -1010,6 +1072,16 @@ export const App = () => {
1010
1072
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1011
1073
  }
1012
1074
 
1075
+ const selectDiffCommentLine = (renderLine: number, side: DiffCommentSide | null) => {
1076
+ const lineAnchors = diffCommentAnchors.filter((anchor) => anchor.renderLine === renderLine)
1077
+ const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1078
+ if (!nextAnchor) return
1079
+ suppressNextDiffCommentScrollRef.current = true
1080
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1081
+ setDiffFileIndex(nextAnchor.fileIndex)
1082
+ setDiffCommentMode(true)
1083
+ }
1084
+
1013
1085
  const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1014
1086
  setCommentModal((current) => {
1015
1087
  const next = transform({ body: current.body, cursor: current.cursor })
@@ -1675,11 +1747,11 @@ export const App = () => {
1675
1747
  selectDiffCommentSide("RIGHT")
1676
1748
  return
1677
1749
  }
1678
- if (key.name === "]" && selectedDiffState?.status === "ready") {
1750
+ if (key.name === "]" && selectedDiffState?._tag === "Ready") {
1679
1751
  jumpDiffFile(1)
1680
1752
  return
1681
1753
  }
1682
- if (key.name === "[" && selectedDiffState?.status === "ready") {
1754
+ if (key.name === "[" && selectedDiffState?._tag === "Ready") {
1683
1755
  jumpDiffFile(-1)
1684
1756
  return
1685
1757
  }
@@ -1691,7 +1763,7 @@ export const App = () => {
1691
1763
  setDiffCommentMode(false)
1692
1764
  return
1693
1765
  }
1694
- if (key.name === "c" && selectedDiffState?.status === "ready") {
1766
+ if (key.name === "c" && selectedDiffState?._tag === "Ready") {
1695
1767
  enterDiffCommentMode()
1696
1768
  return
1697
1769
  }
@@ -1711,32 +1783,7 @@ export const App = () => {
1711
1783
  scrollDiffBy(halfPage)
1712
1784
  return
1713
1785
  }
1714
- if (isShiftG(key)) {
1715
- scrollDiffTo(Number.MAX_SAFE_INTEGER)
1716
- setPendingG(false)
1717
- if (pendingGTimeoutRef.current !== null) {
1718
- clearTimeout(pendingGTimeoutRef.current)
1719
- pendingGTimeoutRef.current = null
1720
- }
1721
- return
1722
- }
1723
- if (key.name === "g") {
1724
- if (pendingG) {
1725
- scrollDiffTo(0)
1726
- setPendingG(false)
1727
- if (pendingGTimeoutRef.current !== null) {
1728
- clearTimeout(pendingGTimeoutRef.current)
1729
- pendingGTimeoutRef.current = null
1730
- }
1731
- } else {
1732
- setPendingG(true)
1733
- pendingGTimeoutRef.current = setTimeout(() => {
1734
- setPendingG(false)
1735
- pendingGTimeoutRef.current = null
1736
- }, 500)
1737
- }
1738
- return
1739
- }
1786
+ if (handleVimGoto(key, () => scrollDiffTo(0), () => scrollDiffTo(Number.MAX_SAFE_INTEGER))) return
1740
1787
  if (key.name === "up" || key.name === "k") {
1741
1788
  scrollDiffBy(-1)
1742
1789
  return
@@ -1766,11 +1813,11 @@ export const App = () => {
1766
1813
  flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1767
1814
  return
1768
1815
  }
1769
- if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
1816
+ if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
1770
1817
  jumpDiffFile(1)
1771
1818
  return
1772
1819
  }
1773
- if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
1820
+ if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
1774
1821
  jumpDiffFile(-1)
1775
1822
  return
1776
1823
  }
@@ -1822,14 +1869,9 @@ export const App = () => {
1822
1869
  setDetailScrollOffset(0)
1823
1870
  return
1824
1871
  }
1825
- if (key.name === "end" || isShiftG(key)) {
1872
+ if (key.name === "end") {
1826
1873
  detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1827
1874
  setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
1828
- setPendingG(false)
1829
- if (pendingGTimeoutRef.current !== null) {
1830
- clearTimeout(pendingGTimeoutRef.current)
1831
- pendingGTimeoutRef.current = null
1832
- }
1833
1875
  return
1834
1876
  }
1835
1877
  if (key.name === "pageup") {
@@ -1842,24 +1884,10 @@ export const App = () => {
1842
1884
  setDetailScrollOffset((current) => current + halfPage)
1843
1885
  return
1844
1886
  }
1845
- if (key.name === "g") {
1846
- if (pendingG) {
1847
- detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1848
- setDetailScrollOffset(0)
1849
- setPendingG(false)
1850
- if (pendingGTimeoutRef.current !== null) {
1851
- clearTimeout(pendingGTimeoutRef.current)
1852
- pendingGTimeoutRef.current = null
1853
- }
1854
- } else {
1855
- setPendingG(true)
1856
- pendingGTimeoutRef.current = setTimeout(() => {
1857
- setPendingG(false)
1858
- pendingGTimeoutRef.current = null
1859
- }, 500)
1860
- }
1861
- return
1862
- }
1887
+ if (handleVimGoto(key,
1888
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: 0 }); setDetailScrollOffset(0) },
1889
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER }); setDetailScrollOffset(Number.MAX_SAFE_INTEGER) },
1890
+ )) return
1863
1891
  if (key.name === "up" || key.name === "k") {
1864
1892
  detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1865
1893
  setDetailScrollOffset((current) => Math.max(0, current - 1))
@@ -2001,31 +2029,10 @@ export const App = () => {
2001
2029
  })
2002
2030
  return
2003
2031
  }
2004
- // Vim-style navigation: gg to go to top, G to go to bottom
2005
- if (isShiftG(key)) {
2006
- setSelectedIndex((_current) => {
2007
- if (visiblePullRequests.length === 0) return 0
2008
- return visiblePullRequests.length - 1
2009
- })
2010
- return
2011
- }
2012
- if (key.name === "g") {
2013
- if (pendingG) {
2014
- setSelectedIndex(0)
2015
- setPendingG(false)
2016
- if (pendingGTimeoutRef.current !== null) {
2017
- clearTimeout(pendingGTimeoutRef.current)
2018
- pendingGTimeoutRef.current = null
2019
- }
2020
- } else {
2021
- setPendingG(true)
2022
- pendingGTimeoutRef.current = setTimeout(() => {
2023
- setPendingG(false)
2024
- pendingGTimeoutRef.current = null
2025
- }, 500)
2026
- }
2027
- return
2028
- }
2032
+ if (handleVimGoto(key,
2033
+ () => setSelectedIndex(0),
2034
+ () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2035
+ )) return
2029
2036
  if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2030
2037
  setDetailFullView(true)
2031
2038
  setDetailScrollOffset(0)
@@ -2137,7 +2144,7 @@ export const App = () => {
2137
2144
  pullRequest={selectedPullRequest}
2138
2145
  diffState={selectedDiffState}
2139
2146
  stackedFiles={stackedDiffFiles}
2140
- fileIndex={diffFileIndex}
2147
+ scrollTop={diffScrollTop}
2141
2148
  view={effectiveDiffRenderView}
2142
2149
  wrapMode={diffWrapMode}
2143
2150
  paneWidth={contentWidth}
@@ -2148,7 +2155,7 @@ export const App = () => {
2148
2155
  commentMode={diffCommentMode}
2149
2156
  selectedCommentAnchor={selectedDiffCommentAnchor}
2150
2157
  selectedCommentThread={selectedDiffCommentThread}
2151
- commentCount={selectedDiffCommentCount}
2158
+ onSelectCommentLine={selectDiffCommentLine}
2152
2159
  themeId={themeId}
2153
2160
  />
2154
2161
  ) : isWideLayout && detailFullView ? (
@@ -2168,10 +2175,10 @@ export const App = () => {
2168
2175
  </scrollbox>
2169
2176
  </box>
2170
2177
  ) : isWideLayout ? (
2171
- <box flexGrow={1} flexDirection="row">
2178
+ <box key="wide-main" flexGrow={1} flexDirection="row">
2172
2179
  <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2173
2180
  <scrollbox height={wideBodyHeight} flexGrow={0}>
2174
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2181
+ <PullRequestList key={`wide-${leftContentWidth}`} {...prListProps} contentWidth={leftContentWidth} />
2175
2182
  </scrollbox>
2176
2183
  </box>
2177
2184
  <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
@@ -2204,13 +2211,13 @@ export const App = () => {
2204
2211
  </scrollbox>
2205
2212
  </box>
2206
2213
  ) : (
2207
- <box height={wideBodyHeight} flexDirection="column">
2208
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2214
+ <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2215
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2209
2216
  <Divider width={contentWidth} />
2210
2217
  <box flexGrow={1} flexDirection="column">
2211
2218
  <scrollbox flexGrow={1}>
2212
2219
  <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2213
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2220
+ <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
2214
2221
  </box>
2215
2222
  </scrollbox>
2216
2223
  </box>
package/src/domain.ts CHANGED
@@ -1,8 +1,14 @@
1
- export type PullRequestState = "open" | "closed" | "merged"
1
+ import { Schema } from "effect"
2
2
 
3
- export const pullRequestQueueModes = ["authored", "review", "assigned", "mentioned"] as const
3
+ export const LoadStatus = Schema.Literals(["loading", "ready", "error"])
4
+ export type LoadStatus = Schema.Schema.Type<typeof LoadStatus>
4
5
 
5
- export type PullRequestQueueMode = typeof pullRequestQueueModes[number]
6
+ export const PullRequestState = Schema.Literals(["open", "closed", "merged"])
7
+ export type PullRequestState = Schema.Schema.Type<typeof PullRequestState>
8
+
9
+ export const PullRequestQueueMode = Schema.Literals(["authored", "review", "assigned", "mentioned"])
10
+ export type PullRequestQueueMode = Schema.Schema.Type<typeof PullRequestQueueMode>
11
+ export const pullRequestQueueModes = PullRequestQueueMode.literals
6
12
 
7
13
  export const pullRequestQueueLabels = {
8
14
  authored: "authored",
@@ -21,11 +27,30 @@ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, auth
21
27
  return qualifiers[mode]
22
28
  }
23
29
 
24
- export type CheckConclusion = "success" | "failure" | "neutral" | "skipped" | "cancelled" | "timed_out"
30
+ export const CheckConclusion = Schema.Literals(["success", "failure", "neutral", "skipped", "cancelled", "timed_out"])
31
+ export type CheckConclusion = Schema.Schema.Type<typeof CheckConclusion>
32
+
33
+ export const CheckRunStatus = Schema.Literals(["completed", "in_progress", "queued", "pending"])
34
+ export type CheckRunStatus = Schema.Schema.Type<typeof CheckRunStatus>
35
+
36
+ export const CheckRollupStatus = Schema.Literals(["passing", "pending", "failing", "none"])
37
+ export type CheckRollupStatus = Schema.Schema.Type<typeof CheckRollupStatus>
38
+
39
+ export const ReviewStatus = Schema.Literals(["draft", "approved", "changes", "review", "none"])
40
+ export type ReviewStatus = Schema.Schema.Type<typeof ReviewStatus>
41
+
42
+ export const Mergeable = Schema.Literals(["mergeable", "conflicting", "unknown"])
43
+ export type Mergeable = Schema.Schema.Type<typeof Mergeable>
44
+
45
+ export const DiffCommentSide = Schema.Literals(["LEFT", "RIGHT"])
46
+ export type DiffCommentSide = Schema.Schema.Type<typeof DiffCommentSide>
47
+
48
+ export const PullRequestMergeAction = Schema.Literals(["squash", "auto", "admin", "disable-auto"])
49
+ export type PullRequestMergeAction = Schema.Schema.Type<typeof PullRequestMergeAction>
25
50
 
26
51
  export interface CheckItem {
27
52
  readonly name: string
28
- readonly status: "completed" | "in_progress" | "queued" | "pending"
53
+ readonly status: CheckRunStatus
29
54
  readonly conclusion: CheckConclusion | null
30
55
  }
31
56
 
@@ -34,8 +59,6 @@ export interface PullRequestLabel {
34
59
  readonly color: string | null
35
60
  }
36
61
 
37
- export type DiffCommentSide = "LEFT" | "RIGHT"
38
-
39
62
  export interface CreatePullRequestCommentInput {
40
63
  readonly repository: string
41
64
  readonly number: number
@@ -69,8 +92,8 @@ export interface PullRequestItem {
69
92
  readonly deletions: number
70
93
  readonly changedFiles: number
71
94
  readonly state: PullRequestState
72
- readonly reviewStatus: "draft" | "approved" | "changes" | "review" | "none"
73
- readonly checkStatus: "passing" | "pending" | "failing" | "none"
95
+ readonly reviewStatus: ReviewStatus
96
+ readonly checkStatus: CheckRollupStatus
74
97
  readonly checkSummary: string | null
75
98
  readonly checks: readonly CheckItem[]
76
99
  readonly autoMergeEnabled: boolean
@@ -86,11 +109,9 @@ export interface PullRequestMergeInfo {
86
109
  readonly title: string
87
110
  readonly state: PullRequestState
88
111
  readonly isDraft: boolean
89
- readonly mergeable: "mergeable" | "conflicting" | "unknown"
90
- readonly reviewStatus: PullRequestItem["reviewStatus"]
91
- readonly checkStatus: PullRequestItem["checkStatus"]
112
+ readonly mergeable: Mergeable
113
+ readonly reviewStatus: ReviewStatus
114
+ readonly checkStatus: CheckRollupStatus
92
115
  readonly checkSummary: string | null
93
116
  readonly autoMergeEnabled: boolean
94
117
  }
95
-
96
- export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
@@ -1,27 +1,26 @@
1
1
  import { Context, Effect, Layer, Schema } from "effect"
2
2
  import { config } from "../config.js"
3
- import { pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment } from "../domain.js"
3
+ import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
4
4
  import { getMergeActionDefinition } from "../mergeActions.js"
5
5
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
6
6
 
7
7
  const NullableString = Schema.NullOr(Schema.String)
8
- const OptionalNullableString = Schema.optional(NullableString)
9
- const OptionalNullableNumber = Schema.optional(Schema.NullOr(Schema.Number))
10
-
11
- const RawCheckRunSchema = Schema.Struct({
12
- __typename: Schema.Literal("CheckRun"),
13
- name: OptionalNullableString,
14
- status: OptionalNullableString,
15
- conclusion: OptionalNullableString,
16
- })
17
-
18
- const RawStatusContextSchema = Schema.Struct({
19
- __typename: Schema.Literal("StatusContext"),
20
- context: OptionalNullableString,
21
- state: OptionalNullableString,
22
- })
8
+ const OptionalNullableString = Schema.optionalKey(NullableString)
9
+ const OptionalNullableNumber = Schema.optionalKey(Schema.NullOr(Schema.Number))
23
10
 
24
- const RawCheckContextSchema = Schema.Union([RawCheckRunSchema, RawStatusContextSchema])
11
+ const RawCheckContextSchema = Schema.Union([
12
+ Schema.Struct({
13
+ __typename: Schema.tag("CheckRun"),
14
+ name: OptionalNullableString,
15
+ status: OptionalNullableString,
16
+ conclusion: OptionalNullableString,
17
+ }),
18
+ Schema.Struct({
19
+ __typename: Schema.tag("StatusContext"),
20
+ context: OptionalNullableString,
21
+ state: OptionalNullableString,
22
+ }),
23
+ ]).pipe(Schema.toTaggedUnion("__typename"))
25
24
 
26
25
  const RawAuthorSchema = Schema.Struct({ login: Schema.String })
27
26
  const RawRepositorySchema = Schema.Struct({ nameWithOwner: Schema.String })
@@ -55,7 +54,7 @@ const RawPullRequestNodeSchema = Schema.Struct({
55
54
  additions: Schema.Number,
56
55
  deletions: Schema.Number,
57
56
  changedFiles: Schema.Number,
58
- statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Struct({
57
+ statusCheckRollup: Schema.optionalKey(Schema.NullOr(Schema.Struct({
59
58
  contexts: Schema.Struct({ nodes: Schema.Array(RawCheckContextSchema) }),
60
59
  }))),
61
60
  })
@@ -88,22 +87,20 @@ const MergeInfoResponseSchema = Schema.Struct({
88
87
  statusCheckRollup: Schema.Array(RawCheckContextSchema),
89
88
  })
90
89
 
91
- const DiffCommentSideSchema = Schema.Union([Schema.Literal("LEFT"), Schema.Literal("RIGHT")])
92
-
93
90
  const PullRequestCommentSchema = Schema.Struct({
94
- id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
91
+ id: Schema.optionalKey(Schema.NullOr(Schema.Union([Schema.Number, Schema.String]))),
95
92
  node_id: OptionalNullableString,
96
93
  body: OptionalNullableString,
97
94
  html_url: OptionalNullableString,
98
95
  url: OptionalNullableString,
99
96
  created_at: OptionalNullableString,
100
- user: Schema.optional(Schema.NullOr(Schema.Struct({
97
+ user: Schema.optionalKey(Schema.NullOr(Schema.Struct({
101
98
  login: OptionalNullableString,
102
99
  }))),
103
100
  path: OptionalNullableString,
104
101
  line: OptionalNullableNumber,
105
102
  original_line: OptionalNullableNumber,
106
- side: Schema.optional(Schema.NullOr(DiffCommentSideSchema)),
103
+ side: Schema.optionalKey(Schema.NullOr(DiffCommentSide)),
107
104
  })
108
105
 
109
106
  const CommentsResponseSchema = Schema.Union([
@@ -205,13 +202,13 @@ const normalizeDate = (value: string | null | undefined) => {
205
202
  const getPullRequestState = (item: { readonly state: string; readonly merged: boolean }): PullRequestItem["state"] =>
206
203
  item.merged ? "merged" : item.state.toLowerCase() === "open" ? "open" : "closed"
207
204
 
208
- const REVIEW_STATUS_BY_DECISION: Record<string, PullRequestItem["reviewStatus"]> = {
205
+ const REVIEW_STATUS_BY_DECISION: Record<string, ReviewStatus> = {
209
206
  APPROVED: "approved",
210
207
  CHANGES_REQUESTED: "changes",
211
208
  REVIEW_REQUIRED: "review",
212
209
  }
213
210
 
214
- const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): PullRequestItem["reviewStatus"] => {
211
+ const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): ReviewStatus => {
215
212
  if (item.isDraft) return "draft"
216
213
  if (item.reviewDecision) return REVIEW_STATUS_BY_DECISION[item.reviewDecision] ?? "none"
217
214
  return "none"
@@ -239,19 +236,24 @@ const normalizeCheckStatus = (raw: string | null | undefined): CheckItem["status
239
236
  const normalizeCheckConclusion = (raw: string | null | undefined): CheckItem["conclusion"] =>
240
237
  raw ? CHECK_CONCLUSION_BY_RAW[raw] ?? null : null
241
238
 
242
- const getContextStatus = (context: RawCheckContext): CheckItem["status"] => {
243
- if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
244
- if (context.state === "PENDING") return "in_progress"
245
- return "completed"
246
- }
239
+ const getContextStatus = (context: RawCheckContext): CheckItem["status"] =>
240
+ RawCheckContextSchema.match(context, {
241
+ CheckRun: (run) => normalizeCheckStatus(run.status),
242
+ StatusContext: (status) => status.state === "PENDING" ? "in_progress" : "completed",
243
+ })
247
244
 
248
- const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] => {
249
- if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
250
- if (context.state === "SUCCESS") return "success"
251
- if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
252
- return null
245
+ const STATUS_CONTEXT_CONCLUSION: Record<string, NonNullable<CheckItem["conclusion"]>> = {
246
+ SUCCESS: "success",
247
+ FAILURE: "failure",
248
+ ERROR: "failure",
253
249
  }
254
250
 
251
+ const getContextConclusion = (context: RawCheckContext): CheckItem["conclusion"] =>
252
+ RawCheckContextSchema.match(context, {
253
+ CheckRun: (run) => normalizeCheckConclusion(run.conclusion),
254
+ StatusContext: (status) => (status.state ? STATUS_CONTEXT_CONCLUSION[status.state] : null) ?? null,
255
+ })
256
+
255
257
  const getCheckInfoFromContexts = (contexts: readonly RawCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
256
258
  if (contexts.length === 0) {
257
259
  return { checkStatus: "none", checkSummary: null, checks: [] }
@@ -379,12 +381,12 @@ const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullReque
379
381
 
380
382
  export type GitHubError = CommandError | JsonParseError | Schema.SchemaError
381
383
 
382
- const MERGEABLE_BY_RAW: Record<string, PullRequestMergeInfo["mergeable"]> = {
384
+ const MERGEABLE_BY_RAW: Record<string, Mergeable> = {
383
385
  MERGEABLE: "mergeable",
384
386
  CONFLICTING: "conflicting",
385
387
  }
386
388
 
387
- const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] =>
389
+ const normalizeMergeable = (value: string): Mergeable =>
388
390
  MERGEABLE_BY_RAW[value] ?? "unknown"
389
391
 
390
392
  export class GitHubService extends Context.Service<GitHubService, {
@@ -1,10 +1,14 @@
1
+ import { Data } from "effect"
1
2
  import { colors } from "./colors.js"
2
3
  import { TextLine } from "./primitives.js"
3
4
 
4
- export interface RetryProgress {
5
- readonly attempt: number
6
- readonly max: number
7
- }
5
+ export type RetryProgress = Data.TaggedEnum<{
6
+ Idle: {}
7
+ Retrying: { readonly attempt: number; readonly max: number }
8
+ }>
9
+
10
+ export const RetryProgress = Data.taggedEnum<RetryProgress>()
11
+ export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
8
12
 
9
13
  export const FooterHints = ({
10
14
  filterEditing,
@@ -29,7 +33,7 @@ export const FooterHints = ({
29
33
  hasError: boolean
30
34
  isLoading: boolean
31
35
  loadingIndicator: string
32
- retryProgress: RetryProgress | null
36
+ retryProgress: RetryProgress
33
37
  }) => {
34
38
  if (filterEditing) {
35
39
  return (
@@ -148,7 +152,7 @@ export const FooterHints = ({
148
152
  <span fg={colors.muted}> clear </span>
149
153
  </>
150
154
  ) : null}
151
- {retryProgress ? (
155
+ {retryProgress._tag === "Retrying" ? (
152
156
  <>
153
157
  <span fg={colors.status.pending}>retry</span>
154
158
  <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
@@ -1,8 +1,8 @@
1
- import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
1
+ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
- import type { PullRequestItem, PullRequestReviewComment } from "../domain.js"
3
+ import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
4
  import { colors, type ThemeId } from "./colors.js"
5
- import { createDiffSyntaxStyle, diffFileStats, diffFileStatText, diffStatText, safeDiffFileIndex, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffFilePatch } from "./diff.js"
5
+ import { createDiffSyntaxStyle, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
6
6
  import { LoadingPane, StatusCard } from "./DetailsPane.js"
7
7
  import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
8
  import { shortRepoName } from "./pullRequests.js"
@@ -27,8 +27,7 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
27
27
  )
28
28
  }
29
29
 
30
- const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
31
- const stats = diffFileStats(file)
30
+ const FileStats = ({ stats }: { stats: DiffFileStats }) => {
32
31
  return (
33
32
  <>
34
33
  {stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
@@ -38,16 +37,32 @@ const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
38
37
  )
39
38
  }
40
39
 
41
- const FileHeader = ({ file, index, count, width }: { file: StackedDiffFilePatch["file"]; index: number; count: number; width: number }) => {
40
+ const FileHeader = ({
41
+ file,
42
+ index,
43
+ count,
44
+ width,
45
+ suffix = "",
46
+ suffixColor = colors.muted,
47
+ }: {
48
+ file: StackedDiffFilePatch["file"]
49
+ index: number
50
+ count: number
51
+ width: number
52
+ suffix?: string
53
+ suffixColor?: string
54
+ }) => {
42
55
  const counter = `${index + 1}/${count}`
43
- const statsText = diffFileStatText(file)
44
- const nameWidth = Math.max(1, width - counter.length - statsText.length - 5)
56
+ const stats = diffFileStats(file)
57
+ const statsText = diffFileStatsText(stats)
58
+ const nameWidth = Math.max(1, width - counter.length - statsText.length - suffix.length - 5)
45
59
  return (
46
60
  <TextLine>
47
61
  <span fg={colors.muted}>{counter} </span>
48
62
  <span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
49
63
  {statsText ? <span fg={colors.muted}> </span> : null}
50
- <FileStats file={file} />
64
+ <FileStats stats={stats} />
65
+ {suffix ? <span fg={suffixColor}>{suffix}</span> : null}
51
66
  </TextLine>
52
67
  )
53
68
  }
@@ -56,7 +71,7 @@ export const PullRequestDiffPane = ({
56
71
  pullRequest,
57
72
  diffState,
58
73
  stackedFiles,
59
- fileIndex,
74
+ scrollTop,
60
75
  view,
61
76
  wrapMode,
62
77
  paneWidth,
@@ -67,29 +82,27 @@ export const PullRequestDiffPane = ({
67
82
  commentMode,
68
83
  selectedCommentAnchor,
69
84
  selectedCommentThread,
70
- commentCount,
85
+ onSelectCommentLine,
71
86
  themeId,
72
87
  }: {
73
88
  pullRequest: PullRequestItem | null
74
89
  diffState: PullRequestDiffState | undefined
75
90
  stackedFiles: readonly StackedDiffFilePatch[]
76
- fileIndex: number
77
- view: "unified" | "split"
78
- wrapMode: "none" | "word"
91
+ scrollTop: number
92
+ view: DiffView
93
+ wrapMode: DiffWrapMode
79
94
  paneWidth: number
80
95
  height: number
81
96
  loadingIndicator: string
82
97
  scrollRef: Ref<ScrollBoxRenderable>
83
98
  setDiffRef: (index: number, diff: DiffRenderable | null) => void
84
99
  commentMode: boolean
85
- selectedCommentAnchor: DiffCommentAnchor | null
100
+ selectedCommentAnchor: StackedDiffCommentAnchor | null
86
101
  selectedCommentThread: readonly PullRequestReviewComment[]
87
- commentCount: number
102
+ onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
88
103
  themeId: ThemeId
89
104
  }) => {
90
- const readyFiles = diffState?.status === "ready" ? diffState.files : []
91
- const safeIndex = safeDiffFileIndex(readyFiles, fileIndex)
92
- const file = readyFiles[safeIndex] ?? null
105
+ const readyFiles = diffState?._tag === "Ready" ? diffState.files : []
93
106
  const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
94
107
 
95
108
  if (!pullRequest) {
@@ -101,7 +114,7 @@ export const PullRequestDiffPane = ({
101
114
  const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
102
115
  const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
103
116
 
104
- if (!diffState || diffState.status === "loading") {
117
+ if (!diffState || diffState._tag === "Loading") {
105
118
  return (
106
119
  <box height={height} flexDirection="column">
107
120
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -118,7 +131,7 @@ export const PullRequestDiffPane = ({
118
131
  )
119
132
  }
120
133
 
121
- if (diffState.status === "error") {
134
+ if (diffState._tag === "Error") {
122
135
  return (
123
136
  <box height={height} flexDirection="column">
124
137
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -130,20 +143,11 @@ export const PullRequestDiffPane = ({
130
143
  )
131
144
  }
132
145
 
133
- if (readyFiles.length === 0 || !file) {
146
+ if (readyFiles.length === 0 || stackedFiles.length === 0) {
134
147
  return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
135
148
  }
136
149
 
137
- const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
138
- const currentFileStatsText = file ? diffFileStatText(file) : ""
139
150
  const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
140
- const commentLabel = commentMode && selectedCommentAnchor
141
- ? ` ${selectedSideLabel} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
142
- : commentMode ? " c no lines" : commentCount > 0 ? ` ● ${commentCount}` : ""
143
- const commentLabelColor = commentMode && selectedCommentAnchor?.side === "LEFT"
144
- ? colors.status.failing
145
- : commentMode ? colors.status.passing : colors.status.passing
146
- const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - currentFileStatsText.length - commentLabel.length - 4)
147
151
  const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
148
152
  ? selectedCommentThread[selectedCommentThread.length - 1]!
149
153
  : null
@@ -152,6 +156,31 @@ export const PullRequestDiffPane = ({
152
156
  const commentPeekMeta = commentPeek && selectedCommentAnchor
153
157
  ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
154
158
  : ""
159
+ const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
160
+ const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
161
+ const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
162
+ const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
163
+ const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
164
+ const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
165
+ const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
166
+ if (!commentMode) return ""
167
+ if (!selectedCommentAnchor) return " c no lines"
168
+ if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
169
+ return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
170
+ }
171
+ const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
172
+ const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
173
+ if (event.button !== 0) return
174
+ const localY = event.y - this.viewport.y
175
+ if (localY < 0 || localY >= this.viewport.height) return
176
+ const localX = event.x - this.viewport.x
177
+ const side = view === "split"
178
+ ? localX < Math.floor(paneWidth / 2) ? "LEFT" : "RIGHT"
179
+ : null
180
+ onSelectCommentLine(Math.max(0, Math.floor(this.scrollTop + localY)), side)
181
+ event.preventDefault()
182
+ event.stopPropagation()
183
+ }
155
184
 
156
185
  return (
157
186
  <box height={height} flexDirection="column">
@@ -163,17 +192,8 @@ export const PullRequestDiffPane = ({
163
192
  <DiffStats pullRequest={pullRequest} />
164
193
  </TextLine>
165
194
  </box>
166
- <box height={1} paddingLeft={1} paddingRight={1}>
167
- <TextLine>
168
- <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
169
- <span fg={colors.muted}> {fileCounter}</span>
170
- {currentFileStatsText ? <span fg={colors.muted}> </span> : null}
171
- <FileStats file={file} />
172
- {commentLabel ? <span fg={commentLabelColor}>{commentLabel}</span> : null}
173
- </TextLine>
174
- </box>
175
195
  <Divider width={paneWidth} />
176
- <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false}>
196
+ <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
177
197
  {stackedFiles.map((stackedFile) => (
178
198
  <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
179
199
  {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
@@ -207,6 +227,25 @@ export const PullRequestDiffPane = ({
207
227
  </box>
208
228
  ))}
209
229
  </scrollbox>
230
+ {stickyFile ? (
231
+ <box position="absolute" top={2} left={0} width={paneWidth} height={2} zIndex={10} flexDirection="column" backgroundColor={colors.background}>
232
+ {incomingFile ? (
233
+ <>
234
+ <Divider width={paneWidth} />
235
+ <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
236
+ <FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
237
+ </box>
238
+ </>
239
+ ) : (
240
+ <>
241
+ <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
242
+ <FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
243
+ </box>
244
+ <Divider width={paneWidth} />
245
+ </>
246
+ )}
247
+ </box>
248
+ ) : null}
210
249
  {commentPeek ? (
211
250
  <>
212
251
  <Divider width={paneWidth} />
@@ -1,11 +1,10 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import type { PullRequestItem } from "../domain.js"
2
+ import type { LoadStatus, PullRequestItem } from "../domain.js"
3
3
  import { daysOpen } from "../date.js"
4
4
  import { colors } from "./colors.js"
5
5
  import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
6
  import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
7
7
 
8
- export type LoadStatus = "loading" | "ready" | "error"
9
8
  export type PullRequestGroups = Array<[string, PullRequestItem[]]>
10
9
 
11
10
  const GROUP_ICON = "◆"
@@ -77,8 +76,8 @@ const PullRequestRow = ({
77
76
  const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
78
77
 
79
78
  return (
80
- <box height={1} onMouseDown={onSelect}>
81
- <TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
79
+ <box width={contentWidth} height={1} onMouseDown={onSelect}>
80
+ <TextLine width={contentWidth} fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
82
81
  <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
83
82
  <span> </span>
84
83
  <span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
@@ -117,7 +116,7 @@ export const PullRequestList = ({
117
116
  const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
118
117
 
119
118
  return (
120
- <box flexDirection="column">
119
+ <box width={contentWidth} flexDirection="column">
121
120
  <SectionTitle title="PULL REQUESTS" />
122
121
  {showFilterBar ? (
123
122
  <TextLine>
@@ -138,11 +137,11 @@ export const PullRequestList = ({
138
137
  <PullRequestRow
139
138
  key={pullRequest.url}
140
139
  pullRequest={pullRequest}
141
- selected={pullRequest.url === selectedUrl}
142
- contentWidth={contentWidth}
143
- numWidth={numWidth}
144
- filterText={filterText}
145
- onSelect={() => onSelectPullRequest(pullRequest.url)}
140
+ selected={pullRequest.url === selectedUrl}
141
+ contentWidth={contentWidth}
142
+ numWidth={numWidth}
143
+ filterText={filterText}
144
+ onSelect={() => onSelectPullRequest(pullRequest.url)}
146
145
  />
147
146
  ))}
148
147
  </box>
package/src/ui/diff.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import { parseColor, SyntaxStyle } from "@opentui/core"
2
+ import { Data, Schema } from "effect"
2
3
  import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
3
4
  import { colors } from "./colors.js"
4
5
 
6
+ export const DiffView = Schema.Literals(["unified", "split"])
7
+ export type DiffView = Schema.Schema.Type<typeof DiffView>
8
+
9
+ export const DiffWrapMode = Schema.Literals(["none", "word"])
10
+ export type DiffWrapMode = Schema.Schema.Type<typeof DiffWrapMode>
11
+
12
+ export const DiffCommentKind = Schema.Literals(["addition", "deletion", "context"])
13
+ export type DiffCommentKind = Schema.Schema.Type<typeof DiffCommentKind>
14
+
5
15
  export interface DiffFilePatch {
6
16
  readonly name: string
7
17
  readonly filetype: string | undefined
@@ -25,7 +35,7 @@ export interface DiffCommentAnchor {
25
35
  readonly path: string
26
36
  readonly line: number
27
37
  readonly side: DiffCommentSide
28
- readonly kind: "addition" | "deletion" | "context"
38
+ readonly kind: DiffCommentKind
29
39
  readonly renderLine: number
30
40
  readonly text: string
31
41
  }
@@ -35,10 +45,13 @@ export type StackedDiffCommentAnchor = DiffCommentAnchor & {
35
45
  readonly localRenderLine: number
36
46
  }
37
47
 
38
- export type PullRequestDiffState =
39
- | { readonly status: "loading" }
40
- | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
41
- | { readonly status: "error"; readonly error: string }
48
+ export type PullRequestDiffState = Data.TaggedEnum<{
49
+ Loading: {}
50
+ Ready: { readonly patch: string; readonly files: readonly DiffFilePatch[] }
51
+ Error: { readonly error: string }
52
+ }>
53
+
54
+ export const PullRequestDiffState = Data.taggedEnum<PullRequestDiffState>()
42
55
 
43
56
  export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
44
57
  keyword: { fg: parseColor(colors.accent), bold: true },
@@ -176,8 +189,8 @@ export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number
176
189
 
177
190
  export const buildStackedDiffFiles = (
178
191
  files: readonly DiffFilePatch[],
179
- view: "unified" | "split",
180
- wrapMode: "none" | "word",
192
+ view: DiffView,
193
+ wrapMode: DiffWrapMode,
181
194
  width: number,
182
195
  ): readonly StackedDiffFilePatch[] => {
183
196
  let offset = 0
@@ -197,14 +210,14 @@ export const buildStackedDiffFiles = (
197
210
  })
198
211
  }
199
212
 
213
+ export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
214
+ stackedFiles.reduce<StackedDiffFilePatch | undefined>((current, file) => file.headerLine <= line ? file : current, undefined)
215
+
200
216
  export const diffStatText = (pullRequest: PullRequestItem) => {
201
217
  if (!pullRequest.detailLoaded) return "loading details"
202
218
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
203
- return [
204
- pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
205
- pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
206
- files,
207
- ].filter((part): part is string => part !== null).join(" ")
219
+ const stats = diffFileStatsText(pullRequest)
220
+ return stats ? `${stats} ${files}` : files
208
221
  }
209
222
 
210
223
  export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
@@ -213,7 +226,7 @@ export const diffCommentAnchorKey = diffCommentLocationKey
213
226
 
214
227
  type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
215
228
 
216
- const diffContentWidth = (lines: readonly string[], view: "unified" | "split", width: number) => {
229
+ const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {
217
230
  const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
218
231
  return view === "split"
219
232
  ? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
@@ -242,14 +255,17 @@ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
242
255
  }
243
256
 
244
257
  export const diffFileStatText = (file: DiffFilePatch) => {
245
- const stats = diffFileStats(file)
258
+ return diffFileStatsText(diffFileStats(file))
259
+ }
260
+
261
+ export const diffFileStatsText = (stats: DiffFileStats) => {
246
262
  return [
247
263
  stats.additions > 0 ? `+${stats.additions}` : null,
248
264
  stats.deletions > 0 ? `-${stats.deletions}` : null,
249
265
  ].filter((part): part is string => part !== null).join(" ")
250
266
  }
251
267
 
252
- export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "split" = "unified", wrapMode: "none" | "word" = "none", width = 120): readonly DiffCommentAnchor[] => {
268
+ export const getDiffCommentAnchors = (file: DiffFilePatch, view: DiffView = "unified", wrapMode: DiffWrapMode = "none", width = 120): readonly DiffCommentAnchor[] => {
253
269
  const anchors: DiffCommentAnchor[] = []
254
270
  const lines = file.patch.split("\n")
255
271
  const contentWidth = diffContentWidth(lines, view, width)
@@ -330,8 +346,8 @@ export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "sp
330
346
 
331
347
  export const getStackedDiffCommentAnchors = (
332
348
  stackedFiles: readonly StackedDiffFilePatch[],
333
- view: "unified" | "split" = "unified",
334
- wrapMode: "none" | "word" = "none",
349
+ view: DiffView = "unified",
350
+ wrapMode: DiffWrapMode = "none",
335
351
  width = 120,
336
352
  ): readonly StackedDiffCommentAnchor[] =>
337
353
  stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
@@ -354,7 +370,7 @@ export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: numb
354
370
  return currentTop
355
371
  }
356
372
 
357
- const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
373
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: DiffWrapMode) => {
358
374
  if (wrapMode === "none") return 1
359
375
  return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
360
376
  }
@@ -394,7 +410,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
394
410
  return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
395
411
  }
396
412
 
397
- export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
413
+ export const patchRenderableLineCount = (patch: string, view: DiffView, wrapMode: DiffWrapMode, width: number) => {
398
414
  const lines = patch.split("\n")
399
415
  const contentWidth = diffContentWidth(lines, view, width)
400
416
  let count = 0
package/src/ui/modals.tsx CHANGED
@@ -96,19 +96,19 @@ export const initialThemeModalState: ThemeModalState = {
96
96
 
97
97
  export type Modal = Data.TaggedEnum<{
98
98
  None: {}
99
- Label: { readonly state: LabelModalState }
100
- Close: { readonly state: CloseModalState }
101
- Merge: { readonly state: MergeModalState }
102
- Comment: { readonly state: CommentModalState }
103
- CommentThread: { readonly state: CommentThreadModalState }
104
- Theme: { readonly state: ThemeModalState }
99
+ Label: LabelModalState
100
+ Close: CloseModalState
101
+ Merge: MergeModalState
102
+ Comment: CommentModalState
103
+ CommentThread: CommentThreadModalState
104
+ Theme: ThemeModalState
105
105
  }>
106
106
 
107
107
  export const Modal = Data.taggedEnum<Modal>()
108
108
  export const initialModal: Modal = Modal.None()
109
109
 
110
110
  export type ModalTag = Modal["_tag"]
111
- export type ModalState<Tag extends Exclude<ModalTag, "None">> = Extract<Modal, { _tag: Tag }>["state"]
111
+ export type ModalState<Tag extends Exclude<ModalTag, "None">> = Omit<Extract<Modal, { _tag: Tag }>, "_tag">
112
112
 
113
113
  export const modalInitialStates = {
114
114
  Label: initialLabelModalState,
@@ -29,8 +29,8 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
29
29
  </box>
30
30
  )
31
31
 
32
- export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
33
- <box height={1}>
32
+ export const TextLine = ({ children, fg = colors.text, bg, width }: { children: React.ReactNode; fg?: string; bg?: string | undefined; width?: number }) => (
33
+ <box height={1} {...(width === undefined ? {} : { width })}>
34
34
  {bg ? (
35
35
  <text wrapMode="none" truncate fg={fg} bg={bg}>
36
36
  {children}