@kitlangton/ghui 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -1,43 +1,71 @@
1
- import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
2
- import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
1
+ import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
+ import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
3
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
4
  import { Cause, Effect, Layer, Schedule } from "effect"
5
5
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
6
6
  import * as Atom from "effect/unstable/reactivity/Atom"
7
- import { useEffect, useMemo, useRef, useState } from "react"
7
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
8
+ import { useContext, useEffect, useMemo, useRef, useState } from "react"
9
+ import { buildAppCommands } from "./appCommands.js"
10
+ import type { AppCommand } from "./commands.js"
11
+ import { clampCommandIndex, commandEnabled, filterCommands } from "./commands.js"
8
12
  import { config } from "./config.js"
9
- import { pullRequestQueueLabels, pullRequestQueueModes, type CreatePullRequestCommentInput, type DiffCommentSide, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestQueueMode, type PullRequestReviewComment } from "./domain.js"
13
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
10
14
  import { formatShortDate, formatTimestamp } from "./date.js"
15
+ import { errorMessage } from "./errors.js"
11
16
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
12
17
  import { Observability } from "./observability.js"
18
+ import { mergeCachedDetails } from "./pullRequestCache.js"
19
+ import { activePullRequestViews, initialPullRequestView, nextView, parseRepositoryInput, type PullRequestView, viewCacheKey, viewEquals, viewLabel, viewMode, viewRepository } from "./pullRequestViews.js"
20
+ import { BrowserOpener } from "./services/BrowserOpener.js"
21
+ import { Clipboard } from "./services/Clipboard.js"
22
+ import { CommandRunner } from "./services/CommandRunner.js"
13
23
  import { GitHubService } from "./services/GitHubService.js"
14
24
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
15
- import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
25
+ import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
16
26
  import { backspace as editorBackspace, deleteForward as editorDeleteForward, deleteToLineEnd, deleteToLineStart, deleteWordBackward, deleteWordForward, insertText, moveLeft as editorMoveLeft, moveLineEnd, moveLineStart, moveRight as editorMoveRight, moveVertically, moveWordBackward, moveWordForward, type CommentEditorValue } from "./ui/commentEditor.js"
17
- import { buildStackedDiffFiles, diffCommentAnchorKey, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffCommentAnchor } from "./ui/diff.js"
18
- import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
19
- import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
27
+ import { buildStackedDiffFiles, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
28
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
29
+ import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
20
30
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
21
- import { CloseModal, CommentModal, CommentThreadModal, initialCloseModalState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialThemeModalState, LabelModal, MergeModal, Modal, ThemeModal, type CloseModalState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type ThemeModalState } from "./ui/modals.js"
31
+ import { CommandPalette } from "./ui/CommandPalette.js"
32
+ 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"
22
33
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
23
34
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
24
- import { PullRequestList } from "./ui/PullRequestList.js"
35
+ import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
36
+ import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
25
37
 
26
- const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
27
- const initialThemeId = await Effect.runPromise(loadStoredThemeId)
38
+ const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
39
+ if (value === undefined) return fallback
40
+ const parsed = Number.parseInt(value, 10)
41
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
42
+ }
43
+
44
+ const mockPrCount = parseOptionalPositiveInt(process.env.GHUI_MOCK_PR_COUNT, null)
45
+ const pullRequestPageSize = Math.min(100, parseOptionalPositiveInt(process.env.GHUI_PR_PAGE_SIZE, config.prPageSize) ?? config.prPageSize)
46
+ const githubServiceLayer = mockPrCount !== null
47
+ ? (await import("./services/MockGitHubService.js")).MockGitHubService.layer({ prCount: mockPrCount, repoCount: parseOptionalPositiveInt(process.env.GHUI_MOCK_REPO_COUNT, 4) ?? 4 })
48
+ : GitHubService.layerNoDeps
28
49
 
29
- type LoadStatus = "loading" | "ready" | "error"
50
+ const githubRuntime = Atom.runtime(
51
+ Layer.mergeAll(githubServiceLayer, Clipboard.layerNoDeps, BrowserOpener.layerNoDeps).pipe(
52
+ Layer.provide(CommandRunner.layer),
53
+ Layer.provideMerge(Observability.layer),
54
+ ),
55
+ )
56
+ const initialThemeId = await Effect.runPromise(loadStoredThemeId)
30
57
 
31
58
  interface PullRequestLoad {
32
- readonly queueMode: PullRequestQueueMode
59
+ readonly view: PullRequestView
33
60
  readonly data: readonly PullRequestItem[]
34
61
  readonly fetchedAt: Date | null
35
- readonly detailsFetchedAt: Date | null
62
+ readonly endCursor: string | null
63
+ readonly hasNextPage: boolean
36
64
  }
37
65
 
38
66
  interface DetailPlaceholderInput {
39
67
  readonly status: LoadStatus
40
- readonly retryProgress: RetryProgress | null
68
+ readonly retryProgress: RetryProgress
41
69
  readonly loadingIndicator: string
42
70
  readonly visibleCount: number
43
71
  readonly filterText: string
@@ -59,7 +87,7 @@ type DiffRenderableRuntimeSides = {
59
87
 
60
88
  interface AppliedDiffLineColor {
61
89
  readonly anchor: StackedDiffCommentAnchor
62
- readonly view: "unified" | "split"
90
+ readonly view: DiffView
63
91
  }
64
92
 
65
93
  interface AppliedDiffLineColorState {
@@ -67,85 +95,103 @@ interface AppliedDiffLineColorState {
67
95
  readonly entries: readonly AppliedDiffLineColor[]
68
96
  }
69
97
 
98
+ interface DetailHydration {
99
+ readonly token: symbol
100
+ notifyError: boolean
101
+ }
102
+
70
103
  const PR_FETCH_RETRIES = 6
71
104
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
72
105
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
73
106
  const AUTO_REFRESH_JITTER_MS = 10_000
107
+ const DIFF_STICKY_HEADER_LINES = 2
74
108
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
75
-
76
- const mergeCachedDetails = (fresh: readonly PullRequestItem[], cached: readonly PullRequestItem[] | undefined) => {
77
- if (!cached) return fresh
78
- const cachedByUrl = new Map(cached.map((pullRequest) => [pullRequest.url, pullRequest]))
79
- return fresh.map((pullRequest) => {
80
- const cachedPullRequest = cachedByUrl.get(pullRequest.url)
81
- if (!cachedPullRequest?.detailLoaded) return pullRequest
82
- return {
83
- ...pullRequest,
84
- body: cachedPullRequest.body,
85
- labels: cachedPullRequest.labels,
86
- additions: cachedPullRequest.additions,
87
- deletions: cachedPullRequest.deletions,
88
- changedFiles: cachedPullRequest.changedFiles,
89
- checkStatus: cachedPullRequest.checkStatus,
90
- checkSummary: cachedPullRequest.checkSummary,
91
- checks: cachedPullRequest.checks,
92
- detailLoaded: true,
93
- } satisfies PullRequestItem
94
- })
109
+ const MAX_REPOSITORY_CACHE_ENTRIES = 8
110
+ const LOAD_MORE_SELECTION_THRESHOLD = 8
111
+ const DETAIL_PREFETCH_BEHIND = 1
112
+ const DETAIL_PREFETCH_AHEAD = 3
113
+ const DETAIL_PREFETCH_CONCURRENCY = 3
114
+ const DETAIL_PREFETCH_DELAY_MS = 120
115
+
116
+ const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
117
+ const seen = new Set(existing.map((pullRequest) => pullRequest.url))
118
+ const mergedIncoming = mergeCachedDetails(incoming, existing)
119
+ return [...existing, ...mergedIncoming.filter((pullRequest) => !seen.has(pullRequest.url))]
95
120
  }
96
121
 
97
- const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
98
- const queueModeAtom = Atom.make<PullRequestQueueMode>("authored").pipe(Atom.keepAlive)
99
- const queueLoadCacheAtom = Atom.make<Partial<Record<PullRequestQueueMode, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
100
- const queueSelectionAtom = Atom.make<Partial<Record<PullRequestQueueMode, number>>>({}).pipe(Atom.keepAlive)
122
+ const retryProgressAtom = Atom.make<RetryProgress>(initialRetryProgress).pipe(Atom.keepAlive)
123
+ const activeViewAtom = Atom.make<PullRequestView>(initialPullRequestView(config.repository)).pipe(Atom.keepAlive)
124
+ const queueLoadCacheAtom = Atom.make<Partial<Record<string, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
125
+ const queueSelectionAtom = Atom.make<Partial<Record<string, number>>>({}).pipe(Atom.keepAlive)
126
+ const trimQueueLoadCache = (cache: Partial<Record<string, PullRequestLoad>>) => {
127
+ const repositoryKeys = Object.keys(cache).filter((key) => key.startsWith("repository:"))
128
+ if (repositoryKeys.length <= MAX_REPOSITORY_CACHE_ENTRIES) return cache
129
+ const remove = new Set(repositoryKeys.slice(0, repositoryKeys.length - MAX_REPOSITORY_CACHE_ENTRIES))
130
+ return Object.fromEntries(Object.entries(cache).filter(([key]) => !remove.has(key))) as Partial<Record<string, PullRequestLoad>>
131
+ }
101
132
  const pullRequestsAtom = githubRuntime.atom(
102
133
  GitHubService.use((github) =>
103
134
  Effect.gen(function*() {
104
- const queueMode = yield* Atom.get(queueModeAtom)
105
- yield* Atom.set(retryProgressAtom, null)
106
- const data = yield* github.listOpenPullRequests(queueMode).pipe(
107
- Effect.tapError(() =>
108
- Atom.update(retryProgressAtom, (current) => ({
109
- attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
110
- max: PR_FETCH_RETRIES,
111
- }))
112
- ),
113
- Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
114
- Effect.tapError(() => Atom.set(retryProgressAtom, null)),
115
- )
135
+ const view = yield* Atom.get(activeViewAtom)
136
+ const queueMode = viewMode(view)
137
+ const repository = viewRepository(view)
138
+ const cacheKey = viewCacheKey(view)
139
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
140
+ const page = yield* github.listOpenPullRequestPage({
141
+ mode: queueMode,
142
+ repository,
143
+ cursor: null,
144
+ pageSize: Math.min(pullRequestPageSize, config.prFetchLimit),
145
+ }).pipe(
146
+ Effect.tapError(() =>
147
+ Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
148
+ attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 1, PR_FETCH_RETRIES),
149
+ max: PR_FETCH_RETRIES,
150
+ }))
151
+ ),
152
+ Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
153
+ Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)),
154
+ )
116
155
 
117
- yield* Atom.set(retryProgressAtom, null)
156
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
118
157
  const cache = yield* Atom.get(queueLoadCacheAtom)
158
+ const existingLoad = cache[cacheKey]
159
+ const data = mergeCachedDetails(page.items, existingLoad?.data)
119
160
  const load = {
120
- queueMode,
121
- data: mergeCachedDetails(data, cache[queueMode]?.data),
161
+ view,
162
+ data,
122
163
  fetchedAt: new Date(),
123
- detailsFetchedAt: null,
164
+ endCursor: page.endCursor,
165
+ hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
124
166
  } satisfies PullRequestLoad
125
- yield* Atom.set(queueLoadCacheAtom, { ...cache, [queueMode]: load })
167
+ const nextCache = { ...cache }
168
+ delete nextCache[cacheKey]
169
+ nextCache[cacheKey] = load
170
+ yield* Atom.set(queueLoadCacheAtom, trimQueueLoadCache(nextCache))
126
171
  return load
127
172
  })
128
173
  ),
129
174
  ).pipe(Atom.keepAlive)
