@kitlangton/ghui 0.1.19 → 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,37 +1,66 @@
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 LoadStatus, 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, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
18
- import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.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"
19
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
+ }
28
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
49
+
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)
29
57
 
30
58
  interface PullRequestLoad {
31
- readonly queueMode: PullRequestQueueMode
59
+ readonly view: PullRequestView
32
60
  readonly data: readonly PullRequestItem[]
33
61
  readonly fetchedAt: Date | null
34
- readonly detailsFetchedAt: Date | null
62
+ readonly endCursor: string | null
63
+ readonly hasNextPage: boolean
35
64
  }
36
65
 
37
66
  interface DetailPlaceholderInput {
@@ -66,87 +95,103 @@ interface AppliedDiffLineColorState {
66
95
  readonly entries: readonly AppliedDiffLineColor[]
67
96
  }
68
97
 
98
+ interface DetailHydration {
99
+ readonly token: symbol
100
+ notifyError: boolean
101
+ }
102
+
69
103
  const PR_FETCH_RETRIES = 6
70
104
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
71
105
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
72
106
  const AUTO_REFRESH_JITTER_MS = 10_000
73
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
122
  const retryProgressAtom = Atom.make<RetryProgress>(initialRetryProgress).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)
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)
135
+ const view = yield* Atom.get(activeViewAtom)
136
+ const queueMode = viewMode(view)
137
+ const repository = viewRepository(view)
138
+ const cacheKey = viewCacheKey(view)
105
139
  yield* Atom.set(retryProgressAtom, initialRetryProgress)
106
- const data = yield* github.listOpenPullRequests(queueMode).pipe(
107
- Effect.tapError(() =>
108
- Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
109
- attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 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, initialRetryProgress)),
115
- )
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
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 diffScrollTopAtom = Atom.make(0).pipe(Atom.keepAlive)
141
- const diffRenderViewAtom = Atom.make<DiffView>("split").pipe(Atom.keepAlive)
142
- const diffWrapModeAtom = Atom.make<DiffWrapMode>("none").pipe(Atom.keepAlive)
143
- const diffCommentModeAtom = Atom.make(false).pipe(Atom.keepAlive)
144
- 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)
145
190
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
146
191
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
147
192
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
148
193
 
149
- const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
194
+ const activeModalAtom = Atom.make<Modal>(initialModal)
150
195
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
151
196
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
152
197
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -157,12 +202,110 @@ const usernameAtom = githubRuntime.atom(
157
202
  : Effect.succeed(config.author.replace(/^@/, "")),
158
203
  ).pipe(Atom.keepAlive)
159
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
+
160
299
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
161
300
  GitHubService.use((github) => github.listRepoLabels(repository))
162
301
  )
163
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
164
- GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
302
+ const listOpenPullRequestPageAtom = githubRuntime.fn<ListPullRequestPageInput>()((input) =>
303
+ GitHubService.use((github) => github.listOpenPullRequestPage(input))
165
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
+ })
166
309
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
167
310
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
168
311
  )
@@ -172,9 +315,10 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
172
315
  const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
173
316
  GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
174
317
  )
175
- const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
176
- GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
177
- )
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
+ })
178
322
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
179
323
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
180
324
  )
@@ -188,12 +332,13 @@ const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
188
332
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
189
333
  )
190
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)))
191
337
 
192
338
  const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
193
339
 
194
- const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
195
340
 
196
- const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
341
+ const pasteText = (event: PasteEvent) => new TextDecoder().decode(event.bytes)
197
342
 
198
343
  const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
199
344
  const normalized = query.trim().toLowerCase()
@@ -210,91 +355,34 @@ const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) =>
210
355
  return scores.length > 0 ? Math.min(...scores) : null
211
356
  }
212
357
 
213
- const clipboardCommands = (): readonly (readonly string[])[] => {
214
- if (process.platform === "darwin") return [["pbcopy"]]
215
- if (process.platform === "linux") {
216
- return [
217
- ...(process.env.WAYLAND_DISPLAY ? [["wl-copy"]] : []),
218
- ["xclip", "-selection", "clipboard"],
219
- ["xsel", "--clipboard", "--input"],
220
- ]
221
- }
222
- return []
223
- }
224
-
225
- const copyToClipboard = async (text: string) => {
226
- const commands = clipboardCommands()
227
- let lastError = ""
228
-
229
- for (const command of commands) {
230
- let proc: Bun.Subprocess<"pipe", "ignore", "pipe">
231
- try {
232
- proc = Bun.spawn({
233
- cmd: [...command],
234
- stdin: "pipe",
235
- stdout: "ignore",
236
- stderr: "pipe",
237
- })
238
- } catch (error) {
239
- lastError = errorMessage(error)
240
- continue
241
- }
242
-
243
- proc.stdin.write(text)
244
- proc.stdin.end()
245
-
246
- const exitCode = await proc.exited
247
- if (exitCode === 0) return
248
-
249
- const stderr = await Bun.readableStreamToText(proc.stderr)
250
- lastError = stderr.trim()
251
- }
252
-
253
- const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
254
- throw new Error(lastError || `Clipboard is not available.${installHint}`)
255
- }
256
-
257
- const openPullRequestInBrowser = async (pullRequest: PullRequestItem) => {
258
- const proc = Bun.spawn({
259
- cmd: ["gh", "pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"],
260
- stdout: "ignore",
261
- stderr: "pipe",
262
- })
263
-
264
- const exitCode = await proc.exited
265
- if (exitCode === 0) return
266
-
267
- const stderr = await Bun.readableStreamToText(proc.stderr)
268
- throw new Error(stderr.trim() || "Could not open PR in browser")
269
- }
270
-
271
- const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
358
+ const pullRequestMetadataText = (pullRequest: PullRequestItem) => {
272
359
  const lines = [
273
360
  pullRequest.title,
274
361
  `${pullRequest.repository} #${pullRequest.number}`,
275
362
  pullRequest.url,
276
363
  ]
277
-
278
364
  const review = reviewLabel(pullRequest)
279
- if (review) {
280
- lines.push(`review: ${review}`)
281
- }
282
- if (pullRequest.checkSummary) {
283
- lines.push(pullRequest.checkSummary)
284
- }
365
+ if (review) lines.push(`review: ${review}`)
366
+ if (pullRequest.checkSummary) lines.push(pullRequest.checkSummary)
367
+ return lines.join("\n")
368
+ }
285
369
 
