@kitlangton/ghui 0.2.1 → 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.1",
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
@@ -13,7 +13,7 @@ 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")
@@ -616,6 +627,7 @@ export const App = () => {
616
627
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
617
628
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
618
629
  const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
630
+ const submitPullRequestReview = useAtomSet(submitPullRequestReviewAtom, { mode: "promise" })
619
631
  const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
620
632
  const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
621
633
  const terminalWidth = width ?? 100
@@ -724,6 +736,10 @@ export const App = () => {
724
736
  : [],
725
737
  [selectedDiffState, diffWhitespaceMode],
726
738
  )
739
+ const changedFileResults = useMemo(
740
+ () => changedFilesModalActive ? filterChangedFiles(readyDiffFiles, changedFilesModal.query) : [],
741
+ [changedFilesModalActive, readyDiffFiles, changedFilesModal.query],
742
+ )
727
743
  const displayedDiffState = useMemo(
728
744
  () => selectedDiffState?._tag === "Ready"
729
745
  ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
@@ -1154,7 +1170,7 @@ export const App = () => {
1154
1170
  }, [diffFullView])
1155
1171
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1156
1172
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
1157
- 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"
1158
1174
  const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
1159
1175
 
1160
1176
  useEffect(() => {
@@ -1383,9 +1399,9 @@ export const App = () => {
1383
1399
  return () => { scroll.verticalScrollBar.off("change", sync) }
1384
1400
  }, [isWideLayout, detailFullView, diffFullView, syncDetailPreviewScrollState])
1385
1401
 
1386
- const jumpDiffFile = (delta: 1 | -1) => {
1402
+ const selectDiffFile = (index: number) => {
1387
1403
  if (readyDiffFiles.length === 0) return
1388
- const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1404
+ const nextIndex = safeDiffFileIndex(readyDiffFiles, index)
1389
1405
  setDiffFileIndex(nextIndex)
1390
1406
  setDiffCommentRangeStartIndex(null)
1391
1407
  const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
@@ -1395,6 +1411,26 @@ export const App = () => {
1395
1411
  scrollToDiffFile(nextIndex)
1396
1412
  }
1397
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
+
1398
1434
  const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1399
1435
  ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1400
1436
  : diffCommentAnchors
@@ -1474,6 +1510,27 @@ export const App = () => {
1474
1510
  })
1475
1511
  }
1476
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
+
1477
1534
  const openDiffCommentModal = () => {
1478
1535
  if (!selectedDiffCommentAnchor || !selectedPullRequest) return
1479
1536
  setCommentModal(initialCommentModalState)
@@ -1583,6 +1640,31 @@ export const App = () => {
1583
1640
  })
1584
1641
  }
1585
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
+
1586
1668
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1587
1669
  void openInBrowser(pullRequest)
1588
1670
  .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
@@ -1860,10 +1942,18 @@ export const App = () => {
1860
1942
  editComment((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1861
1943
  return true
1862
1944
  }
1945
+ if (submitReviewModalActive) {
1946
+ editSubmitReview((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1947
+ return true
1948
+ }
1863
1949
  if (labelModalActive) {
1864
1950
  setLabelModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1865
1951
  return true
1866
1952
  }
1953
+ if (changedFilesModalActive) {
1954
+ setChangedFilesModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1955
+ return true
1956
+ }
1867
1957
  if (filterMode) {
1868
1958
  setFilterDraft((current) => current + singleLineText(text))
1869
1959
  return true
@@ -1883,7 +1973,7 @@ export const App = () => {
1883
1973
  return () => {
1884
1974
  keyInput.off("paste", handlePaste)
1885
1975
  }
1886
- }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, labelModalActive, filterMode])
1976
+ }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, submitReviewModalActive, labelModalActive, changedFilesModalActive, filterMode])
1887
1977
 
1888
1978
  const appCommands: readonly AppCommand[] = buildAppCommands({
1889
1979
  pullRequestStatus,
@@ -1945,11 +2035,13 @@ export const App = () => {
1945
2035
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1946
2036
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1947
2037
  toggleDiffWhitespaceMode,
2038
+ openChangedFilesModal,
1948
2039
  jumpDiffFile,
1949
2040
  openSelectedDiffComment,
1950
2041
  toggleDiffCommentRange,
1951
2042
  moveDiffCommentThread,
1952
2043
  openDiffCommentModal,
2044
+ openSubmitReviewModal,
1953
2045
  togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1954
2046
  openLabelModal,
1955
2047
  openMergeModal,
@@ -2015,6 +2107,15 @@ export const App = () => {
2015
2107
  const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
2016
2108
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
2017
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
+ })
2018
2119
  const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
2019
2120
  const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
2020
2121
  return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
@@ -2089,6 +2190,8 @@ export const App = () => {
2089
2190
  closeModalActive,
2090
2191
  mergeModalActive,
2091
2192
  commentThreadModalActive,
2193
+ changedFilesModalActive,
2194
+ submitReviewModalActive,
2092
2195
  labelModalActive,
2093
2196
  themeModalActive,
2094
2197
  openRepositoryModalActive,
@@ -2100,6 +2203,8 @@ export const App = () => {
2100
2203
  textInputActive: commentModalActive
2101
2204
  || commandPaletteActive
2102
2205
  || openRepositoryModalActive
2206
+ || changedFilesModalActive
2207
+ || submitReviewModalActive
2103
2208
  || labelModalActive
2104
2209
  || filterMode
2105
2210
  || (themeModalActive && themeModal.filterMode),
@@ -2119,6 +2224,32 @@ export const App = () => {
2119
2224
  openInlineComment: openDiffCommentModal,
2120
2225
  scrollBy: scrollCommentThread,
2121
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
+ },
2122
2253
  labelModal: {
2123
2254
  closeModal: closeActiveModal,
2124
2255
  toggleSelected: toggleLabelAtIndex,
@@ -2180,7 +2311,6 @@ export const App = () => {
2180
2311
  else runCommandById("diff.close")
2181
2312
  },
2182
2313
  openSelectedComment: openSelectedDiffComment,
2183
- addComment: () => runCommandById("diff.add-comment"),
2184
2314
  toggleRange: () => runCommandById("diff.toggle-range"),
2185
2315
  toggleView: () => runCommandById("diff.toggle-view"),
2186
2316
  toggleWrap: () => runCommandById("diff.toggle-wrap"),
@@ -2191,6 +2321,8 @@ export const App = () => {
2191
2321
  moveAnchorToBoundary: moveDiffCommentToBoundary,
2192
2322
  alignAnchor: alignSelectedDiffCommentAnchor,
2193
2323
  selectSide: selectDiffCommentSide,
2324
+ openChangedFiles: () => runCommandById("diff.changed-files"),
2325
+ openSubmitReview: () => runCommandById("diff.submit-review"),
2194
2326
  nextFile: () => runCommandById("diff.next-file"),
2195
2327
  previousFile: () => runCommandById("diff.previous-file"),
2196
2328
  openInBrowser: () => runCommandById("pull.open-browser"),
@@ -2274,9 +2406,21 @@ export const App = () => {
2274
2406
  return
2275
2407
  }
2276
2408
 
2409
+ if (submitReviewModalActive) {
2410
+ const text = printableKeyText(key)
2411
+ if (text) editSubmitReview((state) => insertText(state, text))
2412
+ return
2413
+ }
2277
2414
 
2278
-
2279
-
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
+ }
2280
2424
  if (labelModalActive) {
2281
2425
  if (isSingleLineInputKey(key)) {
2282
2426
  setLabelModal((current) => ({
@@ -2344,6 +2488,11 @@ export const App = () => {
2344
2488
  const labelModalHeight = Math.min(20, terminalHeight - 4)
2345
2489
  const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2346
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)
2347
2496
  const sizedModal = (minW: number, maxW: number, padX: number, maxH: number) => {
2348
2497
  const w = Math.min(maxW, Math.max(minW, contentWidth - padX))
2349
2498
  const h = Math.min(maxH, terminalHeight - 4)
@@ -2364,6 +2513,11 @@ export const App = () => {
2364
2513
  const commentThreadModalHeight = commentThreadLayout.height
2365
2514
  const commentThreadModalLeft = commentThreadLayout.left
2366
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
2367
2521
  const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2368
2522
  ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2369
2523
  : "No diff line selected"
@@ -2509,7 +2663,7 @@ export const App = () => {
2509
2663
  hasSelection={selectedPullRequest !== null}
2510
2664
  canCloseSelection={selectedPullRequest?.state === "open"}
2511
2665
  hasError={pullRequestStatus === "error"}
2512
- isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running}
2666
+ isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running || submitReviewModal.running}
2513
2667
  loadingIndicator={loadingIndicator}
2514
2668
  retryProgress={retryProgress}
2515
2669
  />
@@ -2557,6 +2711,27 @@ export const App = () => {
2557
2711
  offsetTop={commentThreadModalTop}
2558
2712
  />
2559
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}
2560
2735
  {mergeModalActive ? (
2561
2736
  <MergeModal
2562
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*() {
package/src/domain.ts CHANGED
@@ -16,10 +16,10 @@ export const pullRequestQueueLabels = {
16
16
  mentioned: "mentioned",
17
17
  } as const satisfies Record<PullRequestQueueMode, string>
18
18
 
19
- export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
19
+ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, repository: string | null) => {
20
20
  const qualifiers = {
21
- repository: repository ? `repo:${repository}` : `author:${author}`,
22
- authored: `author:${author}`,
21
+ repository: repository ? `repo:${repository}` : "author:@me",
22
+ authored: "author:@me",
23
23
  review: "review-requested:@me",
24
24
  assigned: "assignee:@me",
25
25
  mentioned: "mentions:@me",
@@ -44,6 +44,9 @@ export type DiffCommentSide = Schema.Schema.Type<typeof DiffCommentSide>
44
44
 
45
45
  export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
46
46
 
47
+ export const pullRequestReviewEvents = ["COMMENT", "APPROVE", "REQUEST_CHANGES"] as const
48
+ export type PullRequestReviewEvent = (typeof pullRequestReviewEvents)[number]
49
+
47
50
  export interface CheckItem {
48
51
  readonly name: string
49
52
  readonly status: CheckRunStatus
@@ -67,6 +70,13 @@ export interface CreatePullRequestCommentInput {
67
70
  readonly body: string
68
71
  }
69
72
 
73
+ export interface SubmitPullRequestReviewInput {
74
+ readonly repository: string
75
+ readonly number: number
76
+ readonly event: PullRequestReviewEvent
77
+ readonly body: string
78
+ }
79
+
70
80
  export interface PullRequestReviewComment {
71
81
  readonly id: string
72
82
  readonly path: string
package/src/keymap/all.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { context } from "@ghui/keymap"
2
+ import { changedFilesModalKeymap, type ChangedFilesModalCtx } from "./changedFilesModal.ts"
2
3
  import { closeModalKeymap, type CloseModalCtx } from "./closeModal.ts"
3
4
  import { commandPaletteKeymap, type CommandPaletteCtx } from "./commandPalette.ts"
4
5
  import { commentModalKeymap, type CommentModalCtx } from "./commentModal.ts"
@@ -10,6 +11,7 @@ import { labelModalKeymap, type LabelModalCtx } from "./labelModal.ts"
10
11
  import { listNavKeymap, type ListNavCtx } from "./listNav.ts"
11
12
  import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts"
12
13
  import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts"
14
+ import { submitReviewModalKeymap, type SubmitReviewModalCtx } from "./submitReviewModal.ts"
13
15
  import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts"
14
16
 
15
17
  export interface AppCtx {
@@ -17,6 +19,8 @@ export interface AppCtx {
17
19
  readonly closeModalActive: boolean
18
20
  readonly mergeModalActive: boolean
19
21
  readonly commentThreadModalActive: boolean
22
+ readonly changedFilesModalActive: boolean
23
+ readonly submitReviewModalActive: boolean
20
24
  readonly labelModalActive: boolean
21
25
  readonly themeModalActive: boolean
22
26
  readonly openRepositoryModalActive: boolean
@@ -34,6 +38,8 @@ export interface AppCtx {
34
38
  readonly closeModal: CloseModalCtx
35
39
  readonly mergeModal: MergeModalCtx
36
40
  readonly commentThreadModal: CommentThreadModalCtx
41
+ readonly changedFilesModal: ChangedFilesModalCtx
42
+ readonly submitReviewModal: SubmitReviewModalCtx
37
43
  readonly labelModal: LabelModalCtx
38
44
  readonly themeModal: ThemeModalCtx
39
45
  readonly openRepositoryModal: OpenRepositoryModalCtx
@@ -51,15 +57,20 @@ export interface AppCtx {
51
57
 
52
58
  const App = context<AppCtx>()
53
59
 
60
+ const modalActive = (a: AppCtx): boolean =>
61
+ a.closeModalActive
62
+ || a.mergeModalActive
63
+ || a.commentThreadModalActive
64
+ || a.changedFilesModalActive
65
+ || a.submitReviewModalActive
66
+ || a.labelModalActive
67
+ || a.themeModalActive
68
+ || a.openRepositoryModalActive
69
+ || a.commentModalActive
70
+ || a.commandPaletteActive
71
+
54
72
  const inListMode = (a: AppCtx): boolean =>
55
- !a.closeModalActive
56
- && !a.mergeModalActive
57
- && !a.commentThreadModalActive
58
- && !a.labelModalActive
59
- && !a.themeModalActive
60
- && !a.openRepositoryModalActive
61
- && !a.commentModalActive
62
- && !a.commandPaletteActive
73
+ !modalActive(a)
63
74
  && !a.filterMode
64
75
  && !a.diffFullView
65
76
  && !a.detailFullView
@@ -87,6 +98,8 @@ export const appKeymap = App(
87
98
  closeModalKeymap.scope((a) => a.closeModalActive && a.closeModal),
88
99
  mergeModalKeymap.scope((a) => a.mergeModalActive && a.mergeModal),
89
100
  commentThreadModalKeymap.scope((a) => a.commentThreadModalActive && a.commentThreadModal),
101
+ changedFilesModalKeymap.scope((a) => a.changedFilesModalActive && a.changedFilesModal),
102
+ submitReviewModalKeymap.scope((a) => a.submitReviewModalActive && a.submitReviewModal),
90
103
  labelModalKeymap.scope((a) => a.labelModalActive && a.labelModal),
91
104
  themeModalKeymap.scope((a) => a.themeModalActive && a.themeModal),
92
105
  openRepositoryModalKeymap.scope((a) => a.openRepositoryModalActive && a.openRepositoryModal),
@@ -95,8 +108,8 @@ export const appKeymap = App(
95
108
  filterModeKeymap.scope((a) => a.filterMode && a.filterModeCtx),
96
109
 
97
110
  // Full-view layers (only when no modal is on top)
98
- diffViewKeymap.scope((a) => a.diffFullView && !a.commandPaletteActive && a.diff),
99
- detailViewKeymap.scope((a) => a.detailFullView && !a.commandPaletteActive && a.detail),
111
+ diffViewKeymap.scope((a) => a.diffFullView && !modalActive(a) && a.diff),
112
+ detailViewKeymap.scope((a) => a.detailFullView && !modalActive(a) && a.detail),
100
113
 
101
114
  // PR list nav
102
115
  listNavKeymap.scope((a) => inListMode(a) && a.listNav),