130
- const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
131
- const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
132
- const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
133
- const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
134
- const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
135
- const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
136
- const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
137
- const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
138
- const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
139
- const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
140
- const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
141
- const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
142
- const diffCommentModeAtom = Atom.make(false).pipe(Atom.keepAlive)
143
- const diffCommentAnchorIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
175
+ const selectedIndexAtom = Atom.make(0)
176
+ const noticeAtom = Atom.make<string | null>(null)
177
+ const filterQueryAtom = Atom.make("")
178
+ const filterDraftAtom = Atom.make("")
179
+ const filterModeAtom = Atom.make(false)
180
+ const pendingGAtom = Atom.make(false)
181
+ const detailFullViewAtom = Atom.make(false)
182
+ const detailScrollOffsetAtom = Atom.make(0)
183
+ const diffFullViewAtom = Atom.make(false)
184
+ const diffFileIndexAtom = Atom.make(0)
185
+ const diffScrollTopAtom = Atom.make(0)
186
+ const diffRenderViewAtom = Atom.make<DiffView>("split")
187
+ const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
188
+ const diffCommentModeAtom = Atom.make(false)
189
+ const diffCommentAnchorIndexAtom = Atom.make(0)
144
190
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
145
191
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
146
192
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
147
193
 
148
- const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
194
+ const activeModalAtom = Atom.make<Modal>(initialModal)
149
195
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
150
196
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
151
197
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -156,12 +202,110 @@ const usernameAtom = githubRuntime.atom(
156
202
  : Effect.succeed(config.author.replace(/^@/, "")),
157
203
  ).pipe(Atom.keepAlive)
158
204
 
205
+ const pullRequestLoadAtom = Atom.make((get) => {
206
+ const view = get(activeViewAtom)
207
+ const cacheKey = viewCacheKey(view)
208
+ const cache = get(queueLoadCacheAtom)
209
+ const result = get(pullRequestsAtom)
210
+ const resolved = AsyncResult.getOrElse(result, () => null)
211
+ return cache[cacheKey] ?? (resolved && viewCacheKey(resolved.view) === cacheKey ? resolved : null)
212
+ })
213
+
214
+ const isLoadingQueueModeAtom = Atom.make((get) => {
215
+ const cacheKey = viewCacheKey(get(activeViewAtom))
216
+ const resolved = AsyncResult.getOrElse(get(pullRequestsAtom), () => null)
217
+ return resolved !== null && viewCacheKey(resolved.view) !== cacheKey
218
+ })
219
+
220
+ const pullRequestStatusAtom = Atom.make((get): LoadStatus => {
221
+ const result = get(pullRequestsAtom)
222
+ const load = get(pullRequestLoadAtom)
223
+ const isLoadingQueue = get(isLoadingQueueModeAtom)
224
+ if ((result.waiting || isLoadingQueue) && load === null) return "loading"
225
+ return AsyncResult.isFailure(result) ? "error" : "ready"
226
+ })
227
+
228
+ const displayedPullRequestsAtom = Atom.make((get) => {
229
+ const load = get(pullRequestLoadAtom)
230
+ const overrides = get(pullRequestOverridesAtom)
231
+ const recentlyCompleted = get(recentlyCompletedPullRequestsAtom)
232
+ const source = load?.data ?? []
233
+ const seenUrls = new Set<string>()
234
+ const open = source.map((pullRequest) => {
235
+ seenUrls.add(pullRequest.url)
236
+ return recentlyCompleted[pullRequest.url] ?? overrides[pullRequest.url] ?? pullRequest
237
+ })
238
+ return [
239
+ ...open,
240
+ ...Object.values(recentlyCompleted).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
241
+ ]
242
+ })
243
+
244
+ const effectiveFilterQueryAtom = Atom.make((get) =>
245
+ (get(filterModeAtom) ? get(filterDraftAtom) : get(filterQueryAtom)).trim().toLowerCase(),
246
+ )
247
+
248
+ const filteredPullRequestsAtom = Atom.make((get) => {
249
+ const pullRequests = get(displayedPullRequestsAtom)
250
+ const query = get(effectiveFilterQueryAtom)
251
+ if (query.length === 0) return pullRequests
252
+ return pullRequests.flatMap((pullRequest) => {
253
+ const score = pullRequestFilterScore(pullRequest, query)
254
+ return score === null ? [] : [{ pullRequest, score }]
255
+ }).sort((left, right) =>
256
+ left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
257
+ ).map(({ pullRequest }) => pullRequest)
258
+ })
259
+
260
+ const visibleRepoOrderAtom = Atom.make((get) => {
261
+ const query = get(effectiveFilterQueryAtom)
262
+ if (query.length === 0) return [] as readonly string[]
263
+ return [...new Set(get(filteredPullRequestsAtom).map((pullRequest) => pullRequest.repository))]
264
+ })
265
+
266
+ const visibleGroupsAtom = Atom.make((get) =>
267
+ groupBy(get(filteredPullRequestsAtom), (pullRequest) => pullRequest.repository, get(visibleRepoOrderAtom)),
268
+ )
269
+
270
+ const visiblePullRequestsAtom = Atom.make((get) => get(visibleGroupsAtom).flatMap(([, pullRequests]) => pullRequests))
271
+
272
+ const groupStartsAtom = Atom.make((get) => {
273
+ const groups = get(visibleGroupsAtom)
274
+ const starts: number[] = []
275
+ for (let index = 0; index < groups.length; index++) {
276
+ if (index === 0) starts.push(0)
277
+ else starts.push(starts[index - 1]! + groups[index - 1]![1].length)
278
+ }
279
+ return starts
280
+ })
281
+
282
+ const selectedPullRequestAtom = Atom.make((get) => {
283
+ const pullRequests = get(visiblePullRequestsAtom)
284
+ const index = get(selectedIndexAtom)
285
+ return pullRequests[index] ?? null
286
+ })
287
+
288
+ const selectedDiffKeyAtom = Atom.make((get) => {
289
+ const pullRequest = get(selectedPullRequestAtom)
290
+ return pullRequest ? pullRequestDiffKey(pullRequest) : null
291
+ })
292
+
293
+ const selectedDiffStateAtom = Atom.make((get) => {
294
+ const key = get(selectedDiffKeyAtom)
295
+ if (!key) return undefined
296
+ return get(pullRequestDiffCacheAtom)[key]
297
+ })
298
+
159
299
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
160
300
  GitHubService.use((github) => github.listRepoLabels(repository))
161
301
  )
162
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
163
- GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
302
+ const listOpenPullRequestPageAtom = githubRuntime.fn<ListPullRequestPageInput>()((input) =>
303
+ GitHubService.use((github) => github.listOpenPullRequestPage(input))
164
304
  )
305
+ const pullRequestDetailsAtom = Atom.family((key: string) => {
306
+ const { repository, number } = parsePullRequestDetailAtomKey(key)
307
+ return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDetails(repository, number)))
308
+ })
165
309
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
166
310
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
167
311
  )
@@ -171,9 +315,10 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
171
315
  const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
172
316
  GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
173
317
  )
174
- const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
175
- GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
176
- )
318
+ const pullRequestDiffAtom = Atom.family((key: string) => {
319
+ const { repository, number } = parsePullRequestDiffAtomKey(key)
320
+ return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDiff(repository, number)))
321
+ })
177
322
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
178
323
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
179
324
  )
@@ -187,12 +332,13 @@ const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
187
332
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
188
333
  )
189
334
  const createPullRequestCommentAtom = githubRuntime.fn<CreatePullRequestCommentInput>()((input) => GitHubService.use((github) => github.createPullRequestComment(input)))
335
+ const copyToClipboardAtom = githubRuntime.fn<string>()((text) => Clipboard.use((clipboard) => clipboard.copy(text)))
336
+ const openInBrowserAtom = githubRuntime.fn<PullRequestItem>()((pullRequest) => BrowserOpener.use((browser) => browser.openPullRequest(pullRequest)))
190
337
 
191
338
  const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
192
339
 
193
- const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
194
340
 
195
- const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
341
+ const pasteText = (event: PasteEvent) => new TextDecoder().decode(event.bytes)
196
342
 
197
343
  const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
198
344
  const normalized = query.trim().toLowerCase()
@@ -209,91 +355,34 @@ const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) =>
209
355
  return scores.length > 0 ? Math.min(...scores) : null
210
356
  }
211
357
 
212
- const clipboardCommands = (): readonly (readonly string[])[] => {
213
- if (process.platform === "darwin") return [["pbcopy"]]
214
- if (process.platform === "linux") {
215
- return [
216
- ...(process.env.WAYLAND_DISPLAY ? [["wl-copy"]] : []),
217
- ["xclip", "-selection", "clipboard"],
218
- ["xsel", "--clipboard", "--input"],
219
- ]
220
- }
221
- return []
222
- }
223
-
224
- const copyToClipboard = async (text: string) => {
225
- const commands = clipboardCommands()
226
- let lastError = ""
227
-
228
- for (const command of commands) {
229
- let proc: Bun.Subprocess<"pipe", "ignore", "pipe">
230
- try {
231
- proc = Bun.spawn({
232
- cmd: [...command],
233
- stdin: "pipe",
234
- stdout: "ignore",
235
- stderr: "pipe",
236
- })
237
- } catch (error) {
238
- lastError = errorMessage(error)
239
- continue
240
- }
241
-
242
- proc.stdin.write(text)
243
- proc.stdin.end()
244
-
245
- const exitCode = await proc.exited
246
- if (exitCode === 0) return
247
-
248
- const stderr = await Bun.readableStreamToText(proc.stderr)
249
- lastError = stderr.trim()
250
- }
251
-
252
- const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
253
- throw new Error(lastError || `Clipboard is not available.${installHint}`)
254
- }
255
-
256
- const openPullRequestInBrowser = async (pullRequest: PullRequestItem) => {
257
- const proc = Bun.spawn({
258
- cmd: ["gh", "pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"],
259
- stdout: "ignore",
260
- stderr: "pipe",
261
- })
262
-
263
- const exitCode = await proc.exited
264
- if (exitCode === 0) return
265
-
266
- const stderr = await Bun.readableStreamToText(proc.stderr)
267
- throw new Error(stderr.trim() || "Could not open PR in browser")
268
- }
269
-
270
- const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
358
+ const pullRequestMetadataText = (pullRequest: PullRequestItem) => {
271
359
  const lines = [
272
360
  pullRequest.title,
273
361
  `${pullRequest.repository} #${pullRequest.number}`,
274
362
  pullRequest.url,
275
363
  ]
276
-
277
364
  const review = reviewLabel(pullRequest)
278
- if (review) {
279
- lines.push(`review: ${review}`)
280
- }
281
- if (pullRequest.checkSummary) {
282
- lines.push(pullRequest.checkSummary)
283
- }
365
+ if (review) lines.push(`review: ${review}`)
366
+ if (pullRequest.checkSummary) lines.push(pullRequest.checkSummary)
367
+ return lines.join("\n")
368
+ }
284
369
 
285
- await copyToClipboard(lines.join("\n"))
370
+ const pullRequestDetailKey = (pullRequest: PullRequestItem) => `${pullRequest.url}:${pullRequest.headRefOid}`
371
+ const pullRequestRevisionAtomKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}\u0000${pullRequest.number}\u0000${pullRequest.headRefOid}`
372
+ const parsePullRequestRevisionAtomKey = (key: string, label: string) => {
373
+ const [repository, number] = key.split("\u0000")
374
+ if (!repository || !number) throw new Error(`Invalid pull request ${label} key: ${key}`)
375
+ return { repository, number: Number.parseInt(number, 10) }
286
376
  }
377
+ const pullRequestDetailAtomKey = pullRequestRevisionAtomKey
378
+ const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
379
+ const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
380
+ const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
287
381
 
288
382
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
289
383
 
290
384
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
291
385
 
292
- const nextQueueMode = (mode: PullRequestQueueMode, delta: 1 | -1) => {
293
- const index = pullRequestQueueModes.indexOf(mode)
294
- return pullRequestQueueModes[(index + delta + pullRequestQueueModes.length) % pullRequestQueueModes.length]!
295
- }
296
-
297
386
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
298
387
  `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
299
388
 
@@ -320,7 +409,14 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
320
409
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
321
410
  }
322
411
 
