@kitlangton/ghui 0.3.0 → 0.3.3

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.
Files changed (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
package/src/App.tsx DELETED
@@ -1,2779 +0,0 @@
1
- import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
- import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useKeymap } from "@ghui/keymap/react"
4
- import { appKeymap, type AppCtx } from "./keymap/all.js"
5
- import { useOpenTuiSubscribe } from "./keyboard/opentuiAdapter.js"
6
- import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
7
- import { Cause, Effect, Layer, Schedule } from "effect"
8
- import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
9
- import * as Atom from "effect/unstable/reactivity/Atom"
10
- import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
11
- import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
12
- import { buildAppCommands } from "./appCommands.js"
13
- import type { AppCommand } from "./commands.js"
14
- import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
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, type SubmitPullRequestReviewInput } from "./domain.js"
17
- import { formatShortDate, formatTimestamp } from "./date.js"
18
- import { errorMessage } from "./errors.js"
19
- import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
20
- import { Observability } from "./observability.js"
21
- import { mergeCachedDetails } from "./pullRequestCache.js"
22
- import { activePullRequestViews, initialPullRequestView, nextView, parseRepositoryInput, type PullRequestView, viewCacheKey, viewEquals, viewLabel, viewMode, viewRepository } from "./pullRequestViews.js"
23
- import { BrowserOpener } from "./services/BrowserOpener.js"
24
- import { Clipboard } from "./services/Clipboard.js"
25
- import { CommandRunner } from "./services/CommandRunner.js"
26
- import { GitHubService } from "./services/GitHubService.js"
27
- import { loadStoredDiffWhitespaceMode, loadStoredThemeId, saveStoredDiffWhitespaceMode, saveStoredThemeId } from "./themeStore.js"
28
- import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
29
- 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"
30
- import { buildStackedDiffFiles, diffAnchorOnSide, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, minimizeWhitespaceDiffFiles, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileIndexAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type StackedDiffCommentAnchor, verticalDiffAnchor } from "./ui/diff.js"
31
- import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailConversationStatus, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
32
- import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
33
- import { LoadingLogoPane } from "./ui/LoadingLogo.js"
34
- import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
35
- import { CommandPalette } from "./ui/CommandPalette.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
- import { groupBy, reviewLabel } from "./ui/pullRequests.js"
38
- import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
39
- import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
40
- import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
41
- import { SPINNER_FRAMES } from "./ui/spinner.js"
42
-
43
- const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
44
- if (value === undefined) return fallback
45
- const parsed = Number.parseInt(value, 10)
46
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
47
- }
48
-
49
- const mockPrCount = parseOptionalPositiveInt(process.env.GHUI_MOCK_PR_COUNT, null)
50
- const pullRequestPageSize = Math.min(100, parseOptionalPositiveInt(process.env.GHUI_PR_PAGE_SIZE, config.prPageSize) ?? config.prPageSize)
51
- const githubServiceLayer = mockPrCount !== null
52
- ? (await import("./services/MockGitHubService.js")).MockGitHubService.layer({ prCount: mockPrCount, repoCount: parseOptionalPositiveInt(process.env.GHUI_MOCK_REPO_COUNT, 4) ?? 4 })
53
- : GitHubService.layerNoDeps
54
-
55
- const githubRuntime = Atom.runtime(
56
- Layer.mergeAll(githubServiceLayer, Clipboard.layerNoDeps, BrowserOpener.layerNoDeps).pipe(
57
- Layer.provide(CommandRunner.layer),
58
- Layer.provideMerge(Observability.layer),
59
- ),
60
- )
61
- const [initialThemeId, initialDiffWhitespaceMode] = await Promise.all([
62
- Effect.runPromise(loadStoredThemeId),
63
- Effect.runPromise(loadStoredDiffWhitespaceMode),
64
- ])
65
-
66
- interface PullRequestLoad {
67
- readonly view: PullRequestView
68
- readonly data: readonly PullRequestItem[]
69
- readonly fetchedAt: Date | null
70
- readonly endCursor: string | null
71
- readonly hasNextPage: boolean
72
- }
73
-
74
- interface DetailPlaceholderInput {
75
- readonly status: LoadStatus
76
- readonly retryProgress: RetryProgress
77
- readonly loadingIndicator: string
78
- readonly visibleCount: number
79
- readonly filterText: string
80
- }
81
-
82
- type DiffLineColorConfig = {
83
- readonly gutter: string
84
- readonly content: string
85
- }
86
-
87
- type DiffSideRenderable = {
88
- readonly setLineColor: (line: number, color: DiffLineColorConfig) => void
89
- }
90
-
91
- type DiffRenderableRuntimeSides = {
92
- readonly leftSide?: DiffSideRenderable
93
- readonly rightSide?: DiffSideRenderable
94
- }
95
-
96
- interface AppliedDiffLineColor {
97
- readonly anchor: StackedDiffCommentAnchor
98
- readonly view: DiffView
99
- }
100
-
101
- interface AppliedDiffLineColorState {
102
- readonly contextKey: string | null
103
- readonly entries: readonly AppliedDiffLineColor[]
104
- }
105
-
106
- interface DiffCommentRangeSelection {
107
- readonly start: StackedDiffCommentAnchor
108
- readonly end: StackedDiffCommentAnchor
109
- }
110
-
111
- interface DetailHydration {
112
- readonly token: symbol
113
- notifyError: boolean
114
- }
115
-
116
- const PR_FETCH_RETRIES = 6
117
- const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
118
- const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
119
- const AUTO_REFRESH_JITTER_MS = 10_000
120
- const DIFF_STICKY_HEADER_LINES = 2
121
- const MAX_REPOSITORY_CACHE_ENTRIES = 8
122
- const LOAD_MORE_SELECTION_THRESHOLD = 8
123
- const LOAD_MORE_SCROLL_THRESHOLD = 3
124
- const DETAIL_PREFETCH_BEHIND = 1
125
- const DETAIL_PREFETCH_AHEAD = 3
126
- const DETAIL_PREFETCH_CONCURRENCY = 3
127
- const DETAIL_PREFETCH_DELAY_MS = 120
128
- const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
129
- const seen = new Set(existing.map((pullRequest) => pullRequest.url))
130
- const mergedIncoming = mergeCachedDetails(incoming, existing)
131
- return [...existing, ...mergedIncoming.filter((pullRequest) => !seen.has(pullRequest.url))]
132
- }
133
-
134
- const retryProgressAtom = Atom.make<RetryProgress>(initialRetryProgress).pipe(Atom.keepAlive)
135
- const activeViewAtom = Atom.make<PullRequestView>(initialPullRequestView()).pipe(Atom.keepAlive)
136
- const queueLoadCacheAtom = Atom.make<Partial<Record<string, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
137
- const queueSelectionAtom = Atom.make<Partial<Record<string, number>>>({}).pipe(Atom.keepAlive)
138
- const trimQueueLoadCache = (cache: Partial<Record<string, PullRequestLoad>>) => {
139
- const repositoryKeys = Object.keys(cache).filter((key) => key.startsWith("repository:"))
140
- if (repositoryKeys.length <= MAX_REPOSITORY_CACHE_ENTRIES) return cache
141
- const remove = new Set(repositoryKeys.slice(0, repositoryKeys.length - MAX_REPOSITORY_CACHE_ENTRIES))
142
- return Object.fromEntries(Object.entries(cache).filter(([key]) => !remove.has(key))) as Partial<Record<string, PullRequestLoad>>
143
- }
144
- const pullRequestsAtom = githubRuntime.atom(
145
- GitHubService.use((github) =>
146
- Effect.gen(function*() {
147
- const view = yield* Atom.get(activeViewAtom)
148
- const queueMode = viewMode(view)
149
- const repository = viewRepository(view)
150
- const cacheKey = viewCacheKey(view)
151
- yield* Atom.set(retryProgressAtom, initialRetryProgress)
152
- const page = yield* github.listOpenPullRequestPage({
153
- mode: queueMode,
154
- repository,
155
- cursor: null,
156
- pageSize: Math.min(pullRequestPageSize, config.prFetchLimit),
157
- }).pipe(
158
- Effect.tapError(() =>
159
- Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
160
- attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 1, PR_FETCH_RETRIES),
161
- max: PR_FETCH_RETRIES,
162
- }))
163
- ),
164
- Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
165
- Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)),
166
- )
167
-
168
- yield* Atom.set(retryProgressAtom, initialRetryProgress)
169
- const cache = yield* Atom.get(queueLoadCacheAtom)
170
- const existingLoad = cache[cacheKey]
171
- const data = mergeCachedDetails(page.items, existingLoad?.data)
172
- const load = {
173
- view,
174
- data,
175
- fetchedAt: new Date(),
176
- endCursor: page.endCursor,
177
- hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
178
- } satisfies PullRequestLoad
179
- const nextCache = { ...cache }
180
- delete nextCache[cacheKey]
181
- nextCache[cacheKey] = load
182
- yield* Atom.set(queueLoadCacheAtom, trimQueueLoadCache(nextCache))
183
- return load
184
- })
185
- ),
186
- ).pipe(Atom.keepAlive)
187
- const selectedIndexAtom = Atom.make(0)
188
- const noticeAtom = Atom.make<string | null>(null)
189
- const filterQueryAtom = Atom.make("")
190
- const filterDraftAtom = Atom.make("")
191
- const filterModeAtom = Atom.make(false)
192
- const detailFullViewAtom = Atom.make(false)
193
- const detailScrollOffsetAtom = Atom.make(0)
194
- const diffFullViewAtom = Atom.make(false)
195
- const diffFileIndexAtom = Atom.make(0)
196
- const diffScrollTopAtom = Atom.make(0)
197
- const diffRenderViewAtom = Atom.make<DiffView>("split")
198
- const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
199
- const diffWhitespaceModeAtom = Atom.make<DiffWhitespaceMode>(initialDiffWhitespaceMode)
200
- const diffCommentAnchorIndexAtom = Atom.make(0)
201
- const diffPreferredSideAtom = Atom.make<DiffCommentSide | null>(null)
202
- const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
203
- const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
204
- const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
205
- const pullRequestConversationAtom = Atom.make<Record<string, readonly PullRequestConversationItem[]>>({}).pipe(Atom.keepAlive)
206
- const pullRequestConversationLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
207
- const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
208
-
209
- const activeModalAtom = Atom.make<Modal>(initialModal)
210
- const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
211
- const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
212
- const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
213
- const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
214
- const usernameAtom = githubRuntime.atom(
215
- GitHubService.use((github) => github.getAuthenticatedUser()),
216
- ).pipe(Atom.keepAlive)
217
-
218
- const pullRequestLoadAtom = Atom.make((get) => {
219
- const view = get(activeViewAtom)
220
- const cacheKey = viewCacheKey(view)
221
- const cache = get(queueLoadCacheAtom)
222
- const result = get(pullRequestsAtom)
223
- const resolved = AsyncResult.getOrElse(result, () => null)
224
- return cache[cacheKey] ?? (resolved && viewCacheKey(resolved.view) === cacheKey ? resolved : null)
225
- })
226
-
227
- const isLoadingQueueModeAtom = Atom.make((get) => {
228
- const cacheKey = viewCacheKey(get(activeViewAtom))
229
- const resolved = AsyncResult.getOrElse(get(pullRequestsAtom), () => null)
230
- return resolved !== null && viewCacheKey(resolved.view) !== cacheKey
231
- })
232
-
233
- const pullRequestStatusAtom = Atom.make((get): LoadStatus => {
234
- const result = get(pullRequestsAtom)
235
- const load = get(pullRequestLoadAtom)
236
- const isLoadingQueue = get(isLoadingQueueModeAtom)
237
- if ((result.waiting || isLoadingQueue) && load === null) return "loading"
238
- return AsyncResult.isFailure(result) ? "error" : "ready"
239
- })
240
-
241
- const displayedPullRequestsAtom = Atom.make((get) => {
242
- const load = get(pullRequestLoadAtom)
243
- const overrides = get(pullRequestOverridesAtom)
244
- const recentlyCompleted = get(recentlyCompletedPullRequestsAtom)
245
- const source = load?.data ?? []
246
- const seenUrls = new Set<string>()
247
- const open = source.map((pullRequest) => {
248
- seenUrls.add(pullRequest.url)
249
- return recentlyCompleted[pullRequest.url] ?? overrides[pullRequest.url] ?? pullRequest
250
- })
251
- return [
252
- ...open,
253
- ...Object.values(recentlyCompleted).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
254
- ]
255
- })
256
-
257
- const effectiveFilterQueryAtom = Atom.make((get) =>
258
- (get(filterModeAtom) ? get(filterDraftAtom) : get(filterQueryAtom)).trim().toLowerCase(),
259
- )
260
-
261
- const filteredPullRequestsAtom = Atom.make((get) => {
262
- const pullRequests = get(displayedPullRequestsAtom)
263
- const query = get(effectiveFilterQueryAtom)
264
- if (query.length === 0) return pullRequests
265
- return pullRequests.flatMap((pullRequest) => {
266
- const score = pullRequestFilterScore(pullRequest, query)
267
- return score === null ? [] : [{ pullRequest, score }]
268
- }).sort((left, right) =>
269
- left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
270
- ).map(({ pullRequest }) => pullRequest)
271
- })
272
-
273
- const visibleRepoOrderAtom = Atom.make((get) => {
274
- const query = get(effectiveFilterQueryAtom)
275
- if (query.length === 0) return [] as readonly string[]
276
- return [...new Set(get(filteredPullRequestsAtom).map((pullRequest) => pullRequest.repository))]
277
- })
278
-
279
- const visibleGroupsAtom = Atom.make((get) =>
280
- groupBy(get(filteredPullRequestsAtom), (pullRequest) => pullRequest.repository, get(visibleRepoOrderAtom)),
281
- )
282
-
283
- const visiblePullRequestsAtom = Atom.make((get) => get(visibleGroupsAtom).flatMap(([, pullRequests]) => pullRequests))
284
-
285
- const groupStartsAtom = Atom.make((get) => {
286
- const groups = get(visibleGroupsAtom)
287
- const starts: number[] = []
288
- for (let index = 0; index < groups.length; index++) {
289
- if (index === 0) starts.push(0)
290
- else starts.push(starts[index - 1]! + groups[index - 1]![1].length)
291
- }
292
- return starts
293
- })
294
-
295
- const selectedPullRequestAtom = Atom.make((get) => {
296
- const pullRequests = get(visiblePullRequestsAtom)
297
- const index = get(selectedIndexAtom)
298
- return pullRequests[index] ?? null
299
- })
300
-
301
- const selectedDiffKeyAtom = Atom.make((get) => {
302
- const pullRequest = get(selectedPullRequestAtom)
303
- return pullRequest ? pullRequestDiffKey(pullRequest) : null
304
- })
305
-
306
- const selectedDiffStateAtom = Atom.make((get) => {
307
- const key = get(selectedDiffKeyAtom)
308
- if (!key) return undefined
309
- return get(pullRequestDiffCacheAtom)[key]
310
- })
311
-
312
- const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
313
- GitHubService.use((github) => github.listRepoLabels(repository))
314
- )
315
- const listOpenPullRequestPageAtom = githubRuntime.fn<ListPullRequestPageInput>()((input) =>
316
- GitHubService.use((github) => github.listOpenPullRequestPage(input))
317
- )
318
- const pullRequestDetailsAtom = Atom.family((key: string) => {
319
- const { repository, number } = parsePullRequestDetailAtomKey(key)
320
- return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDetails(repository, number)))
321
- })
322
- const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
323
- GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
324
- )
325
- const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
326
- GitHubService.use((github) => github.removePullRequestLabel(input.repository, input.number, input.label))
327
- )
328
- const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
329
- GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
330
- )
331
- const pullRequestDiffAtom = Atom.family((key: string) => {
332
- const { repository, number } = parsePullRequestDiffAtomKey(key)
333
- return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDiff(repository, number)))
334
- })
335
- const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
336
- GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
337
- )
338
- const listPullRequestConversationAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
339
- GitHubService.use((github) => github.listPullRequestConversation(input.repository, input.number))
340
- )
341
- const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
342
- GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
343
- )
344
- const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
345
- GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
346
- )
347
- const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
348
- GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
349
- )
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)))
352
- const copyToClipboardAtom = githubRuntime.fn<string>()((text) => Clipboard.use((clipboard) => clipboard.copy(text)))
353
- const openInBrowserAtom = githubRuntime.fn<PullRequestItem>()((pullRequest) => BrowserOpener.use((browser) => browser.openPullRequest(pullRequest)))
354
-
355
- const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
356
-
357
-
358
- const pasteText = (event: PasteEvent) => new TextDecoder().decode(event.bytes)
359
-
360
- const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
361
- const normalized = query.trim().toLowerCase()
362
- if (normalized.length === 0) return 0
363
- const fields = [
364
- pullRequest.title.toLowerCase(),
365
- pullRequest.repository.toLowerCase(),
366
- String(pullRequest.number),
367
- ]
368
- const scores = fields.flatMap((field, index) => {
369
- const matchIndex = field.indexOf(normalized)
370
- return matchIndex >= 0 ? [index * 1000 + matchIndex] : []
371
- })
372
- return scores.length > 0 ? Math.min(...scores) : null
373
- }
374
-
375
- const pullRequestMetadataText = (pullRequest: PullRequestItem) => {
376
- const lines = [
377
- pullRequest.title,
378
- `${pullRequest.repository} #${pullRequest.number}`,
379
- pullRequest.url,
380
- ]
381
- const review = reviewLabel(pullRequest)
382
- if (review) lines.push(`review: ${review}`)
383
- if (pullRequest.checkSummary) lines.push(pullRequest.checkSummary)
384
- return lines.join("\n")
385
- }
386
-
387
- const pullRequestDetailKey = (pullRequest: PullRequestItem) => `${pullRequest.url}:${pullRequest.headRefOid}`
388
- const pullRequestRevisionAtomKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}\u0000${pullRequest.number}\u0000${pullRequest.headRefOid}`
389
- const parsePullRequestRevisionAtomKey = (key: string, label: string) => {
390
- const [repository, number] = key.split("\u0000")
391
- if (!repository || !number) throw new Error(`Invalid pull request ${label} key: ${key}`)
392
- return { repository, number: Number.parseInt(number, 10) }
393
- }
394
- const pullRequestDetailAtomKey = pullRequestRevisionAtomKey
395
- const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
396
- const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
397
- const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
398
-
399
-
400
-
401
- const diffCommentThreadMapKey = (diffKey: string, location: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
402
- `${diffKey}:${diffCommentLocationKey(location)}`
403
-
404
- const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
405
- diffCommentThreadMapKey(pullRequestDiffKey(pullRequest), comment)
406
-
407
- const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
408
- const threads: Record<string, PullRequestReviewComment[]> = {}
409
- for (const comment of comments) {
410
- const key = diffCommentThreadKey(pullRequest, comment)
411
- const thread = threads[key]
412
- if (thread) thread.push(comment)
413
- else threads[key] = [comment]
414
- }
415
- return threads
416
- }
417
-
418
- const isLocalDiffComment = (comment: PullRequestReviewComment) => comment.id.startsWith("local:")
419
-
420
- const reviewStatusAfterSubmit = {
421
- COMMENT: null,
422
- APPROVE: "approved",
423
- REQUEST_CHANGES: "changes",
424
- } satisfies Record<SubmitPullRequestReviewInput["event"], PullRequestItem["reviewStatus"] | null>
425
-
426
- const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig => {
427
- if (anchor.kind === "addition") {
428
- return { gutter: colors.diff.addedLineNumberBg, content: colors.diff.addedBg }
429
- }
430
- if (anchor.kind === "deletion") {
431
- return { gutter: colors.diff.removedLineNumberBg, content: colors.diff.removedBg }
432
- }
433
- return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
434
- }
435
-
436
- const selectedDiffCommentAccentByKind = {
437
- addition: () => colors.status.passing,
438
- deletion: () => colors.status.failing,
439
- context: () => colors.muted,
440
- } satisfies Record<DiffCommentKind, () => string>
441
-
442
- const selectedDiffCommentAccent = (kind: DiffCommentKind) => selectedDiffCommentAccentByKind[kind]()
443
-
444
- const mixDiffLineContentColor = (base: string, accent: string, amount: number) =>
445
- mixHex(base === "transparent" ? colors.background : base, accent, amount)
446
-
447
- const diffCommentLineColor = (anchor: DiffCommentAnchor, kind: "selected" | "range" | "thread"): DiffLineColorConfig => {
448
- const original = originalDiffLineColor(anchor)
449
- const accent = kind === "thread" ? colors.status.pending : selectedDiffCommentAccent(anchor.kind)
450
- if (kind === "thread") return { ...original, gutter: mixHex(original.gutter, accent, 0.45) }
451
- return {
452
- gutter: mixHex(original.gutter, accent, kind === "selected" ? 0.68 : 0.42),
453
- content: mixDiffLineContentColor(original.content, accent, kind === "selected" ? 0.2 : 0.1),
454
- }
455
- }
456
-
457
- const sameDiffCommentTarget = (left: DiffCommentAnchor, right: DiffCommentAnchor) =>
458
- left.path === right.path && left.side === right.side
459
-
460
- const diffCommentRangeSelection = (start: StackedDiffCommentAnchor | null, end: StackedDiffCommentAnchor | null): DiffCommentRangeSelection | null => {
461
- if (!start || !end || !sameDiffCommentTarget(start, end)) return null
462
- return start.line <= end.line ? { start, end } : { start: end, end: start }
463
- }
464
-
465
- const diffCommentRangeContains = (range: DiffCommentRangeSelection, anchor: StackedDiffCommentAnchor) =>
466
- sameDiffCommentTarget(range.start, anchor) && anchor.line >= range.start.line && anchor.line <= range.end.line
467
-
468
- const diffCommentRangeLabel = (range: DiffCommentRangeSelection) =>
469
- range.start.line === range.end.line
470
- ? diffCommentAnchorLabel(range.end)
471
- : `${diffCommentSideLabel(range.end)} ${diffCommentLineLabel(range.start)}-${diffCommentLineLabel(range.end)}`
472
-
473
- const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
474
- const withSides = diff as unknown as DiffRenderableRuntimeSides
475
- if (view === "split") {
476
- const target = anchor.side === "LEFT" ? withSides.leftSide : withSides.rightSide
477
- return target ? [target] : []
478
- }
479
- return withSides.leftSide ? [withSides.leftSide] : []
480
- }
481
-
482
- const setDiffCommentLineColor = (diff: DiffRenderable, entry: AppliedDiffLineColor, color: DiffLineColorConfig) => {
483
- for (const target of diffSideTargets(diff, entry.anchor, entry.view)) {
484
- target.setLineColor(entry.anchor.localRenderLine, color)
485
- }
486
- }
487
-
488
- const getDetailPlaceholderContent = ({
489
- status,
490
- retryProgress,
491
- loadingIndicator,
492
- visibleCount,
493
- filterText,
494
- }: DetailPlaceholderInput): DetailPlaceholderContent => {
495
- if (status === "loading") {
496
- return {
497
- title: `${loadingIndicator} Loading pull requests`,
498
- hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
499
- }
500
- }
501
-
502
- if (status === "error") {
503
- return {
504
- title: "Could not load pull requests",
505
- hint: "Press r to retry",
506
- }
507
- }
508
-
509
- if (visibleCount === 0 && filterText.length > 0) {
510
- return {
511
- title: "No matching pull requests",
512
- hint: "Press esc to clear the filter",
513
- }
514
- }
515
-
516
- if (visibleCount === 0) {
517
- return {
518
- title: "No open pull requests",
519
- hint: "Press r to refresh",
520
- }
521
- }
522
-
523
- return {
524
- title: "Select a pull request",
525
- hint: "Use up/down to move",
526
- }
527
- }
528
-
529
- export const App = () => {
530
- const renderer = useRenderer()
531
- const { width, height } = useTerminalDimensions()
532
- const registry = useContext(RegistryContext)
533
- const pullRequestResult = useAtomValue(pullRequestsAtom)
534
- const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
535
- const [activeView, setActiveView] = useAtom(activeViewAtom)
536
- const setQueueLoadCache = useAtomSet(queueLoadCacheAtom)
537
- const setQueueSelection = useAtomSet(queueSelectionAtom)
538
- const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
539
- const [notice, setNotice] = useAtom(noticeAtom)
540
- const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
541
- const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
542
- const [filterMode, setFilterMode] = useAtom(filterModeAtom)
543
- const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
544
- const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
545
- const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
546
- const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
547
- const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
548
- const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
549
- const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
550
- const [diffWhitespaceMode, setDiffWhitespaceMode] = useAtom(diffWhitespaceModeAtom)
551
- const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
552
- const [diffPreferredSide, setDiffPreferredSide] = useAtom(diffPreferredSideAtom)
553
- const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
554
- const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
555
- const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
556
- const setPullRequestConversation = useAtomSet(pullRequestConversationAtom)
557
- const setPullRequestConversationLoaded = useAtomSet(pullRequestConversationLoadedAtom)
558
- const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
559
- const [activeModal, setActiveModal] = useAtom(activeModalAtom)
560
- const [themeId, setThemeId] = useAtom(themeIdAtom)
561
- const closeActiveModal = () => setActiveModal(initialModal)
562
- const labelModalActive = Modal.$is("Label")(activeModal)
563
- const closeModalActive = Modal.$is("Close")(activeModal)
564
- const mergeModalActive = Modal.$is("Merge")(activeModal)
565
- const commentModalActive = Modal.$is("Comment")(activeModal)
566
- const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
567
- const changedFilesModalActive = Modal.$is("ChangedFiles")(activeModal)
568
- const submitReviewModalActive = Modal.$is("SubmitReview")(activeModal)
569
- const themeModalActive = Modal.$is("Theme")(activeModal)
570
- const commandPaletteActive = Modal.$is("CommandPalette")(activeModal)
571
- const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal)
572
- const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
573
- const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
574
- const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
575
- const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
576
- const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
577
- const changedFilesModal: ChangedFilesModalState = changedFilesModalActive ? activeModal : initialChangedFilesModalState
578
- const submitReviewModal: SubmitReviewModalState = submitReviewModalActive ? activeModal : initialSubmitReviewModalState
579
- const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
580
- const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState
581
- const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState
582
- const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
583
- (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
584
- const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
585
- if (typeof next === "function") {
586
- const updater = next as (prev: ModalState<Tag>) => ModalState<Tag>
587
- if (current._tag !== tag) return current
588
- return ctor(updater(current as unknown as ModalState<Tag>))
589
- }
590
- return ctor(next)
591
- })
592
- const setLabelModal = makeModalSetter("Label")
593
- const setCloseModal = makeModalSetter("Close")
594
- const setMergeModal = makeModalSetter("Merge")
595
- const setCommentModal = makeModalSetter("Comment")
596
- const setCommentThreadModal = makeModalSetter("CommentThread")
597
- const setChangedFilesModal = makeModalSetter("ChangedFiles")
598
- const setSubmitReviewModal = makeModalSetter("SubmitReview")
599
- const setThemeModal = makeModalSetter("Theme")
600
- const setCommandPalette = makeModalSetter("CommandPalette")
601
- const setOpenRepositoryModal = makeModalSetter("OpenRepository")
602
- setActiveTheme(themeId)
603
- const themeIdRef = useRef(themeId)
604
- const themeModalRef = useRef(themeModal)
605
- themeIdRef.current = themeId
606
- themeModalRef.current = themeModal
607
- const setLabelCache = useAtomSet(labelCacheAtom)
608
- const setPullRequestOverrides = useAtomSet(pullRequestOverridesAtom)
609
- const setRecentlyCompletedPullRequests = useAtomSet(recentlyCompletedPullRequestsAtom)
610
- const retryProgress = useAtomValue(retryProgressAtom)
611
- const [loadingFrame, setLoadingFrame] = useState(0)
612
- const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
613
- const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
614
- const [terminalFocused, setTerminalFocused] = useState(true)
615
- const [startupLoadComplete, setStartupLoadComplete] = useState(false)
616
- const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
617
- const [detailPreviewScrollTop, setDetailPreviewScrollTop] = useState(0)
618
- const usernameResult = useAtomValue(usernameAtom)
619
- const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
620
- const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
621
- const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
622
- const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
623
- const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
624
- const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
625
- const listPullRequestConversation = useAtomSet(listPullRequestConversationAtom, { mode: "promise" })
626
- const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
627
- const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
628
- const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
629
- const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
630
- const submitPullRequestReview = useAtomSet(submitPullRequestReviewAtom, { mode: "promise" })
631
- const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
632
- const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
633
- const terminalWidth = width ?? 100
634
- const terminalHeight = height ?? 24
635
- const contentWidth = Math.max(1, terminalWidth)
636
- const isWideLayout = terminalWidth >= 100
637
- const splitGap = 1
638
- const sectionPadding = 1
639
- const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
640
- const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
641
- const dividerJunctionAt = Math.max(1, leftPaneWidth)
642
- const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
643
- const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
644
- const wideDetailLines = Math.max(8, terminalHeight - 8)
645
- const wideBodyHeight = Math.max(8, terminalHeight - 4)
646
- const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
647
- const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
648
- const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
649
- const detailHydrationRef = useRef(new Map<string, DetailHydration>())
650
- const refreshGenerationRef = useRef(0)
651
- const didMountQueueModeRef = useRef(false)
652
- const lastPullRequestRefreshAtRef = useRef(0)
653
- const terminalFocusedRef = useRef(true)
654
- const terminalWasBlurredRef = useRef(false)
655
- const pullRequestStatusRef = useRef<LoadStatus>("loading")
656
- const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {})
657
- const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
658
- const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
659
- const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
660
- const detailPreviewScrollTopRef = useRef(0)
661
- const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
662
- const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
663
- const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
664
- const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
665
- const suppressNextDiffCommentScrollRef = useRef(false)
666
- const headerFooterWidth = Math.max(24, contentWidth - 2)
667
-
668
- const flashNotice = (message: string) => {
669
- if (noticeTimeoutRef.current !== null) {
670
- clearTimeout(noticeTimeoutRef.current)
671
- }
672
- setNotice(message)
673
- noticeTimeoutRef.current = globalThis.setTimeout(() => {
674
- setNotice((current) => (current === message ? null : current))
675
- }, 2500)
676
- }
677
-
678
- useEffect(() => {
679
- renderer.setBackgroundColor(colors.background)
680
- }, [renderer, themeId])
681
-
682
- useEffect(() => () => {
683
- refreshGenerationRef.current += 1
684
- detailHydrationRef.current.clear()
685
- if (noticeTimeoutRef.current !== null) {
686
- clearTimeout(noticeTimeoutRef.current)
687
- }
688
- if (diffPrefetchTimeoutRef.current !== null) {
689
- clearTimeout(diffPrefetchTimeoutRef.current)
690
- }
691
- if (detailPrefetchTimeoutRef.current !== null) {
692
- clearTimeout(detailPrefetchTimeoutRef.current)
693
- }
694
- }, [])
695
-
696
- const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
697
- const pullRequests = useAtomValue(displayedPullRequestsAtom)
698
- const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
699
- const isInitialLoading = !startupLoadComplete && pullRequestStatus === "loading" && pullRequests.length === 0
700
- const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
701
- const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
702
- pullRequestStatusRef.current = pullRequestStatus
703
-
704
- const visibleFilterText = filterMode ? filterDraft : filterQuery
705
-
706
- const visibleGroups = useAtomValue(visibleGroupsAtom)
707
- const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
708
- const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
709
- const pullRequestConversation = useAtomValue(pullRequestConversationAtom)
710
- const pullRequestConversationLoaded = useAtomValue(pullRequestConversationLoadedAtom)
711
- const selectedRepository = viewRepository(activeView)
712
- const activeViews = activePullRequestViews(activeView)
713
- const currentQueueCacheKey = viewCacheKey(activeView)
714
- const loadedPullRequestCount = pullRequestLoad?.data.length ?? 0
715
- const hasMorePullRequests = Boolean(pullRequestLoad?.hasNextPage && loadedPullRequestCount < config.prFetchLimit)
716
- const isLoadingMorePullRequests = loadingMoreKey === currentQueueCacheKey
717
- const pullRequestListRows = useMemo(() => buildPullRequestListRows({
718
- groups: visibleGroups,
719
- status: pullRequestStatus,
720
- error: pullRequestError,
721
- filterText: visibleFilterText,
722
- showFilterBar: filterMode || filterQuery.length > 0,
723
- loadedCount: loadedPullRequestCount,
724
- hasMore: hasMorePullRequests,
725
- isLoadingMore: isLoadingMorePullRequests,
726
- }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
727
- const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
728
- const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
729
- const selectedConversationItems = selectedDiffKey ? pullRequestConversation[selectedDiffKey] ?? [] : []
730
- const selectedConversationStatus: DetailConversationStatus = selectedDiffKey ? pullRequestConversationLoaded[selectedDiffKey] ?? "idle" : "idle"
731
- const selectedDiffState = useAtomValue(selectedDiffStateAtom)
732
- const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
733
- const readyDiffFiles = useMemo(
734
- () => selectedDiffState?._tag === "Ready"
735
- ? diffWhitespaceMode === "ignore" ? minimizeWhitespaceDiffFiles(selectedDiffState.files) : selectedDiffState.files
736
- : [],
737
- [selectedDiffState, diffWhitespaceMode],
738
- )
739
- const changedFileResults = useMemo(
740
- () => changedFilesModalActive ? filterChangedFiles(readyDiffFiles, changedFilesModal.query) : [],
741
- [changedFilesModalActive, readyDiffFiles, changedFilesModal.query],
742
- )
743
- const displayedDiffState = useMemo(
744
- () => selectedDiffState?._tag === "Ready"
745
- ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
746
- : selectedDiffState,
747
- [selectedDiffState, readyDiffFiles],
748
- )
749
- const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
750
- const diffCommentAnchors = useMemo(
751
- () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
752
- [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
753
- )
754
- const selectedDiffCommentAnchorIndex = Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))
755
- const selectedDiffCommentAnchor = diffCommentAnchors[selectedDiffCommentAnchorIndex] ?? null
756
- const diffCommentRangeStartAnchor = diffCommentRangeStartIndex === null
757
- ? null
758
- : diffCommentAnchors[Math.max(0, Math.min(diffCommentRangeStartIndex, diffCommentAnchors.length - 1))] ?? null
759
- const selectedDiffCommentRange = useMemo(
760
- () => diffCommentRangeSelection(diffCommentRangeStartAnchor, selectedDiffCommentAnchor),
761
- [diffCommentRangeStartAnchor, selectedDiffCommentAnchor],
762
- )
763
- const selectedDiffCommentRangeAnchors = useMemo(
764
- () => selectedDiffCommentRange
765
- ? diffCommentAnchors.filter((anchor) => diffCommentRangeContains(selectedDiffCommentRange, anchor))
766
- : [],
767
- [diffCommentAnchors, selectedDiffCommentRange],
768
- )
769
- const diffCommentRangeActive = selectedDiffCommentRange !== null
770
- const selectedDiffCommentLabel = selectedDiffCommentRange
771
- ? diffCommentRangeLabel(selectedDiffCommentRange)
772
- : selectedDiffCommentAnchor ? diffCommentAnchorLabel(selectedDiffCommentAnchor) : null
773
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? diffCommentThreadMapKey(selectedDiffKey, selectedDiffCommentAnchor) : null
774
- const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
775
- const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
776
- const diffCommentThreadAnchors = useMemo(() => {
777
- if (!selectedDiffKey) return [] as readonly StackedDiffCommentAnchor[]
778
- const seen = new Set<string>()
779
- return diffCommentAnchors.filter((anchor) => {
780
- const key = diffCommentLocationKey(anchor)
781
- if (seen.has(key)) return false
782
- if ((diffCommentThreads[diffCommentThreadMapKey(selectedDiffKey, anchor)]?.length ?? 0) === 0) return false
783
- seen.add(key)
784
- return true
785
- })
786
- }, [diffCommentAnchors, diffCommentThreads, selectedDiffKey])
787
- const groupStarts = useAtomValue(groupStartsAtom)
788
- const getCurrentGroupIndex = (current: number) => {
789
- if (groupStarts.length === 0) return 0
790
- let low = 0
791
- let high = groupStarts.length - 1
792
- while (low < high) {
793
- const mid = (low + high + 1) >>> 1
794
- if (groupStarts[mid]! <= current) low = mid
795
- else high = mid - 1
796
- }
797
- return low
798
- }
799
- const summaryRight = pullRequestLoad?.fetchedAt
800
- ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
801
- : pullRequestStatus === "loading"
802
- ? "loading pull requests..."
803
- : ""
804
- const headerLeft = username ? `GHUI ${username} ${viewLabel(activeView)}` : `GHUI ${viewLabel(activeView)}`
805
- const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
806
- const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
807
- const selectPullRequestByUrl = (url: string) => {
808
- const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
809
- if (index >= 0) {
810
- setSelectedIndex(index)
811
- setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: index }))
812
- }
813
- }
814
- const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
815
- const pullRequest = pullRequests.find((item) => item.url === url)
816
- if (!pullRequest) return
817
- setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
818
- }
819
- const markPullRequestCompleted = (pullRequest: PullRequestItem, state: "closed" | "merged") => {
820
- setRecentlyCompletedPullRequests((current) => ({
821
- ...current,
822
- [pullRequest.url]: {
823
- ...pullRequest,
824
- state,
825
- autoMergeEnabled: false,
826
- },
827
- }))
828
- }
829
- const restoreOptimisticPullRequest = (pullRequest: PullRequestItem) => {
830
- setRecentlyCompletedPullRequests((current) => {
831
- if (!(pullRequest.url in current)) return current
832
- const next = { ...current }
833
- delete next[pullRequest.url]
834
- return next
835
- })
836
- updatePullRequest(pullRequest.url, () => pullRequest)
837
- }
838
- const refreshPullRequests = (message?: string, options: { readonly resetTransientState?: boolean } = {}) => {
839
- refreshGenerationRef.current += 1
840
- detailHydrationRef.current.clear()
841
- if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
842
- setLoadingMoreKey(null)
843
- setPullRequestOverrides({})
844
- if (options.resetTransientState) {
845
- setRecentlyCompletedPullRequests({})
846
- setPullRequestConversation({})
847
- setPullRequestConversationLoaded({})
848
- }
849
- if (message) {
850
- setNotice(null)
851
- setRefreshCompletionMessage(message)
852
- setRefreshStartedAt(lastPullRequestRefreshAtRef.current)
853
- }
854
- refreshPullRequestsAtom()
855
- }
856
- refreshPullRequestsRef.current = refreshPullRequests
857
- const switchViewTo = (view: PullRequestView) => {
858
- if (viewEquals(view, activeView)) return
859
- refreshGenerationRef.current += 1
860
- setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: selectedIndex }))
861
- setActiveView(view)
862
- setSelectedIndex(registry.get(queueSelectionAtom)[viewCacheKey(view)] ?? 0)
863
- setRecentlyCompletedPullRequests({})
864
- detailHydrationRef.current.clear()
865
- if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
866
- setLoadingMoreKey(null)
867
- setDetailFullView(false)
868
- setDiffFullView(false)
869
- setDiffCommentRangeStartIndex(null)
870
- setFilterDraft(filterQuery)
871
- setNotice(null)
872
- setRefreshCompletionMessage(null)
873
- setRefreshStartedAt(null)
874
- }
875
- const switchQueueMode = (delta: 1 | -1) => {
876
- switchViewTo(nextView(activeView, activeViews, delta))
877
- }
878
- const loadMorePullRequests = () => {
879
- if (!pullRequestLoad || !hasMorePullRequests || isLoadingMorePullRequests || !pullRequestLoad.endCursor) return false
880
- const remaining = config.prFetchLimit - pullRequestLoad.data.length
881
- if (remaining <= 0) return false
882
- const cacheKey = currentQueueCacheKey
883
- const generation = refreshGenerationRef.current
884
- setLoadingMoreKey(cacheKey)
885
- void loadPullRequestPage({
886
- mode: viewMode(activeView),
887
- repository: selectedRepository,
888
- cursor: pullRequestLoad.endCursor,
889
- pageSize: Math.min(pullRequestPageSize, remaining),
890
- }).then((page) => {
891
- if (generation !== refreshGenerationRef.current) return
892
- setQueueLoadCache((current) => {
893
- const load = current[cacheKey]
894
- if (!load) return current
895
- const data = appendPullRequestPage(load.data, page.items)
896
- return {
897
- ...current,
898
- [cacheKey]: {
899
- ...load,
900
- data,
901
- endCursor: page.endCursor,
902
- hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
903
- },
904
- }
905
- })
906
- }).catch((error) => {
907
- flashNotice(errorMessage(error))
908
- }).finally(() => {
909
- setLoadingMoreKey((current) => current === cacheKey ? null : current)
910
- })
911
- return true
912
- }
913
- const applyPullRequestDetail = (detail: PullRequestItem) => {
914
- setQueueLoadCache((current) => {
915
- const next = { ...current }
916
- let changed = false
917
- for (const [cacheKey, load] of Object.entries(current)) {
918
- if (!load) continue
919
- const index = load.data.findIndex((pullRequest) => pullRequest.url === detail.url)
920
- if (index < 0) continue
921
- const data = [...load.data]
922
- data[index] = detail
923
- changed = true
924
- next[cacheKey] = { ...load, data }
925
- }
926
- return changed ? next : current
927
- })
928
- }
929
- const hydratePullRequestDetails = (pullRequest: PullRequestItem, notifyError: boolean) => {
930
- if (pullRequest.state !== "open" || pullRequest.detailLoaded) return false
931
- const detailKey = pullRequestDetailKey(pullRequest)
932
- const existing = detailHydrationRef.current.get(detailKey)
933
- if (existing) {
934
- if (notifyError) existing.notifyError = true
935
- return false
936
- }
937
- if (!notifyError && detailHydrationRef.current.size >= DETAIL_PREFETCH_CONCURRENCY) return false
938
- const entry: DetailHydration = { token: Symbol(detailKey), notifyError }
939
- detailHydrationRef.current.set(detailKey, entry)
940
- const generation = refreshGenerationRef.current
941
- const atom = pullRequestDetailsAtom(pullRequestDetailAtomKey(pullRequest))
942
- void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })).then((detail) => {
943
- if (generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) applyPullRequestDetail(detail)
944
- }).catch((error) => {
945
- if (entry.notifyError && generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) flashNotice(errorMessage(error))
946
- }).finally(() => {
947
- if (detailHydrationRef.current.get(detailKey) === entry) detailHydrationRef.current.delete(detailKey)
948
- })
949
- return true
950
- }
951
- const loadPullRequestConversation = (pullRequest: PullRequestItem, force = false) => {
952
- const key = pullRequestDiffKey(pullRequest)
953
- const previousLoadState = registry.get(pullRequestConversationLoadedAtom)[key]
954
- if (!force && previousLoadState) return
955
- const generation = refreshGenerationRef.current
956
- setPullRequestConversationLoaded((current) => ({ ...current, [key]: "loading" }))
957
- void listPullRequestConversation({ repository: pullRequest.repository, number: pullRequest.number })
958
- .then((items) => {
959
- if (generation !== refreshGenerationRef.current) return
960
- setPullRequestConversation((current) => ({ ...current, [key]: items }))
961
- setPullRequestConversationLoaded((current) => ({ ...current, [key]: "ready" }))
962
- })
963
- .catch((error) => {
964
- if (generation !== refreshGenerationRef.current) return
965
- setPullRequestConversationLoaded((current) => {
966
- if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
967
- const next = { ...current }
968
- delete next[key]
969
- return next
970
- })
971
- flashNotice(errorMessage(error))
972
- })
973
- }
974
- maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
975
- if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
976
- const lastRefreshAt = lastPullRequestRefreshAtRef.current
977
- if (lastRefreshAt > 0 && Date.now() - lastRefreshAt < minimumAgeMs) return
978
- refreshPullRequestsRef.current()
979
- }
980
-
981
- useEffect(() => {
982
- const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
983
- if (fetchedAt !== undefined) {
984
- lastPullRequestRefreshAtRef.current = fetchedAt
985
- }
986
- }, [pullRequestLoad?.fetchedAt])
987
-
988
- useEffect(() => {
989
- if (!didMountQueueModeRef.current) {
990
- didMountQueueModeRef.current = true
991
- return
992
- }
993
- if (registry.get(queueLoadCacheAtom)[currentQueueCacheKey]) return
994
- refreshPullRequestsAtom()
995
- }, [currentQueueCacheKey, refreshPullRequestsAtom, registry])
996
-
997
- useEffect(() => {
998
- if (!refreshCompletionMessage || refreshStartedAt === null) return
999
- const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
1000
- const isHydratingDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1001
- if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
1002
- flashNotice(`✓ ${refreshCompletionMessage}`)
1003
- setRefreshCompletionMessage(null)
1004
- setRefreshStartedAt(null)
1005
- } else if (pullRequestStatus === "error") {
1006
- flashNotice("Refresh failed")
1007
- setRefreshCompletionMessage(null)
1008
- setRefreshStartedAt(null)
1009
- }
1010
- }, [refreshCompletionMessage, refreshStartedAt, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests])
1011
-
1012
- useEffect(() => {
1013
- const handleFocus = () => {
1014
- terminalFocusedRef.current = true
1015
- setTerminalFocused(true)
1016
- if (terminalWasBlurredRef.current) {
1017
- maybeRefreshPullRequestsRef.current(FOCUS_RETURN_REFRESH_MIN_MS)
1018
- }
1019
- }
1020
- const handleBlur = () => {
1021
- terminalWasBlurredRef.current = true
1022
- terminalFocusedRef.current = false
1023
- setTerminalFocused(false)
1024
- }
1025
-
1026
- renderer.on("focus", handleFocus)
1027
- renderer.on("blur", handleBlur)
1028
- return () => {
1029
- renderer.off("focus", handleFocus)
1030
- renderer.off("blur", handleBlur)
1031
- }
1032
- }, [renderer])
1033
-
1034
- useEffect(() => {
1035
- if (!terminalFocused) return
1036
- const lastRefreshAt = lastPullRequestRefreshAtRef.current || Date.now()
1037
- const ageMs = Date.now() - lastRefreshAt
1038
- const delayMs = Math.max(0, FOCUSED_IDLE_REFRESH_MS - ageMs) + Math.floor(Math.random() * AUTO_REFRESH_JITTER_MS)
1039
- const timeout = globalThis.setTimeout(() => {
1040
- maybeRefreshPullRequestsRef.current(FOCUSED_IDLE_REFRESH_MS)
1041
- }, delayMs)
1042
- return () => globalThis.clearTimeout(timeout)
1043
- }, [terminalFocused, pullRequestLoad?.fetchedAt])
1044
-
1045
- useEffect(() => {
1046
- setSelectedIndex((current) => {
1047
- if (visiblePullRequests.length === 0) return 0
1048
- return Math.max(0, Math.min(current, visiblePullRequests.length - 1))
1049
- })
1050
- }, [visiblePullRequests.length])
1051
-
1052
- useEffect(() => {
1053
- setQueueSelection((current) => current[currentQueueCacheKey] === selectedIndex ? current : { ...current, [currentQueueCacheKey]: selectedIndex })
1054
- }, [currentQueueCacheKey, selectedIndex])
1055
-
1056
- useEffect(() => {
1057
- if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0) return
1058
- const thresholdIndex = Math.max(0, visiblePullRequests.length - LOAD_MORE_SELECTION_THRESHOLD)
1059
- if (selectedIndex >= thresholdIndex) loadMorePullRequests()
1060
- }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
1061
-
1062
- useEffect(() => {
1063
- if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView) return
1064
- if (!hasMorePullRequests || isLoadingMorePullRequests) return
1065
- const checkScroll = () => {
1066
- const scroll = prListScrollRef.current
1067
- if (!scroll || scroll.viewport.height <= 0) return
1068
- const bottom = scroll.scrollTop + scroll.viewport.height
1069
- if (bottom >= scroll.scrollHeight - LOAD_MORE_SCROLL_THRESHOLD) loadMorePullRequests()
1070
- }
1071
- checkScroll()
1072
- const interval = globalThis.setInterval(checkScroll, 120)
1073
- return () => globalThis.clearInterval(interval)
1074
- }, [visiblePullRequests.length, filterMode, filterQuery, detailFullView, diffFullView, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
1075
-
1076
- useEffect(() => {
1077
- const scroll = prListScrollRef.current
1078
- if (!scroll || selectedPullRequestRowIndex === null) return
1079
- const viewportHeight = scroll.viewport.height
1080
- if (viewportHeight <= 0) return
1081
- const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, selectedPullRequestRowIndex, 2)
1082
- if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
1083
- }, [selectedPullRequestRowIndex])
1084
-
1085
- useEffect(() => {
1086
- setDiffFileIndex(0)
1087
- setDiffScrollTop(0)
1088
- setDiffCommentAnchorIndex(0)
1089
- setDiffPreferredSide(null)
1090
- setDiffCommentRangeStartIndex(null)
1091
- detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
1092
- if (detailPreviewScrollTopRef.current !== 0) {
1093
- detailPreviewScrollTopRef.current = 0
1094
- setDetailPreviewScrollTop(0)
1095
- }
1096
- }, [selectedIndex])
1097
-
1098
- useEffect(() => {
1099
- setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current))
1100
- }, [readyDiffFiles.length])
1101
-
1102
- useEffect(() => {
1103
- setDiffCommentAnchorIndex((current) => {
1104
- if (diffCommentAnchors.length === 0) return 0
1105
- return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
1106
- })
1107
- setDiffCommentRangeStartIndex((current) => {
1108
- if (current === null || diffCommentAnchors.length === 0) return null
1109
- return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
1110
- })
1111
- }, [diffCommentAnchors.length])
1112
-
1113
- useEffect(() => {
1114
- if (!diffFullView || !selectedDiffCommentAnchor) return
1115
- setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
1116
- }, [diffFullView, selectedDiffCommentAnchor?.fileIndex])
1117
-
1118
- useEffect(() => {
1119
- const previous = diffCommentLineColorsRef.current
1120
- if (previous.contextKey === diffLineColorContextKey) {
1121
- for (const entry of previous.entries) {
1122
- const diff = diffRenderableRefs.current.get(entry.anchor.fileIndex)
1123
- if (diff) setDiffCommentLineColor(diff, entry, originalDiffLineColor(entry.anchor))
1124
- }
1125
- }
1126
-
1127
- const nextEntries: AppliedDiffLineColor[] = []
1128
- const appliedKeys = new Set<string>()
1129
- const applyLineColor = (anchor: StackedDiffCommentAnchor, color: DiffLineColorConfig, override = false) => {
1130
- const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
1131
- if (appliedKeys.has(key) && !override) return
1132
- appliedKeys.add(key)
1133
- const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
1134
- const diff = diffRenderableRefs.current.get(anchor.fileIndex)
1135
- if (diff) setDiffCommentLineColor(diff, entry, color)
1136
- if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
1137
- nextEntries.push(entry)
1138
- }
1139
- }
1140
-
1141
- for (const anchor of diffCommentThreadAnchors) {
1142
- applyLineColor(anchor, diffCommentLineColor(anchor, "thread"))
1143
- }
1144
- if (selectedDiffCommentRangeAnchors.length > 0) {
1145
- for (const anchor of selectedDiffCommentRangeAnchors) {
1146
- applyLineColor(anchor, diffCommentLineColor(anchor, "range"), true)
1147
- }
1148
- }
1149
- if (selectedDiffCommentAnchor) {
1150
- applyLineColor(selectedDiffCommentAnchor, diffCommentLineColor(selectedDiffCommentAnchor, "selected"), true)
1151
- if (suppressNextDiffCommentScrollRef.current) {
1152
- suppressNextDiffCommentScrollRef.current = false
1153
- } else {
1154
- ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
1155
- }
1156
- } else {
1157
- suppressNextDiffCommentScrollRef.current = false
1158
- }
1159
- diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
1160
- }, [selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, selectedDiffCommentRangeAnchors, diffLineColorContextKey, effectiveDiffRenderView, diffCommentThreadAnchors])
1161
-
1162
- // Scroll the selected line into view when the diff view is opened. Previously
1163
- // opentui's `focused` scrollbox did this auto-scroll on mount; with the keymap
1164
- // migration the scrollbox is `focusable={false}` so we have to scroll explicitly.
1165
- useEffect(() => {
1166
- if (!diffFullView) return
1167
- if (!selectedDiffCommentAnchor) return
1168
- ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
1169
- // eslint-disable-next-line react-hooks/exhaustive-deps
1170
- }, [diffFullView])
1171
- const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1172
- const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
1173
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || submitReviewModal.running || selectedDiffState?._tag === "Loading"
1174
- const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
1175
-
1176
- useEffect(() => {
1177
- if (!hasActiveLoadingIndicator) return
1178
- const interval = globalThis.setInterval(() => {
1179
- setLoadingFrame((current) => current + 1)
1180
- }, 120)
1181
- return () => globalThis.clearInterval(interval)
1182
- }, [hasActiveLoadingIndicator])
1183
-
1184
- useEffect(() => {
1185
- if (isInitialLoading) setLoadingFrame(0)
1186
- }, [isInitialLoading])
1187
-
1188
- useEffect(() => {
1189
- if (startupLoadComplete || pullRequestStatus === "loading") return
1190
- setStartupLoadComplete(true)
1191
- }, [startupLoadComplete, pullRequestStatus])
1192
-
1193
- useEffect(() => {
1194
- if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1195
- hydratePullRequestDetails(selectedPullRequest, true)
1196
- }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
1197
-
1198
- useEffect(() => {
1199
- if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1200
- loadPullRequestConversation(selectedPullRequest)
1201
- }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.repository, selectedPullRequest?.number])
1202
-
1203
- useEffect(() => {
1204
- if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1205
- if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
1206
- detailPrefetchTimeoutRef.current = globalThis.setTimeout(() => {
1207
- detailPrefetchTimeoutRef.current = null
1208
- let started = 0
1209
- for (let distance = 1; distance <= Math.max(DETAIL_PREFETCH_AHEAD, DETAIL_PREFETCH_BEHIND); distance++) {
1210
- const offsets = [distance <= DETAIL_PREFETCH_AHEAD ? distance : null, distance <= DETAIL_PREFETCH_BEHIND ? -distance : null]
1211
- for (const offset of offsets) {
1212
- if (offset === null) continue
1213
- if (started >= DETAIL_PREFETCH_CONCURRENCY) return
1214
- const pullRequest = visiblePullRequests[selectedIndex + offset]
1215
- if (pullRequest && hydratePullRequestDetails(pullRequest, false)) started += 1
1216
- }
1217
- }
1218
- }, DETAIL_PREFETCH_DELAY_MS)
1219
- return () => {
1220
- if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1221
- }
1222
- }, [pullRequestStatus, currentQueueCacheKey, selectedIndex, visiblePullRequests])
1223
-
1224
- const detailPlaceholderContent = getDetailPlaceholderContent({
1225
- status: pullRequestStatus,
1226
- retryProgress,
1227
- loadingIndicator,
1228
- visibleCount: visiblePullRequests.length,
1229
- filterText: visibleFilterText,
1230
- })
1231
- const isSelectedPullRequestDetailLoading = selectedPullRequest !== null && !selectedPullRequest.detailLoaded
1232
- const detailLoadingContent: DetailPlaceholderContent = selectedPullRequest ? {
1233
- title: `${loadingIndicator} Loading pull request details`,
1234
- hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1235
- } : detailPlaceholderContent
1236
- const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1237
-
1238
- const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
1239
- const key = pullRequestDiffKey(pullRequest)
1240
- const previousLoadState = registry.get(diffCommentsLoadedAtom)[key]
1241
- if (!force && previousLoadState) return
1242
- setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
1243
- void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
1244
- .then((comments) => {
1245
- setDiffCommentsLoaded((current) => ({ ...current, [key]: "ready" }))
1246
- setDiffCommentThreads((current) => {
1247
- const prefix = `${key}:`
1248
- const threads = groupDiffCommentThreads(pullRequest, comments)
1249
- const next: Record<string, readonly PullRequestReviewComment[]> = Object.fromEntries(
1250
- Object.entries(current).filter(([threadKey]) => !threadKey.startsWith(prefix)),
1251
- )
1252
-
1253
- for (const [threadKey, threadComments] of Object.entries(current)) {
1254
- if (!threadKey.startsWith(prefix)) continue
1255
- const localComments = threadComments.filter(isLocalDiffComment)
1256
- if (localComments.length > 0) {
1257
- next[threadKey] = [...(threads[threadKey] ?? []), ...localComments]
1258
- }
1259
- }
1260
-
1261
- for (const [threadKey, threadComments] of Object.entries(threads)) {
1262
- if (!next[threadKey]) next[threadKey] = threadComments
1263
- }
1264
-
1265
- return next
1266
- })
1267
- })
1268
- .catch((error) => {
1269
- setDiffCommentsLoaded((current) => {
1270
- if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
1271
- const next = { ...current }
1272
- delete next[key]
1273
- return next
1274
- })
1275
- flashNotice(errorMessage(error))
1276
- })
1277
- }
1278
-
1279
- const loadPullRequestDiff = (pullRequest: PullRequestItem, options: { readonly force?: boolean; readonly includeComments?: boolean } = {}) => {
1280
- const force = options.force ?? false
1281
- const includeComments = options.includeComments ?? false
1282
- const key = pullRequestDiffKey(pullRequest)
1283
- const existing = registry.get(pullRequestDiffCacheAtom)[key]
1284
- if (includeComments) loadPullRequestComments(pullRequest, force)
1285
- if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
1286
-
1287
- setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
1288
- const atom = pullRequestDiffAtom(pullRequestDiffAtomKey(pullRequest))
1289
- if (force) registry.refresh(atom)
1290
- void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }))
1291
- .then((patch) => {
1292
- setPullRequestDiffCache((current) => ({
1293
- ...current,
1294
- [key]: PullRequestDiffState.Ready({ patch, files: splitPatchFiles(patch) }),
1295
- }))
1296
- })
1297
- .catch((error) => {
1298
- setPullRequestDiffCache((current) => ({
1299
- ...current,
1300
- [key]: PullRequestDiffState.Error({ error: errorMessage(error) }),
1301
- }))
1302
- flashNotice(errorMessage(error))
1303
- })
1304
- }
1305
-
1306
- useEffect(() => {
1307
- if (!selectedPullRequest || diffFullView) return
1308
- if (diffPrefetchTimeoutRef.current !== null) {
1309
- clearTimeout(diffPrefetchTimeoutRef.current)
1310
- }
1311
- diffPrefetchTimeoutRef.current = setTimeout(() => {
1312
- loadPullRequestDiff(selectedPullRequest)
1313
- }, 250)
1314
- return () => {
1315
- if (diffPrefetchTimeoutRef.current !== null) {
1316
- clearTimeout(diffPrefetchTimeoutRef.current)
1317
- diffPrefetchTimeoutRef.current = null
1318
- }
1319
- }
1320
- }, [selectedIndex, selectedPullRequest?.url, diffFullView])
1321
-
1322
- const openDiffView = () => {
1323
- if (!selectedPullRequest) return
1324
- diffRenderableRefs.current.clear()
1325
- diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
1326
- setDiffFullView(true)
1327
- setDetailFullView(false)
1328
- setDiffFileIndex(0)
1329
- setDiffScrollTop(0)
1330
- setDiffCommentAnchorIndex(0)
1331
- setDiffPreferredSide(null)
1332
- setDiffCommentRangeStartIndex(null)
1333
- setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1334
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1335
- loadPullRequestDiff(selectedPullRequest, { includeComments: true })
1336
- }
1337
-
1338
- const setDiffRenderableRef = (index: number, diff: DiffRenderable | null) => {
1339
- if (diff) diffRenderableRefs.current.set(index, diff)
1340
- else diffRenderableRefs.current.delete(index)
1341
- }
1342
-
1343
- const scrollToDiffFile = (index: number) => {
1344
- const stackedFile = stackedDiffFiles[index]
1345
- diffScrollRef.current?.scrollTo({ x: 0, y: stackedFile?.headerLine ?? 0 })
1346
- syncDiffScrollState()
1347
- }
1348
-
1349
- const syncDiffScrollState = () => {
1350
- const scrollTop = diffScrollRef.current?.scrollTop
1351
- if (scrollTop === undefined || stackedDiffFiles.length === 0) return
1352
- setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
1353
- const nextIndex = Math.max(0, stackedDiffFileIndexAtLine(stackedDiffFiles, scrollTop))
1354
- setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1355
- }
1356
-
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
- }
1374
-
1375
- const ensureDiffLineVisible = (line: number) => {
1376
- const scroll = diffScrollRef.current
1377
- if (!scroll) return
1378
- const viewportHeight = Math.max(1, wideBodyHeight - (selectedDiffCommentThread.length > 0 ? 6 : 3))
1379
- const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line, DIFF_STICKY_HEADER_LINES)
1380
- if (nextTop !== scroll.scrollTop) {
1381
- scroll.scrollTo({ x: 0, y: nextTop })
1382
- syncDiffScrollState()
1383
- }
1384
- }
1385
-
1386
- useEffect(() => {
1387
- if (!diffFullView) return
1388
- const interval = globalThis.setInterval(syncDiffScrollState, 80)
1389
- return () => globalThis.clearInterval(interval)
1390
- }, [diffFullView, stackedDiffFiles])
1391
-
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) => {
1403
- if (readyDiffFiles.length === 0) return
1404
- const nextIndex = safeDiffFileIndex(readyDiffFiles, index)
1405
- setDiffFileIndex(nextIndex)
1406
- setDiffCommentRangeStartIndex(null)
1407
- const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
1408
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === targetSide)
1409
- ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1410
- if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1411
- scrollToDiffFile(nextIndex)
1412
- }
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
-
1434
- const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1435
- ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1436
- : diffCommentAnchors
1437
-
1438
- const moveDiffCommentAnchor = (delta: number, options: { readonly preserveViewportRow?: boolean } = {}) => {
1439
- const anchors = navigableDiffCommentAnchors()
1440
- if (anchors.length === 0) return
1441
- const currentAnchor = selectedDiffCommentAnchor && anchors.includes(selectedDiffCommentAnchor) ? selectedDiffCommentAnchor : anchors[0]
1442
- const nextAnchor = verticalDiffAnchor(anchors, currentAnchor ?? null, delta, diffPreferredSide)
1443
- if (!nextAnchor) return
1444
- if (options.preserveViewportRow) {
1445
- const scroll = diffScrollRef.current
1446
- if (scroll && currentAnchor) {
1447
- const maxScreenOffset = Math.max(DIFF_STICKY_HEADER_LINES, scroll.viewport.height - 2)
1448
- const screenOffset = Math.max(DIFF_STICKY_HEADER_LINES, Math.min(maxScreenOffset, currentAnchor.renderLine - scroll.scrollTop))
1449
- const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
1450
- const nextTop = Math.max(0, Math.min(maxScrollTop, nextAnchor.renderLine - screenOffset))
1451
- suppressNextDiffCommentScrollRef.current = true
1452
- scroll.scrollTo({ x: 0, y: nextTop })
1453
- syncDiffScrollState()
1454
- }
1455
- }
1456
- setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1457
- }
1458
-
1459
- const moveDiffCommentToBoundary = (boundary: "first" | "last") => {
1460
- const anchors = navigableDiffCommentAnchors()
1461
- const nextAnchor = boundary === "first" ? anchors[0] : anchors[anchors.length - 1]
1462
- if (!nextAnchor) return
1463
- setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1464
- setDiffFileIndex(nextAnchor.fileIndex)
1465
- }
1466
-
1467
- const alignSelectedDiffCommentAnchor = (position: "top" | "center" | "bottom") => {
1468
- if (!selectedDiffCommentAnchor) return
1469
- const scroll = diffScrollRef.current
1470
- if (!scroll) return
1471
- const viewportHeight = Math.max(1, scroll.viewport.height)
1472
- const offset = position === "top"
1473
- ? DIFF_STICKY_HEADER_LINES
1474
- : position === "center"
1475
- ? Math.max(DIFF_STICKY_HEADER_LINES, Math.floor(viewportHeight / 2))
1476
- : Math.max(DIFF_STICKY_HEADER_LINES, viewportHeight - 2)
1477
- const maxScrollTop = Math.max(0, scroll.scrollHeight - viewportHeight)
1478
- const nextTop = Math.max(0, Math.min(maxScrollTop, selectedDiffCommentAnchor.renderLine - offset))
1479
- scroll.scrollTo({ x: 0, y: nextTop })
1480
- syncDiffScrollState()
1481
- }
1482
-
1483
- const selectDiffCommentSide = (side: DiffCommentSide) => {
1484
- setDiffPreferredSide(side)
1485
- if (!selectedDiffCommentAnchor) return
1486
- const nextAnchor = diffAnchorOnSide(diffCommentAnchors, selectedDiffCommentAnchor, side)
1487
- if (!nextAnchor) return
1488
- setDiffCommentRangeStartIndex(null)
1489
- setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1490
- }
1491
-
1492
- const selectDiffCommentLine = (renderLine: number, side: DiffCommentSide | null) => {
1493
- const lineAnchors = diffCommentAnchors.filter((anchor) => anchor.renderLine === renderLine)
1494
- const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1495
- if (!nextAnchor) return
1496
- suppressNextDiffCommentScrollRef.current = true
1497
- setDiffPreferredSide(side ?? nextAnchor.side)
1498
- if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1499
- setDiffCommentRangeStartIndex(null)
1500
- }
1501
- setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1502
- setDiffFileIndex(nextAnchor.fileIndex)
1503
- }
1504
-
1505
- const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1506
- setCommentModal((current) => {
1507
- const next = transform({ body: current.body, cursor: current.cursor })
1508
- if (next.body === current.body && next.cursor === current.cursor && current.error === null) return current
1509
- return { ...current, body: next.body, cursor: next.cursor, error: null }
1510
- })
1511
- }
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
-
1534
- const openDiffCommentModal = () => {
1535
- if (!selectedDiffCommentAnchor || !selectedPullRequest) return
1536
- setCommentModal(initialCommentModalState)
1537
- }
1538
-
1539
- const openDiffCommentThreadModal = () => {
1540
- if (!selectedDiffCommentAnchor || selectedDiffCommentThread.length === 0) return
1541
- setCommentThreadModal({ scrollOffset: 0 })
1542
- }
1543
-
1544
- const openSelectedDiffComment = () => {
1545
- if (diffCommentRangeActive) {
1546
- openDiffCommentModal()
1547
- return
1548
- }
1549
- if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
1550
- else openDiffCommentModal()
1551
- }
1552
-
1553
- const toggleDiffCommentRange = () => {
1554
- if (!selectedDiffCommentAnchor) return
1555
- setDiffCommentRangeStartIndex((current) => current === null ? selectedDiffCommentAnchorIndex : null)
1556
- }
1557
-
1558
- const moveDiffCommentThread = (delta: 1 | -1) => {
1559
- if (diffCommentThreadAnchors.length === 0) {
1560
- flashNotice("No diff comments")
1561
- return
1562
- }
1563
- const currentIndex = selectedDiffCommentAnchor
1564
- ? diffCommentThreadAnchors.findIndex((anchor) => diffCommentLocationKey(anchor) === diffCommentLocationKey(selectedDiffCommentAnchor))
1565
- : -1
1566
- const nextAnchor = currentIndex >= 0
1567
- ? diffCommentThreadAnchors[(currentIndex + delta + diffCommentThreadAnchors.length) % diffCommentThreadAnchors.length]
1568
- : delta > 0
1569
- ? diffCommentThreadAnchors.find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine > selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[0]
1570
- : [...diffCommentThreadAnchors].reverse().find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine < selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[diffCommentThreadAnchors.length - 1]
1571
- if (!nextAnchor) return
1572
- setDiffCommentRangeStartIndex(null)
1573
- setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1574
- setDiffFileIndex(nextAnchor.fileIndex)
1575
- }
1576
-
1577
- const submitDiffComment = () => {
1578
- if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1579
- const body = commentModal.body.trim()
1580
- if (body.length === 0) {
1581
- setCommentModal((current) => ({ ...current, error: "Write a comment before saving." }))
1582
- return
1583
- }
1584
-
1585
- const targetRange = selectedDiffCommentRange
1586
- const target = targetRange?.end ?? selectedDiffCommentAnchor
1587
- const threadKey = selectedDiffKey ? diffCommentThreadMapKey(selectedDiffKey, target) : null
1588
- const optimisticComment = {
1589
- id: `local:${Date.now()}`,
1590
- path: target.path,
1591
- line: target.line,
1592
- side: target.side,
1593
- author: username ?? "you",
1594
- body,
1595
- createdAt: new Date(),
1596
- url: null,
1597
- } satisfies PullRequestReviewComment
1598
- const rangeInput = targetRange && targetRange.start.line !== targetRange.end.line
1599
- ? { startLine: targetRange.start.line, startSide: targetRange.start.side }
1600
- : {}
1601
- const input = {
1602
- repository: selectedPullRequest.repository,
1603
- number: selectedPullRequest.number,
1604
- commitId: selectedPullRequest.headRefOid,
1605
- path: target.path,
1606
- line: target.line,
1607
- side: target.side,
1608
- body,
1609
- ...rangeInput,
1610
- } satisfies CreatePullRequestCommentInput
1611
-
1612
- if (threadKey) {
1613
- setDiffCommentThreads((current) => ({
1614
- ...current,
1615
- [threadKey]: [...(current[threadKey] ?? []), optimisticComment],
1616
- }))
1617
- }
1618
- closeActiveModal()
1619
- setDiffCommentRangeStartIndex(null)
1620
- flashNotice(`Commenting on ${target.path}:${target.line}`)
1621
- void createPullRequestComment(input).then((comment) => {
1622
- if (threadKey) {
1623
- setDiffCommentThreads((current) => ({
1624
- ...current,
1625
- [threadKey]: (current[threadKey] ?? []).map((existing) => existing.id === optimisticComment.id ? comment : existing),
1626
- }))
1627
- }
1628
- flashNotice(`Commented on ${target.path}:${target.line}`)
1629
- }).catch((error) => {
1630
- if (threadKey) {
1631
- setDiffCommentThreads((current) => {
1632
- const next = { ...current }
1633
- const comments = (next[threadKey] ?? []).filter((comment) => comment.id !== optimisticComment.id)
1634
- if (comments.length > 0) next[threadKey] = comments
1635
- else delete next[threadKey]
1636
- return next
1637
- })
1638
- }
1639
- flashNotice(errorMessage(error))
1640
- })
1641
- }
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
-
1668
- const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1669
- void openInBrowser(pullRequest)
1670
- .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
1671
- .catch((error) => flashNotice(errorMessage(error)))
1672
- }
1673
-
1674
- const copySelectedPullRequestMetadata = () => {
1675
- if (!selectedPullRequest) return
1676
- void copyToClipboard(pullRequestMetadataText(selectedPullRequest))
1677
- .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1678
- .catch((error) => flashNotice(errorMessage(error)))
1679
- }
1680
-
1681
- const toggleSelectedPullRequestDraftStatus = () => {
1682
- if (!selectedPullRequest) return
1683
- const previousPullRequest = selectedPullRequest
1684
- const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1685
- updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1686
- ...pullRequest,
1687
- reviewStatus: nextReviewStatus,
1688
- }))
1689
- void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1690
- .then(() => {
1691
- flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1692
- })
1693
- .catch((error) => {
1694
- updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1695
- flashNotice(errorMessage(error))
1696
- })
1697
- }
1698
-
1699
- const openCloseModal = () => {
1700
- if (!selectedPullRequest || selectedPullRequest.state !== "open") return
1701
- setCloseModal({
1702
- repository: selectedPullRequest.repository,
1703
- number: selectedPullRequest.number,
1704
- title: selectedPullRequest.title,
1705
- url: selectedPullRequest.url,
1706
- running: false,
1707
- error: null,
1708
- })
1709
- }
1710
-
1711
- const confirmClosePullRequest = () => {
1712
- if (!closeModal.repository || closeModal.number === null || !closeModal.url || closeModal.running) return
1713
- const { repository, number, url } = closeModal
1714
- const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.url === url)
1715
- const previousPullRequest = targetPullRequest ?? null
1716
-
1717
- setCloseModal((current) => ({ ...current, running: true, error: null }))
1718
- void closePullRequest({ repository, number })
1719
- .then(() => {
1720
- if (previousPullRequest) markPullRequestCompleted(previousPullRequest, "closed")
1721
- closeActiveModal()
1722
- refreshPullRequests(`Closed #${number}`)
1723
- })
1724
- .catch((error) => {
1725
- setCloseModal((current) => ({ ...current, running: false, error: errorMessage(error) }))
1726
- flashNotice(errorMessage(error))
1727
- })
1728
- }
1729
-
1730
- const openThemeModal = () => {
1731
- setThemeModal({
1732
- query: "",
1733
- filterMode: false,
1734
- initialThemeId: themeId,
1735
- })
1736
- }
1737
-
1738
- const closeThemeModal = (confirm: boolean) => {
1739
- const selectedTheme = themeDefinitions.find((theme) => theme.id === themeIdRef.current)
1740
- if (!confirm) {
1741
- setThemeId(themeModal.initialThemeId)
1742
- } else if (selectedTheme) {
1743
- void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
1744
- flashNotice(`Theme: ${selectedTheme.name}`)
1745
- }
1746
- closeActiveModal()
1747
- }
1748
-
1749
- const previewTheme = (id: ThemeId) => {
1750
- if (id === themeIdRef.current) return
1751
- themeIdRef.current = id
1752
- setThemeId(id)
1753
- }
1754
-
1755
- const toggleDiffWhitespaceMode = () => {
1756
- const next = diffWhitespaceMode === "ignore" ? "show" : "ignore"
1757
- setDiffWhitespaceMode(next)
1758
- void Effect.runPromise(saveStoredDiffWhitespaceMode(next)).catch((error) => flashNotice(errorMessage(error)))
1759
- }
1760
-
1761
- const moveThemeSelection = (delta: number) => {
1762
- const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
1763
- if (filteredThemes.length === 0) return
1764
- const currentIndex = Math.max(0, filteredThemes.findIndex((theme) => theme.id === themeIdRef.current))
1765
- const selectedIndex = Math.max(0, Math.min(filteredThemes.length - 1, currentIndex + delta))
1766
- if (selectedIndex === currentIndex) return
1767
- const theme = filteredThemes[selectedIndex]
1768
- if (theme) previewTheme(theme.id)
1769
- }
1770
-
1771
- const updateThemeQuery = (query: string, options: { readonly previewFirst?: boolean; readonly filterMode?: boolean } = {}) => {
1772
- const current = themeModalRef.current
1773
- const next = {
1774
- ...current,
1775
- query,
1776
- filterMode: options.filterMode ?? current.filterMode,
1777
- }
1778
- if (next.query === current.query && next.filterMode === current.filterMode) return
1779
-
1780
- themeModalRef.current = next
1781
- setThemeModal(next)
1782
-
1783
- if (options.previewFirst && query.trim().length > 0) {
1784
- const firstTheme = filterThemeDefinitions(query)[0]
1785
- if (firstTheme) previewTheme(firstTheme.id)
1786
- }
1787
- }
1788
-
1789
- const editThemeQuery = (transform: (query: string) => string) => {
1790
- updateThemeQuery(transform(themeModalRef.current.query), { previewFirst: true })
1791
- }
1792
-
1793
- const openLabelModal = () => {
1794
- if (!selectedPullRequest) return
1795
- const repository = selectedPullRequest.repository
1796
- const cachedLabels = registry.get(labelCacheAtom)[repository]
1797
- if (cachedLabels) {
1798
- setLabelModal({
1799
- repository,
1800
- query: "",
1801
- selectedIndex: 0,
1802
- availableLabels: cachedLabels,
1803
- loading: false,
1804
- })
1805
- return
1806
- }
1807
-
1808
- setLabelModal({ repository, query: "", selectedIndex: 0, availableLabels: [], loading: true })
1809
- void loadRepoLabels(repository)
1810
- .then((labels) => {
1811
- setLabelCache((current) => ({ ...current, [repository]: labels }))
1812
- setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
1813
- })
1814
- .catch((error) => {
1815
- setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
1816
- flashNotice(errorMessage(error))
1817
- })
1818
- }
1819
-
1820
- const openMergeModal = () => {
1821
- if (!selectedPullRequest) return
1822
- const repository = selectedPullRequest.repository
1823
- const number = selectedPullRequest.number
1824
- const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
1825
- setMergeModal({
1826
- repository,
1827
- number,
1828
- selectedIndex: 0,
1829
- loading: true,
1830
- running: false,
1831
- info: seededInfo,
1832
- error: null,
1833
- })
1834
- void getPullRequestMergeInfo({ repository, number })
1835
- .then((info) => {
1836
- setMergeModal((current) => current.repository === repository && current.number === number
1837
- ? { ...current, loading: false, info, selectedIndex: 0 }
1838
- : current)
1839
- })
1840
- .catch((error) => {
1841
- setMergeModal((current) => current.repository === repository && current.number === number
1842
- ? { ...current, loading: false, error: errorMessage(error) }
1843
- : current)
1844
- })
1845
- }
1846
-
1847
- const confirmMergeAction = () => {
1848
- if (!mergeModal.info || mergeModal.loading || mergeModal.running) return
1849
- const options = availableMergeActions(mergeModal.info)
1850
- const option = options[mergeModal.selectedIndex]
1851
- if (!option) return
1852
-
1853
- const { repository, number } = mergeModal.info
1854
- const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
1855
- const previousPullRequest = targetPullRequest ?? null
1856
-
1857
- if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
1858
- updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
1859
- }
1860
- if (targetPullRequest && option.optimisticState === "merged") markPullRequestCompleted(targetPullRequest, "merged")
1861
-
1862
- closeActiveModal()
1863
- void mergePullRequest({ repository, number, action: option.action })
1864
- .then(() => {
1865
- if (option.refreshOnSuccess) {
1866
- refreshPullRequests(`${option.pastTense} #${number}`)
1867
- } else {
1868
- flashNotice(`${option.pastTense} #${number}`)
1869
- }
1870
- })
1871
- .catch((error) => {
1872
- if (previousPullRequest) restoreOptimisticPullRequest(previousPullRequest)
1873
- flashNotice(errorMessage(error))
1874
- })
1875
- }
1876
-
1877
- const toggleLabelAtIndex = () => {
1878
- if (!selectedPullRequest) return
1879
- const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1880
- const label = filtered[labelModal.selectedIndex]
1881
- if (!label) return
1882
-
1883
- const isActive = selectedPullRequest.labels.some((l) => l.name.toLowerCase() === label.name.toLowerCase())
1884
- const previousPullRequest = selectedPullRequest
1885
-
1886
- if (isActive) {
1887
- updatePullRequest(selectedPullRequest.url, (pr) => ({
1888
- ...pr,
1889
- labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
1890
- }))
1891
- void removePullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1892
- .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
1893
- .catch((error) => {
1894
- updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1895
- flashNotice(errorMessage(error))
1896
- })
1897
- } else {
1898
- updatePullRequest(selectedPullRequest.url, (pr) => ({
1899
- ...pr,
1900
- labels: [...pr.labels, { name: label.name, color: label.color }],
1901
- }))
1902
- void addPullRequestLabel({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, label: label.name })
1903
- .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
1904
- .catch((error) => {
1905
- updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1906
- flashNotice(errorMessage(error))
1907
- })
1908
- }
1909
- }
1910
-
1911
- const openCommandPalette = () => {
1912
- setCommandPalette(initialCommandPaletteState)
1913
- }
1914
- const openRepositoryPicker = () => {
1915
- setOpenRepositoryModal({ query: selectedRepository ?? "", error: null })
1916
- }
1917
- const openRepositoryFromInput = () => {
1918
- const repository = parseRepositoryInput(openRepositoryModal.query)
1919
- if (!repository) {
1920
- setOpenRepositoryModal((current) => ({ ...current, error: "Enter a repository as owner/name or a GitHub URL." }))
1921
- return
1922
- }
1923
- closeActiveModal()
1924
- switchViewTo({ _tag: "Repository", repository })
1925
- flashNotice(`Opened ${repository}`)
1926
- }
1927
- const insertPastedText = (text: string) => {
1928
- if (text.length === 0) return false
1929
- if (commandPaletteActive) {
1930
- setCommandPalette((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1931
- return true
1932
- }
1933
- if (openRepositoryModalActive) {
1934
- setOpenRepositoryModal((current) => ({ ...current, query: current.query + singleLineText(text), error: null }))
1935
- return true
1936
- }
1937
- if (themeModalActive && themeModal.filterMode) {
1938
- editThemeQuery((query) => query + singleLineText(text))
1939
- return true
1940
- }
1941
- if (commentModalActive) {
1942
- editComment((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1943
- return true
1944
- }
1945
- if (submitReviewModalActive) {
1946
- editSubmitReview((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1947
- return true
1948
- }
1949
- if (labelModalActive) {
1950
- setLabelModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1951
- return true
1952
- }
1953
- if (changedFilesModalActive) {
1954
- setChangedFilesModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1955
- return true
1956
- }
1957
- if (filterMode) {
1958
- setFilterDraft((current) => current + singleLineText(text))
1959
- return true
1960
- }
1961
- return false
1962
- }
1963
-
1964
- useEffect(() => {
1965
- const handlePaste = (event: PasteEvent) => {
1966
- if (insertPastedText(pasteText(event))) event.preventDefault()
1967
- }
1968
- const keyInput = renderer.keyInput as unknown as {
1969
- on: (event: "paste", handler: (event: PasteEvent) => void) => void
1970
- off: (event: "paste", handler: (event: PasteEvent) => void) => void
1971
- }
1972
- keyInput.on("paste", handlePaste)
1973
- return () => {
1974
- keyInput.off("paste", handlePaste)
1975
- }
1976
- }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, submitReviewModalActive, labelModalActive, changedFilesModalActive, filterMode])
1977
-
1978
- const appCommands: readonly AppCommand[] = buildAppCommands({
1979
- pullRequestStatus,
1980
- filterQuery,
1981
- filterMode,
1982
- selectedRepository,
1983
- activeViews,
1984
- activeView,
1985
- loadedPullRequestCount,
1986
- hasMorePullRequests,
1987
- isLoadingMorePullRequests,
1988
- selectedPullRequest,
1989
- detailFullView,
1990
- diffFullView,
1991
- diffReady: selectedDiffState?._tag === "Ready",
1992
- effectiveDiffRenderView,
1993
- diffWrapMode,
1994
- diffWhitespaceMode,
1995
- readyDiffFileCount: readyDiffFiles.length,
1996
- diffFileIndex,
1997
- diffRangeActive: diffCommentRangeActive,
1998
- selectedDiffCommentAnchorLabel: selectedDiffCommentLabel,
1999
- selectedDiffCommentThreadCount: selectedDiffCommentThread.length,
2000
- hasDiffCommentThreads: diffCommentThreadAnchors.length > 0,
2001
- actions: {
2002
- openCommandPalette,
2003
- refreshPullRequests,
2004
- openFilter: () => {
2005
- setFilterDraft(filterQuery)
2006
- setFilterMode(true)
2007
- },
2008
- clearFilter: () => {
2009
- setFilterQuery("")
2010
- setFilterDraft("")
2011
- setFilterMode(false)
2012
- },
2013
- openThemeModal,
2014
- openRepositoryPicker,
2015
- loadMorePullRequests,
2016
- switchViewTo,
2017
- openDetails: () => {
2018
- setDetailFullView(true)
2019
- setDetailScrollOffset(0)
2020
- },
2021
- closeDetails: () => {
2022
- setDetailFullView(false)
2023
- setDetailScrollOffset(0)
2024
- },
2025
- openDiffView,
2026
- closeDiffView: () => {
2027
- setDiffFullView(false)
2028
- setDiffCommentRangeStartIndex(null)
2029
- },
2030
- reloadDiff: () => {
2031
- if (!selectedPullRequest) return
2032
- loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
2033
- flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
2034
- },
2035
- toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
2036
- toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
2037
- toggleDiffWhitespaceMode,
2038
- openChangedFilesModal,
2039
- jumpDiffFile,
2040
- openSelectedDiffComment,
2041
- toggleDiffCommentRange,
2042
- moveDiffCommentThread,
2043
- openDiffCommentModal,
2044
- openSubmitReviewModal,
2045
- togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
2046
- openLabelModal,
2047
- openMergeModal,
2048
- openCloseModal,
2049
- openPullRequestInBrowser: () => {
2050
- if (selectedPullRequest) openSelectedPullRequestInBrowser(selectedPullRequest)
2051
- },
2052
- copyPullRequestMetadata: copySelectedPullRequestMetadata,
2053
- quit: () => renderer.destroy(),
2054
- },
2055
- })
2056
- const runCommand = (command: AppCommand, options: { readonly notifyDisabled?: boolean; readonly closePalette?: boolean } = {}) => {
2057
- if (!commandEnabled(command)) {
2058
- if (options.notifyDisabled && command.disabledReason) flashNotice(command.disabledReason)
2059
- return false
2060
- }
2061
- if (options.closePalette) closeActiveModal()
2062
- command.run()
2063
- return true
2064
- }
2065
- const runCommandById = (id: string, options: { readonly notifyDisabled?: boolean } = {}) => {
2066
- const command = appCommands.find((entry) => entry.id === id)
2067
- return command ? runCommand(command, options) : false
2068
- }
2069
- const runCommandByIdRef = useRef(runCommandById)
2070
- runCommandByIdRef.current = runCommandById
2071
- const dynamicPaletteCommands: readonly AppCommand[] = (() => {
2072
- if (!commandPaletteActive) return []
2073
- const repository = parseRepositoryInput(commandPalette.query)
2074
- if (!repository || repository === selectedRepository) return []
2075
- return [defineCommand({
2076
- id: `view.repository.dynamic:${repository}`,
2077
- title: `Open ${repository}`,
2078
- scope: "View",
2079
- subtitle: "Switch to this repository",
2080
- run: () => switchViewTo({ _tag: "Repository", repository }),
2081
- })]
2082
- })()
2083
- // Dynamic commands always pin to the top of the palette; they came directly from the
2084
- // user's typed input so they shouldn't be filtered by fuzzy score against themselves.
2085
- const staticPaletteCommands = commandPaletteActive
2086
- ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)
2087
- : []
2088
- const commandPaletteCommands = commandPaletteActive
2089
- ? [
2090
- ...dynamicPaletteCommands,
2091
- ...(commandPalette.query.trim().length > 0 ? staticPaletteCommands : sortCommandsByScope(staticPaletteCommands)),
2092
- ]
2093
- : []
2094
- const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
2095
- const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
2096
-
2097
- // === Helpers used by the keymap layers ===
2098
- const moveMergeSelection = (delta: -1 | 1) => setMergeModal((current) => {
2099
- const max = Math.max(0, availableMergeActions(mergeModal.info).length - 1)
2100
- return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
2101
- })
2102
- const scrollCommentThread = (delta: number) => setCommentThreadModal((current) => ({
2103
- ...current,
2104
- scrollOffset: Math.max(0, current.scrollOffset + delta),
2105
- }))
2106
- const moveLabelSelection = (delta: -1 | 1) => setLabelModal((current) => {
2107
- const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
2108
- return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
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
- })
2119
- const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
2120
- const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
2121
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2122
- })
2123
- const selectCommandPaletteIndex = (index: number) => setCommandPalette((current) => {
2124
- const selectedIndex = clampCommandIndex(index, commandPaletteCommands)
2125
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2126
- })
2127
- const runCommandPaletteCommand = (command: AppCommand) => {
2128
- runCommand(command, { notifyDisabled: true, closePalette: true })
2129
- }
2130
- const scrollDetailFullViewBy = (delta: number) => {
2131
- detailScrollRef.current?.scrollBy({ x: 0, y: delta })
2132
- setDetailScrollOffset((current) => Math.max(0, current + delta))
2133
- }
2134
- const scrollDetailFullViewTo = (y: number) => {
2135
- detailScrollRef.current?.scrollTo({ x: 0, y })
2136
- setDetailScrollOffset(y)
2137
- }
2138
- const moveSelectedToPreviousGroup = () => setSelectedIndex((current) => {
2139
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2140
- const currentGroup = getCurrentGroupIndex(current)
2141
- if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
2142
- return groupStarts[currentGroup - 1]!
2143
- })
2144
- const moveSelectedToNextGroup = () => setSelectedIndex((current) => {
2145
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2146
- const currentGroup = getCurrentGroupIndex(current)
2147
- if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
2148
- return groupStarts[currentGroup + 1]!
2149
- })
2150
- const stepSelected = (delta: number) => setSelectedIndex((current) => {
2151
- if (visiblePullRequests.length === 0) return 0
2152
- return Math.max(0, Math.min(visiblePullRequests.length - 1, current + delta))
2153
- })
2154
- const stepSelectedDown = (count = 1) => {
2155
- if (visiblePullRequests.length === 0) return
2156
- if (selectedIndex + count >= visiblePullRequests.length && hasMorePullRequests) {
2157
- loadMorePullRequests()
2158
- }
2159
- stepSelected(count)
2160
- }
2161
- const stepSelectedUp = (count = 1) => stepSelected(-count)
2162
- const stepSelectedDownWithLoadMore = () => {
2163
- if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2164
- loadMorePullRequests()
2165
- return
2166
- }
2167
- setSelectedIndex((current) => {
2168
- if (visiblePullRequests.length === 0) return 0
2169
- return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2170
- })
2171
- }
2172
- const stepSelectedUpWrap = () => setSelectedIndex((current) => {
2173
- if (visiblePullRequests.length === 0) return 0
2174
- return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2175
- })
2176
- const handleQuitOrClose = () => {
2177
- if (themeModalActive) {
2178
- closeThemeModal(false)
2179
- return
2180
- }
2181
- if (activeModal._tag !== "None") {
2182
- closeActiveModal()
2183
- return
2184
- }
2185
- runCommandById("app.quit")
2186
- }
2187
-
2188
- // === Build the keymap context ===
2189
- const appCtx: AppCtx = {
2190
- closeModalActive,
2191
- mergeModalActive,
2192
- commentThreadModalActive,
2193
- changedFilesModalActive,
2194
- submitReviewModalActive,
2195
- labelModalActive,
2196
- themeModalActive,
2197
- openRepositoryModalActive,
2198
- commentModalActive,
2199
- commandPaletteActive,
2200
- filterMode,
2201
- diffFullView,
2202
- detailFullView,
2203
- textInputActive: commentModalActive
2204
- || commandPaletteActive
2205
- || openRepositoryModalActive
2206
- || changedFilesModalActive
2207
- || submitReviewModalActive
2208
- || labelModalActive
2209
- || filterMode
2210
- || (themeModalActive && themeModal.filterMode),
2211
- closeModal: {
2212
- closeModal: closeActiveModal,
2213
- confirmClose: confirmClosePullRequest,
2214
- },
2215
- mergeModal: {
2216
- availableActionCount: availableMergeActions(mergeModal.info).length,
2217
- closeModal: closeActiveModal,
2218
- confirmMerge: confirmMergeAction,
2219
- moveSelection: moveMergeSelection,
2220
- },
2221
- commentThreadModal: {
2222
- halfPage,
2223
- closeModal: closeActiveModal,
2224
- openInlineComment: openDiffCommentModal,
2225
- scrollBy: scrollCommentThread,
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
- },
2253
- labelModal: {
2254
- closeModal: closeActiveModal,
2255
- toggleSelected: toggleLabelAtIndex,
2256
- moveSelection: moveLabelSelection,
2257
- },
2258
- themeModal: {
2259
- filterMode: themeModal.filterMode,
2260
- hasFilteredResults: filterThemeDefinitions(themeModal.query).length > 0,
2261
- closeWithoutSaving: () => closeThemeModal(false),
2262
- clearFilter: () => updateThemeQuery("", { filterMode: false }),
2263
- enterFilterMode: () => updateThemeQuery("", { filterMode: true }),
2264
- confirmSelection: () => closeThemeModal(true),
2265
- moveSelection: moveThemeSelection,
2266
- },
2267
- openRepositoryModal: {
2268
- closeModal: closeActiveModal,
2269
- openFromInput: openRepositoryFromInput,
2270
- },
2271
- commentModal: {
2272
- closeModal: closeActiveModal,
2273
- submit: submitDiffComment,
2274
- insertNewline: () => editComment((state) => insertText(state, "\n")),
2275
- moveLeft: () => editComment(editorMoveLeft),
2276
- moveRight: () => editComment(editorMoveRight),
2277
- moveUp: () => editComment((state) => moveVertically(state, -1)),
2278
- moveDown: () => editComment((state) => moveVertically(state, 1)),
2279
- moveLineStart: () => editComment(moveLineStart),
2280
- moveLineEnd: () => editComment(moveLineEnd),
2281
- moveWordBackward: () => editComment(moveWordBackward),
2282
- moveWordForward: () => editComment(moveWordForward),
2283
- backspace: () => editComment(editorBackspace),
2284
- deleteForward: () => editComment(editorDeleteForward),
2285
- deleteWordBackward: () => editComment(deleteWordBackward),
2286
- deleteWordForward: () => editComment(deleteWordForward),
2287
- deleteToLineStart: () => editComment(deleteToLineStart),
2288
- deleteToLineEnd: () => editComment(deleteToLineEnd),
2289
- },
2290
- commandPalette: {
2291
- closeModal: closeActiveModal,
2292
- runSelected: () => {
2293
- if (selectedCommand) runCommandPaletteCommand(selectedCommand)
2294
- },
2295
- moveSelection: moveCommandPaletteSelection,
2296
- },
2297
- filterModeCtx: {
2298
- cancel: () => {
2299
- setFilterDraft(filterQuery)
2300
- setFilterMode(false)
2301
- },
2302
- commit: () => {
2303
- setFilterQuery(filterDraft)
2304
- setFilterMode(false)
2305
- },
2306
- },
2307
- diff: {
2308
- halfPage,
2309
- handleEscape: () => {
2310
- if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2311
- else runCommandById("diff.close")
2312
- },
2313
- openSelectedComment: openSelectedDiffComment,
2314
- toggleRange: () => runCommandById("diff.toggle-range"),
2315
- toggleView: () => runCommandById("diff.toggle-view"),
2316
- toggleWrap: () => runCommandById("diff.toggle-wrap"),
2317
- reload: () => runCommandById("diff.reload"),
2318
- nextThread: () => runCommandById("diff.next-thread"),
2319
- previousThread: () => runCommandById("diff.previous-thread"),
2320
- moveAnchor: moveDiffCommentAnchor,
2321
- moveAnchorToBoundary: moveDiffCommentToBoundary,
2322
- alignAnchor: alignSelectedDiffCommentAnchor,
2323
- selectSide: selectDiffCommentSide,
2324
- openChangedFiles: () => runCommandById("diff.changed-files"),
2325
- openSubmitReview: () => runCommandById("diff.submit-review"),
2326
- nextFile: () => runCommandById("diff.next-file"),
2327
- previousFile: () => runCommandById("diff.previous-file"),
2328
- openInBrowser: () => runCommandById("pull.open-browser"),
2329
- },
2330
- detail: {
2331
- halfPage,
2332
- scrollBy: scrollDetailFullViewBy,
2333
- scrollTo: scrollDetailFullViewTo,
2334
- closeDetail: () => runCommandById("detail.close"),
2335
- openTheme: () => runCommandById("theme.open"),
2336
- openDiff: () => runCommandById("diff.open"),
2337
- closePullRequest: () => runCommandById("pull.close"),
2338
- openLabels: () => runCommandById("pull.labels"),
2339
- openMerge: () => runCommandById("pull.merge"),
2340
- toggleDraft: () => runCommandById("pull.toggle-draft"),
2341
- refresh: () => runCommandById("pull.refresh"),
2342
- openInBrowser: () => runCommandById("pull.open-browser"),
2343
- copyMetadata: () => runCommandById("pull.copy-metadata"),
2344
- },
2345
- listNav: {
2346
- halfPage,
2347
- visibleCount: visiblePullRequests.length,
2348
- hasFilter: filterQuery.length > 0,
2349
- canScrollDetailPreview: isWideLayout && selectedPullRequest !== null,
2350
- runCommandById: (id) => { runCommandById(id) },
2351
- switchQueueMode,
2352
- scrollDetailPreviewBy,
2353
- scrollDetailPreviewTo,
2354
- clearFilter: () => { runCommandById("filter.clear") },
2355
- stepSelected,
2356
- stepSelectedUp,
2357
- stepSelectedDown,
2358
- stepSelectedUpWrap,
2359
- stepSelectedDownWithLoadMore,
2360
- moveSelectedToPreviousGroup,
2361
- moveSelectedToNextGroup,
2362
- setSelected: (index) => setSelectedIndex(index),
2363
- },
2364
- openCommandPalette: () => { runCommandById("command.open") },
2365
- handleQuitOrClose,
2366
- }
2367
-
2368
- useKeymap(appKeymap, appCtx, useOpenTuiSubscribe())
2369
-
2370
- useKeyboard((key) => {
2371
- if (commandPaletteActive) {
2372
- if (isSingleLineInputKey(key)) {
2373
- setCommandPalette((current) => {
2374
- const query = editSingleLineInput(current.query, key) ?? current.query
2375
- return current.query === query && current.selectedIndex === 0 ? current : { ...current, query, selectedIndex: 0 }
2376
- })
2377
- }
2378
- return
2379
- }
2380
-
2381
- if (openRepositoryModalActive) {
2382
- if (isSingleLineInputKey(key)) {
2383
- setOpenRepositoryModal((current) => ({
2384
- ...current,
2385
- query: editSingleLineInput(current.query, key) ?? current.query,
2386
- error: null,
2387
- }))
2388
- }
2389
- return
2390
- }
2391
-
2392
- // q / ctrl+c quit/close-modal logic now lives in the keymap layer
2393
- // (handleQuitOrClose). This useKeyboard callback only handles raw text
2394
- // input for modals that need character-by-character accumulation.
2395
-
2396
- if (themeModalActive) {
2397
- if (themeModal.filterMode && isSingleLineInputKey(key)) {
2398
- editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
2399
- }
2400
- return
2401
- }
2402
-
2403
- if (commentModalActive) {
2404
- const text = printableKeyText(key)
2405
- if (text) editComment((state) => insertText(state, text))
2406
- return
2407
- }
2408
-
2409
- if (submitReviewModalActive) {
2410
- const text = printableKeyText(key)
2411
- if (text) editSubmitReview((state) => insertText(state, text))
2412
- return
2413
- }
2414
-
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
- }
2424
- if (labelModalActive) {
2425
- if (isSingleLineInputKey(key)) {
2426
- setLabelModal((current) => ({
2427
- ...current,
2428
- query: editSingleLineInput(current.query, key) ?? current.query,
2429
- selectedIndex: 0,
2430
- }))
2431
- }
2432
- return
2433
- }
2434
-
2435
- if (filterMode) {
2436
- if (isSingleLineInputKey(key)) {
2437
- setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
2438
- }
2439
- }
2440
- })
2441
-
2442
- if (isInitialLoading) {
2443
- return (
2444
- <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
2445
- <LoadingLogoPane content={detailPlaceholderContent} width={contentWidth} height={terminalHeight} frame={loadingFrame} />
2446
- </box>
2447
- )
2448
- }
2449
-
2450
- const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2451
- const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
2452
- const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout)
2453
- const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight)
2454
- const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth, selectedConversationItems, selectedConversationStatus)
2455
- const fullscreenDetailBodyScrollable = fullscreenDetailBodyHeight > fullscreenDetailBodyViewportHeight
2456
- const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2457
- const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2458
- const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
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
- })
2470
-
2471
- const prListProps = {
2472
- groups: visibleGroups,
2473
- selectedUrl: selectedPullRequest?.url ?? null,
2474
- status: pullRequestStatus,
2475
- error: pullRequestError,
2476
- filterText: visibleFilterText,
2477
- showFilterBar: filterMode || filterQuery.length > 0,
2478
- isFilterEditing: filterMode,
2479
- loadedCount: loadedPullRequestCount,
2480
- hasMore: hasMorePullRequests,
2481
- isLoadingMore: isLoadingMorePullRequests,
2482
- loadingIndicator,
2483
- onSelectPullRequest: selectPullRequestByUrl,
2484
- } as const
2485
-
2486
- const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
2487
- const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
2488
- const labelModalHeight = Math.min(20, terminalHeight - 4)
2489
- const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
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)
2496
- const sizedModal = (minW: number, maxW: number, padX: number, maxH: number) => {
2497
- const w = Math.min(maxW, Math.max(minW, contentWidth - padX))
2498
- const h = Math.min(maxH, terminalHeight - 4)
2499
- return { width: w, height: h, left: centeredOffset(contentWidth, w), top: centeredOffset(terminalHeight, h) }
2500
- }
2501
- const closeLayout = sizedModal(46, 68, 12, 12)
2502
- const closeModalWidth = closeLayout.width
2503
- const closeModalHeight = closeLayout.height
2504
- const closeModalLeft = closeLayout.left
2505
- const closeModalTop = closeLayout.top
2506
- const commentLayout = sizedModal(46, 76, 8, 16)
2507
- const commentModalWidth = commentLayout.width
2508
- const commentModalHeight = commentLayout.height
2509
- const commentModalLeft = commentLayout.left
2510
- const commentModalTop = commentLayout.top
2511
- const commentThreadLayout = sizedModal(50, 86, 8, 22)
2512
- const commentThreadModalWidth = commentThreadLayout.width
2513
- const commentThreadModalHeight = commentThreadLayout.height
2514
- const commentThreadModalLeft = commentThreadLayout.left
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
2521
- const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2522
- ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2523
- : "No diff line selected"
2524
- const mergeLayout = sizedModal(46, 68, 12, 16)
2525
- const mergeModalWidth = mergeLayout.width
2526
- const mergeModalHeight = mergeLayout.height
2527
- const mergeModalLeft = mergeLayout.left
2528
- const mergeModalTop = mergeLayout.top
2529
- const themeLayout = sizedModal(38, 58, 12, 16)
2530
- const themeModalWidth = themeLayout.width
2531
- const themeModalHeight = themeLayout.height
2532
- const themeModalLeft = themeLayout.left
2533
- const themeModalTop = themeLayout.top
2534
- const openRepositoryLayout = sizedModal(46, 76, 8, 8)
2535
- const openRepositoryModalWidth = openRepositoryLayout.width
2536
- const openRepositoryModalHeight = openRepositoryLayout.height
2537
- const openRepositoryModalLeft = openRepositoryLayout.left
2538
- const openRepositoryModalTop = openRepositoryLayout.top
2539
- const commandPaletteLayout = sizedModal(50, 88, 8, 24)
2540
- const commandPaletteWidth = commandPaletteLayout.width
2541
- const commandPaletteHeight = commandPaletteLayout.height
2542
- const commandPaletteLeft = commandPaletteLayout.left
2543
- const commandPaletteTop = commandPaletteLayout.top
2544
-
2545
- return (
2546
- <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
2547
- <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
2548
- <PlainLine text={headerLine} fg={colors.muted} bold />
2549
- </box>
2550
- {isWideLayout && !detailFullView && !diffFullView ? (
2551
- <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
2552
- ) : (
2553
- <Divider width={contentWidth} />
2554
- )}
2555
- {diffFullView ? (
2556
- <PullRequestDiffPane
2557
- pullRequest={selectedPullRequest}
2558
- diffState={displayedDiffState}
2559
- stackedFiles={stackedDiffFiles}
2560
- scrollTop={diffScrollTop}
2561
- view={effectiveDiffRenderView}
2562
- whitespaceMode={diffWhitespaceMode}
2563
- wrapMode={diffWrapMode}
2564
- paneWidth={contentWidth}
2565
- height={wideBodyHeight}
2566
- loadingIndicator={loadingIndicator}
2567
- scrollRef={diffScrollRef}
2568
- setDiffRef={setDiffRenderableRef}
2569
- selectedCommentAnchor={selectedDiffCommentAnchor}
2570
- selectedCommentLabel={selectedDiffCommentLabel}
2571
- selectedCommentThread={selectedDiffCommentThread}
2572
- onSelectCommentLine={selectDiffCommentLine}
2573
- themeId={themeId}
2574
- />
2575
- ) : detailFullView && isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2576
- <box flexGrow={1} flexDirection="column">
2577
- <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks={isWideLayout} />
2578
- <LoadingPane content={detailLoadingContent} width={contentWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout))} />
2579
- </box>
2580
- ) : isWideLayout && detailFullView ? (
2581
- <box flexGrow={1} flexDirection="column">
2582
- {selectedPullRequest ? (
2583
- <>
2584
- <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks />
2585
- <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2586
- <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2587
- </scrollbox>
2588
- </>
2589
- ) : (
2590
- <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2591
- )}
2592
- </box>
2593
- ) : isWideLayout ? (
2594
- <box key="wide-main" flexGrow={1} flexDirection="row">
2595
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
2596
- <scrollbox ref={prListScrollRef} focusable={false} height={wideBodyHeight} flexGrow={0}>
2597
- <box paddingLeft={sectionPadding} paddingRight={0}>
2598
- <PullRequestList key={`wide-${leftContentWidth}`} {...prListProps} contentWidth={leftContentWidth} />
2599
- </box>
2600
- </scrollbox>
2601
- </box>
2602
- <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
2603
- <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
2604
- {isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2605
- <>
2606
- <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2607
- <LoadingPane content={detailLoadingContent} width={rightPaneWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true))} />
2608
- </>
2609
- ) : selectedPullRequest ? (
2610
- <>
2611
- <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2612
- <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2613
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2614
- </scrollbox>
2615
- </>
2616
- ) : (
2617
- <DetailPlaceholder content={detailPlaceholderContent} paneWidth={rightPaneWidth} />
2618
- )}
2619
- </box>
2620
- </box>
2621
- ) : detailFullView ? (
2622
- <box flexGrow={1} flexDirection="column">
2623
- {selectedPullRequest ? (
2624
- <>
2625
- <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} />
2626
- <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2627
- <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2628
- </scrollbox>
2629
- </>
2630
- ) : (
2631
- <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2632
- )}
2633
- </box>
2634
- ) : (
2635
- <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2636
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2637
- <Divider width={contentWidth} />
2638
- <box flexGrow={1} flexDirection="column">
2639
- <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
2640
- <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2641
- <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
2642
- </box>
2643
- </scrollbox>
2644
- </box>
2645
- </box>
2646
- )}
2647
-
2648
- {isWideLayout && !detailFullView && !diffFullView ? (
2649
- <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
2650
- ) : (
2651
- <Divider width={contentWidth} />
2652
- )}
2653
- <box paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
2654
- {footerNotice ? (
2655
- <PlainLine text={footerNotice} fg={colors.count} />
2656
- ) : (
2657
- <FooterHints
2658
- filterEditing={filterMode}
2659
- showFilterClear={filterMode || filterQuery.length > 0}
2660
- detailFullView={detailFullView}
2661
- diffFullView={diffFullView}
2662
- diffRangeActive={diffCommentRangeActive}
2663
- hasSelection={selectedPullRequest !== null}
2664
- canCloseSelection={selectedPullRequest?.state === "open"}
2665
- hasError={pullRequestStatus === "error"}
2666
- isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running || submitReviewModal.running}
2667
- loadingIndicator={loadingIndicator}
2668
- retryProgress={retryProgress}
2669
- />
2670
- )}
2671
- </box>
2672
- {labelModalActive ? (
2673
- <LabelModal
2674
- state={labelModal}
2675
- currentLabels={selectedPullRequest?.labels ?? []}
2676
- modalWidth={labelModalWidth}
2677
- modalHeight={labelModalHeight}
2678
- offsetLeft={labelModalLeft}
2679
- offsetTop={labelModalTop}
2680
- loadingIndicator={loadingIndicator}
2681
- />
2682
- ) : null}
2683
- {closeModalActive ? (
2684
- <CloseModal
2685
- state={closeModal}
2686
- modalWidth={closeModalWidth}
2687
- modalHeight={closeModalHeight}
2688
- offsetLeft={closeModalLeft}
2689
- offsetTop={closeModalTop}
2690
- loadingIndicator={loadingIndicator}
2691
- />
2692
- ) : null}
2693
- {commentModalActive ? (
2694
- <CommentModal
2695
- state={commentModal}
2696
- anchorLabel={commentAnchorLabel}
2697
- modalWidth={commentModalWidth}
2698
- modalHeight={commentModalHeight}
2699
- offsetLeft={commentModalLeft}
2700
- offsetTop={commentModalTop}
2701
- />
2702
- ) : null}
2703
- {commentThreadModalActive ? (
2704
- <CommentThreadModal
2705
- state={commentThreadModal}
2706
- anchorLabel={commentAnchorLabel}
2707
- comments={selectedDiffCommentThread}
2708
- modalWidth={commentThreadModalWidth}
2709
- modalHeight={commentThreadModalHeight}
2710
- offsetLeft={commentThreadModalLeft}
2711
- offsetTop={commentThreadModalTop}
2712
- />
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}
2735
- {mergeModalActive ? (
2736
- <MergeModal
2737
- state={mergeModal}
2738
- modalWidth={mergeModalWidth}
2739
- modalHeight={mergeModalHeight}
2740
- offsetLeft={mergeModalLeft}
2741
- offsetTop={mergeModalTop}
2742
- loadingIndicator={loadingIndicator}
2743
- />
2744
- ) : null}
2745
- {themeModalActive ? (
2746
- <ThemeModal
2747
- state={themeModal}
2748
- activeThemeId={themeId}
2749
- modalWidth={themeModalWidth}
2750
- modalHeight={themeModalHeight}
2751
- offsetLeft={themeModalLeft}
2752
- offsetTop={themeModalTop}
2753
- />
2754
- ) : null}
2755
- {openRepositoryModalActive ? (
2756
- <OpenRepositoryModal
2757
- state={openRepositoryModal}
2758
- modalWidth={openRepositoryModalWidth}
2759
- modalHeight={openRepositoryModalHeight}
2760
- offsetLeft={openRepositoryModalLeft}
2761
- offsetTop={openRepositoryModalTop}
2762
- />
2763
- ) : null}
2764
- {commandPaletteActive ? (
2765
- <CommandPalette
2766
- commands={commandPaletteCommands}
2767
- query={commandPalette.query}
2768
- selectedIndex={selectedCommandIndex}
2769
- modalWidth={commandPaletteWidth}
2770
- modalHeight={commandPaletteHeight}
2771
- offsetLeft={commandPaletteLeft}
2772
- offsetTop={commandPaletteTop}
2773
- onSelectCommandIndex={selectCommandPaletteIndex}
2774
- onRunCommand={runCommandPaletteCommand}
2775
- />
2776
- ) : null}
2777
- </box>
2778
- )
2779
- }