@kitlangton/ghui 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -44,15 +44,12 @@ bun run dev
44
44
 
45
45
  ## Configuration
46
46
 
47
- - `GHUI_AUTHOR`: author passed to `gh search prs`, defaults to `@me`
48
- - `GHUI_REPO`: optional `owner/name` repository queue for browsing all open PRs in a repo
49
47
  - `GHUI_PR_FETCH_LIMIT`: max PRs fetched, defaults to `200`
50
48
 
51
49
  Example:
52
50
 
53
51
  ```bash
54
- GHUI_AUTHOR=@me ghui
55
- GHUI_REPO=basecamp/omarchy ghui
52
+ GHUI_PR_FETCH_LIMIT=100 ghui
56
53
  ```
57
54
 
58
55
  You can also copy `.env.example` to `.env` and edit the values locally.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -8,12 +8,12 @@ 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"
15
15
  import { config } from "./config.js"
16
- import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestConversationItem, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestConversationItem, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment, type SubmitPullRequestReviewInput } from "./domain.js"
17
17
  import { formatShortDate, formatTimestamp } from "./date.js"
18
18
  import { errorMessage } from "./errors.js"
19
19
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
@@ -33,7 +33,7 @@ import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHin
33
33
  import { LoadingLogoPane } from "./ui/LoadingLogo.js"
34
34
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
35
35
  import { CommandPalette } from "./ui/CommandPalette.js"
36
- import { CloseModal, CommentModal, CommentThreadModal, filterLabels, initialCloseModalState, initialCommandPaletteState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialOpenRepositoryModalState, initialThemeModalState, LabelModal, MergeModal, Modal, OpenRepositoryModal, ThemeModal, type CloseModalState, type CommandPaletteState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type OpenRepositoryModalState, type ThemeModalState } from "./ui/modals.js"
36
+ import { ChangedFilesModal, CloseModal, CommentModal, CommentThreadModal, filterChangedFiles, filterLabels, initialChangedFilesModalState, initialCloseModalState, initialCommandPaletteState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialOpenRepositoryModalState, initialSubmitReviewModalState, initialThemeModalState, LabelModal, MergeModal, Modal, OpenRepositoryModal, submitReviewOptions, SubmitReviewModal, ThemeModal, type ChangedFilesModalState, type CloseModalState, type CommandPaletteState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type OpenRepositoryModalState, type SubmitReviewModalState, type ThemeModalState } from "./ui/modals.js"
37
37
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
38
38
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
39
39
  import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
@@ -132,7 +132,7 @@ const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: r
132
132
  }
133
133
 
134
134
  const retryProgressAtom = Atom.make<RetryProgress>(initialRetryProgress).pipe(Atom.keepAlive)
135
- const activeViewAtom = Atom.make<PullRequestView>(initialPullRequestView(config.repository)).pipe(Atom.keepAlive)
135
+ const activeViewAtom = Atom.make<PullRequestView>(initialPullRequestView()).pipe(Atom.keepAlive)
136
136
  const queueLoadCacheAtom = Atom.make<Partial<Record<string, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
137
137
  const queueSelectionAtom = Atom.make<Partial<Record<string, number>>>({}).pipe(Atom.keepAlive)
138
138
  const trimQueueLoadCache = (cache: Partial<Record<string, PullRequestLoad>>) => {
@@ -212,9 +212,7 @@ const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}
212
212
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
213
213
  const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
214
214
  const usernameAtom = githubRuntime.atom(
215
- config.author === "@me"
216
- ? GitHubService.use((github) => github.getAuthenticatedUser())
217
- : Effect.succeed(config.author.replace(/^@/, "")),
215
+ GitHubService.use((github) => github.getAuthenticatedUser()),
218
216
  ).pipe(Atom.keepAlive)
219
217
 