323
- const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: "unified" | "split") => {
412
+ const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
413
+ const accent = kind === "thread"
414
+ ? colors.status.pending
415
+ : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
416
+ return mixHex(originalDiffLineColor(anchor).gutter, accent, 0.45)
417
+ }
418
+
419
+ const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
324
420
  const withSides = diff as unknown as DiffRenderableRuntimeSides
325
421
  if (view === "split") {
326
422
  const target = anchor.side === "LEFT" ? withSides.leftSide : withSides.rightSide
@@ -345,7 +441,7 @@ const getDetailPlaceholderContent = ({
345
441
  if (status === "loading") {
346
442
  return {
347
443
  title: `${loadingIndicator} Loading pull requests`,
348
- hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
444
+ hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
349
445
  }
350
446
  }
351
447
 
@@ -379,11 +475,12 @@ const getDetailPlaceholderContent = ({
379
475
  export const App = () => {
380
476
  const renderer = useRenderer()
381
477
  const { width, height } = useTerminalDimensions()
478
+ const registry = useContext(RegistryContext)
382
479
  const pullRequestResult = useAtomValue(pullRequestsAtom)
383
480
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
384
- const [queueMode, setQueueMode] = useAtom(queueModeAtom)
385
- const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
386
- const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
481
+ const [activeView, setActiveView] = useAtom(activeViewAtom)
482
+ const setQueueLoadCache = useAtomSet(queueLoadCacheAtom)
483
+ const setQueueSelection = useAtomSet(queueSelectionAtom)
387
484
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
388
485
  const [notice, setNotice] = useAtom(noticeAtom)
389
486
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -391,16 +488,17 @@ export const App = () => {
391
488
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
392
489
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
393
490
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
394
- const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
491
+ const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
395
492
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
396
493
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
494
+ const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
397
495
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
398
496
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
399
497
  const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
400
498
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
401
499
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
402
- const [diffCommentsLoaded, setDiffCommentsLoaded] = useAtom(diffCommentsLoadedAtom)
403
- const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
500
+ const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
501
+ const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
404
502
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
405
503
  const [themeId, setThemeId] = useAtom(themeIdAtom)
406
504
  const closeActiveModal = () => setActiveModal(initialModal)
@@ -410,22 +508,25 @@ export const App = () => {
410
508
  const commentModalActive = Modal.$is("Comment")(activeModal)
411
509
  const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
412
510
  const themeModalActive = Modal.$is("Theme")(activeModal)
413
- const labelModal: LabelModalState = labelModalActive ? activeModal.state : initialLabelModalState
414
- const closeModal: CloseModalState = closeModalActive ? activeModal.state : initialCloseModalState
415
- const mergeModal: MergeModalState = mergeModalActive ? activeModal.state : initialMergeModalState
416
- const commentModal: CommentModalState = commentModalActive ? activeModal.state : initialCommentModalState
417
- const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal.state : initialCommentThreadModalState
418
- const themeModal: ThemeModalState = themeModalActive ? activeModal.state : initialThemeModalState
511
+ const commandPaletteActive = Modal.$is("CommandPalette")(activeModal)
512
+ const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal)
513
+ const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
514
+ const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
515
+ const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
516
+ const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
517
+ const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
518
+ const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
519
+ const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState
520
+ const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState
419
521
  const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
420
522
  (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
421
- const ctor = Modal[tag] as (args: { state: ModalState<Tag> }) => Modal
523
+ const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
422
524
  if (typeof next === "function") {
423
525
  const updater = next as (prev: ModalState<Tag>) => ModalState<Tag>
424
526
  if (current._tag !== tag) return current
425
- const prev = (current as unknown as { readonly state: ModalState<Tag> }).state
426
- return ctor({ state: updater(prev) })
527
+ return ctor(updater(current as unknown as ModalState<Tag>))
427
528
  }
428
- return ctor({ state: next })
529
+ return ctor(next)
429
530
  })
430
531
  const setLabelModal = makeModalSetter("Label")
431
532
  const setCloseModal = makeModalSetter("Close")
@@ -433,31 +534,35 @@ export const App = () => {
433
534
  const setCommentModal = makeModalSetter("Comment")
434
535
  const setCommentThreadModal = makeModalSetter("CommentThread")
435
536
  const setThemeModal = makeModalSetter("Theme")
537
+ const setCommandPalette = makeModalSetter("CommandPalette")
538
+ const setOpenRepositoryModal = makeModalSetter("OpenRepository")
436
539
  setActiveTheme(themeId)
437
540
  const themeIdRef = useRef(themeId)
438
541
  const themeModalRef = useRef(themeModal)
439
542
  themeIdRef.current = themeId
440
543
  themeModalRef.current = themeModal
441
- const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
442
- const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
443
- const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
544
+ const setLabelCache = useAtomSet(labelCacheAtom)
545
+ const setPullRequestOverrides = useAtomSet(pullRequestOverridesAtom)
546
+ const setRecentlyCompletedPullRequests = useAtomSet(recentlyCompletedPullRequestsAtom)
444
547
  const retryProgress = useAtomValue(retryProgressAtom)
445
548
  const [loadingFrame, setLoadingFrame] = useState(0)
446
549
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
447
550
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
448
551
  const [terminalFocused, setTerminalFocused] = useState(true)
552
+ const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
449
553
  const usernameResult = useAtomValue(usernameAtom)
450
554
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
451
- const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
555
+ const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
452
556
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
453
557
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
454
558
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
455
- const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
456
559
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
457
560
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
458
561
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
459
562
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
460
563
  const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
564
+ const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
565
+ const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
461
566
  const terminalWidth = width ?? 100
462
567
  const terminalHeight = height ?? 24
463
568
  const contentWidth = Math.max(1, terminalWidth)
@@ -467,14 +572,15 @@ export const App = () => {
467
572
  const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
468
573
  const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
469
574
  const dividerJunctionAt = Math.max(1, leftPaneWidth)
470
- const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
575
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
471
576
  const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
472
- const wideDetailLines = Math.max(8, terminalHeight - 8) // fill available vertical space
577
+ const wideDetailLines = Math.max(8, terminalHeight - 8)
473
578
  const wideBodyHeight = Math.max(8, terminalHeight - 4)
474
579
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
475
580
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
476
581
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
477
- const detailHydrationRef = useRef<number | null>(null)
582
+ const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
583
+ const detailHydrationRef = useRef(new Map<string, DetailHydration>())
478
584
  const refreshGenerationRef = useRef(0)
479
585
  const didMountQueueModeRef = useRef(false)
480
586
  const lastPullRequestRefreshAtRef = useRef(0)
@@ -484,7 +590,9 @@ export const App = () => {
484
590
  const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
485
591
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
486
592
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
593
+ const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
487
594
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
595
+ const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
488
596
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
489
597
  const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
490
598
  const suppressNextDiffCommentScrollRef = useRef(false)
@@ -505,6 +613,8 @@ export const App = () => {
505
613
  }, [renderer, themeId])
506
614
 
507
615
  useEffect(() => () => {
616
+ refreshGenerationRef.current += 1
617
+ detailHydrationRef.current.clear()
508
618
  if (noticeTimeoutRef.current !== null) {
509
619
  clearTimeout(noticeTimeoutRef.current)
510
620
  }
@@ -514,105 +624,83 @@ export const App = () => {
514
624
  if (diffPrefetchTimeoutRef.current !== null) {
515
625
  clearTimeout(diffPrefetchTimeoutRef.current)
516
626
  }
627
+ if (detailPrefetchTimeoutRef.current !== null) {
628
+ clearTimeout(detailPrefetchTimeoutRef.current)
629
+ }
517
630
  }, [])
518
631
 
519
- const resolvedPullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
520
- const pullRequestLoad = queueLoadCache[queueMode] ?? (resolvedPullRequestLoad?.queueMode === queueMode ? resolvedPullRequestLoad : null)
521
- const isLoadingQueueMode = resolvedPullRequestLoad !== null && resolvedPullRequestLoad.queueMode !== queueMode
522
- const pullRequests = useMemo(() => {
523
- const source = pullRequestLoad?.data ?? []
524
- const seenUrls = new Set<string>()
525
- const openPullRequests = source.map((pullRequest) => {
526
- seenUrls.add(pullRequest.url)
527
- return recentlyCompletedPullRequests[pullRequest.url] ?? pullRequestOverrides[pullRequest.url] ?? pullRequest
528
- })
529
-
530
- return [
531
- ...openPullRequests,
532
- ...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
533
- ]
534
- }, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
535
- const pullRequestStatus: LoadStatus = (pullRequestResult.waiting || isLoadingQueueMode) && pullRequestLoad === null
536
- ? "loading"
537
- : AsyncResult.isFailure(pullRequestResult)
538
- ? "error"
539
- : "ready"
632
+ const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
633
+ const pullRequests = useAtomValue(displayedPullRequestsAtom)
634
+ const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
540
635
  const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
541
636
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
542
637
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
543
638
  pullRequestStatusRef.current = pullRequestStatus
544
639
 
545
- const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
546
640
  const visibleFilterText = filterMode ? filterDraft : filterQuery
547
641
 
548
- const filteredPullRequests = useMemo(() => {
549
- if (effectiveFilterQuery.length === 0) return pullRequests
550
- return pullRequests.flatMap((pullRequest) => {
551
- const score = pullRequestFilterScore(pullRequest, effectiveFilterQuery)
552
- return score === null ? [] : [{ pullRequest, score }]
553
- }).sort((left, right) =>
554
- left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
555
- ).map(({ pullRequest }) => pullRequest)
556
- }, [pullRequests, effectiveFilterQuery])
557
-
558
- const visibleRepoOrder = useMemo(() => effectiveFilterQuery.length > 0
559
- ? [...new Set(filteredPullRequests.map((pullRequest) => pullRequest.repository))]
560
- : [], [filteredPullRequests, effectiveFilterQuery.length])
561
- const visibleGroups = useMemo(
562
- () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository, visibleRepoOrder),
563
- [filteredPullRequests, visibleRepoOrder],
564
- )
565
- const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
566
- const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
567
- const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
642
+ const visibleGroups = useAtomValue(visibleGroupsAtom)
643
+ const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
644
+ const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
645
+ const selectedRepository = viewRepository(activeView)
646
+ const activeViews = activePullRequestViews(activeView)
647
+ const currentQueueCacheKey = viewCacheKey(activeView)
648
+ const loadedPullRequestCount = pullRequestLoad?.data.length ?? 0
649
+ const hasMorePullRequests = Boolean(pullRequestLoad?.hasNextPage && loadedPullRequestCount < config.prFetchLimit)
650
+ const isLoadingMorePullRequests = loadingMoreKey === currentQueueCacheKey
651
+ const pullRequestListRows = useMemo(() => buildPullRequestListRows({
652
+ groups: visibleGroups,
653
+ status: pullRequestStatus,
654
+ error: pullRequestError,
655
+ filterText: visibleFilterText,
656
+ showFilterBar: filterMode || filterQuery.length > 0,
657
+ loadedCount: loadedPullRequestCount,
658
+ hasMore: hasMorePullRequests,
659
+ isLoadingMore: isLoadingMorePullRequests,
660
+ }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
661
+ const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
662
+ const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
663
+ const selectedDiffState = useAtomValue(selectedDiffStateAtom)
568
664
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
569
- const readyDiffFiles = selectedDiffState?.status === "ready" ? selectedDiffState.files : []
665
+ const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
570
666
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
571
- const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
572
- const selectedDiffCommentCount = useMemo(() => selectedDiffKey
573
- ? Object.entries(diffCommentThreads)
574
- .filter(([key, comments]) => key.startsWith(`${selectedDiffKey}:`) && comments.length > 0)
575
- .reduce((count, [, comments]) => count + comments.length, 0)
576
- : 0, [diffCommentThreads, selectedDiffKey])
577
667
  const diffCommentAnchors = useMemo(
578
668
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
579
669
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
580
670
  )
581
671
  const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
582
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentAnchorKey(selectedDiffCommentAnchor)}` : null
672
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentLocationKey(selectedDiffCommentAnchor)}` : null
583
673
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
584
674
  const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
585
675
  const diffCommentRows = useMemo(
586
676
  () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
587
677
  [diffCommentAnchors],
588
678
  )
589
- const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
590
- if (index === 0) {
591
- starts.push(0)
592
- return starts
593
- }
594
- starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
595
- return starts
596
- }, []), [visibleGroups])
679
+ const groupStarts = useAtomValue(groupStartsAtom)
597
680
  const getCurrentGroupIndex = (current: number) => {
598
- for (let index = groupStarts.length - 1; index >= 0; index--) {
599
- if (groupStarts[index]! <= current) return index
600
- }
601
- return 0
681
+ if (groupStarts.length === 0) return 0
682
+ let low = 0
683
+ let high = groupStarts.length - 1
684
+ while (low < high) {
685
+ const mid = (low + high + 1) >>> 1
686
+ if (groupStarts[mid]! <= current) low = mid
687
+ else high = mid - 1
688
+ }
689
+ return low
602
690
  }
603
691
  const summaryRight = pullRequestLoad?.fetchedAt
604
692
  ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
605
693
  : pullRequestStatus === "loading"
606
694
  ? "loading pull requests..."
607
695
  : ""
608
- const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
696
+ const headerLeft = username ? `GHUI ${username} ${viewLabel(activeView)}` : `GHUI ${viewLabel(activeView)}`
609
697
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
610
698
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
611
699
  const selectPullRequestByUrl = (url: string) => {
612
700
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
613
701
  if (index >= 0) {
614
702
  setSelectedIndex(index)
615
- setQueueSelection((current) => ({ ...current, [queueMode]: index }))
703
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: index }))
616
704
  }
617
705
  }
618
706
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
@@ -622,6 +710,9 @@ export const App = () => {
622
710
  }
623
711
  const refreshPullRequests = (message?: string) => {
624
712
  refreshGenerationRef.current += 1
713
+ detailHydrationRef.current.clear()
714
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
715
+ setLoadingMoreKey(null)
625
716
  setPullRequestOverrides({})
626
717
  if (message) {
627
718
  setNotice(null)
@@ -631,15 +722,16 @@ export const App = () => {
631
722
  refreshPullRequestsAtom()
632
723
  }
633
724
  refreshPullRequestsRef.current = refreshPullRequests
634
- const switchQueueMode = (delta: 1 | -1) => {
635
- const mode = nextQueueMode(queueMode, delta)
636
- if (mode === queueMode) return
725
+ const switchViewTo = (view: PullRequestView) => {
726
+ if (viewEquals(view, activeView)) return
637
727
  refreshGenerationRef.current += 1
638
- setQueueSelection((current) => ({ ...current, [queueMode]: selectedIndex }))
639
- setQueueMode(mode)
640
- setSelectedIndex(queueSelection[mode] ?? 0)
728
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: selectedIndex }))
729
+ setActiveView(view)
730
+ setSelectedIndex(registry.get(queueSelectionAtom)[viewCacheKey(view)] ?? 0)
641
731
  setRecentlyCompletedPullRequests({})
642
- detailHydrationRef.current = null
732
+ detailHydrationRef.current.clear()
733
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
734
+ setLoadingMoreKey(null)
643
735
  setDetailFullView(false)
644
736
  setDiffFullView(false)
645
737
  setDiffCommentMode(false)
@@ -648,6 +740,82 @@ export const App = () => {
648
740
  setRefreshCompletionMessage(null)
649
741
  setRefreshStartedAt(null)
650
742
  }
743
+ const switchQueueMode = (delta: 1 | -1) => {
744
+ switchViewTo(nextView(activeView, activeViews, delta))
745
+ }
746
+ const loadMorePullRequests = () => {
747
+ if (!pullRequestLoad || !hasMorePullRequests || isLoadingMorePullRequests || !pullRequestLoad.endCursor) return false
748
+ const remaining = config.prFetchLimit - pullRequestLoad.data.length
749
+ if (remaining <= 0) return false
750
+ const cacheKey = currentQueueCacheKey
751
+ const generation = refreshGenerationRef.current
752
+ setLoadingMoreKey(cacheKey)
753
+ void loadPullRequestPage({
754
+ mode: viewMode(activeView),
755
+ repository: selectedRepository,
756
+ cursor: pullRequestLoad.endCursor,
757
+ pageSize: Math.min(pullRequestPageSize, remaining),
758
+ }).then((page) => {
759
+ if (generation !== refreshGenerationRef.current) return
760
+ setQueueLoadCache((current) => {
761
+ const load = current[cacheKey]
762
+ if (!load) return current
763
+ const data = appendPullRequestPage(load.data, page.items)
764
+ return {
765
+ ...current,
766
+ [cacheKey]: {
767
+ ...load,
768
+ data,
769
+ endCursor: page.endCursor,
770
+ hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
771
+ },
772
+ }
773
+ })
774
+ }).catch((error) => {
775
+ flashNotice(errorMessage(error))
776
+ }).finally(() => {
777
+ setLoadingMoreKey((current) => current === cacheKey ? null : current)
778
+ })
779
+ return true
780
+ }
781
+ const applyPullRequestDetail = (detail: PullRequestItem) => {
782
+ setQueueLoadCache((current) => {
783
+ const next = { ...current }
784
+ let changed = false
785
+ for (const [cacheKey, load] of Object.entries(current)) {
786
+ if (!load) continue
787
+ const index = load.data.findIndex((pullRequest) => pullRequest.url === detail.url)
788
+ if (index < 0) continue
789
+ const data = [...load.data]
790
+ data[index] = detail
791
+ changed = true
792
+ next[cacheKey] = { ...load, data }
793
+ }
794
+ return changed ? next : current
795
+ })
796
+ }
797
+ const hydratePullRequestDetails = (pullRequest: PullRequestItem, notifyError: boolean) => {
798
+ if (pullRequest.state !== "open" || pullRequest.detailLoaded) return false
799
+ const detailKey = pullRequestDetailKey(pullRequest)
800
+ const existing = detailHydrationRef.current.get(detailKey)
801
+ if (existing) {
802
+ if (notifyError) existing.notifyError = true
803
+ return false
804
+ }
805
+ if (!notifyError && detailHydrationRef.current.size >= DETAIL_PREFETCH_CONCURRENCY) return false
806
+ const entry: DetailHydration = { token: Symbol(detailKey), notifyError }
807
+ detailHydrationRef.current.set(detailKey, entry)
808
+ const generation = refreshGenerationRef.current
809
+ const atom = pullRequestDetailsAtom(pullRequestDetailAtomKey(pullRequest))
810
+ void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })).then((detail) => {
811
+ if (generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) applyPullRequestDetail(detail)
812
+ }).catch((error) => {
813
+ if (entry.notifyError && generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) flashNotice(errorMessage(error))
814
+ }).finally(() => {
815
+ if (detailHydrationRef.current.get(detailKey) === entry) detailHydrationRef.current.delete(detailKey)
816
+ })
817
+ return true
818
+ }
651
819
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
652
820
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
653
821
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -667,14 +835,14 @@ export const App = () => {
667
835
  didMountQueueModeRef.current = true
668
836
  return
669
837
  }
670
- if (queueLoadCache[queueMode]) return
838
+ if (registry.get(queueLoadCacheAtom)[currentQueueCacheKey]) return
671
839
  refreshPullRequestsAtom()
672
- }, [queueMode, queueLoadCache, refreshPullRequestsAtom])
840
+ }, [currentQueueCacheKey, refreshPullRequestsAtom, registry])
673
841
 