286
- 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) }
287
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")
288
381
 
289
382
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
290
383
 
291
384
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
292
385
 
293
- const nextQueueMode = (mode: PullRequestQueueMode, delta: 1 | -1) => {
294
- const index = pullRequestQueueModes.indexOf(mode)
295
- return pullRequestQueueModes[(index + delta + pullRequestQueueModes.length) % pullRequestQueueModes.length]!
296
- }
297
-
298
386
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
299
387
  `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
300
388
 
@@ -321,28 +409,11 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
321
409
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
322
410
  }
323
411
 
324
- const mixHexColor = (color: string, base: string, amount: number) => {
325
- const parse = (hex: string) => {
326
- const normalized = hex.replace(/^#/, "")
327
- if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return null
328
- return {
329
- r: Number.parseInt(normalized.slice(0, 2), 16),
330
- g: Number.parseInt(normalized.slice(2, 4), 16),
331
- b: Number.parseInt(normalized.slice(4, 6), 16),
332
- }
333
- }
334
- const left = parse(color)
335
- const right = parse(base)
336
- if (!left || !right) return color
337
- const channel = (key: "r" | "g" | "b") => Math.round(left[key] * amount + right[key] * (1 - amount)).toString(16).padStart(2, "0")
338
- return `#${channel("r")}${channel("g")}${channel("b")}`
339
- }
340
-
341
412
  const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
342
413
  const accent = kind === "thread"
343
414
  ? colors.status.pending
344
415
  : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
345
- return mixHexColor(accent, originalDiffLineColor(anchor).gutter, 0.45)
416
+ return mixHex(originalDiffLineColor(anchor).gutter, accent, 0.45)
346
417
  }
347
418
 
348
419
  const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