220
218
  const pullRequestLoadAtom = Atom.make((get) => {
@@ -350,6 +348,7 @@ const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
350
348
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
351
349
  )
352
350
  const createPullRequestCommentAtom = githubRuntime.fn<CreatePullRequestCommentInput>()((input) => GitHubService.use((github) => github.createPullRequestComment(input)))
351
+ const submitPullRequestReviewAtom = githubRuntime.fn<SubmitPullRequestReviewInput>()((input) => GitHubService.use((github) => github.submitPullRequestReview(input)))
353
352
  const copyToClipboardAtom = githubRuntime.fn<string>()((text) => Clipboard.use((clipboard) => clipboard.copy(text)))
354
353
  const openInBrowserAtom = githubRuntime.fn<PullRequestItem>()((pullRequest) => BrowserOpener.use((browser) => browser.openPullRequest(pullRequest)))
355
354
 
@@ -418,6 +417,12 @@ const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonl
418
417
 
419
418
  const isLocalDiffComment = (comment: PullRequestReviewComment) => comment.id.startsWith("local:")
420
419
 
420
+ const reviewStatusAfterSubmit = {
421
+ COMMENT: null,
422
+ APPROVE: "approved",
423
+ REQUEST_CHANGES: "changes",
424
+ } satisfies Record<SubmitPullRequestReviewInput["event"], PullRequestItem["reviewStatus"] | null>
425
+
421
426
  const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig => {
422
427
  if (anchor.kind === "addition") {
423
428
  return { gutter: colors.diff.addedLineNumberBg, content: colors.diff.addedBg }
@@ -559,6 +564,8 @@ export const App = () => {
559
564
  const mergeModalActive = Modal.$is("Merge")(activeModal)
560
565
  const commentModalActive = Modal.$is("Comment")(activeModal)
561
566
  const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
567
+ const changedFilesModalActive = Modal.$is("ChangedFiles")(activeModal)
568
+ const submitReviewModalActive = Modal.$is("SubmitReview")(activeModal)
562
569
  const themeModalActive = Modal.$is("Theme")(activeModal)
563
570
  const commandPaletteActive = Modal.$is("CommandPalette")(activeModal)
564
571
  const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal)
@@ -567,6 +574,8 @@ export const App = () => {
567
574
  const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
568
575
  const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
569
576
  const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
577
+ const changedFilesModal: ChangedFilesModalState = changedFilesModalActive ? activeModal : initialChangedFilesModalState
578
+ const submitReviewModal: SubmitReviewModalState = submitReviewModalActive ? activeModal : initialSubmitReviewModalState
570
579
  const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
571
580
  const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState
572
581
  const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState
@@ -585,6 +594,8 @@ export const App = () => {
585
594
  const setMergeModal = makeModalSetter("Merge")
586
595
  const setCommentModal = makeModalSetter("Comment")
587
596
  const setCommentThreadModal = makeModalSetter("CommentThread")
597
+ const setChangedFilesModal = makeModalSetter("ChangedFiles")
598
+ const setSubmitReviewModal = makeModalSetter("SubmitReview")
588
599
  const setThemeModal = makeModalSetter("Theme")
589
600
  const setCommandPalette = makeModalSetter("CommandPalette")
590
601
  const setOpenRepositoryModal = makeModalSetter("OpenRepository")
@@ -603,6 +614,7 @@ export const App = () => {
603
614
  const [terminalFocused, setTerminalFocused] = useState(true)
604
615
  const [startupLoadComplete, setStartupLoadComplete] = useState(false)
605
616
  const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
617
+ const [detailPreviewScrollTop, setDetailPreviewScrollTop] = useState(0)
606
618
  const usernameResult = useAtomValue(usernameAtom)
607
619
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
608
620
  const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
@@ -615,6 +627,7 @@ export const App = () => {
615
627
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
616
628
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
617
629
  const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
630
+ const submitPullRequestReview = useAtomSet(submitPullRequestReviewAtom, { mode: "promise" })
618
631
  const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
619
632
  const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
620
633
  const terminalWidth = width ?? 100
@@ -644,6 +657,7 @@ export const App = () => {
644
657
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
645
658
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
646
659
  const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
660
+ const detailPreviewScrollTopRef = useRef(0)
647
661
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
648
662
  const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
649
663
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
@@ -722,6 +736,10 @@ export const App = () => {
722
736
  : [],
723
737
  [selectedDiffState, diffWhitespaceMode],
724
738
  )
739
+ const changedFileResults = useMemo(
740
+ () => changedFilesModalActive ? filterChangedFiles(readyDiffFiles, changedFilesModal.query) : [],
741
+ [changedFilesModalActive, readyDiffFiles, changedFilesModal.query],
742
+ )
725
743
  const displayedDiffState = useMemo(
726
744
  () => selectedDiffState?._tag === "Ready"
727
745
  ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
@@ -1071,6 +1089,10 @@ export const App = () => {
1071
1089
  setDiffPreferredSide(null)
1072
1090
  setDiffCommentRangeStartIndex(null)
1073
1091
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
1092
+ if (detailPreviewScrollTopRef.current !== 0) {
1093
+ detailPreviewScrollTopRef.current = 0
1094
+ setDetailPreviewScrollTop(0)
1095
+ }
1074
1096
  }, [selectedIndex])
1075
1097
 
1076
1098
  useEffect(() => {
@@ -1148,7 +1170,7 @@ export const App = () => {
1148
1170
  }, [diffFullView])
1149
1171
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1150
1172
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
1151
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
1173
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || submitReviewModal.running || selectedDiffState?._tag === "Loading"
1152
1174
  const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
1153
1175
 
1154
1176
  useEffect(() => {
@@ -1211,8 +1233,6 @@ export const App = () => {
1211
1233
  title: `${loadingIndicator} Loading pull request details`,
1212
1234
  hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1213
1235
  } : detailPlaceholderContent
1214
- const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true, rightContentWidth, selectedConversationItems, selectedConversationStatus)
1215
-
1216
1236
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1217
1237
 
1218
1238
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
@@ -1334,8 +1354,23 @@ export const App = () => {
1334
1354
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1335
1355
  }
1336
1356
 
1337
- const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1338
- const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1357
+ const syncDetailPreviewScrollState = useCallback(() => {
1358
+ const scrollTop = detailPreviewScrollRef.current?.scrollTop
1359
+ if (scrollTop === undefined) return
1360
+ const nextTop = Math.max(0, Math.floor(scrollTop))
1361
+ if (detailPreviewScrollTopRef.current === nextTop) return
1362
+ detailPreviewScrollTopRef.current = nextTop
1363
+ setDetailPreviewScrollTop(nextTop)
1364
+ }, [])
1365
+
1366
+ const scrollDetailPreviewBy = (y: number) => {
1367
+ detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1368
+ syncDetailPreviewScrollState()
1369
+ }
1370
+ const scrollDetailPreviewTo = (y: number) => {
1371
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1372
+ syncDetailPreviewScrollState()
1373
+ }
1339
1374
 
1340
1375
  const ensureDiffLineVisible = (line: number) => {
1341
1376
  const scroll = diffScrollRef.current
@@ -1354,9 +1389,19 @@ export const App = () => {
1354
1389
  return () => globalThis.clearInterval(interval)
1355
1390
  }, [diffFullView, stackedDiffFiles])
1356
1391
 
1357
- const jumpDiffFile = (delta: 1 | -1) => {
1392
+ useLayoutEffect(() => {
1393
+ if (!isWideLayout || detailFullView || diffFullView) return
1394
+ const scroll = detailPreviewScrollRef.current
1395
+ if (!scroll) return
1396
+ const sync = () => { syncDetailPreviewScrollState() }
1397
+ scroll.verticalScrollBar.on("change", sync)
1398
+ syncDetailPreviewScrollState()
1399
+ return () => { scroll.verticalScrollBar.off("change", sync) }
1400
+ }, [isWideLayout, detailFullView, diffFullView, syncDetailPreviewScrollState])
1401
+
1402
+ const selectDiffFile = (index: number) => {
1358
1403
  if (readyDiffFiles.length === 0) return
1359
- const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1404
+ const nextIndex = safeDiffFileIndex(readyDiffFiles, index)
1360
1405
  setDiffFileIndex(nextIndex)
1361
1406
  setDiffCommentRangeStartIndex(null)
1362
1407
  const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
@@ -1366,6 +1411,26 @@ export const App = () => {
1366
1411
  scrollToDiffFile(nextIndex)
1367
1412
  }
1368
1413
 
1414
+ const jumpDiffFile = (delta: 1 | -1) => {
1415
+ selectDiffFile(diffFileIndex + delta)
1416
+ }
1417
+
1418
+ const openChangedFilesModal = () => {
1419
+ if (readyDiffFiles.length === 0) return
1420
+ setChangedFilesModal({
1421
+ query: "",
1422
+ selectedIndex: safeDiffFileIndex(readyDiffFiles, diffFileIndex),
1423
+ })
1424
+ }
1425
+
1426
+ const selectChangedFile = () => {
1427
+ const selectedIndex = changedFileResults.length === 0 ? 0 : Math.max(0, Math.min(changedFilesModal.selectedIndex, changedFileResults.length - 1))
1428
+ const entry = changedFileResults[selectedIndex]
1429
+ if (!entry) return
1430
+ closeActiveModal()
1431
+ selectDiffFile(entry.index)
1432
+ }
1433
+
1369
1434
  const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1370
1435
  ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1371
1436
  : diffCommentAnchors
@@ -1445,6 +1510,27 @@ export const App = () => {
1445
1510
  })
1446
1511
  }
1447
1512
 
1513
+ const editSubmitReview = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1514
+ setSubmitReviewModal((current) => {
1515
+ const next = transform({ body: current.body, cursor: current.cursor })
1516
+ if (next.body === current.body && next.cursor === current.cursor && current.error === null) return current
1517
+ return { ...current, body: next.body, cursor: next.cursor, error: null }
1518
+ })
1519
+ }
1520
+
1521
+ const openSubmitReviewModal = () => {
1522
+ if (!selectedPullRequest || selectedPullRequest.state !== "open") return
1523
+ setSubmitReviewModal({
1524
+ repository: selectedPullRequest.repository,
1525
+ number: selectedPullRequest.number,
1526
+ selectedIndex: 0,
1527
+ body: "",
1528
+ cursor: 0,
1529
+ running: false,
1530
+ error: null,
1531
+ })
1532
+ }
1533
+
1448
1534
  const openDiffCommentModal = () => {
1449
1535
  if (!selectedDiffCommentAnchor || !selectedPullRequest) return
1450
1536
  setCommentModal(initialCommentModalState)
@@ -1554,6 +1640,31 @@ export const App = () => {
1554
1640
  })
1555
1641
  }
1556
1642
 
1643
+ const confirmSubmitReview = () => {
1644
+ if (!submitReviewModal.repository || submitReviewModal.number === null || submitReviewModal.running) return
1645
+ const option = submitReviewOptions[submitReviewModal.selectedIndex]
1646
+ if (!option) return
1647
+ const repository = submitReviewModal.repository
1648
+ const number = submitReviewModal.number
1649
+ const body = submitReviewModal.body.trim()
1650
+ const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number) ?? null
1651
+ const nextReviewStatus = reviewStatusAfterSubmit[option.event]
1652
+
1653
+ setSubmitReviewModal((current) => ({ ...current, running: true, error: null }))
1654
+ void submitPullRequestReview({ repository, number, event: option.event, body })
1655
+ .then(() => {
1656
+ if (targetPullRequest && nextReviewStatus) {
1657
+ updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, reviewStatus: nextReviewStatus }))
1658
+ }
1659
+ closeActiveModal()
1660
+ flashNotice(`Submitted ${option.title.toLowerCase()} review for #${number}`)
1661
+ })
1662
+ .catch((error) => {
1663
+ setSubmitReviewModal((current) => ({ ...current, running: false, error: errorMessage(error) }))
1664
+ flashNotice(errorMessage(error))
1665
+ })
1666
+ }
1667
+
1557
1668
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1558
1669
  void openInBrowser(pullRequest)
1559
1670
  .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
@@ -1831,10 +1942,18 @@ export const App = () => {
1831
1942
  editComment((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1832
1943
  return true
1833
1944
  }
1945
+ if (submitReviewModalActive) {
1946
+ editSubmitReview((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1947
+ return true
1948
+ }
1834
1949
  if (labelModalActive) {
1835
1950
  setLabelModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1836
1951
  return true
1837
1952
  }
1953
+ if (changedFilesModalActive) {
1954
+ setChangedFilesModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1955
+ return true
1956
+ }
1838
1957
  if (filterMode) {
1839
1958
  setFilterDraft((current) => current + singleLineText(text))
1840
1959
  return true
@@ -1854,7 +1973,7 @@ export const App = () => {
1854
1973
  return () => {
1855
1974
  keyInput.off("paste", handlePaste)
1856
1975
  }
1857
- }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, labelModalActive, filterMode])
1976
+ }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, submitReviewModalActive, labelModalActive, changedFilesModalActive, filterMode])
1858
1977
 
1859
1978
  const appCommands: readonly AppCommand[] = buildAppCommands({
1860
1979
  pullRequestStatus,
@@ -1916,11 +2035,13 @@ export const App = () => {
1916
2035
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1917
2036
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1918
2037
  toggleDiffWhitespaceMode,
2038
+ openChangedFilesModal,
1919
2039
  jumpDiffFile,
1920
2040
  openSelectedDiffComment,
1921
2041
  toggleDiffCommentRange,
1922
2042
  moveDiffCommentThread,
1923
2043
  openDiffCommentModal,
2044
+ openSubmitReviewModal,
1924
2045
  togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1925
2046
  openLabelModal,
1926
2047
  openMergeModal,
@@ -1986,6 +2107,15 @@ export const App = () => {
1986
2107
  const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
1987
2108
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1988
2109
  })
2110
+ const moveChangedFileSelection = (delta: -1 | 1) => setChangedFilesModal((current) => {
2111
+ const max = Math.max(0, changedFileResults.length - 1)
2112
+ const selectedIndex = Math.max(0, Math.min(max, current.selectedIndex + delta))
2113
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2114
+ })
2115
+ const moveSubmitReviewActionSelection = (delta: -1 | 1) => setSubmitReviewModal((current) => {
2116
+ const max = Math.max(0, submitReviewOptions.length - 1)
2117
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)), error: null }
2118
+ })
1989
2119
  const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
1990
2120
  const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
1991
2121
  return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
@@ -2060,6 +2190,8 @@ export const App = () => {
2060
2190
  closeModalActive,
2061
2191
  mergeModalActive,
2062
2192
  commentThreadModalActive,
2193
+ changedFilesModalActive,
2194
+ submitReviewModalActive,
2063
2195
  labelModalActive,
2064
2196
  themeModalActive,
2065
2197
  openRepositoryModalActive,
@@ -2071,6 +2203,8 @@ export const App = () => {
2071
2203
  textInputActive: commentModalActive
2072
2204
  || commandPaletteActive
2073
2205
  || openRepositoryModalActive
2206
+ || changedFilesModalActive
2207
+ || submitReviewModalActive
2074
2208
  || labelModalActive
2075
2209
  || filterMode
2076
2210
  || (themeModalActive && themeModal.filterMode),
@@ -2090,6 +2224,32 @@ export const App = () => {
2090
2224
  openInlineComment: openDiffCommentModal,
2091
2225
  scrollBy: scrollCommentThread,
2092
2226
  },
2227
+ changedFilesModal: {
2228
+ hasResults: changedFileResults.length > 0,
2229
+ closeModal: closeActiveModal,
2230
+ selectFile: selectChangedFile,
2231
+ moveSelection: moveChangedFileSelection,
2232
+ },
2233
+ submitReviewModal: {
2234
+ closeModal: closeActiveModal,
2235
+ submit: confirmSubmitReview,
2236
+ insertNewline: () => editSubmitReview((state) => insertText(state, "\n")),
2237
+ moveActionSelection: moveSubmitReviewActionSelection,
2238
+ moveLeft: () => editSubmitReview(editorMoveLeft),
2239
+ moveRight: () => editSubmitReview(editorMoveRight),
2240
+ moveUp: () => editSubmitReview((state) => moveVertically(state, -1)),
2241
+ moveDown: () => editSubmitReview((state) => moveVertically(state, 1)),
2242
+ moveLineStart: () => editSubmitReview(moveLineStart),
2243
+ moveLineEnd: () => editSubmitReview(moveLineEnd),
2244
+ moveWordBackward: () => editSubmitReview(moveWordBackward),
2245
+ moveWordForward: () => editSubmitReview(moveWordForward),
2246
+ backspace: () => editSubmitReview(editorBackspace),
2247
+ deleteForward: () => editSubmitReview(editorDeleteForward),
2248
+ deleteWordBackward: () => editSubmitReview(deleteWordBackward),
2249
+ deleteWordForward: () => editSubmitReview(deleteWordForward),
2250
+ deleteToLineStart: () => editSubmitReview(deleteToLineStart),
2251
+ deleteToLineEnd: () => editSubmitReview(deleteToLineEnd),
2252
+ },
2093
2253
  labelModal: {
2094
2254
  closeModal: closeActiveModal,
2095
2255
  toggleSelected: toggleLabelAtIndex,
@@ -2151,7 +2311,6 @@ export const App = () => {
2151
2311
  else runCommandById("diff.close")
2152
2312
  },
2153
2313
  openSelectedComment: openSelectedDiffComment,
2154
- addComment: () => runCommandById("diff.add-comment"),
2155
2314
  toggleRange: () => runCommandById("diff.toggle-range"),
2156
2315
  toggleView: () => runCommandById("diff.toggle-view"),
2157
2316
  toggleWrap: () => runCommandById("diff.toggle-wrap"),
@@ -2162,6 +2321,8 @@ export const App = () => {
2162
2321
  moveAnchorToBoundary: moveDiffCommentToBoundary,
2163
2322
  alignAnchor: alignSelectedDiffCommentAnchor,
2164
2323
  selectSide: selectDiffCommentSide,
2324
+ openChangedFiles: () => runCommandById("diff.changed-files"),
2325
+ openSubmitReview: () => runCommandById("diff.submit-review"),
2165
2326
  nextFile: () => runCommandById("diff.next-file"),
2166
2327
  previousFile: () => runCommandById("diff.previous-file"),
2167
2328
  openInBrowser: () => runCommandById("pull.open-browser"),
@@ -2245,9 +2406,21 @@ export const App = () => {
2245
2406
  return
2246
2407
  }
2247
2408
 
2409
+ if (submitReviewModalActive) {
2410
+ const text = printableKeyText(key)
2411
+ if (text) editSubmitReview((state) => insertText(state, text))
2412
+ return
2413
+ }
2248
2414
 
2249
-
2250
-
2415
+ if (changedFilesModalActive) {
2416
+ if (isSingleLineInputKey(key)) {
2417
+ setChangedFilesModal((current) => {
2418
+ const query = editSingleLineInput(current.query, key) ?? current.query
2419
+ return query === current.query ? current : { ...current, query, selectedIndex: 0 }
2420
+ })
2421
+ }
2422
+ return
2423
+ }
2251
2424
  if (labelModalActive) {
2252
2425
  if (isSingleLineInputKey(key)) {
2253
2426
  setLabelModal((current) => ({
@@ -2284,6 +2457,16 @@ export const App = () => {
2284
2457
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2285
2458
  const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
2286
2459
  const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2460
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows({
2461
+ pullRequest: selectedPullRequest,
2462
+ paneWidth: rightPaneWidth,
2463
+ showChecks: true,
2464
+ contentWidth: rightContentWidth,
2465
+ conversationItems: selectedConversationItems,
2466
+ conversationStatus: selectedConversationStatus,
2467
+ bodyScrollTop: detailPreviewScrollTop,
2468
+ bodyViewportHeight: wideDetailBodyViewportHeight,
2469
+ })
2287
2470
 
2288
2471
  const prListProps = {
2289
2472
  groups: visibleGroups,
@@ -2305,6 +2488,11 @@ export const App = () => {
2305
2488
  const labelModalHeight = Math.min(20, terminalHeight - 4)
2306
2489
  const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2307
2490
  const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
2491
+ const longestDiffFileName = changedFilesModalActive ? readyDiffFiles.reduce((max, file) => Math.max(max, file.name.length), 0) : 0
2492
+ const changedFilesModalWidth = changedFilesModalActive ? Math.min(Math.max(46, longestDiffFileName + 16), 88, contentWidth - 4) : 46
2493
+ const changedFilesModalHeight = Math.min(22, terminalHeight - 4)
2494
+ const changedFilesModalLeft = centeredOffset(contentWidth, changedFilesModalWidth)
2495
+ const changedFilesModalTop = centeredOffset(terminalHeight, changedFilesModalHeight)
2308
2496
  const sizedModal = (minW: number, maxW: number, padX: number, maxH: number) => {
2309
2497
  const w = Math.min(maxW, Math.max(minW, contentWidth - padX))
2310
2498
  const h = Math.min(maxH, terminalHeight - 4)
@@ -2325,6 +2513,11 @@ export const App = () => {
2325
2513
  const commentThreadModalHeight = commentThreadLayout.height
2326
2514
  const commentThreadModalLeft = commentThreadLayout.left
2327
2515
  const commentThreadModalTop = commentThreadLayout.top
2516
+ const submitReviewLayout = sizedModal(54, 84, 8, 18)
2517
+ const submitReviewModalWidth = submitReviewLayout.width
2518
+ const submitReviewModalHeight = submitReviewLayout.height
2519
+ const submitReviewModalLeft = submitReviewLayout.left
2520
+ const submitReviewModalTop = submitReviewLayout.top
2328
2521
  const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2329
2522
  ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2330
2523
  : "No diff line selected"
@@ -2470,7 +2663,7 @@ export const App = () => {
2470
2663
  hasSelection={selectedPullRequest !== null}
2471
2664
  canCloseSelection={selectedPullRequest?.state === "open"}
2472
2665
  hasError={pullRequestStatus === "error"}
2473
- isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running}
2666
+ isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running || submitReviewModal.running}
2474
2667
  loadingIndicator={loadingIndicator}
2475
2668
  retryProgress={retryProgress}
2476
2669
  />
@@ -2518,6 +2711,27 @@ export const App = () => {
2518
2711
  offsetTop={commentThreadModalTop}
2519
2712
  />
2520
2713
  ) : null}
2714
+ {changedFilesModalActive ? (
2715
+ <ChangedFilesModal
2716
+ state={changedFilesModal}
2717
+ results={changedFileResults}
2718
+ totalCount={readyDiffFiles.length}
2719
+ modalWidth={changedFilesModalWidth}
2720
+ modalHeight={changedFilesModalHeight}
2721
+ offsetLeft={changedFilesModalLeft}
2722
+ offsetTop={changedFilesModalTop}
2723
+ />
2724
+ ) : null}
2725
+ {submitReviewModalActive ? (
2726
+ <SubmitReviewModal
2727
+ state={submitReviewModal}
2728
+ modalWidth={submitReviewModalWidth}
2729
+ modalHeight={submitReviewModalHeight}
2730
+ offsetLeft={submitReviewModalLeft}
2731
+ offsetTop={submitReviewModalTop}
2732
+ loadingIndicator={loadingIndicator}
2733
+ />
2734
+ ) : null}
2521
2735
  {mergeModalActive ? (
2522
2736
  <MergeModal
2523
2737
  state={mergeModal}
@@ -21,11 +21,13 @@ interface AppCommandActions {
21
21
  readonly toggleDiffRenderView: () => void
22
22
  readonly toggleDiffWrapMode: () => void
23
23
  readonly toggleDiffWhitespaceMode: () => void
24
+ readonly openChangedFilesModal: () => void
24
25
  readonly jumpDiffFile: (delta: 1 | -1) => void
25
26
  readonly openSelectedDiffComment: () => void
26
27
  readonly toggleDiffCommentRange: () => void
27
28
  readonly moveDiffCommentThread: (delta: 1 | -1) => void
28
29
  readonly openDiffCommentModal: () => void
30
+ readonly openSubmitReviewModal: () => void
29
31
  readonly togglePullRequestDraftStatus: () => void
30
32
  readonly openLabelModal: () => void
31
33
  readonly openMergeModal: () => void
@@ -99,6 +101,10 @@ export const buildAppCommands = ({
99
101
  const diffThreadReason = diffFullView && diffReady
100
102
  ? hasDiffCommentThreads ? null : "No diff comments loaded."
101
103
  : diffOpenReadyReason
104
+ const changedFilesReason = diffFullView && diffReady
105
+ ? readyDiffFileCount > 0 ? null : "No changed files loaded."
106
+ : diffOpenReadyReason
107
+ const submitReviewReason = diffFullView && diffReady ? noOpenPullRequestReason : diffOpenReadyReason
102
108
  const loadMoreDisabledReason = isLoadingMorePullRequests
103
109
  ? "Already loading more pull requests."
104
110
  : hasMorePullRequests ? null : "No more pull requests loaded by this view."
@@ -256,13 +262,23 @@ export const buildAppCommands = ({
256
262
  keywords: ["whitespace", "spacing", "ignore", "show"],
257
263
  run: actions.toggleDiffWhitespaceMode,
258
264
  }),
265
+ defineCommand({
266
+ id: "diff.changed-files",
267
+ title: "Open changed files navigator",
268
+ scope: "Diff",
269
+ subtitle: readyDiffFileCount > 0 ? `${readyDiffFileCount} changed files` : "No diff files loaded",
270
+ shortcut: "f",
271
+ disabledReason: changedFilesReason,
272
+ keywords: ["files", "navigator", "search"],
273
+ run: actions.openChangedFilesModal,
274
+ }),
259
275
  defineCommand({
260
276
  id: "diff.next-file",
261
277
  title: "Next diff file",
262
278
  scope: "Diff",
263
279
  subtitle: readyDiffFileCount > 0 ? `${diffFileIndex + 1}/${readyDiffFileCount}` : "No diff files loaded",
264
280
  shortcut: "]",
265
- disabledReason: diffFullView && readyDiffFileCount > 0 ? null : diffOpenReadyReason,
281
+ disabledReason: changedFilesReason,
266
282
  run: () => actions.jumpDiffFile(1),
267
283
  }),
268
284
  defineCommand({
@@ -271,7 +287,7 @@ export const buildAppCommands = ({
271
287
  scope: "Diff",
272
288
  subtitle: readyDiffFileCount > 0 ? `${diffFileIndex + 1}/${readyDiffFileCount}` : "No diff files loaded",
273
289
  shortcut: "[",
274
- disabledReason: diffFullView && readyDiffFileCount > 0 ? null : diffOpenReadyReason,
290
+ disabledReason: changedFilesReason,
275
291
  run: () => actions.jumpDiffFile(-1),
276
292
  }),
277
293
  defineCommand({
@@ -319,11 +335,20 @@ export const buildAppCommands = ({
319
335
  title: "Add comment on selected diff line",
320
336
  scope: "Diff",
321
337
  subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
322
- shortcut: "a",
323
338
  disabledReason: selectedDiffLineReason,
324
339
  keywords: ["review", "reply"],
325
340
  run: actions.openDiffCommentModal,
326
341
  }),
342
+ defineCommand({
343
+ id: "diff.submit-review",
344
+ title: "Submit pull request review",
345
+ scope: "Diff",
346
+ subtitle: selectedPullRequestLabel,
347
+ shortcut: "R",
348
+ disabledReason: submitReviewReason,
349
+ keywords: ["review", "approve", "request changes"],
350
+ run: actions.openSubmitReviewModal,
351
+ }),
327
352
  forSelected({
328
353
  id: "pull.toggle-draft",
329
354
  title: selectedPullRequest?.reviewStatus === "draft" ? "Mark ready for review" : "Mark as draft",
package/src/config.ts CHANGED
@@ -5,10 +5,6 @@ const positiveIntOr = (fallback: number) => (value: number) => Number.isFinite(v
5
5
  const pageSizeOr = (fallback: number) => (value: number) => Math.min(100, positiveIntOr(fallback)(value))
6
6
 
7
7
  const appConfig = Config.all({
8
- author: Config.string("GHUI_AUTHOR").pipe(
9
- Config.withDefault("@me"),
10
- Config.map((value) => value.trim() || "@me"),
11
- ),
12
8
  prFetchLimit: Config.int("GHUI_PR_FETCH_LIMIT").pipe(
13
9
  Config.withDefault(200),
14
10
  Config.map(positiveIntOr(200)),
@@ -17,10 +13,6 @@ const appConfig = Config.all({
17
13
  Config.withDefault(50),
18
14
  Config.map(pageSizeOr(50)),
19
15
  ),
20
- repository: Config.string("GHUI_REPO").pipe(
21
- Config.withDefault(""),
22
- Config.map((value) => value.trim() || null),
23
- ),
24
16
  })
25
17
 
26
18
  export const config = Effect.runSync(Effect.gen(function*() {