674
842
  useEffect(() => {
675
843
  if (!refreshCompletionMessage || refreshStartedAt === null) return
676
844
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
677
- const isHydratingDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
845
+ const isHydratingDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
678
846
  if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
679
847
  flashNotice(`✓ ${refreshCompletionMessage}`)
680
848
  setRefreshCompletionMessage(null)
@@ -727,12 +895,29 @@ export const App = () => {
727
895
  }, [visiblePullRequests.length])
728
896
 
729
897
  useEffect(() => {
730
- setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
731
- }, [queueMode, selectedIndex])
898
+ setQueueSelection((current) => current[currentQueueCacheKey] === selectedIndex ? current : { ...current, [currentQueueCacheKey]: selectedIndex })
899
+ }, [currentQueueCacheKey, selectedIndex])
900
+
901
+ useEffect(() => {
902
+ if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0) return
903
+ const thresholdIndex = Math.max(0, visiblePullRequests.length - LOAD_MORE_SELECTION_THRESHOLD)
904
+ if (selectedIndex >= thresholdIndex) loadMorePullRequests()
905
+ }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
906
+
907
+ useEffect(() => {
908
+ const scroll = prListScrollRef.current
909
+ if (!scroll || selectedPullRequestRowIndex === null) return
910
+ const viewportHeight = scroll.viewport.height
911
+ if (viewportHeight <= 0) return
912
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, selectedPullRequestRowIndex, 2)
913
+ if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
914
+ }, [selectedPullRequestRowIndex])
732
915
 
733
916
  useEffect(() => {
734
917
  setDiffFileIndex(0)
918
+ setDiffScrollTop(0)
735
919
  setDiffCommentAnchorIndex(0)
920
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
736
921
  }, [selectedIndex])
737
922
 