@@ -404,11 +475,12 @@ const getDetailPlaceholderContent = ({
404
475
  export const App = () => {
405
476
  const renderer = useRenderer()
406
477
  const { width, height } = useTerminalDimensions()
478
+ const registry = useContext(RegistryContext)
407
479
  const pullRequestResult = useAtomValue(pullRequestsAtom)
408
480
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
409
- const [queueMode, setQueueMode] = useAtom(queueModeAtom)
410
- const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
411
- const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
481
+ const [activeView, setActiveView] = useAtom(activeViewAtom)
482
+ const setQueueLoadCache = useAtomSet(queueLoadCacheAtom)
483
+ const setQueueSelection = useAtomSet(queueSelectionAtom)
412
484
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
413
485
  const [notice, setNotice] = useAtom(noticeAtom)
414
486
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -416,7 +488,7 @@ export const App = () => {
416
488
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
417
489
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
418
490
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
419
- const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
491
+ const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
420
492
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
421
493
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
422
494
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
@@ -425,8 +497,8 @@ export const App = () => {
425
497
  const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
426
498
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
427
499
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
428
- const [diffCommentsLoaded, setDiffCommentsLoaded] = useAtom(diffCommentsLoadedAtom)
429
- const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
500
+ const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
501
+ const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
430
502
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
431
503
  const [themeId, setThemeId] = useAtom(themeIdAtom)
432
504
  const closeActiveModal = () => setActiveModal(initialModal)
@@ -436,12 +508,16 @@ export const App = () => {
436
508
  const commentModalActive = Modal.$is("Comment")(activeModal)
437
509
  const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
438
510
  const themeModalActive = Modal.$is("Theme")(activeModal)
511
+ const commandPaletteActive = Modal.$is("CommandPalette")(activeModal)
512
+ const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal)
439
513
  const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
440
514
  const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
441
515
  const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
442
516
  const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
443
517
  const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
444
518
  const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
519
+ const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState
520
+ const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState
445
521
  const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
446
522
  (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
447
523
  const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
@@ -458,31 +534,35 @@ export const App = () => {
458
534
  const setCommentModal = makeModalSetter("Comment")
459
535
  const setCommentThreadModal = makeModalSetter("CommentThread")
460
536
  const setThemeModal = makeModalSetter("Theme")
537
+ const setCommandPalette = makeModalSetter("CommandPalette")
538
+ const setOpenRepositoryModal = makeModalSetter("OpenRepository")
461
539
  setActiveTheme(themeId)
462
540
  const themeIdRef = useRef(themeId)
463
541
  const themeModalRef = useRef(themeModal)
464
542
  themeIdRef.current = themeId
465
543
  themeModalRef.current = themeModal
466
- const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
467
- const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
468
- const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
544
+ const setLabelCache = useAtomSet(labelCacheAtom)
545
+ const setPullRequestOverrides = useAtomSet(pullRequestOverridesAtom)
546
+ const setRecentlyCompletedPullRequests = useAtomSet(recentlyCompletedPullRequestsAtom)
469
547
  const retryProgress = useAtomValue(retryProgressAtom)
470
548
  const [loadingFrame, setLoadingFrame] = useState(0)
471
549
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
472
550
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
473
551
  const [terminalFocused, setTerminalFocused] = useState(true)
552
+ const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
474
553
  const usernameResult = useAtomValue(usernameAtom)
475
554
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
476
- const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
555
+ const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
477
556
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
478
557
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
479
558
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
480
- const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
481
559
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
482
560
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
483
561
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
484
562
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
485
563
  const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
564
+ const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
565
+ const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
486
566
  const terminalWidth = width ?? 100
487
567
  const terminalHeight = height ?? 24
488
568
  const contentWidth = Math.max(1, terminalWidth)
@@ -492,14 +572,15 @@ export const App = () => {
492
572
  const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
493
573
  const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
494
574
  const dividerJunctionAt = Math.max(1, leftPaneWidth)
495
- 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)
496
576
  const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
497
- const wideDetailLines = Math.max(8, terminalHeight - 8) // fill available vertical space
577
+ const wideDetailLines = Math.max(8, terminalHeight - 8)
498
578
  const wideBodyHeight = Math.max(8, terminalHeight - 4)
499
579
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
500
580
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
501
581
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
502
- const detailHydrationRef = useRef<number | null>(null)
582
+ const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
583
+ const detailHydrationRef = useRef(new Map<string, DetailHydration>())
503
584
  const refreshGenerationRef = useRef(0)
504
585
  const didMountQueueModeRef = useRef(false)
505
586
  const lastPullRequestRefreshAtRef = useRef(0)
@@ -509,7 +590,9 @@ export const App = () => {
509
590
  const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
510
591
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
511
592
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
593
+ const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
512
594
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
595
+ const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
513
596
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
514
597
  const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
515
598
  const suppressNextDiffCommentScrollRef = useRef(false)
@@ -530,6 +613,8 @@ export const App = () => {
530
613
  }, [renderer, themeId])
531
614
 
532
615
  useEffect(() => () => {
616
+ refreshGenerationRef.current += 1
617
+ detailHydrationRef.current.clear()
533
618
  if (noticeTimeoutRef.current !== null) {
534
619
  clearTimeout(noticeTimeoutRef.current)
535
620
  }
@@ -539,100 +624,83 @@ export const App = () => {
539
624
  if (diffPrefetchTimeoutRef.current !== null) {
540
625
  clearTimeout(diffPrefetchTimeoutRef.current)
541
626
  }
627
+ if (detailPrefetchTimeoutRef.current !== null) {
628
+ clearTimeout(detailPrefetchTimeoutRef.current)
629
+ }
542
630
  }, [])
543
631
 
544
- const resolvedPullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
545
- const pullRequestLoad = queueLoadCache[queueMode] ?? (resolvedPullRequestLoad?.queueMode === queueMode ? resolvedPullRequestLoad : null)
546
- const isLoadingQueueMode = resolvedPullRequestLoad !== null && resolvedPullRequestLoad.queueMode !== queueMode
547
- const pullRequests = useMemo(() => {
548
- const source = pullRequestLoad?.data ?? []
549
- const seenUrls = new Set<string>()
550
- const openPullRequests = source.map((pullRequest) => {
551
- seenUrls.add(pullRequest.url)
552
- return recentlyCompletedPullRequests[pullRequest.url] ?? pullRequestOverrides[pullRequest.url] ?? pullRequest
553
- })
554
-
555
- return [
556
- ...openPullRequests,
557
- ...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
558
- ]
559
- }, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
560
- const pullRequestStatus: LoadStatus = (pullRequestResult.waiting || isLoadingQueueMode) && pullRequestLoad === null
561
- ? "loading"
562
- : AsyncResult.isFailure(pullRequestResult)
563
- ? "error"
564
- : "ready"
632
+ const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
633
+ const pullRequests = useAtomValue(displayedPullRequestsAtom)
634
+ const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
565
635
  const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
566
636
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
567
637
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
568
638
  pullRequestStatusRef.current = pullRequestStatus
569
639
 
570
- const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
571
640
  const visibleFilterText = filterMode ? filterDraft : filterQuery
572
641
 
573
- const filteredPullRequests = useMemo(() => {
574
- if (effectiveFilterQuery.length === 0) return pullRequests
575
- return pullRequests.flatMap((pullRequest) => {
576
- const score = pullRequestFilterScore(pullRequest, effectiveFilterQuery)
577
- return score === null ? [] : [{ pullRequest, score }]
578
- }).sort((left, right) =>
579
- left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
580
- ).map(({ pullRequest }) => pullRequest)
581
- }, [pullRequests, effectiveFilterQuery])
582
-
583
- const visibleRepoOrder = useMemo(() => effectiveFilterQuery.length > 0
584
- ? [...new Set(filteredPullRequests.map((pullRequest) => pullRequest.repository))]
585
- : [], [filteredPullRequests, effectiveFilterQuery.length])
586
- const visibleGroups = useMemo(
587
- () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository, visibleRepoOrder),
588
- [filteredPullRequests, visibleRepoOrder],
589
- )
590
- const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
591
- const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
592
- 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)
593
664
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
594
665
  const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
595
666
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
596
- const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
597
667
  const diffCommentAnchors = useMemo(
598
668
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
599
669
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
600
670
  )
601
671
  const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
602
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentAnchorKey(selectedDiffCommentAnchor)}` : null
672
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentLocationKey(selectedDiffCommentAnchor)}` : null
603
673
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
604
674
  const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
605
675
  const diffCommentRows = useMemo(
606
676
  () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
607
677
  [diffCommentAnchors],
608
678
  )
609
- const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
610
- if (index === 0) {
611
- starts.push(0)
612
- return starts
613
- }
614
- starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
615
- return starts
616
- }, []), [visibleGroups])
679
+ const groupStarts = useAtomValue(groupStartsAtom)
617
680
  const getCurrentGroupIndex = (current: number) => {
618
- for (let index = groupStarts.length - 1; index >= 0; index--) {
619
- if (groupStarts[index]! <= current) return index
620
- }
621
- 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
622
690
  }
623
691
  const summaryRight = pullRequestLoad?.fetchedAt
624
692
  ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
625
693
  : pullRequestStatus === "loading"
626
694
  ? "loading pull requests..."
627
695
  : ""
628
- const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
696
+ const headerLeft = username ? `GHUI ${username} ${viewLabel(activeView)}` : `GHUI ${viewLabel(activeView)}`
629
697
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
630
698
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
631
699
  const selectPullRequestByUrl = (url: string) => {
632
700
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
633
701
  if (index >= 0) {
634
702
  setSelectedIndex(index)
635
- setQueueSelection((current) => ({ ...current, [queueMode]: index }))
703
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: index }))
636
704
  }
637
705
  }
