@kitlangton/ghui 0.2.1 → 0.3.2

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