738
923
  useEffect(() => {
@@ -772,13 +957,13 @@ export const App = () => {
772
957
 
773
958
  if (selectedDiffKey) {
774
959
  for (const anchor of diffCommentAnchors) {
775
- if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
776
- applyLineColor(anchor, colors.status.pending)
960
+ if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentLocationKey(anchor)}`]?.length ?? 0) > 0) {
961
+ applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
777
962
  }
778
963
  }
779
964
  }
780
965
  if (diffCommentMode && selectedDiffCommentAnchor) {
781
- applyLineColor(selectedDiffCommentAnchor, selectedDiffCommentAnchor.side === "RIGHT" ? colors.status.passing : colors.status.failing, true)
966
+ applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
782
967
  if (suppressNextDiffCommentScrollRef.current) {
783
968
  suppressNextDiffCommentScrollRef.current = false
784
969
  } else {
@@ -789,9 +974,9 @@ export const App = () => {
789
974
  }
790
975
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
791
976
  }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
792
- const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
977
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
793
978
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
794
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
979
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
795
980
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
796
981
 
797
982
  useEffect(() => {
@@ -803,31 +988,30 @@ export const App = () => {
803
988
  }, [hasActiveLoadingIndicator])
804
989
 
805
990
  useEffect(() => {
806
- const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
807
- if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
808
- if (detailHydrationRef.current === fetchedAt || pullRequestLoad?.detailsFetchedAt?.getTime() === fetchedAt) return
809
- if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
810
- detailHydrationRef.current = fetchedAt
811
- const generation = refreshGenerationRef.current
812
- void loadPullRequestDetails(queueMode).then((details) => {
813
- if (generation !== refreshGenerationRef.current) return
814
- setQueueLoadCache((current) => {
815
- const load = current[queueMode]
816
- if (!load) return current
817
- const detailsByUrl = new Map(details.map((detail) => [detail.url, detail]))
818
- return {
819
- ...current,
820
- [queueMode]: {
821
- ...load,
822
- data: load.data.map((pullRequest) => detailsByUrl.get(pullRequest.url) ?? pullRequest),
823
- detailsFetchedAt: load.fetchedAt,
824
- },
991
+ if (pullRequestStatus !== "ready" || !selectedPullRequest) return
992
+ hydratePullRequestDetails(selectedPullRequest, true)
993
+ }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
994
+
995
+ useEffect(() => {
996
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
997
+ if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
998
+ detailPrefetchTimeoutRef.current = globalThis.setTimeout(() => {
999
+ detailPrefetchTimeoutRef.current = null
1000
+ let started = 0
1001
+ for (let distance = 1; distance <= Math.max(DETAIL_PREFETCH_AHEAD, DETAIL_PREFETCH_BEHIND); distance++) {
1002
+ const offsets = [distance <= DETAIL_PREFETCH_AHEAD ? distance : null, distance <= DETAIL_PREFETCH_BEHIND ? -distance : null]
1003
+ for (const offset of offsets) {
1004
+ if (offset === null) continue
1005
+ if (started >= DETAIL_PREFETCH_CONCURRENCY) return
1006
+ const pullRequest = visiblePullRequests[selectedIndex + offset]
1007
+ if (pullRequest && hydratePullRequestDetails(pullRequest, false)) started += 1
825
1008
  }
826
- })
827
- }).catch((error) => {
828
- flashNotice(errorMessage(error))
829
- })
830
- }, [queueMode, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
1009
+ }
1010
+ }, DETAIL_PREFETCH_DELAY_MS)
1011
+ return () => {
1012
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1013
+ }
1014
+ }, [pullRequestStatus, currentQueueCacheKey, selectedIndex, visiblePullRequests])
831
1015
 
832
1016
  const detailPlaceholderContent = getDetailPlaceholderContent({
833
1017
  status: pullRequestStatus,
@@ -836,13 +1020,18 @@ export const App = () => {
836
1020
  visibleCount: visiblePullRequests.length,
837
1021
  filterText: visibleFilterText,
838
1022
  })
839
- const detailJunctions = getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1023
+ const isSelectedPullRequestDetailLoading = selectedPullRequest !== null && !selectedPullRequest.detailLoaded
1024
+ const detailLoadingContent: DetailPlaceholderContent = selectedPullRequest ? {
1025
+ title: `${loadingIndicator} Loading pull request details`,
1026
+ hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1027
+ } : detailPlaceholderContent
1028
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
840
1029
 
841
1030
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
842
1031
 
843
1032
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
844
1033
  const key = pullRequestDiffKey(pullRequest)
845
- const previousLoadState = diffCommentsLoaded[key]
1034
+ const previousLoadState = registry.get(diffCommentsLoadedAtom)[key]
846
1035
  if (!force && previousLoadState) return
847
1036
  setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
848
1037
  void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
@@ -885,22 +1074,24 @@ export const App = () => {
885
1074
  const force = options.force ?? false
886
1075
  const includeComments = options.includeComments ?? false
887
1076
  const key = pullRequestDiffKey(pullRequest)
888
- const existing = pullRequestDiffCache[key]
1077
+ const existing = registry.get(pullRequestDiffCacheAtom)[key]
889
1078
  if (includeComments) loadPullRequestComments(pullRequest, force)
890
- if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
1079
+ if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
891
1080
 
892
- setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
893
- void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
1081
+ setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
1082
+ const atom = pullRequestDiffAtom(pullRequestDiffAtomKey(pullRequest))
1083
+ if (force) registry.refresh(atom)
1084
+ void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }))
894
1085
  .then((patch) => {
895
1086
  setPullRequestDiffCache((current) => ({
896
1087
  ...current,
897
- [key]: { status: "ready", patch, files: splitPatchFiles(patch) },
1088
+ [key]: PullRequestDiffState.Ready({ patch, files: splitPatchFiles(patch) }),
898
1089
  }))
899
1090
  })
900
1091
  .catch((error) => {
901
1092
  setPullRequestDiffCache((current) => ({
902
1093
  ...current,
903
- [key]: { status: "error", error: errorMessage(error) },
1094
+ [key]: PullRequestDiffState.Error({ error: errorMessage(error) }),
904
1095
  }))
905
1096
  flashNotice(errorMessage(error))
906
1097
  })
@@ -930,6 +1121,7 @@ export const App = () => {
930
1121
  setDetailFullView(false)
931
1122
  setDiffCommentMode(false)
932
1123
  setDiffFileIndex(0)
1124
+ setDiffScrollTop(0)
933
1125
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
934
1126
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
935
1127
  loadPullRequestDiff(selectedPullRequest, { includeComments: true })
@@ -943,35 +1135,77 @@ export const App = () => {
943
1135
  const scrollToDiffFile = (index: number) => {
944
1136
  const stackedFile = stackedDiffFiles[index]
945
1137
  diffScrollRef.current?.scrollTo({ x: 0, y: stackedFile?.headerLine ?? 0 })
1138
+ syncDiffScrollState()
946
1139
  }
947
1140
 
948
- const syncDiffFileIndexToScroll = () => {
1141
+ const syncDiffScrollState = () => {
949
1142
  const scrollTop = diffScrollRef.current?.scrollTop
950
1143
  if (scrollTop === undefined || stackedDiffFiles.length === 0) return
951
- const nextIndex = stackedDiffFiles.reduce((current, file) => file.headerLine <= scrollTop + 1 ? file.index : current, 0)
1144
+ setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
1145
+ const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
952
1146
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
953
1147
  }
954
1148
 
955
1149
  const scrollDiffBy = (y: number) => {
956
1150
  diffScrollRef.current?.scrollBy({ x: 0, y })
957
- syncDiffFileIndexToScroll()
1151
+ syncDiffScrollState()
958
1152
  }
959
1153
 
960
1154
  const scrollDiffTo = (y: number) => {
961
1155
  diffScrollRef.current?.scrollTo({ x: 0, y })
962
- if (y <= 0) setDiffFileIndex(0)
963
- else if (y === Number.MAX_SAFE_INTEGER) setDiffFileIndex(Math.max(0, readyDiffFiles.length - 1))
964
- else syncDiffFileIndexToScroll()
1156
+ syncDiffScrollState()
1157
+ }
1158
+ const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1159
+ const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1160
+
1161
+ const clearPendingGTimeout = () => {
1162
+ if (pendingGTimeoutRef.current !== null) {
1163
+ clearTimeout(pendingGTimeoutRef.current)
1164
+ pendingGTimeoutRef.current = null
1165
+ }
1166
+ }
1167
+
1168
+ const handleVimGoto = (key: { readonly name: string; readonly shift?: boolean }, gotoStart: () => void, gotoEnd: () => void): boolean => {
1169
+ if (isShiftG(key)) {
1170
+ gotoEnd()
1171
+ setPendingG(false)
1172
+ clearPendingGTimeout()
1173
+ return true
1174
+ }
1175
+ if (key.name === "g") {
1176
+ if (pendingG) {
1177
+ gotoStart()
1178
+ setPendingG(false)
1179
+ clearPendingGTimeout()
1180
+ } else {
1181
+ setPendingG(true)
1182
+ pendingGTimeoutRef.current = setTimeout(() => {
1183
+ setPendingG(false)
1184
+ pendingGTimeoutRef.current = null
1185
+ }, 500)
1186
+ }
1187
+ return true
1188
+ }
1189
+ return false
965
1190
  }
966
1191
 
967
1192
  const ensureDiffLineVisible = (line: number) => {
968
1193
  const scroll = diffScrollRef.current
969
1194
  if (!scroll) return
970
1195
  const viewportHeight = Math.max(1, wideBodyHeight - (selectedDiffCommentThread.length > 0 ? 6 : 3))
971
- const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line)
972
- if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
1196
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line, DIFF_STICKY_HEADER_LINES)
1197
+ if (nextTop !== scroll.scrollTop) {
1198
+ scroll.scrollTo({ x: 0, y: nextTop })
1199
+ syncDiffScrollState()
1200
+ }
973
1201
  }
974
1202
 
1203
+ useEffect(() => {
1204
+ if (!diffFullView) return
1205
+ const interval = globalThis.setInterval(syncDiffScrollState, 80)
1206
+ return () => globalThis.clearInterval(interval)
1207
+ }, [diffFullView, stackedDiffFiles])
1208
+
975
1209
  const jumpDiffFile = (delta: 1 | -1) => {
976
1210
  if (readyDiffFiles.length === 0) return
977
1211
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
@@ -987,7 +1221,7 @@ export const App = () => {
987
1221
  const enterDiffCommentMode = () => {
988
1222
  const scrollTop = diffScrollRef.current?.scrollTop ?? 0
989
1223
  suppressNextDiffCommentScrollRef.current = true
990
- setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop))
1224
+ setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop + DIFF_STICKY_HEADER_LINES))
991
1225
  setDiffCommentMode(true)
992
1226
  }
993
1227
 
@@ -1010,6 +1244,16 @@ export const App = () => {
1010
1244
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1011
1245
  }
1012
1246
 
1247
+ const selectDiffCommentLine = (renderLine: number, side: DiffCommentSide | null) => {
1248
+ const lineAnchors = diffCommentAnchors.filter((anchor) => anchor.renderLine === renderLine)
1249
+ const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1250
+ if (!nextAnchor) return
1251
+ suppressNextDiffCommentScrollRef.current = true
1252
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1253
+ setDiffFileIndex(nextAnchor.fileIndex)
1254
+ setDiffCommentMode(true)
1255
+ }
1256
+
1013
1257
  const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1014
1258
  setCommentModal((current) => {
1015
1259
  const next = transform({ body: current.body, cursor: current.cursor })
@@ -1089,14 +1333,14 @@ export const App = () => {
1089
1333
  }
1090
1334
 
1091
1335
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1092
- void openPullRequestInBrowser(pullRequest)
1336
+ void openInBrowser(pullRequest)
1093
1337
  .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
1094
1338
  .catch((error) => flashNotice(errorMessage(error)))
1095
1339
  }
1096
1340
 
1097
1341
  const copySelectedPullRequestMetadata = () => {
1098
1342
  if (!selectedPullRequest) return
1099
- void copyPullRequestMetadata(selectedPullRequest)
1343
+ void copyToClipboard(pullRequestMetadataText(selectedPullRequest))
1100
1344
  .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1101
1345
  .catch((error) => flashNotice(errorMessage(error)))
1102
1346
  }
@@ -1219,7 +1463,7 @@ export const App = () => {
1219
1463
  const openLabelModal = () => {
1220
1464
  if (!selectedPullRequest) return
1221
1465
  const repository = selectedPullRequest.repository
1222
- const cachedLabels = labelCache[repository]
1466
+ const cachedLabels = registry.get(labelCacheAtom)[repository]
1223
1467
  if (cachedLabels) {
1224
1468
  setLabelModal({
1225
1469
  repository,
@@ -1318,9 +1562,7 @@ export const App = () => {
1318
1562
 
1319
1563
  const toggleLabelAtIndex = () => {
1320
1564
  if (!selectedPullRequest) return
1321
- const filtered = labelModal.availableLabels.filter((label) =>
1322
- labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1323
- )
1565
+ const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1324
1566
  const label = filtered[labelModal.selectedIndex]
1325
1567
  if (!label) return
1326
1568
 
@@ -1352,7 +1594,214 @@ export const App = () => {
1352
1594
  }
1353
1595
  }
1354
1596
 
1597
+ const openCommandPalette = () => {
1598
+ setCommandPalette(initialCommandPaletteState)
1599
+ }
1600
+ const openRepositoryPicker = () => {
1601
+ setOpenRepositoryModal({ query: selectedRepository ?? "", error: null })
1602
+ }
1603
+ const openRepositoryFromInput = () => {
1604
+ const repository = parseRepositoryInput(openRepositoryModal.query)
1605
+ if (!repository) {
1606
+ setOpenRepositoryModal((current) => ({ ...current, error: "Enter a repository as owner/name or a GitHub URL." }))
1607
+ return
1608
+ }
1609
+ closeActiveModal()
1610
+ switchViewTo({ _tag: "Repository", repository })
1611
+ flashNotice(`Opened ${repository}`)
1612
+ }
1613
+ const insertPastedText = (text: string) => {
1614
+ if (text.length === 0) return false
1615
+ if (commandPaletteActive) {
1616
+ setCommandPalette((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1617
+ return true
1618
+ }
1619
+ if (openRepositoryModalActive) {
1620
+ setOpenRepositoryModal((current) => ({ ...current, query: current.query + singleLineText(text), error: null }))
1621
+ return true
1622
+ }
1623
+ if (themeModalActive && themeModal.filterMode) {
1624
+ editThemeQuery((query) => query + singleLineText(text))
1625
+ return true
1626
+ }
1627
+ if (commentModalActive) {
1628
+ editComment((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1629
+ return true
1630
+ }
1631
+ if (labelModalActive) {
1632
+ setLabelModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1633
+ return true
1634
+ }
1635
+ if (filterMode) {
1636
+ setFilterDraft((current) => current + singleLineText(text))
1637
+ return true
1638
+ }
1639
+ return false
1640
+ }
1641
+
1642
+ useEffect(() => {
1643
+ const handlePaste = (event: PasteEvent) => {
1644
+ if (insertPastedText(pasteText(event))) event.preventDefault()
1645
+ }
1646
+ const keyInput = renderer.keyInput as unknown as {
1647
+ on: (event: "paste", handler: (event: PasteEvent) => void) => void
1648
+ off: (event: "paste", handler: (event: PasteEvent) => void) => void
1649
+ }
1650
+ keyInput.on("paste", handlePaste)
1651
+ return () => {
1652
+ keyInput.off("paste", handlePaste)
1653
+ }
1654
+ }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, labelModalActive, filterMode])
1655
+
1656
+ const appCommands: readonly AppCommand[] = buildAppCommands({
1657
+ pullRequestStatus,
1658
+ filterQuery,
1659
+ filterMode,
1660
+ selectedRepository,
1661
+ activeViews,
1662
+ activeView,
1663
+ loadedPullRequestCount,
1664
+ hasMorePullRequests,
1665
+ isLoadingMorePullRequests,
1666
+ selectedPullRequest,
1667
+ detailFullView,
1668
+ diffFullView,
1669
+ diffReady: selectedDiffState?._tag === "Ready",
1670
+ effectiveDiffRenderView,
1671
+ diffWrapMode,
1672
+ readyDiffFileCount: readyDiffFiles.length,
1673
+ diffFileIndex,
1674
+ diffCommentMode,
1675
+ selectedDiffCommentAnchorLabel: selectedDiffCommentAnchor ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line}` : null,
1676
+ actions: {
1677
+ openCommandPalette,
1678
+ refreshPullRequests,
1679
+ openFilter: () => {
1680
+ setFilterDraft(filterQuery)
1681
+ setFilterMode(true)
1682
+ },
1683
+ clearFilter: () => {
1684
+ setFilterQuery("")
1685
+ setFilterDraft("")
1686
+ setFilterMode(false)
1687
+ },
1688
+ openThemeModal,
1689
+ openRepositoryPicker,
1690
+ loadMorePullRequests,
1691
+ switchViewTo,
1692
+ openDetails: () => {
1693
+ setDetailFullView(true)
1694
+ setDetailScrollOffset(0)
1695
+ },
1696
+ closeDetails: () => {
1697
+ setDetailFullView(false)
1698
+ setDetailScrollOffset(0)
1699
+ },
1700
+ openDiffView,
1701
+ closeDiffView: () => {
1702
+ setDiffFullView(false)
1703
+ setDiffCommentMode(false)
1704
+ },
1705
+ reloadDiff: () => {
1706
+ if (!selectedPullRequest) return
1707
+ loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1708
+ flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1709
+ },
1710
+ toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1711
+ toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1712
+ jumpDiffFile,
1713
+ toggleDiffCommentMode: () => {
1714
+ if (diffCommentMode) setDiffCommentMode(false)
1715
+ else enterDiffCommentMode()
1716
+ },
1717
+ openDiffCommentModal,
1718
+ togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1719
+ openLabelModal,
1720
+ openMergeModal,
1721
+ openCloseModal,
1722
+ openPullRequestInBrowser: () => {
1723
+ if (selectedPullRequest) openSelectedPullRequestInBrowser(selectedPullRequest)
1724
+ },
1725
+ copyPullRequestMetadata: copySelectedPullRequestMetadata,
1726
+ quit: () => renderer.destroy(),
1727
+ },
1728
+ })
1729
+ const runCommand = (command: AppCommand, options: { readonly notifyDisabled?: boolean; readonly closePalette?: boolean } = {}) => {
1730
+ if (!commandEnabled(command)) {
1731
+ if (options.notifyDisabled && command.disabledReason) flashNotice(command.disabledReason)
1732
+ return false
1733
+ }
1734
+ if (options.closePalette) closeActiveModal()
1735
+ command.run()
1736
+ return true
1737
+ }
1738
+ const runCommandById = (id: string, options: { readonly notifyDisabled?: boolean } = {}) => {
1739
+ const command = appCommands.find((entry) => entry.id === id)
1740
+ return command ? runCommand(command, options) : false
1741
+ }
1742
+ const commandPaletteCommands = commandPaletteActive ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query) : []
1743
+ const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1744
+ const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1745
+
1355
1746
  useKeyboard((key) => {
1747
+ if (commandPaletteActive) {
1748
+ if (key.name === "escape" || key.ctrl && key.name === "c") {
1749
+ closeActiveModal()
1750
+ return
1751
+ }
1752
+ if (key.name === "return" || key.name === "enter") {
1753
+ if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true })
1754
+ return
1755
+ }
1756
+ if (key.name === "up" || key.name === "k" && !key.ctrl && !key.meta) {
1757
+ setCommandPalette((current) => {
1758
+ const selectedIndex = clampCommandIndex(current.selectedIndex - 1, commandPaletteCommands)
1759
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1760
+ })
1761
+ return
1762
+ }
1763
+ if (key.name === "down" || key.name === "j" && !key.ctrl && !key.meta) {
1764
+ setCommandPalette((current) => {
1765
+ const selectedIndex = clampCommandIndex(current.selectedIndex + 1, commandPaletteCommands)
1766
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1767
+ })
1768
+ return
1769
+ }
1770
+ if (isSingleLineInputKey(key)) {
1771
+ setCommandPalette((current) => {
1772
+ const query = editSingleLineInput(current.query, key) ?? current.query
1773
+ return current.query === query && current.selectedIndex === 0 ? current : { ...current, query, selectedIndex: 0 }
1774
+ })
1775
+ return
1776
+ }
1777
+ return
1778
+ }
1779
+
1780
+ if (openRepositoryModalActive) {
1781
+ if (key.name === "escape" || key.ctrl && key.name === "c") {
1782
+ closeActiveModal()
1783
+ return
1784
+ }
1785
+ if (key.name === "return" || key.name === "enter") {
1786
+ openRepositoryFromInput()
1787
+ return
1788
+ }
1789
+ if (isSingleLineInputKey(key)) {
1790
+ setOpenRepositoryModal((current) => ({
1791
+ ...current,
1792
+ query: editSingleLineInput(current.query, key) ?? current.query,
1793
+ error: null,
1794
+ }))
1795
+ return
1796
+ }
1797
+ return
1798
+ }
1799
+
1800
+ if ((key.ctrl && key.name === "p") || (key.meta && key.name === "k")) {
1801
+ runCommandById("command.open")
1802
+ return
1803
+ }
1804
+
1356
1805
  if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1357
1806
  if (themeModalActive) {
1358
1807
  closeThemeModal(false)
@@ -1362,7 +1811,7 @@ export const App = () => {
1362
1811
  closeActiveModal()
1363
1812
  return
1364
1813
  }
1365
- renderer.destroy()
1814
+ runCommandById("app.quit")
1366
1815
  return
1367
1816
  }
1368
1817
 
@@ -1392,16 +1841,8 @@ export const App = () => {
1392
1841
  moveThemeSelection(1)
1393
1842
  return
1394
1843
  }
1395
- if (themeModal.filterMode && key.name === "backspace") {
1396
- editThemeQuery((query) => query.slice(0, -1))
1397
- return
1398
- }
1399
- if (themeModal.filterMode && key.ctrl && key.name === "u") {
1400
- updateThemeQuery("")
1401
- return
1402
- }
1403
- if (themeModal.filterMode && !key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
1404
- editThemeQuery((query) => query + key.sequence)
1844
+ if (themeModal.filterMode && isSingleLineInputKey(key)) {
1845
+ editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
1405
1846
  return
1406
1847
  }
1407
1848
  return
@@ -1500,8 +1941,9 @@ export const App = () => {
1500
1941
  submitDiffComment()
1501
1942
  return
1502
1943
  }
1503
- if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1504
- editComment((state) => insertText(state, key.sequence))
1944
+ const text = printableKeyText(key)
1945
+ if (text) {
1946
+ editComment((state) => insertText(state, text))
1505
1947
  return
1506
1948
  }
1507
1949
  return
@@ -1574,7 +2016,6 @@ export const App = () => {
1574
2016
  return
1575
2017
  }
1576
2018
 
1577
- // Label modal takes priority over everything else
1578
2019
  if (labelModalActive) {
1579
2020
  if (key.name === "escape") {
1580
2021
  closeActiveModal()
@@ -1592,31 +2033,17 @@ export const App = () => {
1592
2033
  return
1593
2034
  }
1594
2035
  if (key.name === "down" || key.name === "j") {
1595
- const filtered = labelModal.availableLabels.filter((label) =>
1596
- labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1597
- )
2036
+ const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1598
2037
  setLabelModal((current) => ({
1599
2038
  ...current,
1600
2039
  selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
1601
2040
  }))
1602
2041
  return
1603
2042
  }
1604
- if (key.name === "backspace") {
2043
+ if (isSingleLineInputKey(key)) {
1605
2044
  setLabelModal((current) => ({
1606
2045
  ...current,
1607
- query: current.query.slice(0, -1),
1608
- selectedIndex: 0,
1609
- }))
1610
- return
1611
- }
1612
- if (key.ctrl && key.name === "u") {
1613
- setLabelModal((current) => ({ ...current, query: "", selectedIndex: 0 }))
1614
- return
1615
- }
1616
- if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1617
- setLabelModal((current) => ({
1618
- ...current,
1619
- query: current.query + key.sequence,
2046
+ query: editSingleLineInput(current.query, key) ?? current.query,
1620
2047
  selectedIndex: 0,
1621
2048
  }))
1622
2049
  return
@@ -1631,7 +2058,7 @@ export const App = () => {
1631
2058
  return
1632
2059
  }
1633
2060
  if (key.name === "c") {
1634
- setDiffCommentMode(false)
2061
+ runCommandById("diff.comment-mode")
1635
2062
  return
1636
2063
  }
1637
2064
  if (key.name === "return" || key.name === "enter") {
@@ -1640,7 +2067,7 @@ export const App = () => {
1640
2067
  return
1641
2068
  }
1642
2069
  if (key.name === "a") {
1643
- openDiffCommentModal()
2070
+ runCommandById("diff.add-comment")
1644
2071
  return
1645
2072
  }
1646
2073
  if (key.name === "pageup" || key.ctrl && key.name === "u") {
@@ -1675,24 +2102,23 @@ export const App = () => {
1675
2102
  selectDiffCommentSide("RIGHT")
1676
2103
  return
1677
2104
  }
1678
- if (key.name === "]" && selectedDiffState?.status === "ready") {
1679
- jumpDiffFile(1)
2105
+ if (key.name === "]" && selectedDiffState?._tag === "Ready") {
2106
+ runCommandById("diff.next-file")
1680
2107
  return
1681
2108
  }
1682
- if (key.name === "[" && selectedDiffState?.status === "ready") {
1683
- jumpDiffFile(-1)
2109
+ if (key.name === "[" && selectedDiffState?._tag === "Ready") {
2110
+ runCommandById("diff.previous-file")
1684
2111
  return
1685
2112
  }
1686
2113
  return
1687
2114
  }
1688
2115
 
1689
2116
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1690
- setDiffFullView(false)
1691
- setDiffCommentMode(false)
2117
+ runCommandById("diff.close")
1692
2118
  return
1693
2119
  }
1694
- if (key.name === "c" && selectedDiffState?.status === "ready") {
1695
- enterDiffCommentMode()
2120
+ if (key.name === "c" && selectedDiffState?._tag === "Ready") {
2121
+ runCommandById("diff.comment-mode")
1696
2122
  return
1697
2123
  }
1698
2124
  if (key.name === "home") {
@@ -1711,32 +2137,7 @@ export const App = () => {
1711
2137
  scrollDiffBy(halfPage)
1712
2138
  return
1713
2139
  }
1714
- if (isShiftG(key)) {
1715
- scrollDiffTo(Number.MAX_SAFE_INTEGER)
1716
- setPendingG(false)
1717
- if (pendingGTimeoutRef.current !== null) {
1718
- clearTimeout(pendingGTimeoutRef.current)
1719
- pendingGTimeoutRef.current = null
1720
- }
1721
- return
1722
- }
1723
- if (key.name === "g") {
1724
- if (pendingG) {
1725
- scrollDiffTo(0)
1726
- setPendingG(false)
1727
- if (pendingGTimeoutRef.current !== null) {
1728
- clearTimeout(pendingGTimeoutRef.current)
1729
- pendingGTimeoutRef.current = null
1730
- }
1731
- } else {
1732
- setPendingG(true)
1733
- pendingGTimeoutRef.current = setTimeout(() => {
1734
- setPendingG(false)
1735
- pendingGTimeoutRef.current = null
1736
- }, 500)
1737
- }
1738
- return
1739
- }
2140
+ if (handleVimGoto(key, () => scrollDiffTo(0), () => scrollDiffTo(Number.MAX_SAFE_INTEGER))) return
1740
2141
  if (key.name === "up" || key.name === "k") {
1741
2142
  scrollDiffBy(-1)
1742
2143
  return
@@ -1754,67 +2155,64 @@ export const App = () => {
1754
2155
  return
1755
2156
  }
1756
2157
  if (key.name === "v") {
1757
- setDiffRenderView((current) => current === "unified" ? "split" : "unified")
2158
+ runCommandById("diff.toggle-view")
1758
2159
  return
1759
2160
  }
1760
2161
  if (key.name === "w") {
1761
- setDiffWrapMode((current) => current === "none" ? "word" : "none")
2162
+ runCommandById("diff.toggle-wrap")
1762
2163
  return
1763
2164
  }
1764
2165
  if (key.name === "r" && selectedPullRequest) {
1765
- loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1766
- flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
2166
+ runCommandById("diff.reload")
1767
2167
  return
1768
2168
  }
1769
- if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
1770
- jumpDiffFile(1)
2169
+ if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
2170
+ runCommandById("diff.next-file")
1771
2171
  return
1772
2172
  }
1773
- if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
1774
- jumpDiffFile(-1)
2173
+ if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
2174
+ runCommandById("diff.previous-file")
1775
2175
  return
1776
2176
  }
1777
2177
  if (key.name === "o" && selectedPullRequest) {
1778
- openSelectedPullRequestInBrowser(selectedPullRequest)
2178
+ runCommandById("pull.open-browser")
1779
2179
  return
1780
2180
  }
1781
2181
  return
1782
2182
  }
1783
2183
 
1784
- // Fullscreen detail mode handles its own navigation keys.
1785
2184
  if (detailFullView) {
1786
2185
  const plainKey = !key.ctrl && !key.meta && !key.option
1787
2186
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1788
- setDetailFullView(false)
1789
- setDetailScrollOffset(0)
2187
+ runCommandById("detail.close")
1790
2188
  return
1791
2189
  }
1792
2190
  if (isThemeKey(key)) {
1793
- openThemeModal()
2191
+ runCommandById("theme.open")
1794
2192
  return
1795
2193
  }
1796
2194
  if (plainKey && key.name === "d" && selectedPullRequest) {
1797
- openDiffView()
2195
+ runCommandById("diff.open")
1798
2196
  return
1799
2197
  }
1800
2198
  if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
1801
- openCloseModal()
2199
+ runCommandById("pull.close")
1802
2200
  return
1803
2201
  }
1804
2202
  if (plainKey && key.name === "l" && selectedPullRequest) {
1805
- openLabelModal()
2203
+ runCommandById("pull.labels")
1806
2204
  return
1807
2205
  }
1808
2206
  if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
1809
- openMergeModal()
2207
+ runCommandById("pull.merge")
1810
2208
  return
1811
2209
  }
1812
2210
  if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
1813
- toggleSelectedPullRequestDraftStatus()
2211
+ runCommandById("pull.toggle-draft")
1814
2212
  return
1815
2213
  }
1816
2214
  if (plainKey && key.name === "r") {
1817
- refreshPullRequests("Refreshed")
2215
+ runCommandById("pull.refresh")
1818
2216
  return
1819
2217
  }
1820
2218
  if (key.name === "home") {
@@ -1822,14 +2220,9 @@ export const App = () => {
1822
2220
  setDetailScrollOffset(0)
1823
2221
  return
1824
2222
  }
1825
- if (key.name === "end" || isShiftG(key)) {
2223
+ if (key.name === "end") {
1826
2224
  detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1827
2225
  setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
1828
- setPendingG(false)
1829
- if (pendingGTimeoutRef.current !== null) {
1830
- clearTimeout(pendingGTimeoutRef.current)
1831
- pendingGTimeoutRef.current = null
1832
- }
1833
2226
  return
1834
2227
  }
1835
2228
  if (key.name === "pageup") {
@@ -1842,24 +2235,10 @@ export const App = () => {
1842
2235
  setDetailScrollOffset((current) => current + halfPage)
1843
2236
  return
1844
2237
  }
1845
- if (key.name === "g") {
1846
- if (pendingG) {
1847
- detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1848
- setDetailScrollOffset(0)
1849
- setPendingG(false)
1850
- if (pendingGTimeoutRef.current !== null) {
1851
- clearTimeout(pendingGTimeoutRef.current)
1852
- pendingGTimeoutRef.current = null
1853
- }
1854
- } else {
1855
- setPendingG(true)
1856
- pendingGTimeoutRef.current = setTimeout(() => {
1857
- setPendingG(false)
1858
- pendingGTimeoutRef.current = null
1859
- }, 500)
1860
- }
1861
- return
1862
- }
2238
+ if (handleVimGoto(key,
2239
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: 0 }); setDetailScrollOffset(0) },
2240
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER }); setDetailScrollOffset(Number.MAX_SAFE_INTEGER) },
2241
+ )) return
1863
2242
  if (key.name === "up" || key.name === "k") {
1864
2243
  detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1865
2244
  setDetailScrollOffset((current) => Math.max(0, current - 1))
@@ -1881,11 +2260,11 @@ export const App = () => {
1881
2260
  return
1882
2261
  }
1883
2262
  if (plainKey && key.name === "o" && selectedPullRequest) {
1884
- openSelectedPullRequestInBrowser(selectedPullRequest)
2263
+ runCommandById("pull.open-browser")
1885
2264
  return
1886
2265
  }
1887
2266
  if (plainKey && key.name === "y" && selectedPullRequest) {
1888
- copySelectedPullRequestMetadata()
2267
+ runCommandById("pull.copy-metadata")
1889
2268
  return
1890
2269
  }
1891
2270
  return
@@ -1897,25 +2276,13 @@ export const App = () => {
1897
2276
  setFilterMode(false)
1898
2277
  return
1899
2278
  }
1900
- if (key.name === "enter") {
2279
+ if (key.name === "return" || key.name === "enter") {
1901
2280
  setFilterQuery(filterDraft)
1902
2281
  setFilterMode(false)
1903
2282
  return
1904
2283
  }
1905
- if (key.ctrl && key.name === "u") {
1906
- setFilterDraft("")
1907
- return
1908
- }
1909
- if (key.ctrl && key.name === "w") {
1910
- setFilterDraft((current) => deleteLastWord(current))
1911
- return
1912
- }
1913
- if (key.name === "backspace") {
1914
- setFilterDraft((current) => current.slice(0, -1))
1915
- return
1916
- }
1917
- if (!key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
1918
- setFilterDraft((current) => current + key.sequence)
2284
+ if (isSingleLineInputKey(key)) {
2285
+ setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
1919
2286
  return
1920
2287
  }
1921
2288
  }
@@ -1926,25 +2293,40 @@ export const App = () => {
1926
2293
  }
1927
2294
 
1928
2295
  if (isThemeKey(key)) {
1929
- openThemeModal()
2296
+ runCommandById("theme.open")
1930
2297
  return
1931
2298
  }
1932
2299
 
1933
2300
  if (key.name === "/") {
1934
- setFilterDraft(filterQuery)
1935
- setFilterMode(true)
2301
+ runCommandById("filter.open")
1936
2302
  return
1937
2303
  }
1938
2304
  if (key.name === "escape" && filterQuery.length > 0) {
1939
- setFilterQuery("")
1940
- setFilterDraft("")
1941
- setFilterMode(false)
2305
+ runCommandById("filter.clear")
1942
2306
  return
1943
2307
  }
1944
2308
  if (key.name === "r") {
1945
- refreshPullRequests("Refreshed")
2309
+ runCommandById("pull.refresh")
1946
2310
  return
1947
2311
  }
2312
+ if (isWideLayout && selectedPullRequest && !detailFullView && !diffFullView) {
2313
+ if (key.name === "home") {
2314
+ scrollDetailPreviewTo(0)
2315
+ return
2316
+ }
2317
+ if (key.name === "end") {
2318
+ scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER)
2319
+ return
2320
+ }
2321
+ if (key.name === "pageup") {
2322
+ scrollDetailPreviewBy(-halfPage)
2323
+ return
2324
+ }
2325
+ if (key.name === "pagedown") {
2326
+ scrollDetailPreviewBy(halfPage)
2327
+ return
2328
+ }
2329
+ }
1948
2330
  if (
1949
2331
  key.name === "[" ||
1950
2332
  ((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
@@ -1995,68 +2377,50 @@ export const App = () => {
1995
2377
  return
1996
2378
  }
1997
2379
  if (key.name === "down" || key.name === "j") {
2380
+ if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2381
+ loadMorePullRequests()
2382
+ return
2383
+ }
1998
2384
  setSelectedIndex((current) => {
1999
2385
  if (visiblePullRequests.length === 0) return 0
2000
2386
  return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2001
2387
  })
2002
2388
  return
2003
2389
  }
2004
- // Vim-style navigation: gg to go to top, G to go to bottom
2005
- if (isShiftG(key)) {
2006
- setSelectedIndex((_current) => {
2007
- if (visiblePullRequests.length === 0) return 0
2008
- return visiblePullRequests.length - 1
2009
- })
2010
- return
2011
- }
2012
- if (key.name === "g") {
2013
- if (pendingG) {
2014
- setSelectedIndex(0)
2015
- setPendingG(false)
2016
- if (pendingGTimeoutRef.current !== null) {
2017
- clearTimeout(pendingGTimeoutRef.current)
2018
- pendingGTimeoutRef.current = null
2019
- }
2020
- } else {
2021
- setPendingG(true)
2022
- pendingGTimeoutRef.current = setTimeout(() => {
2023
- setPendingG(false)
2024
- pendingGTimeoutRef.current = null
2025
- }, 500)
2026
- }
2027
- return
2028
- }
2390
+ if (handleVimGoto(key,
2391
+ () => setSelectedIndex(0),
2392
+ () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2393
+ )) return
2029
2394
  if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2030
- setDetailFullView(true)
2031
- setDetailScrollOffset(0)
2395
+ runCommandById("detail.open")
2032
2396
  return
2033
2397
  }
2034
2398
  if (key.name === "d" && selectedPullRequest) {
2035
- openDiffView()
2399
+ runCommandById("diff.open")
2036
2400
  return
2037
2401
  }
2038
2402
  if (key.name === "x" && selectedPullRequest?.state === "open") {
2039
- openCloseModal()
2403
+ runCommandById("pull.close")
2040
2404
  return
2041
2405
  }
2042
2406
  if (key.name === "l" && selectedPullRequest) {
2043
- openLabelModal()
2407
+ runCommandById("pull.labels")
2044
2408
  return
2045
2409
  }
2046
2410
  if (key.name === "m" || key.name === "M") {
2047
- if (selectedPullRequest) openMergeModal()
2411
+ if (selectedPullRequest) runCommandById("pull.merge")
2048
2412
  return
2049
2413
  }
2050
2414
  if (key.name === "o" && selectedPullRequest) {
2051
- openSelectedPullRequestInBrowser(selectedPullRequest)
2415
+ runCommandById("pull.open-browser")
2052
2416
  return
2053
2417
  }
2054
2418
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
2055
- toggleSelectedPullRequestDraftStatus()
2419
+ runCommandById("pull.toggle-draft")
2056
2420
  return
2057
2421
  }
2058
2422
  if (key.name === "y" && selectedPullRequest) {
2059
- copySelectedPullRequestMetadata()
2423
+ runCommandById("pull.copy-metadata")
2060
2424
  return
2061
2425
  }
2062
2426
  })