638
706
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
@@ -642,6 +710,9 @@ export const App = () => {
642
710
  }
643
711
  const refreshPullRequests = (message?: string) => {
644
712
  refreshGenerationRef.current += 1
713
+ detailHydrationRef.current.clear()
714
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
715
+ setLoadingMoreKey(null)
645
716
  setPullRequestOverrides({})
646
717
  if (message) {
647
718
  setNotice(null)
@@ -651,15 +722,16 @@ export const App = () => {
651
722
  refreshPullRequestsAtom()
652
723
  }
653
724
  refreshPullRequestsRef.current = refreshPullRequests
654
- const switchQueueMode = (delta: 1 | -1) => {
655
- const mode = nextQueueMode(queueMode, delta)
656
- if (mode === queueMode) return
725
+ const switchViewTo = (view: PullRequestView) => {
726
+ if (viewEquals(view, activeView)) return
657
727
  refreshGenerationRef.current += 1
658
- setQueueSelection((current) => ({ ...current, [queueMode]: selectedIndex }))
659
- setQueueMode(mode)
660
- setSelectedIndex(queueSelection[mode] ?? 0)
728
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: selectedIndex }))
729
+ setActiveView(view)
730
+ setSelectedIndex(registry.get(queueSelectionAtom)[viewCacheKey(view)] ?? 0)
661
731
  setRecentlyCompletedPullRequests({})
662
- detailHydrationRef.current = null
732
+ detailHydrationRef.current.clear()
733
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
734
+ setLoadingMoreKey(null)
663
735
  setDetailFullView(false)
664
736
  setDiffFullView(false)
665
737
  setDiffCommentMode(false)
@@ -668,6 +740,82 @@ export const App = () => {
668
740
  setRefreshCompletionMessage(null)
669
741
  setRefreshStartedAt(null)
670
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
+ }
671
819
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
672
820
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
673
821
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -687,14 +835,14 @@ export const App = () => {
687
835
  didMountQueueModeRef.current = true
688
836
  return
689
837
  }
690
- if (queueLoadCache[queueMode]) return
838
+ if (registry.get(queueLoadCacheAtom)[currentQueueCacheKey]) return
691
839
  refreshPullRequestsAtom()
692
- }, [queueMode, queueLoadCache, refreshPullRequestsAtom])
840
+ }, [currentQueueCacheKey, refreshPullRequestsAtom, registry])
693
841
 