@@ -2066,19 +2430,20 @@ export const App = () => {
2066
2430
  const wideFullscreenDetailScrollable = getDetailsPaneHeight({
2067
2431
  pullRequest: selectedPullRequest,
2068
2432
  contentWidth: fullscreenContentWidth,
2069
- bodyLines: fullscreenBodyLines,
2433
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2070
2434
  paneWidth: contentWidth,
2071
2435
  showChecks: true,
2072
2436
  }) > wideBodyHeight
2073
2437
  const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
2074
2438
  pullRequest: selectedPullRequest,
2075
2439
  contentWidth: fullscreenContentWidth,
2076
- bodyLines: fullscreenBodyLines,
2440
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2077
2441
  paneWidth: contentWidth,
2078
2442
  }) > wideBodyHeight
2079
2443
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2080
2444
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2081
- const wideDetailBodyScrollable = getDetailBodyHeight(selectedPullRequest, rightContentWidth, wideDetailLines) > wideDetailBodyViewportHeight
2445
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2446
+ const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2082
2447
 
2083
2448
  const prListProps = {
2084
2449
  groups: visibleGroups,
@@ -2088,6 +2453,9 @@ export const App = () => {
2088
2453
  filterText: visibleFilterText,
2089
2454
  showFilterBar: filterMode || filterQuery.length > 0,
2090
2455
  isFilterEditing: filterMode,
2456
+ loadedCount: loadedPullRequestCount,
2457
+ hasMore: hasMorePullRequests,
2458
+ isLoadingMore: isLoadingMorePullRequests,
2091
2459
  onSelectPullRequest: selectPullRequestByUrl,
2092
2460
  } as const
2093
2461
 
@@ -2096,29 +2464,49 @@ export const App = () => {
2096
2464
  const labelModalHeight = Math.min(20, terminalHeight - 4)
2097
2465
  const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2098
2466
  const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
2099
- const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
2100
- const closeModalHeight = Math.min(12, terminalHeight - 4)
2101
- const closeModalLeft = centeredOffset(contentWidth, closeModalWidth)
2102
- const closeModalTop = centeredOffset(terminalHeight, closeModalHeight)
2103
- const commentModalWidth = Math.min(76, Math.max(46, contentWidth - 8))
2104
- const commentModalHeight = Math.min(16, terminalHeight - 4)
2105
- const commentModalLeft = centeredOffset(contentWidth, commentModalWidth)
2106
- const commentModalTop = centeredOffset(terminalHeight, commentModalHeight)
2107
- const commentThreadModalWidth = Math.min(86, Math.max(50, contentWidth - 8))
2108
- const commentThreadModalHeight = Math.min(22, terminalHeight - 4)
2109
- const commentThreadModalLeft = centeredOffset(contentWidth, commentThreadModalWidth)
2110
- const commentThreadModalTop = centeredOffset(terminalHeight, commentThreadModalHeight)
2467
+ const sizedModal = (minW: number, maxW: number, padX: number, maxH: number) => {
2468
+ const w = Math.min(maxW, Math.max(minW, contentWidth - padX))
2469
+ const h = Math.min(maxH, terminalHeight - 4)
2470
+ return { width: w, height: h, left: centeredOffset(contentWidth, w), top: centeredOffset(terminalHeight, h) }
2471
+ }
2472
+ const closeLayout = sizedModal(46, 68, 12, 12)
2473
+ const closeModalWidth = closeLayout.width
2474
+ const closeModalHeight = closeLayout.height
2475
+ const closeModalLeft = closeLayout.left
2476
+ const closeModalTop = closeLayout.top
2477
+ const commentLayout = sizedModal(46, 76, 8, 16)
2478
+ const commentModalWidth = commentLayout.width
2479
+ const commentModalHeight = commentLayout.height
2480
+ const commentModalLeft = commentLayout.left
2481
+ const commentModalTop = commentLayout.top
2482
+ const commentThreadLayout = sizedModal(50, 86, 8, 22)
2483
+ const commentThreadModalWidth = commentThreadLayout.width
2484
+ const commentThreadModalHeight = commentThreadLayout.height
2485
+ const commentThreadModalLeft = commentThreadLayout.left
2486
+ const commentThreadModalTop = commentThreadLayout.top
2111
2487
  const commentAnchorLabel = selectedDiffCommentAnchor
2112
2488
  ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2113
2489
  : "No diff line selected"
2114
- const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
2115
- const mergeModalHeight = Math.min(16, terminalHeight - 4)
2116
- const mergeModalLeft = centeredOffset(contentWidth, mergeModalWidth)
2117
- const mergeModalTop = centeredOffset(terminalHeight, mergeModalHeight)
2118
- const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
2119
- const themeModalHeight = Math.min(16, terminalHeight - 4)
2120
- const themeModalLeft = centeredOffset(contentWidth, themeModalWidth)
2121
- const themeModalTop = centeredOffset(terminalHeight, themeModalHeight)
2490
+ const mergeLayout = sizedModal(46, 68, 12, 16)
2491
+ const mergeModalWidth = mergeLayout.width
2492
+ const mergeModalHeight = mergeLayout.height
2493
+ const mergeModalLeft = mergeLayout.left
2494
+ const mergeModalTop = mergeLayout.top
2495
+ const themeLayout = sizedModal(38, 58, 12, 16)
2496
+ const themeModalWidth = themeLayout.width
2497
+ const themeModalHeight = themeLayout.height
2498
+ const themeModalLeft = themeLayout.left
2499
+ const themeModalTop = themeLayout.top
2500
+ const openRepositoryLayout = sizedModal(46, 76, 8, 8)
2501
+ const openRepositoryModalWidth = openRepositoryLayout.width
2502
+ const openRepositoryModalHeight = openRepositoryLayout.height
2503
+ const openRepositoryModalLeft = openRepositoryLayout.left
2504
+ const openRepositoryModalTop = openRepositoryLayout.top
2505
+ const commandPaletteLayout = sizedModal(50, 88, 8, 24)
2506
+ const commandPaletteWidth = commandPaletteLayout.width
2507
+ const commandPaletteHeight = commandPaletteLayout.height
2508
+ const commandPaletteLeft = commandPaletteLayout.left
2509
+ const commandPaletteTop = commandPaletteLayout.top
2122
2510
 
2123
2511
  return (
2124
2512
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -2137,7 +2525,7 @@ export const App = () => {
2137
2525
  pullRequest={selectedPullRequest}
2138
2526
  diffState={selectedDiffState}
2139
2527
  stackedFiles={stackedDiffFiles}
2140
- fileIndex={diffFileIndex}
2528
+ scrollTop={diffScrollTop}
2141
2529
  view={effectiveDiffRenderView}
2142
2530
  wrapMode={diffWrapMode}
2143
2531
  paneWidth={contentWidth}
@@ -2148,9 +2536,14 @@ export const App = () => {
2148
2536
  commentMode={diffCommentMode}
2149
2537
  selectedCommentAnchor={selectedDiffCommentAnchor}
2150
2538
  selectedCommentThread={selectedDiffCommentThread}
2151
- commentCount={selectedDiffCommentCount}
2539
+ onSelectCommentLine={selectDiffCommentLine}
2152
2540
  themeId={themeId}
2153
2541
  />
2542
+ ) : detailFullView && isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2543
+ <box flexGrow={1} flexDirection="column">
2544
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks={isWideLayout} />
2545
+ <LoadingPane content={detailLoadingContent} width={contentWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout))} />
2546
+ </box>
2154
2547
  ) : isWideLayout && detailFullView ? (
2155
2548
  <box flexGrow={1} flexDirection="column">
2156
2549
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
@@ -2159,6 +2552,7 @@ export const App = () => {
2159
2552
  viewerUsername={username}
2160
2553
  contentWidth={fullscreenContentWidth}
2161
2554
  bodyLines={fullscreenBodyLines}
2555
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2162
2556
  paneWidth={contentWidth}
2163
2557
  showChecks
2164
2558
  placeholderContent={detailPlaceholderContent}
@@ -2168,19 +2562,26 @@ export const App = () => {
2168
2562
  </scrollbox>
2169
2563
  </box>
2170
2564
  ) : isWideLayout ? (
2171
- <box flexGrow={1} flexDirection="row">
2172
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2173
- <scrollbox height={wideBodyHeight} flexGrow={0}>
2174
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2565
+ <box key="wide-main" flexGrow={1} flexDirection="row">
2566
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
2567
+ <scrollbox ref={prListScrollRef} focusable={false} height={wideBodyHeight} flexGrow={0}>
2568
+ <box paddingLeft={sectionPadding} paddingRight={0}>
2569
+ <PullRequestList key={`wide-${leftContentWidth}`} {...prListProps} contentWidth={leftContentWidth} />
2570
+ </box>
2175
2571
  </scrollbox>
2176
2572
  </box>
2177
2573
  <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
2178
2574
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
2179
- {selectedPullRequest ? (
2575
+ {isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2576
+ <>
2577
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2578
+ <LoadingPane content={detailLoadingContent} width={rightPaneWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true))} />
2579
+ </>
2580
+ ) : selectedPullRequest ? (
2180
2581
  <>
2181
2582
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2182
- <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2183
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
2583
+ <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2584
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2184
2585
  </scrollbox>
2185
2586
  </>
2186
2587
  ) : (
@@ -2196,6 +2597,7 @@ export const App = () => {
2196
2597
  viewerUsername={username}
2197
2598
  contentWidth={fullscreenContentWidth}
2198
2599
  bodyLines={fullscreenBodyLines}
2600
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2199
2601
  paneWidth={contentWidth}
2200
2602
  placeholderContent={detailPlaceholderContent}
2201
2603
  loadingIndicator={loadingIndicator}
@@ -2204,13 +2606,13 @@ export const App = () => {
2204
2606
  </scrollbox>
2205
2607
  </box>
2206
2608
  ) : (
2207
- <box height={wideBodyHeight} flexDirection="column">
2208
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2609
+ <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2610
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2209
2611
  <Divider width={contentWidth} />
2210
2612
  <box flexGrow={1} flexDirection="column">
2211
- <scrollbox flexGrow={1}>
2613
+ <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
2212
2614
  <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2213
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2615
+ <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
2214
2616
  </box>
2215
2617
  </scrollbox>
2216
2618
  </box>
@@ -2303,6 +2705,26 @@ export const App = () => {
2303
2705
  offsetTop={themeModalTop}
2304
2706
  />
2305
2707
  ) : null}
2708
+ {openRepositoryModalActive ? (
2709
+ <OpenRepositoryModal
2710
+ state={openRepositoryModal}
2711
+ modalWidth={openRepositoryModalWidth}
2712
+ modalHeight={openRepositoryModalHeight}
2713
+ offsetLeft={openRepositoryModalLeft}
2714
+ offsetTop={openRepositoryModalTop}
2715
+ />
2716
+ ) : null}
2717
+ {commandPaletteActive ? (
2718
+ <CommandPalette
2719
+ commands={commandPaletteCommands}
2720
+ query={commandPalette.query}
2721
+ selectedIndex={selectedCommandIndex}
2722
+ modalWidth={commandPaletteWidth}
2723
+ modalHeight={commandPaletteHeight}
2724
+ offsetLeft={commandPaletteLeft}
2725
+ offsetTop={commandPaletteTop}
2726
+ />
2727
+ ) : null}
2306
2728
  </box>
2307
2729
  )
2308
2730
  }