694
842
  useEffect(() => {
695
843
  if (!refreshCompletionMessage || refreshStartedAt === null) return
696
844
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
697
- const isHydratingDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
845
+ const isHydratingDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
698
846
  if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
699
847
  flashNotice(`✓ ${refreshCompletionMessage}`)
700
848
  setRefreshCompletionMessage(null)
@@ -747,13 +895,29 @@ export const App = () => {
747
895
  }, [visiblePullRequests.length])
748
896
 
749
897
  useEffect(() => {
750
- setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
751
- }, [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])
752
915
 
753
916
  useEffect(() => {
754
917
  setDiffFileIndex(0)
755
918
  setDiffScrollTop(0)
756
919
  setDiffCommentAnchorIndex(0)
920
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
757
921
  }, [selectedIndex])
758
922
 
759
923
  useEffect(() => {
@@ -793,7 +957,7 @@ export const App = () => {
793
957
 
794
958
  if (selectedDiffKey) {
795
959
  for (const anchor of diffCommentAnchors) {
796
- if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
960
+ if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentLocationKey(anchor)}`]?.length ?? 0) > 0) {
797
961
  applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
798
962
  }
799
963
  }
@@ -810,7 +974,7 @@ export const App = () => {
810
974
  }
811
975
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
812
976
  }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
813
- const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
977
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
814
978
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
815
979
  const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
816
980
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
@@ -824,31 +988,30 @@ export const App = () => {
824
988
  }, [hasActiveLoadingIndicator])
825
989
 
826
990
  useEffect(() => {
827
- const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
828
- if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
829
- if (detailHydrationRef.current === fetchedAt || pullRequestLoad?.detailsFetchedAt?.getTime() === fetchedAt) return
830
- if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
831
- detailHydrationRef.current = fetchedAt
832
- const generation = refreshGenerationRef.current
833
- void loadPullRequestDetails(queueMode).then((details) => {
834
- if (generation !== refreshGenerationRef.current) return
835
- setQueueLoadCache((current) => {
836
- const load = current[queueMode]
837
- if (!load) return current
838
- const detailsByUrl = new Map(details.map((detail) => [detail.url, detail]))
839
- return {
840
- ...current,
841
- [queueMode]: {
842
- ...load,
843
- data: load.data.map((pullRequest) => detailsByUrl.get(pullRequest.url) ?? pullRequest),
844
- detailsFetchedAt: load.fetchedAt,
845
- },
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
846
1008
  }
847
- })
848
- }).catch((error) => {
849
- flashNotice(errorMessage(error))
850
- })
851
- }, [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])
852
1015
 
853
1016
  const detailPlaceholderContent = getDetailPlaceholderContent({
854
1017
  status: pullRequestStatus,
@@ -857,13 +1020,18 @@ export const App = () => {
857
1020
  visibleCount: visiblePullRequests.length,
858
1021
  filterText: visibleFilterText,
859
1022
  })
860
- 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)
861
1029
 
862
1030
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
863
1031
 
864
1032
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
865
1033
  const key = pullRequestDiffKey(pullRequest)
866
- const previousLoadState = diffCommentsLoaded[key]
1034
+ const previousLoadState = registry.get(diffCommentsLoadedAtom)[key]
867
1035
  if (!force && previousLoadState) return
868
1036
  setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
869
1037
  void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
@@ -906,12 +1074,14 @@ export const App = () => {
906
1074
  const force = options.force ?? false
907
1075
  const includeComments = options.includeComments ?? false
908
1076
  const key = pullRequestDiffKey(pullRequest)
909
- const existing = pullRequestDiffCache[key]
1077
+ const existing = registry.get(pullRequestDiffCacheAtom)[key]
910
1078
  if (includeComments) loadPullRequestComments(pullRequest, force)
911
1079
  if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
912
1080
 
913
1081
  setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
914
- void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
1082
+ const atom = pullRequestDiffAtom(pullRequestDiffAtomKey(pullRequest))
1083
+ if (force) registry.refresh(atom)
1084
+ void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }))
915
1085
  .then((patch) => {
916
1086
  setPullRequestDiffCache((current) => ({
917
1087
  ...current,
@@ -985,6 +1155,8 @@ export const App = () => {
985
1155
  diffScrollRef.current?.scrollTo({ x: 0, y })
986
1156
  syncDiffScrollState()
987
1157
  }
1158
+ const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1159
+ const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
988
1160
 
989
1161
  const clearPendingGTimeout = () => {
990
1162
  if (pendingGTimeoutRef.current !== null) {
@@ -1161,14 +1333,14 @@ export const App = () => {
1161
1333
  }
1162
1334
 
1163
1335
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1164
- void openPullRequestInBrowser(pullRequest)
1336
+ void openInBrowser(pullRequest)
1165
1337
  .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
1166
1338
  .catch((error) => flashNotice(errorMessage(error)))
1167
1339
  }
1168
1340
 
1169
1341
  const copySelectedPullRequestMetadata = () => {
1170
1342
  if (!selectedPullRequest) return
1171
- void copyPullRequestMetadata(selectedPullRequest)
1343
+ void copyToClipboard(pullRequestMetadataText(selectedPullRequest))
1172
1344
  .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1173
1345
  .catch((error) => flashNotice(errorMessage(error)))
1174
1346
  }
@@ -1291,7 +1463,7 @@ export const App = () => {
1291
1463
  const openLabelModal = () => {
1292
1464
  if (!selectedPullRequest) return
1293
1465
  const repository = selectedPullRequest.repository
1294
- const cachedLabels = labelCache[repository]
1466
+ const cachedLabels = registry.get(labelCacheAtom)[repository]
1295
1467
  if (cachedLabels) {
1296
1468
  setLabelModal({
1297
1469
  repository,
@@ -1390,9 +1562,7 @@ export const App = () => {
1390
1562
 
1391
1563
  const toggleLabelAtIndex = () => {
1392
1564
  if (!selectedPullRequest) return
1393
- const filtered = labelModal.availableLabels.filter((label) =>
1394
- labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1395
- )
1565
+ const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1396
1566
  const label = filtered[labelModal.selectedIndex]
1397
1567
  if (!label) return
1398
1568
 
@@ -1424,7 +1594,214 @@ export const App = () => {
1424
1594
  }
1425
1595
  }
1426
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
+
1427
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
+
1428
1805
  if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1429
1806
  if (themeModalActive) {
1430
1807
  closeThemeModal(false)
@@ -1434,7 +1811,7 @@ export const App = () => {
1434
1811
  closeActiveModal()
1435
1812
  return
1436
1813
  }
1437
- renderer.destroy()
1814
+ runCommandById("app.quit")
1438
1815
  return
1439
1816
  }
1440
1817
 
@@ -1464,16 +1841,8 @@ export const App = () => {
1464
1841
  moveThemeSelection(1)
1465
1842
  return
1466
1843
  }
1467
- if (themeModal.filterMode && key.name === "backspace") {
1468
- editThemeQuery((query) => query.slice(0, -1))
1469
- return
1470
- }
1471
- if (themeModal.filterMode && key.ctrl && key.name === "u") {
1472
- updateThemeQuery("")
1473
- return
1474
- }
1475
- if (themeModal.filterMode && !key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
1476
- editThemeQuery((query) => query + key.sequence)
1844
+ if (themeModal.filterMode && isSingleLineInputKey(key)) {
1845
+ editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
1477
1846
  return
1478
1847
  }
1479
1848
  return
@@ -1572,8 +1941,9 @@ export const App = () => {
1572
1941
  submitDiffComment()
1573
1942
  return
1574
1943
  }
1575
- if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1576
- editComment((state) => insertText(state, key.sequence))
1944
+ const text = printableKeyText(key)
1945
+ if (text) {
1946
+ editComment((state) => insertText(state, text))
1577
1947
  return
1578
1948
  }
1579
1949
  return
@@ -1646,7 +2016,6 @@ export const App = () => {
1646
2016
  return
1647
2017
  }
1648
2018
 
1649
- // Label modal takes priority over everything else
1650
2019
  if (labelModalActive) {
1651
2020
  if (key.name === "escape") {
1652
2021
  closeActiveModal()
@@ -1664,31 +2033,17 @@ export const App = () => {
1664
2033
  return
1665
2034
  }
1666
2035
  if (key.name === "down" || key.name === "j") {
1667
- const filtered = labelModal.availableLabels.filter((label) =>
1668
- labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1669
- )
2036
+ const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1670
2037
  setLabelModal((current) => ({
1671
2038
  ...current,
1672
2039
  selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
1673
2040
  }))
1674
2041
  return
1675
2042
  }
1676
- if (key.name === "backspace") {
2043
+ if (isSingleLineInputKey(key)) {
1677
2044
  setLabelModal((current) => ({
1678
2045
  ...current,
1679
- query: current.query.slice(0, -1),
1680
- selectedIndex: 0,
1681
- }))
1682
- return
1683
- }
1684
- if (key.ctrl && key.name === "u") {
1685
- setLabelModal((current) => ({ ...current, query: "", selectedIndex: 0 }))
1686
- return
1687
- }
1688
- if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1689
- setLabelModal((current) => ({
1690
- ...current,
1691
- query: current.query + key.sequence,
2046
+ query: editSingleLineInput(current.query, key) ?? current.query,
1692
2047
  selectedIndex: 0,
1693
2048
  }))
1694
2049
  return
@@ -1703,7 +2058,7 @@ export const App = () => {
1703
2058
  return
1704
2059
  }
1705
2060
  if (key.name === "c") {
1706
- setDiffCommentMode(false)
2061
+ runCommandById("diff.comment-mode")
1707
2062
  return
1708
2063
  }
1709
2064
  if (key.name === "return" || key.name === "enter") {
@@ -1712,7 +2067,7 @@ export const App = () => {
1712
2067
  return
1713
2068
  }
1714
2069
  if (key.name === "a") {
1715
- openDiffCommentModal()
2070
+ runCommandById("diff.add-comment")
1716
2071
  return
1717
2072
  }
1718
2073
  if (key.name === "pageup" || key.ctrl && key.name === "u") {
@@ -1748,23 +2103,22 @@ export const App = () => {
1748
2103
  return
1749
2104
  }
1750
2105
  if (key.name === "]" && selectedDiffState?._tag === "Ready") {
1751
- jumpDiffFile(1)
2106
+ runCommandById("diff.next-file")
1752
2107
  return
1753
2108
  }
1754
2109
  if (key.name === "[" && selectedDiffState?._tag === "Ready") {
1755
- jumpDiffFile(-1)
2110
+ runCommandById("diff.previous-file")
1756
2111
  return
1757
2112
  }
1758
2113
  return
1759
2114
  }
1760
2115
 
1761
2116
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1762
- setDiffFullView(false)
1763
- setDiffCommentMode(false)
2117
+ runCommandById("diff.close")
1764
2118
  return
1765
2119
  }
1766
2120
  if (key.name === "c" && selectedDiffState?._tag === "Ready") {
1767
- enterDiffCommentMode()
2121
+ runCommandById("diff.comment-mode")
1768
2122
  return
1769
2123
  }
1770
2124
  if (key.name === "home") {
@@ -1801,67 +2155,64 @@ export const App = () => {
1801
2155
  return
1802
2156
  }
1803
2157
  if (key.name === "v") {
1804
- setDiffRenderView((current) => current === "unified" ? "split" : "unified")
2158
+ runCommandById("diff.toggle-view")
1805
2159
  return
1806
2160
  }
1807
2161
  if (key.name === "w") {
1808
- setDiffWrapMode((current) => current === "none" ? "word" : "none")
2162
+ runCommandById("diff.toggle-wrap")
1809
2163
  return
1810
2164
  }
1811
2165
  if (key.name === "r" && selectedPullRequest) {
1812
- loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1813
- flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
2166
+ runCommandById("diff.reload")
1814
2167
  return
1815
2168
  }
1816
2169
  if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
1817
- jumpDiffFile(1)
2170
+ runCommandById("diff.next-file")
1818
2171
  return
1819
2172
  }
1820
2173
  if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
1821
- jumpDiffFile(-1)
2174
+ runCommandById("diff.previous-file")
1822
2175
  return
1823
2176
  }
1824
2177
  if (key.name === "o" && selectedPullRequest) {
1825
- openSelectedPullRequestInBrowser(selectedPullRequest)
2178
+ runCommandById("pull.open-browser")
1826
2179
  return
1827
2180
  }
1828
2181
  return
1829
2182
  }
1830
2183
 
1831
- // Fullscreen detail mode handles its own navigation keys.
1832
2184
  if (detailFullView) {
1833
2185
  const plainKey = !key.ctrl && !key.meta && !key.option
1834
2186
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1835
- setDetailFullView(false)
1836
- setDetailScrollOffset(0)
2187
+ runCommandById("detail.close")
1837
2188
  return
1838
2189
  }
1839
2190
  if (isThemeKey(key)) {
1840
- openThemeModal()
2191
+ runCommandById("theme.open")
1841
2192
  return
1842
2193
  }
1843
2194
  if (plainKey && key.name === "d" && selectedPullRequest) {
1844
- openDiffView()
2195
+ runCommandById("diff.open")
1845
2196
  return
1846
2197
  }
1847
2198
  if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
1848
- openCloseModal()
2199
+ runCommandById("pull.close")
1849
2200
  return
1850
2201
  }
1851
2202
  if (plainKey && key.name === "l" && selectedPullRequest) {
1852
- openLabelModal()
2203
+ runCommandById("pull.labels")
1853
2204
  return
1854
2205
  }
1855
2206
  if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
1856
- openMergeModal()
2207
+ runCommandById("pull.merge")
1857
2208
  return
1858
2209
  }
1859
2210
  if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
1860
- toggleSelectedPullRequestDraftStatus()
2211
+ runCommandById("pull.toggle-draft")
1861
2212
  return
1862
2213
  }
1863
2214
  if (plainKey && key.name === "r") {
1864
- refreshPullRequests("Refreshed")
2215
+ runCommandById("pull.refresh")
1865
2216
  return
1866
2217
  }
1867
2218
  if (key.name === "home") {
@@ -1909,11 +2260,11 @@ export const App = () => {
1909
2260
  return
1910
2261
  }
1911
2262
  if (plainKey && key.name === "o" && selectedPullRequest) {
1912
- openSelectedPullRequestInBrowser(selectedPullRequest)
2263
+ runCommandById("pull.open-browser")
1913
2264
  return
1914
2265
  }
1915
2266
  if (plainKey && key.name === "y" && selectedPullRequest) {
1916
- copySelectedPullRequestMetadata()
2267
+ runCommandById("pull.copy-metadata")
1917
2268
  return
1918
2269
  }
1919
2270
  return
@@ -1925,25 +2276,13 @@ export const App = () => {
1925
2276
  setFilterMode(false)
1926
2277
  return
1927
2278
  }
1928
- if (key.name === "enter") {
2279
+ if (key.name === "return" || key.name === "enter") {
1929
2280
  setFilterQuery(filterDraft)
1930
2281
  setFilterMode(false)
1931
2282
  return
1932
2283
  }
1933
- if (key.ctrl && key.name === "u") {
1934
- setFilterDraft("")
1935
- return
1936
- }
1937
- if (key.ctrl && key.name === "w") {
1938
- setFilterDraft((current) => deleteLastWord(current))
1939
- return
1940
- }
1941
- if (key.name === "backspace") {
1942
- setFilterDraft((current) => current.slice(0, -1))
1943
- return
1944
- }
1945
- if (!key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
1946
- setFilterDraft((current) => current + key.sequence)
2284
+ if (isSingleLineInputKey(key)) {
2285
+ setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
1947
2286
  return
1948
2287
  }
1949
2288
  }
@@ -1954,25 +2293,40 @@ export const App = () => {
1954
2293
  }
1955
2294
 
1956
2295
  if (isThemeKey(key)) {
1957
- openThemeModal()
2296
+ runCommandById("theme.open")
1958
2297
  return
1959
2298
  }
1960
2299
 
1961
2300
  if (key.name === "/") {
1962
- setFilterDraft(filterQuery)
1963
- setFilterMode(true)
2301
+ runCommandById("filter.open")
1964
2302
  return
1965
2303
  }
1966
2304
  if (key.name === "escape" && filterQuery.length > 0) {
1967
- setFilterQuery("")
1968
- setFilterDraft("")
1969
- setFilterMode(false)
2305
+ runCommandById("filter.clear")
1970
2306
  return
1971
2307
  }
1972
2308
  if (key.name === "r") {
1973
- refreshPullRequests("Refreshed")
2309
+ runCommandById("pull.refresh")
1974
2310
  return
1975
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
+ }
1976
2330
  if (
1977
2331
  key.name === "[" ||
1978
2332
  ((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
@@ -2023,6 +2377,10 @@ export const App = () => {
2023
2377
  return
2024
2378
  }
2025
2379
  if (key.name === "down" || key.name === "j") {
2380
+ if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2381
+ loadMorePullRequests()
2382
+ return
2383
+ }
2026
2384
  setSelectedIndex((current) => {
2027
2385
  if (visiblePullRequests.length === 0) return 0
2028
2386
  return current >= visiblePullRequests.length - 1 ? 0 : current + 1
@@ -2034,36 +2392,35 @@ export const App = () => {
2034
2392
  () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2035
2393
  )) return
2036
2394
  if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2037
- setDetailFullView(true)
2038
- setDetailScrollOffset(0)
2395
+ runCommandById("detail.open")
2039
2396
  return
2040
2397
  }
2041
2398
  if (key.name === "d" && selectedPullRequest) {
2042
- openDiffView()
2399
+ runCommandById("diff.open")
2043
2400
  return
2044
2401
  }
2045
2402
  if (key.name === "x" && selectedPullRequest?.state === "open") {
2046
- openCloseModal()
2403
+ runCommandById("pull.close")
2047
2404
  return
2048
2405
  }
2049
2406
  if (key.name === "l" && selectedPullRequest) {
2050
- openLabelModal()
2407
+ runCommandById("pull.labels")
2051
2408
  return
2052
2409
  }
2053
2410
  if (key.name === "m" || key.name === "M") {
2054
- if (selectedPullRequest) openMergeModal()
2411
+ if (selectedPullRequest) runCommandById("pull.merge")
2055
2412
  return
2056
2413
  }
2057
2414
  if (key.name === "o" && selectedPullRequest) {
2058
- openSelectedPullRequestInBrowser(selectedPullRequest)
2415
+ runCommandById("pull.open-browser")
2059
2416
  return
2060
2417
  }
2061
2418
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
2062
- toggleSelectedPullRequestDraftStatus()
2419
+ runCommandById("pull.toggle-draft")
2063
2420
  return
2064
2421
  }
2065
2422
  if (key.name === "y" && selectedPullRequest) {
2066
- copySelectedPullRequestMetadata()
2423
+ runCommandById("pull.copy-metadata")
2067
2424
  return
2068
2425
  }
2069
2426
  })
@@ -2073,19 +2430,20 @@ export const App = () => {
2073
2430
  const wideFullscreenDetailScrollable = getDetailsPaneHeight({
2074
2431
  pullRequest: selectedPullRequest,
2075
2432
  contentWidth: fullscreenContentWidth,
2076
- bodyLines: fullscreenBodyLines,
2433
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2077
2434
  paneWidth: contentWidth,
2078
2435
  showChecks: true,
2079
2436
  }) > wideBodyHeight
2080
2437
  const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
2081
2438
  pullRequest: selectedPullRequest,
2082
2439
  contentWidth: fullscreenContentWidth,
2083
- bodyLines: fullscreenBodyLines,
2440
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2084
2441
  paneWidth: contentWidth,
2085
2442
  }) > wideBodyHeight
2086
2443
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2087
2444
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2088
- const wideDetailBodyScrollable = getDetailBodyHeight(selectedPullRequest, rightContentWidth, wideDetailLines) > wideDetailBodyViewportHeight
2445
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2446
+ const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2089
2447
 
2090
2448
  const prListProps = {
2091
2449
  groups: visibleGroups,
@@ -2095,6 +2453,9 @@ export const App = () => {
2095
2453
  filterText: visibleFilterText,
2096
2454
  showFilterBar: filterMode || filterQuery.length > 0,
2097
2455
  isFilterEditing: filterMode,
2456
+ loadedCount: loadedPullRequestCount,
2457
+ hasMore: hasMorePullRequests,
2458
+ isLoadingMore: isLoadingMorePullRequests,
2098
2459
  onSelectPullRequest: selectPullRequestByUrl,
2099
2460
  } as const
2100
2461
 
@@ -2103,29 +2464,49 @@ export const App = () => {
2103
2464
  const labelModalHeight = Math.min(20, terminalHeight - 4)
2104
2465
  const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2105
2466
  const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
2106
- const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
2107
- const closeModalHeight = Math.min(12, terminalHeight - 4)
2108
- const closeModalLeft = centeredOffset(contentWidth, closeModalWidth)
2109
- const closeModalTop = centeredOffset(terminalHeight, closeModalHeight)
2110
- const commentModalWidth = Math.min(76, Math.max(46, contentWidth - 8))
2111
- const commentModalHeight = Math.min(16, terminalHeight - 4)
2112
- const commentModalLeft = centeredOffset(contentWidth, commentModalWidth)
2113
- const commentModalTop = centeredOffset(terminalHeight, commentModalHeight)
2114
- const commentThreadModalWidth = Math.min(86, Math.max(50, contentWidth - 8))
2115
- const commentThreadModalHeight = Math.min(22, terminalHeight - 4)
2116
- const commentThreadModalLeft = centeredOffset(contentWidth, commentThreadModalWidth)
2117
- 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
2118
2487
  const commentAnchorLabel = selectedDiffCommentAnchor
2119
2488
  ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2120
2489
  : "No diff line selected"
2121
- const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
2122
- const mergeModalHeight = Math.min(16, terminalHeight - 4)
2123
- const mergeModalLeft = centeredOffset(contentWidth, mergeModalWidth)
2124
- const mergeModalTop = centeredOffset(terminalHeight, mergeModalHeight)
2125
- const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
2126
- const themeModalHeight = Math.min(16, terminalHeight - 4)
2127
- const themeModalLeft = centeredOffset(contentWidth, themeModalWidth)
2128
- 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
2129
2510
 
2130
2511
  return (
2131
2512
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -2158,6 +2539,11 @@ export const App = () => {
2158
2539
  onSelectCommentLine={selectDiffCommentLine}
2159
2540
  themeId={themeId}
2160
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>
2161
2547
  ) : isWideLayout && detailFullView ? (
2162
2548
  <box flexGrow={1} flexDirection="column">
2163
2549
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
@@ -2166,6 +2552,7 @@ export const App = () => {
2166
2552
  viewerUsername={username}
2167
2553
  contentWidth={fullscreenContentWidth}
2168
2554
  bodyLines={fullscreenBodyLines}
2555
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2169
2556
  paneWidth={contentWidth}
2170
2557
  showChecks
2171
2558
  placeholderContent={detailPlaceholderContent}
@@ -2175,19 +2562,26 @@ export const App = () => {
2175
2562
  </scrollbox>
2176
2563
  </box>
2177
2564
  ) : isWideLayout ? (
2178
- <box key="wide-main" flexGrow={1} flexDirection="row">
2179
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2180
- <scrollbox height={wideBodyHeight} flexGrow={0}>
2181
- <PullRequestList key={`wide-${leftContentWidth}`} {...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>
2182
2571
  </scrollbox>
2183
2572
  </box>
2184
2573
  <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
2185
2574
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
2186
- {selectedPullRequest ? (
2575
+ {isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2187
2576
  <>
2188
2577
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2189
- <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2190
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
2578
+ <LoadingPane content={detailLoadingContent} width={rightPaneWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true))} />
2579
+ </>
2580
+ ) : selectedPullRequest ? (
2581
+ <>
2582
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
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} />
2191
2585
  </scrollbox>
2192
2586
  </>
2193
2587
  ) : (
@@ -2203,6 +2597,7 @@ export const App = () => {
2203
2597
  viewerUsername={username}
2204
2598
  contentWidth={fullscreenContentWidth}
2205
2599
  bodyLines={fullscreenBodyLines}
2600
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2206
2601
  paneWidth={contentWidth}
2207
2602
  placeholderContent={detailPlaceholderContent}
2208
2603
  loadingIndicator={loadingIndicator}
@@ -2215,7 +2610,7 @@ export const App = () => {
2215
2610
  <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2216
2611
  <Divider width={contentWidth} />
2217
2612
  <box flexGrow={1} flexDirection="column">
2218
- <scrollbox flexGrow={1}>
2613
+ <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
2219
2614
  <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2220
2615
  <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
2221
2616
  </box>
@@ -2310,6 +2705,26 @@ export const App = () => {
2310
2705
  offsetTop={themeModalTop}
2311
2706
  />
2312
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}
2313
2728
  </box>
2314
2729
  )
2315
2730
  }