@kitlangton/ghui 0.1.19 → 0.1.21

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,67 @@
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
+ import { useBindings } from "@opentui/keymap/react"
3
4
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
5
  import { Cause, Effect, Layer, Schedule } from "effect"
5
6
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
6
7
  import * as Atom from "effect/unstable/reactivity/Atom"
7
- import { useEffect, useMemo, useRef, useState } from "react"
8
+ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
9
+ import { useContext, useEffect, useMemo, useRef, useState } from "react"
10
+ import { buildAppCommands } from "./appCommands.js"
11
+ import type { AppCommand } from "./commands.js"
12
+ import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
8
13
  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"
14
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
10
15
  import { formatShortDate, formatTimestamp } from "./date.js"
16
+ import { errorMessage } from "./errors.js"
11
17
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
12
18
  import { Observability } from "./observability.js"
19
+ import { mergeCachedDetails } from "./pullRequestCache.js"
20
+ import { activePullRequestViews, initialPullRequestView, nextView, parseRepositoryInput, type PullRequestView, viewCacheKey, viewEquals, viewLabel, viewMode, viewRepository } from "./pullRequestViews.js"
21
+ import { BrowserOpener } from "./services/BrowserOpener.js"
22
+ import { Clipboard } from "./services/Clipboard.js"
23
+ import { CommandRunner } from "./services/CommandRunner.js"
13
24
  import { GitHubService } from "./services/GitHubService.js"
14
25
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
15
- import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
26
+ import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
16
27
  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"
28
+ import { buildStackedDiffFiles, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
29
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
19
30
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
20
31
  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"
32
+ import { CommandPalette } from "./ui/CommandPalette.js"
33
+ 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
34
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
23
35
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
24
- import { PullRequestList } from "./ui/PullRequestList.js"
36
+ import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
37
+ import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
25
38
 
26
- const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
27
- const initialThemeId = await Effect.runPromise(loadStoredThemeId)
39
+ const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
40
+ if (value === undefined) return fallback
41
+ const parsed = Number.parseInt(value, 10)
42
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
43
+ }
28
44
 
45
+ const mockPrCount = parseOptionalPositiveInt(process.env.GHUI_MOCK_PR_COUNT, null)
46
+ const pullRequestPageSize = Math.min(100, parseOptionalPositiveInt(process.env.GHUI_PR_PAGE_SIZE, config.prPageSize) ?? config.prPageSize)
47
+ const githubServiceLayer = mockPrCount !== null
48
+ ? (await import("./services/MockGitHubService.js")).MockGitHubService.layer({ prCount: mockPrCount, repoCount: parseOptionalPositiveInt(process.env.GHUI_MOCK_REPO_COUNT, 4) ?? 4 })
49
+ : GitHubService.layerNoDeps
50
+
51
+ const githubRuntime = Atom.runtime(
52
+ Layer.mergeAll(githubServiceLayer, Clipboard.layerNoDeps, BrowserOpener.layerNoDeps).pipe(
53
+ Layer.provide(CommandRunner.layer),
54
+ Layer.provideMerge(Observability.layer),
55
+ ),
56
+ )
57
+ const initialThemeId = await Effect.runPromise(loadStoredThemeId)
29
58
 
30
59
  interface PullRequestLoad {
31
- readonly queueMode: PullRequestQueueMode
60
+ readonly view: PullRequestView
32
61
  readonly data: readonly PullRequestItem[]
33
62
  readonly fetchedAt: Date | null
34
- readonly detailsFetchedAt: Date | null
63
+ readonly endCursor: string | null
64
+ readonly hasNextPage: boolean
35
65
  }
36
66
 
37
67
  interface DetailPlaceholderInput {
@@ -66,87 +96,104 @@ interface AppliedDiffLineColorState {
66
96
  readonly entries: readonly AppliedDiffLineColor[]
67
97
  }
68
98
 
99
+ interface DetailHydration {
100
+ readonly token: symbol
101
+ notifyError: boolean
102
+ }
103
+
69
104
  const PR_FETCH_RETRIES = 6
70
105
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
71
106
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
72
107
  const AUTO_REFRESH_JITTER_MS = 10_000
73
108
  const DIFF_STICKY_HEADER_LINES = 2
74
109
  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
- })
110
+ const MAX_REPOSITORY_CACHE_ENTRIES = 8
111
+ const LOAD_MORE_SELECTION_THRESHOLD = 8
112
+ const LOAD_MORE_SCROLL_THRESHOLD = 3
113
+ const DETAIL_PREFETCH_BEHIND = 1
114
+ const DETAIL_PREFETCH_AHEAD = 3
115
+ const DETAIL_PREFETCH_CONCURRENCY = 3
116
+ const DETAIL_PREFETCH_DELAY_MS = 120
117
+
118
+ const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
119
+ const seen = new Set(existing.map((pullRequest) => pullRequest.url))
120
+ const mergedIncoming = mergeCachedDetails(incoming, existing)
121
+ return [...existing, ...mergedIncoming.filter((pullRequest) => !seen.has(pullRequest.url))]
95
122
  }
96
123
 
97
124
  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)
125
+ const activeViewAtom = Atom.make<PullRequestView>(initialPullRequestView(config.repository)).pipe(Atom.keepAlive)
126
+ const queueLoadCacheAtom = Atom.make<Partial<Record<string, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
127
+ const queueSelectionAtom = Atom.make<Partial<Record<string, number>>>({}).pipe(Atom.keepAlive)
128
+ const trimQueueLoadCache = (cache: Partial<Record<string, PullRequestLoad>>) => {
129
+ const repositoryKeys = Object.keys(cache).filter((key) => key.startsWith("repository:"))
130
+ if (repositoryKeys.length <= MAX_REPOSITORY_CACHE_ENTRIES) return cache
131
+ const remove = new Set(repositoryKeys.slice(0, repositoryKeys.length - MAX_REPOSITORY_CACHE_ENTRIES))
132
+ return Object.fromEntries(Object.entries(cache).filter(([key]) => !remove.has(key))) as Partial<Record<string, PullRequestLoad>>
133
+ }
101
134
  const pullRequestsAtom = githubRuntime.atom(
102
135
  GitHubService.use((github) =>
103
136
  Effect.gen(function*() {
104
- const queueMode = yield* Atom.get(queueModeAtom)
137
+ const view = yield* Atom.get(activeViewAtom)
138
+ const queueMode = viewMode(view)
139
+ const repository = viewRepository(view)
140
+ const cacheKey = viewCacheKey(view)
105
141
  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
- )
142
+ const page = yield* github.listOpenPullRequestPage({
143
+ mode: queueMode,
144
+ repository,
145
+ cursor: null,
146
+ pageSize: Math.min(pullRequestPageSize, config.prFetchLimit),
147
+ }).pipe(
148
+ Effect.tapError(() =>
149
+ Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
150
+ attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 1, PR_FETCH_RETRIES),
151
+ max: PR_FETCH_RETRIES,
152
+ }))
153
+ ),
154
+ Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
155
+ Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)),
156
+ )
116
157
 
117
158
  yield* Atom.set(retryProgressAtom, initialRetryProgress)
118
159
  const cache = yield* Atom.get(queueLoadCacheAtom)
160
+ const existingLoad = cache[cacheKey]
161
+ const data = mergeCachedDetails(page.items, existingLoad?.data)
119
162
  const load = {
120
- queueMode,
121
- data: mergeCachedDetails(data, cache[queueMode]?.data),
163
+ view,
164
+ data,
122
165
  fetchedAt: new Date(),
123
- detailsFetchedAt: null,
166
+ endCursor: page.endCursor,
167
+ hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
124
168
  } satisfies PullRequestLoad
125
- yield* Atom.set(queueLoadCacheAtom, { ...cache, [queueMode]: load })
169
+ const nextCache = { ...cache }
170
+ delete nextCache[cacheKey]
171
+ nextCache[cacheKey] = load
172
+ yield* Atom.set(queueLoadCacheAtom, trimQueueLoadCache(nextCache))
126
173
  return load
127
174
  })
128
175
  ),
129
176
  ).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)
177
+ const selectedIndexAtom = Atom.make(0)
178
+ const noticeAtom = Atom.make<string | null>(null)
179
+ const filterQueryAtom = Atom.make("")
180
+ const filterDraftAtom = Atom.make("")
181
+ const filterModeAtom = Atom.make(false)
182
+ const pendingGAtom = Atom.make(false)
183
+ const detailFullViewAtom = Atom.make(false)
184
+ const detailScrollOffsetAtom = Atom.make(0)
185
+ const diffFullViewAtom = Atom.make(false)
186
+ const diffFileIndexAtom = Atom.make(0)
187
+ const diffScrollTopAtom = Atom.make(0)
188
+ const diffRenderViewAtom = Atom.make<DiffView>("split")
189
+ const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
190
+ const diffCommentModeAtom = Atom.make(false)
191
+ const diffCommentAnchorIndexAtom = Atom.make(0)
145
192
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
146
193
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
147
194
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
148
195
 
149
- const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
196
+ const activeModalAtom = Atom.make<Modal>(initialModal)
150
197
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
151
198
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
152
199
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -157,12 +204,110 @@ const usernameAtom = githubRuntime.atom(
157
204
  : Effect.succeed(config.author.replace(/^@/, "")),
158
205
  ).pipe(Atom.keepAlive)
159
206
 
207
+ const pullRequestLoadAtom = Atom.make((get) => {
208
+ const view = get(activeViewAtom)
209
+ const cacheKey = viewCacheKey(view)
210
+ const cache = get(queueLoadCacheAtom)
211
+ const result = get(pullRequestsAtom)
212
+ const resolved = AsyncResult.getOrElse(result, () => null)
213
+ return cache[cacheKey] ?? (resolved && viewCacheKey(resolved.view) === cacheKey ? resolved : null)
214
+ })
215
+
216
+ const isLoadingQueueModeAtom = Atom.make((get) => {
217
+ const cacheKey = viewCacheKey(get(activeViewAtom))
218
+ const resolved = AsyncResult.getOrElse(get(pullRequestsAtom), () => null)
219
+ return resolved !== null && viewCacheKey(resolved.view) !== cacheKey
220
+ })
221
+
222
+ const pullRequestStatusAtom = Atom.make((get): LoadStatus => {
223
+ const result = get(pullRequestsAtom)
224
+ const load = get(pullRequestLoadAtom)
225
+ const isLoadingQueue = get(isLoadingQueueModeAtom)
226
+ if ((result.waiting || isLoadingQueue) && load === null) return "loading"
227
+ return AsyncResult.isFailure(result) ? "error" : "ready"
228
+ })
229
+
230
+ const displayedPullRequestsAtom = Atom.make((get) => {
231
+ const load = get(pullRequestLoadAtom)
232
+ const overrides = get(pullRequestOverridesAtom)
233
+ const recentlyCompleted = get(recentlyCompletedPullRequestsAtom)
234
+ const source = load?.data ?? []
235
+ const seenUrls = new Set<string>()
236
+ const open = source.map((pullRequest) => {
237
+ seenUrls.add(pullRequest.url)
238
+ return recentlyCompleted[pullRequest.url] ?? overrides[pullRequest.url] ?? pullRequest
239
+ })
240
+ return [
241
+ ...open,
242
+ ...Object.values(recentlyCompleted).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
243
+ ]
244
+ })
245
+
246
+ const effectiveFilterQueryAtom = Atom.make((get) =>
247
+ (get(filterModeAtom) ? get(filterDraftAtom) : get(filterQueryAtom)).trim().toLowerCase(),
248
+ )
249
+
250
+ const filteredPullRequestsAtom = Atom.make((get) => {
251
+ const pullRequests = get(displayedPullRequestsAtom)
252
+ const query = get(effectiveFilterQueryAtom)
253
+ if (query.length === 0) return pullRequests
254
+ return pullRequests.flatMap((pullRequest) => {
255
+ const score = pullRequestFilterScore(pullRequest, query)
256
+ return score === null ? [] : [{ pullRequest, score }]
257
+ }).sort((left, right) =>
258
+ left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
259
+ ).map(({ pullRequest }) => pullRequest)
260
+ })
261
+
262
+ const visibleRepoOrderAtom = Atom.make((get) => {
263
+ const query = get(effectiveFilterQueryAtom)
264
+ if (query.length === 0) return [] as readonly string[]
265
+ return [...new Set(get(filteredPullRequestsAtom).map((pullRequest) => pullRequest.repository))]
266
+ })
267
+
268
+ const visibleGroupsAtom = Atom.make((get) =>
269
+ groupBy(get(filteredPullRequestsAtom), (pullRequest) => pullRequest.repository, get(visibleRepoOrderAtom)),
270
+ )
271
+
272
+ const visiblePullRequestsAtom = Atom.make((get) => get(visibleGroupsAtom).flatMap(([, pullRequests]) => pullRequests))
273
+
274
+ const groupStartsAtom = Atom.make((get) => {
275
+ const groups = get(visibleGroupsAtom)
276
+ const starts: number[] = []
277
+ for (let index = 0; index < groups.length; index++) {
278
+ if (index === 0) starts.push(0)
279
+ else starts.push(starts[index - 1]! + groups[index - 1]![1].length)
280
+ }
281
+ return starts
282
+ })
283
+
284
+ const selectedPullRequestAtom = Atom.make((get) => {
285
+ const pullRequests = get(visiblePullRequestsAtom)
286
+ const index = get(selectedIndexAtom)
287
+ return pullRequests[index] ?? null
288
+ })
289
+
290
+ const selectedDiffKeyAtom = Atom.make((get) => {
291
+ const pullRequest = get(selectedPullRequestAtom)
292
+ return pullRequest ? pullRequestDiffKey(pullRequest) : null
293
+ })
294
+
295
+ const selectedDiffStateAtom = Atom.make((get) => {
296
+ const key = get(selectedDiffKeyAtom)
297
+ if (!key) return undefined
298
+ return get(pullRequestDiffCacheAtom)[key]
299
+ })
300
+
160
301
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
161
302
  GitHubService.use((github) => github.listRepoLabels(repository))
162
303
  )
163
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
164
- GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
304
+ const listOpenPullRequestPageAtom = githubRuntime.fn<ListPullRequestPageInput>()((input) =>
305
+ GitHubService.use((github) => github.listOpenPullRequestPage(input))
165
306
  )
307
+ const pullRequestDetailsAtom = Atom.family((key: string) => {
308
+ const { repository, number } = parsePullRequestDetailAtomKey(key)
309
+ return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDetails(repository, number)))
310
+ })
166
311
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
167
312
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
168
313
  )
@@ -172,9 +317,10 @@ const removePullRequestLabelAtom = githubRuntime.fn<{ readonly repository: strin
172
317
  const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly isDraft: boolean }>()((input) =>
173
318
  GitHubService.use((github) => github.toggleDraftStatus(input.repository, input.number, input.isDraft))
174
319
  )
175
- const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
176
- GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
177
- )
320
+ const pullRequestDiffAtom = Atom.family((key: string) => {
321
+ const { repository, number } = parsePullRequestDiffAtomKey(key)
322
+ return githubRuntime.atom(GitHubService.use((github) => github.getPullRequestDiff(repository, number)))
323
+ })
178
324
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
179
325
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
180
326
  )
@@ -188,12 +334,13 @@ const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
188
334
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
189
335
  )
190
336
  const createPullRequestCommentAtom = githubRuntime.fn<CreatePullRequestCommentInput>()((input) => GitHubService.use((github) => github.createPullRequestComment(input)))
337
+ const copyToClipboardAtom = githubRuntime.fn<string>()((text) => Clipboard.use((clipboard) => clipboard.copy(text)))
338
+ const openInBrowserAtom = githubRuntime.fn<PullRequestItem>()((pullRequest) => BrowserOpener.use((browser) => browser.openPullRequest(pullRequest)))
191
339
 
192
340
  const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
193
341
 
194
- const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
195
342
 
196
- const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
343
+ const pasteText = (event: PasteEvent) => new TextDecoder().decode(event.bytes)
197
344
 
198
345
  const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
199
346
  const normalized = query.trim().toLowerCase()
@@ -210,91 +357,34 @@ const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) =>
210
357
  return scores.length > 0 ? Math.min(...scores) : null
211
358
  }
212
359
 
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) => {
360
+ const pullRequestMetadataText = (pullRequest: PullRequestItem) => {
272
361
  const lines = [
273
362
  pullRequest.title,
274
363
  `${pullRequest.repository} #${pullRequest.number}`,
275
364
  pullRequest.url,
276
365
  ]
277
-
278
366
  const review = reviewLabel(pullRequest)
279
- if (review) {
280
- lines.push(`review: ${review}`)
281
- }
282
- if (pullRequest.checkSummary) {
283
- lines.push(pullRequest.checkSummary)
284
- }
367
+ if (review) lines.push(`review: ${review}`)
368
+ if (pullRequest.checkSummary) lines.push(pullRequest.checkSummary)
369
+ return lines.join("\n")
370
+ }
285
371
 
286
- await copyToClipboard(lines.join("\n"))
372
+ const pullRequestDetailKey = (pullRequest: PullRequestItem) => `${pullRequest.url}:${pullRequest.headRefOid}`
373
+ const pullRequestRevisionAtomKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}\u0000${pullRequest.number}\u0000${pullRequest.headRefOid}`
374
+ const parsePullRequestRevisionAtomKey = (key: string, label: string) => {
375
+ const [repository, number] = key.split("\u0000")
376
+ if (!repository || !number) throw new Error(`Invalid pull request ${label} key: ${key}`)
377
+ return { repository, number: Number.parseInt(number, 10) }
287
378
  }
379
+ const pullRequestDetailAtomKey = pullRequestRevisionAtomKey
380
+ const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
381
+ const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
382
+ const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
288
383
 
289
384
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
290
385
 
291
386
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
292
387
 
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
388
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
299
389
  `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
300
390
 
@@ -321,28 +411,11 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
321
411
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
322
412
  }
323
413
 
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
414
  const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
342
415
  const accent = kind === "thread"
343
416
  ? colors.status.pending
344
417
  : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
345
- return mixHexColor(accent, originalDiffLineColor(anchor).gutter, 0.45)
418
+ return mixHex(originalDiffLineColor(anchor).gutter, accent, 0.45)
346
419
  }
347
420
 
348
421
  const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
@@ -404,11 +477,12 @@ const getDetailPlaceholderContent = ({
404
477
  export const App = () => {
405
478
  const renderer = useRenderer()
406
479
  const { width, height } = useTerminalDimensions()
480
+ const registry = useContext(RegistryContext)
407
481
  const pullRequestResult = useAtomValue(pullRequestsAtom)
408
482
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
409
- const [queueMode, setQueueMode] = useAtom(queueModeAtom)
410
- const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
411
- const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
483
+ const [activeView, setActiveView] = useAtom(activeViewAtom)
484
+ const setQueueLoadCache = useAtomSet(queueLoadCacheAtom)
485
+ const setQueueSelection = useAtomSet(queueSelectionAtom)
412
486
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
413
487
  const [notice, setNotice] = useAtom(noticeAtom)
414
488
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -416,7 +490,7 @@ export const App = () => {
416
490
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
417
491
  const [pendingG, setPendingG] = useAtom(pendingGAtom)
418
492
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
419
- const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
493
+ const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
420
494
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
421
495
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
422
496
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
@@ -425,8 +499,8 @@ export const App = () => {
425
499
  const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
426
500
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
427
501
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
428
- const [diffCommentsLoaded, setDiffCommentsLoaded] = useAtom(diffCommentsLoadedAtom)
429
- const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
502
+ const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
503
+ const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
430
504
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
431
505
  const [themeId, setThemeId] = useAtom(themeIdAtom)
432
506
  const closeActiveModal = () => setActiveModal(initialModal)
@@ -436,12 +510,16 @@ export const App = () => {
436
510
  const commentModalActive = Modal.$is("Comment")(activeModal)
437
511
  const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
438
512
  const themeModalActive = Modal.$is("Theme")(activeModal)
513
+ const commandPaletteActive = Modal.$is("CommandPalette")(activeModal)
514
+ const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal)
439
515
  const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
440
516
  const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
441
517
  const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
442
518
  const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
443
519
  const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
444
520
  const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
521
+ const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState
522
+ const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState
445
523
  const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
446
524
  (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
447
525
  const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
@@ -458,31 +536,35 @@ export const App = () => {
458
536
  const setCommentModal = makeModalSetter("Comment")
459
537
  const setCommentThreadModal = makeModalSetter("CommentThread")
460
538
  const setThemeModal = makeModalSetter("Theme")
539
+ const setCommandPalette = makeModalSetter("CommandPalette")
540
+ const setOpenRepositoryModal = makeModalSetter("OpenRepository")
461
541
  setActiveTheme(themeId)
462
542
  const themeIdRef = useRef(themeId)
463
543
  const themeModalRef = useRef(themeModal)
464
544
  themeIdRef.current = themeId
465
545
  themeModalRef.current = themeModal
466
- const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
467
- const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
468
- const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
546
+ const setLabelCache = useAtomSet(labelCacheAtom)
547
+ const setPullRequestOverrides = useAtomSet(pullRequestOverridesAtom)
548
+ const setRecentlyCompletedPullRequests = useAtomSet(recentlyCompletedPullRequestsAtom)
469
549
  const retryProgress = useAtomValue(retryProgressAtom)
470
550
  const [loadingFrame, setLoadingFrame] = useState(0)
471
551
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
472
552
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
473
553
  const [terminalFocused, setTerminalFocused] = useState(true)
554
+ const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
474
555
  const usernameResult = useAtomValue(usernameAtom)
475
556
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
476
- const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
557
+ const loadPullRequestPage = useAtomSet(listOpenPullRequestPageAtom, { mode: "promise" })
477
558
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
478
559
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
479
560
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
480
- const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
481
561
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
482
562
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
483
563
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
484
564
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
485
565
  const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
566
+ const copyToClipboard = useAtomSet(copyToClipboardAtom, { mode: "promise" })
567
+ const openInBrowser = useAtomSet(openInBrowserAtom, { mode: "promise" })
486
568
  const terminalWidth = width ?? 100
487
569
  const terminalHeight = height ?? 24
488
570
  const contentWidth = Math.max(1, terminalWidth)
@@ -492,14 +574,15 @@ export const App = () => {
492
574
  const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
493
575
  const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
494
576
  const dividerJunctionAt = Math.max(1, leftPaneWidth)
495
- const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
577
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
496
578
  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
579
+ const wideDetailLines = Math.max(8, terminalHeight - 8)
498
580
  const wideBodyHeight = Math.max(8, terminalHeight - 4)
499
581
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
500
582
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
501
583
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
502
- const detailHydrationRef = useRef<number | null>(null)
584
+ const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
585
+ const detailHydrationRef = useRef(new Map<string, DetailHydration>())
503
586
  const refreshGenerationRef = useRef(0)
504
587
  const didMountQueueModeRef = useRef(false)
505
588
  const lastPullRequestRefreshAtRef = useRef(0)
@@ -509,7 +592,9 @@ export const App = () => {
509
592
  const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
510
593
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
511
594
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
595
+ const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
512
596
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
597
+ const prListScrollRef = useRef<ScrollBoxRenderable | null>(null)
513
598
  const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
514
599
  const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
515
600
  const suppressNextDiffCommentScrollRef = useRef(false)
@@ -530,6 +615,8 @@ export const App = () => {
530
615
  }, [renderer, themeId])
531
616
 
532
617
  useEffect(() => () => {
618
+ refreshGenerationRef.current += 1
619
+ detailHydrationRef.current.clear()
533
620
  if (noticeTimeoutRef.current !== null) {
534
621
  clearTimeout(noticeTimeoutRef.current)
535
622
  }
@@ -539,100 +626,83 @@ export const App = () => {
539
626
  if (diffPrefetchTimeoutRef.current !== null) {
540
627
  clearTimeout(diffPrefetchTimeoutRef.current)
541
628
  }
629
+ if (detailPrefetchTimeoutRef.current !== null) {
630
+ clearTimeout(detailPrefetchTimeoutRef.current)
631
+ }
542
632
  }, [])
543
633
 
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"
634
+ const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
635
+ const pullRequests = useAtomValue(displayedPullRequestsAtom)
636
+ const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
565
637
  const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
566
638
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
567
639
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
568
640
  pullRequestStatusRef.current = pullRequestStatus
569
641
 
570
- const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
571
642
  const visibleFilterText = filterMode ? filterDraft : filterQuery
572
643
 
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
644
+ const visibleGroups = useAtomValue(visibleGroupsAtom)
645
+ const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
646
+ const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
647
+ const selectedRepository = viewRepository(activeView)
648
+ const activeViews = activePullRequestViews(activeView)
649
+ const currentQueueCacheKey = viewCacheKey(activeView)
650
+ const loadedPullRequestCount = pullRequestLoad?.data.length ?? 0
651
+ const hasMorePullRequests = Boolean(pullRequestLoad?.hasNextPage && loadedPullRequestCount < config.prFetchLimit)
652
+ const isLoadingMorePullRequests = loadingMoreKey === currentQueueCacheKey
653
+ const pullRequestListRows = useMemo(() => buildPullRequestListRows({
654
+ groups: visibleGroups,
655
+ status: pullRequestStatus,
656
+ error: pullRequestError,
657
+ filterText: visibleFilterText,
658
+ showFilterBar: filterMode || filterQuery.length > 0,
659
+ loadedCount: loadedPullRequestCount,
660
+ hasMore: hasMorePullRequests,
661
+ isLoadingMore: isLoadingMorePullRequests,
662
+ }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
663
+ const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
664
+ const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
665
+ const selectedDiffState = useAtomValue(selectedDiffStateAtom)
593
666
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
594
667
  const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
595
668
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
596
- const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
597
669
  const diffCommentAnchors = useMemo(
598
670
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
599
671
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
600
672
  )
601
673
  const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
602
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentAnchorKey(selectedDiffCommentAnchor)}` : null
674
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentLocationKey(selectedDiffCommentAnchor)}` : null
603
675
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
604
676
  const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
605
677
  const diffCommentRows = useMemo(
606
678
  () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
607
679
  [diffCommentAnchors],
608
680
  )
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])
681
+ const groupStarts = useAtomValue(groupStartsAtom)
617
682
  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
683
+ if (groupStarts.length === 0) return 0
684
+ let low = 0
685
+ let high = groupStarts.length - 1
686
+ while (low < high) {
687
+ const mid = (low + high + 1) >>> 1
688
+ if (groupStarts[mid]! <= current) low = mid
689
+ else high = mid - 1
690
+ }
691
+ return low
622
692
  }
623
693
  const summaryRight = pullRequestLoad?.fetchedAt
624
694
  ? `updated ${formatShortDate(pullRequestLoad.fetchedAt)} ${formatTimestamp(pullRequestLoad.fetchedAt)}`
625
695
  : pullRequestStatus === "loading"
626
696
  ? "loading pull requests..."
627
697
  : ""
628
- const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
698
+ const headerLeft = username ? `GHUI ${username} ${viewLabel(activeView)}` : `GHUI ${viewLabel(activeView)}`
629
699
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
630
700
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
631
701
  const selectPullRequestByUrl = (url: string) => {
632
702
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
633
703
  if (index >= 0) {
634
704
  setSelectedIndex(index)
635
- setQueueSelection((current) => ({ ...current, [queueMode]: index }))
705
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: index }))
636
706
  }
637
707
  }
638
708
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
@@ -642,6 +712,9 @@ export const App = () => {
642
712
  }
643
713
  const refreshPullRequests = (message?: string) => {
644
714
  refreshGenerationRef.current += 1
715
+ detailHydrationRef.current.clear()
716
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
717
+ setLoadingMoreKey(null)
645
718
  setPullRequestOverrides({})
646
719
  if (message) {
647
720
  setNotice(null)
@@ -651,15 +724,16 @@ export const App = () => {
651
724
  refreshPullRequestsAtom()
652
725
  }
653
726
  refreshPullRequestsRef.current = refreshPullRequests
654
- const switchQueueMode = (delta: 1 | -1) => {
655
- const mode = nextQueueMode(queueMode, delta)
656
- if (mode === queueMode) return
727
+ const switchViewTo = (view: PullRequestView) => {
728
+ if (viewEquals(view, activeView)) return
657
729
  refreshGenerationRef.current += 1
658
- setQueueSelection((current) => ({ ...current, [queueMode]: selectedIndex }))
659
- setQueueMode(mode)
660
- setSelectedIndex(queueSelection[mode] ?? 0)
730
+ setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: selectedIndex }))
731
+ setActiveView(view)
732
+ setSelectedIndex(registry.get(queueSelectionAtom)[viewCacheKey(view)] ?? 0)
661
733
  setRecentlyCompletedPullRequests({})
662
- detailHydrationRef.current = null
734
+ detailHydrationRef.current.clear()
735
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
736
+ setLoadingMoreKey(null)
663
737
  setDetailFullView(false)
664
738
  setDiffFullView(false)
665
739
  setDiffCommentMode(false)
@@ -668,6 +742,82 @@ export const App = () => {
668
742
  setRefreshCompletionMessage(null)
669
743
  setRefreshStartedAt(null)
670
744
  }
745
+ const switchQueueMode = (delta: 1 | -1) => {
746
+ switchViewTo(nextView(activeView, activeViews, delta))
747
+ }
748
+ const loadMorePullRequests = () => {
749
+ if (!pullRequestLoad || !hasMorePullRequests || isLoadingMorePullRequests || !pullRequestLoad.endCursor) return false
750
+ const remaining = config.prFetchLimit - pullRequestLoad.data.length
751
+ if (remaining <= 0) return false
752
+ const cacheKey = currentQueueCacheKey
753
+ const generation = refreshGenerationRef.current
754
+ setLoadingMoreKey(cacheKey)
755
+ void loadPullRequestPage({
756
+ mode: viewMode(activeView),
757
+ repository: selectedRepository,
758
+ cursor: pullRequestLoad.endCursor,
759
+ pageSize: Math.min(pullRequestPageSize, remaining),
760
+ }).then((page) => {
761
+ if (generation !== refreshGenerationRef.current) return
762
+ setQueueLoadCache((current) => {
763
+ const load = current[cacheKey]
764
+ if (!load) return current
765
+ const data = appendPullRequestPage(load.data, page.items)
766
+ return {
767
+ ...current,
768
+ [cacheKey]: {
769
+ ...load,
770
+ data,
771
+ endCursor: page.endCursor,
772
+ hasNextPage: page.hasNextPage && data.length < config.prFetchLimit,
773
+ },
774
+ }
775
+ })
776
+ }).catch((error) => {
777
+ flashNotice(errorMessage(error))
778
+ }).finally(() => {
779
+ setLoadingMoreKey((current) => current === cacheKey ? null : current)
780
+ })
781
+ return true
782
+ }
783
+ const applyPullRequestDetail = (detail: PullRequestItem) => {
784
+ setQueueLoadCache((current) => {
785
+ const next = { ...current }
786
+ let changed = false
787
+ for (const [cacheKey, load] of Object.entries(current)) {
788
+ if (!load) continue
789
+ const index = load.data.findIndex((pullRequest) => pullRequest.url === detail.url)
790
+ if (index < 0) continue
791
+ const data = [...load.data]
792
+ data[index] = detail
793
+ changed = true
794
+ next[cacheKey] = { ...load, data }
795
+ }
796
+ return changed ? next : current
797
+ })
798
+ }
799
+ const hydratePullRequestDetails = (pullRequest: PullRequestItem, notifyError: boolean) => {
800
+ if (pullRequest.state !== "open" || pullRequest.detailLoaded) return false
801
+ const detailKey = pullRequestDetailKey(pullRequest)
802
+ const existing = detailHydrationRef.current.get(detailKey)
803
+ if (existing) {
804
+ if (notifyError) existing.notifyError = true
805
+ return false
806
+ }
807
+ if (!notifyError && detailHydrationRef.current.size >= DETAIL_PREFETCH_CONCURRENCY) return false
808
+ const entry: DetailHydration = { token: Symbol(detailKey), notifyError }
809
+ detailHydrationRef.current.set(detailKey, entry)
810
+ const generation = refreshGenerationRef.current
811
+ const atom = pullRequestDetailsAtom(pullRequestDetailAtomKey(pullRequest))
812
+ void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true })).then((detail) => {
813
+ if (generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) applyPullRequestDetail(detail)
814
+ }).catch((error) => {
815
+ if (entry.notifyError && generation === refreshGenerationRef.current && detailHydrationRef.current.get(detailKey) === entry) flashNotice(errorMessage(error))
816
+ }).finally(() => {
817
+ if (detailHydrationRef.current.get(detailKey) === entry) detailHydrationRef.current.delete(detailKey)
818
+ })
819
+ return true
820
+ }
671
821
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
672
822
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
673
823
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -687,14 +837,14 @@ export const App = () => {
687
837
  didMountQueueModeRef.current = true
688
838
  return
689
839
  }
690
- if (queueLoadCache[queueMode]) return
840
+ if (registry.get(queueLoadCacheAtom)[currentQueueCacheKey]) return
691
841
  refreshPullRequestsAtom()
692
- }, [queueMode, queueLoadCache, refreshPullRequestsAtom])
842
+ }, [currentQueueCacheKey, refreshPullRequestsAtom, registry])
693
843
 
694
844
  useEffect(() => {
695
845
  if (!refreshCompletionMessage || refreshStartedAt === null) return
696
846
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
697
- const isHydratingDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
847
+ const isHydratingDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
698
848
  if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
699
849
  flashNotice(`✓ ${refreshCompletionMessage}`)
700
850
  setRefreshCompletionMessage(null)
@@ -747,13 +897,43 @@ export const App = () => {
747
897
  }, [visiblePullRequests.length])
748
898
 
749
899
  useEffect(() => {
750
- setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
751
- }, [queueMode, selectedIndex])
900
+ setQueueSelection((current) => current[currentQueueCacheKey] === selectedIndex ? current : { ...current, [currentQueueCacheKey]: selectedIndex })
901
+ }, [currentQueueCacheKey, selectedIndex])
902
+
903
+ useEffect(() => {
904
+ if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0) return
905
+ const thresholdIndex = Math.max(0, visiblePullRequests.length - LOAD_MORE_SELECTION_THRESHOLD)
906
+ if (selectedIndex >= thresholdIndex) loadMorePullRequests()
907
+ }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
908
+
909
+ useEffect(() => {
910
+ if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView) return
911
+ if (!hasMorePullRequests || isLoadingMorePullRequests) return
912
+ const checkScroll = () => {
913
+ const scroll = prListScrollRef.current
914
+ if (!scroll || scroll.viewport.height <= 0) return
915
+ const bottom = scroll.scrollTop + scroll.viewport.height
916
+ if (bottom >= scroll.scrollHeight - LOAD_MORE_SCROLL_THRESHOLD) loadMorePullRequests()
917
+ }
918
+ checkScroll()
919
+ const interval = globalThis.setInterval(checkScroll, 120)
920
+ return () => globalThis.clearInterval(interval)
921
+ }, [visiblePullRequests.length, filterMode, filterQuery, detailFullView, diffFullView, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
922
+
923
+ useEffect(() => {
924
+ const scroll = prListScrollRef.current
925
+ if (!scroll || selectedPullRequestRowIndex === null) return
926
+ const viewportHeight = scroll.viewport.height
927
+ if (viewportHeight <= 0) return
928
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, selectedPullRequestRowIndex, 2)
929
+ if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
930
+ }, [selectedPullRequestRowIndex])
752
931
 
753
932
  useEffect(() => {
754
933
  setDiffFileIndex(0)
755
934
  setDiffScrollTop(0)
756
935
  setDiffCommentAnchorIndex(0)
936
+ detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
757
937
  }, [selectedIndex])
758
938
 
759
939
  useEffect(() => {
@@ -793,7 +973,7 @@ export const App = () => {
793
973
 
794
974
  if (selectedDiffKey) {
795
975
  for (const anchor of diffCommentAnchors) {
796
- if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
976
+ if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentLocationKey(anchor)}`]?.length ?? 0) > 0) {
797
977
  applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
798
978
  }
799
979
  }
@@ -810,7 +990,7 @@ export const App = () => {
810
990
  }
811
991
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
812
992
  }, [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)
993
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
814
994
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
815
995
  const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
816
996
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
@@ -824,31 +1004,30 @@ export const App = () => {
824
1004
  }, [hasActiveLoadingIndicator])
825
1005
 
826
1006
  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
- },
1007
+ if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1008
+ hydratePullRequestDetails(selectedPullRequest, true)
1009
+ }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
1010
+
1011
+ useEffect(() => {
1012
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1013
+ if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
1014
+ detailPrefetchTimeoutRef.current = globalThis.setTimeout(() => {
1015
+ detailPrefetchTimeoutRef.current = null
1016
+ let started = 0
1017
+ for (let distance = 1; distance <= Math.max(DETAIL_PREFETCH_AHEAD, DETAIL_PREFETCH_BEHIND); distance++) {
1018
+ const offsets = [distance <= DETAIL_PREFETCH_AHEAD ? distance : null, distance <= DETAIL_PREFETCH_BEHIND ? -distance : null]
1019
+ for (const offset of offsets) {
1020
+ if (offset === null) continue
1021
+ if (started >= DETAIL_PREFETCH_CONCURRENCY) return
1022
+ const pullRequest = visiblePullRequests[selectedIndex + offset]
1023
+ if (pullRequest && hydratePullRequestDetails(pullRequest, false)) started += 1
846
1024
  }
847
- })
848
- }).catch((error) => {
849
- flashNotice(errorMessage(error))
850
- })
851
- }, [queueMode, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
1025
+ }
1026
+ }, DETAIL_PREFETCH_DELAY_MS)
1027
+ return () => {
1028
+ if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1029
+ }
1030
+ }, [pullRequestStatus, currentQueueCacheKey, selectedIndex, visiblePullRequests])
852
1031
 
853
1032
  const detailPlaceholderContent = getDetailPlaceholderContent({
854
1033
  status: pullRequestStatus,
@@ -857,13 +1036,18 @@ export const App = () => {
857
1036
  visibleCount: visiblePullRequests.length,
858
1037
  filterText: visibleFilterText,
859
1038
  })
860
- const detailJunctions = getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1039
+ const isSelectedPullRequestDetailLoading = selectedPullRequest !== null && !selectedPullRequest.detailLoaded
1040
+ const detailLoadingContent: DetailPlaceholderContent = selectedPullRequest ? {
1041
+ title: `${loadingIndicator} Loading pull request details`,
1042
+ hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1043
+ } : detailPlaceholderContent
1044
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
861
1045
 
862
1046
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
863
1047
 
864
1048
  const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
865
1049
  const key = pullRequestDiffKey(pullRequest)
866
- const previousLoadState = diffCommentsLoaded[key]
1050
+ const previousLoadState = registry.get(diffCommentsLoadedAtom)[key]
867
1051
  if (!force && previousLoadState) return
868
1052
  setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
869
1053
  void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
@@ -906,12 +1090,14 @@ export const App = () => {
906
1090
  const force = options.force ?? false
907
1091
  const includeComments = options.includeComments ?? false
908
1092
  const key = pullRequestDiffKey(pullRequest)
909
- const existing = pullRequestDiffCache[key]
1093
+ const existing = registry.get(pullRequestDiffCacheAtom)[key]
910
1094
  if (includeComments) loadPullRequestComments(pullRequest, force)
911
1095
  if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
912
1096
 
913
1097
  setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
914
- void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
1098
+ const atom = pullRequestDiffAtom(pullRequestDiffAtomKey(pullRequest))
1099
+ if (force) registry.refresh(atom)
1100
+ void Effect.runPromise(AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }))
915
1101
  .then((patch) => {
916
1102
  setPullRequestDiffCache((current) => ({
917
1103
  ...current,
@@ -985,6 +1171,8 @@ export const App = () => {
985
1171
  diffScrollRef.current?.scrollTo({ x: 0, y })
986
1172
  syncDiffScrollState()
987
1173
  }
1174
+ const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1175
+ const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
988
1176
 
989
1177
  const clearPendingGTimeout = () => {
990
1178
  if (pendingGTimeoutRef.current !== null) {
@@ -1161,14 +1349,14 @@ export const App = () => {
1161
1349
  }
1162
1350
 
1163
1351
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
1164
- void openPullRequestInBrowser(pullRequest)
1352
+ void openInBrowser(pullRequest)
1165
1353
  .then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
1166
1354
  .catch((error) => flashNotice(errorMessage(error)))
1167
1355
  }
1168
1356
 
1169
1357
  const copySelectedPullRequestMetadata = () => {
1170
1358
  if (!selectedPullRequest) return
1171
- void copyPullRequestMetadata(selectedPullRequest)
1359
+ void copyToClipboard(pullRequestMetadataText(selectedPullRequest))
1172
1360
  .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1173
1361
  .catch((error) => flashNotice(errorMessage(error)))
1174
1362
  }
@@ -1291,7 +1479,7 @@ export const App = () => {
1291
1479
  const openLabelModal = () => {
1292
1480
  if (!selectedPullRequest) return
1293
1481
  const repository = selectedPullRequest.repository
1294
- const cachedLabels = labelCache[repository]
1482
+ const cachedLabels = registry.get(labelCacheAtom)[repository]
1295
1483
  if (cachedLabels) {
1296
1484
  setLabelModal({
1297
1485
  repository,
@@ -1390,9 +1578,7 @@ export const App = () => {
1390
1578
 
1391
1579
  const toggleLabelAtIndex = () => {
1392
1580
  if (!selectedPullRequest) return
1393
- const filtered = labelModal.availableLabels.filter((label) =>
1394
- labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
1395
- )
1581
+ const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
1396
1582
  const label = filtered[labelModal.selectedIndex]
1397
1583
  if (!label) return
1398
1584
 
@@ -1424,274 +1610,505 @@ export const App = () => {
1424
1610
  }
1425
1611
  }
1426
1612
 
1427
- useKeyboard((key) => {
1428
- if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1429
- if (themeModalActive) {
1430
- closeThemeModal(false)
1431
- return
1432
- }
1433
- if (activeModal._tag !== "None") {
1434
- closeActiveModal()
1435
- return
1436
- }
1437
- renderer.destroy()
1613
+ const openCommandPalette = () => {
1614
+ setCommandPalette(initialCommandPaletteState)
1615
+ }
1616
+ const openRepositoryPicker = () => {
1617
+ setOpenRepositoryModal({ query: selectedRepository ?? "", error: null })
1618
+ }
1619
+ const openRepositoryFromInput = () => {
1620
+ const repository = parseRepositoryInput(openRepositoryModal.query)
1621
+ if (!repository) {
1622
+ setOpenRepositoryModal((current) => ({ ...current, error: "Enter a repository as owner/name or a GitHub URL." }))
1438
1623
  return
1439
1624
  }
1625
+ closeActiveModal()
1626
+ switchViewTo({ _tag: "Repository", repository })
1627
+ flashNotice(`Opened ${repository}`)
1628
+ }
1629
+ const insertPastedText = (text: string) => {
1630
+ if (text.length === 0) return false
1631
+ if (commandPaletteActive) {
1632
+ setCommandPalette((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1633
+ return true
1634
+ }
1635
+ if (openRepositoryModalActive) {
1636
+ setOpenRepositoryModal((current) => ({ ...current, query: current.query + singleLineText(text), error: null }))
1637
+ return true
1638
+ }
1639
+ if (themeModalActive && themeModal.filterMode) {
1640
+ editThemeQuery((query) => query + singleLineText(text))
1641
+ return true
1642
+ }
1643
+ if (commentModalActive) {
1644
+ editComment((state) => insertText(state, text.replace(/\r\n?/g, "\n")))
1645
+ return true
1646
+ }
1647
+ if (labelModalActive) {
1648
+ setLabelModal((current) => ({ ...current, query: current.query + singleLineText(text), selectedIndex: 0 }))
1649
+ return true
1650
+ }
1651
+ if (filterMode) {
1652
+ setFilterDraft((current) => current + singleLineText(text))
1653
+ return true
1654
+ }
1655
+ return false
1656
+ }
1440
1657
 
1441
- if (themeModalActive) {
1442
- if (key.name === "escape") {
1443
- if (themeModal.filterMode) {
1444
- updateThemeQuery("", { filterMode: false })
1445
- return
1446
- }
1447
- closeThemeModal(false)
1448
- return
1449
- }
1450
- if (key.name === "/") {
1451
- updateThemeQuery("", { filterMode: true })
1452
- return
1453
- }
1454
- if (key.name === "return" || key.name === "enter") {
1455
- if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
1456
- closeThemeModal(true)
1457
- return
1458
- }
1459
- if (key.name === "up" || (!themeModal.filterMode && key.name === "k")) {
1460
- moveThemeSelection(-1)
1461
- return
1462
- }
1463
- if (key.name === "down" || (!themeModal.filterMode && key.name === "j")) {
1464
- moveThemeSelection(1)
1465
- return
1466
- }
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)
1477
- return
1658
+ useEffect(() => {
1659
+ const handlePaste = (event: PasteEvent) => {
1660
+ if (insertPastedText(pasteText(event))) event.preventDefault()
1661
+ }
1662
+ const keyInput = renderer.keyInput as unknown as {
1663
+ on: (event: "paste", handler: (event: PasteEvent) => void) => void
1664
+ off: (event: "paste", handler: (event: PasteEvent) => void) => void
1665
+ }
1666
+ keyInput.on("paste", handlePaste)
1667
+ return () => {
1668
+ keyInput.off("paste", handlePaste)
1669
+ }
1670
+ }, [renderer, commandPaletteActive, openRepositoryModalActive, themeModalActive, themeModal.filterMode, commentModalActive, labelModalActive, filterMode])
1671
+
1672
+ const appCommands: readonly AppCommand[] = buildAppCommands({
1673
+ pullRequestStatus,
1674
+ filterQuery,
1675
+ filterMode,
1676
+ selectedRepository,
1677
+ activeViews,
1678
+ activeView,
1679
+ loadedPullRequestCount,
1680
+ hasMorePullRequests,
1681
+ isLoadingMorePullRequests,
1682
+ selectedPullRequest,
1683
+ detailFullView,
1684
+ diffFullView,
1685
+ diffReady: selectedDiffState?._tag === "Ready",
1686
+ effectiveDiffRenderView,
1687
+ diffWrapMode,
1688
+ readyDiffFileCount: readyDiffFiles.length,
1689
+ diffFileIndex,
1690
+ diffCommentMode,
1691
+ selectedDiffCommentAnchorLabel: selectedDiffCommentAnchor ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line}` : null,
1692
+ actions: {
1693
+ openCommandPalette,
1694
+ refreshPullRequests,
1695
+ openFilter: () => {
1696
+ setFilterDraft(filterQuery)
1697
+ setFilterMode(true)
1698
+ },
1699
+ clearFilter: () => {
1700
+ setFilterQuery("")
1701
+ setFilterDraft("")
1702
+ setFilterMode(false)
1703
+ },
1704
+ openThemeModal,
1705
+ openRepositoryPicker,
1706
+ loadMorePullRequests,
1707
+ switchViewTo,
1708
+ openDetails: () => {
1709
+ setDetailFullView(true)
1710
+ setDetailScrollOffset(0)
1711
+ },
1712
+ closeDetails: () => {
1713
+ setDetailFullView(false)
1714
+ setDetailScrollOffset(0)
1715
+ },
1716
+ openDiffView,
1717
+ closeDiffView: () => {
1718
+ setDiffFullView(false)
1719
+ setDiffCommentMode(false)
1720
+ },
1721
+ reloadDiff: () => {
1722
+ if (!selectedPullRequest) return
1723
+ loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1724
+ flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1725
+ },
1726
+ toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1727
+ toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1728
+ jumpDiffFile,
1729
+ toggleDiffCommentMode: () => {
1730
+ if (diffCommentMode) setDiffCommentMode(false)
1731
+ else enterDiffCommentMode()
1732
+ },
1733
+ openDiffCommentModal,
1734
+ togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1735
+ openLabelModal,
1736
+ openMergeModal,
1737
+ openCloseModal,
1738
+ openPullRequestInBrowser: () => {
1739
+ if (selectedPullRequest) openSelectedPullRequestInBrowser(selectedPullRequest)
1740
+ },
1741
+ copyPullRequestMetadata: copySelectedPullRequestMetadata,
1742
+ quit: () => renderer.destroy(),
1743
+ },
1744
+ })
1745
+ const runCommand = (command: AppCommand, options: { readonly notifyDisabled?: boolean; readonly closePalette?: boolean } = {}) => {
1746
+ if (!commandEnabled(command)) {
1747
+ if (options.notifyDisabled && command.disabledReason) flashNotice(command.disabledReason)
1748
+ return false
1749
+ }
1750
+ if (options.closePalette) closeActiveModal()
1751
+ command.run()
1752
+ return true
1753
+ }
1754
+ const runCommandById = (id: string, options: { readonly notifyDisabled?: boolean } = {}) => {
1755
+ const command = appCommands.find((entry) => entry.id === id)
1756
+ return command ? runCommand(command, options) : false
1757
+ }
1758
+ const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1759
+ if (!commandPaletteActive) return []
1760
+ const repository = parseRepositoryInput(commandPalette.query)
1761
+ if (!repository || repository === selectedRepository) return []
1762
+ return [defineCommand({
1763
+ id: `view.repository.dynamic:${repository}`,
1764
+ title: `Open ${repository}`,
1765
+ scope: "View",
1766
+ subtitle: "Switch to this repository",
1767
+ run: () => switchViewTo({ _tag: "Repository", repository }),
1768
+ })]
1769
+ })()
1770
+ // Dynamic commands always pin to the top of the palette; they came directly from the
1771
+ // user's typed input so they shouldn't be filtered by fuzzy score against themselves.
1772
+ const commandPaletteCommands = commandPaletteActive
1773
+ ? [
1774
+ ...dynamicPaletteCommands,
1775
+ ...sortCommandsByScope(filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)),
1776
+ ]
1777
+ : []
1778
+ const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1779
+ const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1780
+
1781
+ // Keymap migration phase 2: simple cmd-id bindings move out of useKeyboard.
1782
+ // Gated to "global mode" — no modal active, no full-view, not in filter editing —
1783
+ // so these don't dispatch on top of modal-specific handlers below.
1784
+ const globalKeymapActiveRef = useRef(false)
1785
+ globalKeymapActiveRef.current = !commandPaletteActive
1786
+ && !openRepositoryModalActive
1787
+ && !labelModalActive
1788
+ && !commentModalActive
1789
+ && !commentThreadModalActive
1790
+ && !closeModalActive
1791
+ && !mergeModalActive
1792
+ && !themeModalActive
1793
+ && !diffFullView
1794
+ && !detailFullView
1795
+ && !filterMode
1796
+ const runCommandByIdRef = useRef(runCommandById)
1797
+ runCommandByIdRef.current = runCommandById
1798
+ useBindings(() => ({
1799
+ enabled: () => globalKeymapActiveRef.current,
1800
+ bindings: [
1801
+ { key: "/", cmd: () => runCommandByIdRef.current("filter.open") },
1802
+ { key: "r", cmd: () => runCommandByIdRef.current("pull.refresh") },
1803
+ { key: "t", cmd: () => runCommandByIdRef.current("theme.open") },
1804
+ { key: "d", cmd: () => runCommandByIdRef.current("diff.open") },
1805
+ { key: "l", cmd: () => runCommandByIdRef.current("pull.labels") },
1806
+ { key: "m", cmd: () => runCommandByIdRef.current("pull.merge") },
1807
+ { key: "shift+m", cmd: () => runCommandByIdRef.current("pull.merge") },
1808
+ { key: "x", cmd: () => runCommandByIdRef.current("pull.close") },
1809
+ { key: "o", cmd: () => runCommandByIdRef.current("pull.open-browser") },
1810
+ { key: "s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1811
+ { key: "shift+s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1812
+ { key: "y", cmd: () => runCommandByIdRef.current("pull.copy-metadata") },
1813
+ { key: "return", cmd: () => runCommandByIdRef.current("detail.open") },
1814
+ ],
1815
+ }), [])
1816
+ // Always-on bindings — work even while modals are open.
1817
+ useBindings(() => ({
1818
+ bindings: [
1819
+ { key: "ctrl+p", cmd: () => runCommandByIdRef.current("command.open") },
1820
+ { key: "meta+k", cmd: () => runCommandByIdRef.current("command.open") },
1821
+ ],
1822
+ }), [])
1823
+
1824
+ // CloseModal: escape closes, enter confirms.
1825
+ const closeModalActiveRef = useRef(false)
1826
+ closeModalActiveRef.current = closeModalActive
1827
+ const closeActiveModalRef = useRef(closeActiveModal)
1828
+ closeActiveModalRef.current = closeActiveModal
1829
+ const confirmClosePullRequestRef = useRef(confirmClosePullRequest)
1830
+ confirmClosePullRequestRef.current = confirmClosePullRequest
1831
+ useBindings(() => ({
1832
+ enabled: () => closeModalActiveRef.current,
1833
+ bindings: [
1834
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1835
+ { key: "return", cmd: () => confirmClosePullRequestRef.current() },
1836
+ ],
1837
+ }), [])
1838
+
1839
+ // MergeModal: escape, enter (when options>0), up/down/j/k navigation.
1840
+ const mergeModalActiveRef = useRef(false)
1841
+ mergeModalActiveRef.current = mergeModalActive
1842
+ const mergeModalContextRef = useRef({ availableCount: 0, confirm: confirmMergeAction, setMergeModal })
1843
+ mergeModalContextRef.current = {
1844
+ availableCount: availableMergeActions(mergeModal.info).length,
1845
+ confirm: confirmMergeAction,
1846
+ setMergeModal,
1847
+ }
1848
+ const moveMergeSelection = (delta: -1 | 1) => mergeModalContextRef.current.setMergeModal((current) => {
1849
+ const max = Math.max(0, mergeModalContextRef.current.availableCount - 1)
1850
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1851
+ })
1852
+ useBindings(() => ({
1853
+ enabled: () => mergeModalActiveRef.current,
1854
+ bindings: [
1855
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1856
+ { key: "return", cmd: () => {
1857
+ if (mergeModalContextRef.current.availableCount > 0) mergeModalContextRef.current.confirm()
1858
+ } },
1859
+ { key: "up", cmd: () => moveMergeSelection(-1) },
1860
+ { key: "k", cmd: () => moveMergeSelection(-1) },
1861
+ { key: "down", cmd: () => moveMergeSelection(1) },
1862
+ { key: "j", cmd: () => moveMergeSelection(1) },
1863
+ ],
1864
+ }), [])
1865
+
1866
+ // CommentThreadModal: scroll the thread, shortcut to compose a reply.
1867
+ const commentThreadModalActiveRef = useRef(false)
1868
+ commentThreadModalActiveRef.current = commentThreadModalActive
1869
+ const commentThreadCtxRef = useRef({ openDiffCommentModal, setCommentThreadModal, halfPage })
1870
+ commentThreadCtxRef.current = { openDiffCommentModal, setCommentThreadModal, halfPage }
1871
+ const scrollCommentThread = (delta: number) => commentThreadCtxRef.current.setCommentThreadModal((current) => ({
1872
+ ...current,
1873
+ scrollOffset: Math.max(0, current.scrollOffset + delta),
1874
+ }))
1875
+ useBindings(() => ({
1876
+ enabled: () => commentThreadModalActiveRef.current,
1877
+ bindings: [
1878
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1879
+ { key: "return", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1880
+ { key: "a", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1881
+ { key: "c", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1882
+ { key: "up", cmd: () => scrollCommentThread(-1) },
1883
+ { key: "k", cmd: () => scrollCommentThread(-1) },
1884
+ { key: "down", cmd: () => scrollCommentThread(1) },
1885
+ { key: "j", cmd: () => scrollCommentThread(1) },
1886
+ { key: "pageup", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1887
+ { key: "ctrl+u", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1888
+ { key: "pagedown", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1889
+ { key: "ctrl+d", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1890
+ { key: "ctrl+v", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1891
+ ],
1892
+ }), [])
1893
+
1894
+ // LabelModal: nav keys via keymap; text input stays in useKeyboard fallback.
1895
+ const labelModalActiveRef = useRef(false)
1896
+ labelModalActiveRef.current = labelModalActive
1897
+ const labelModalCtxRef = useRef({ toggleLabelAtIndex, setLabelModal, filteredCount: 0 })
1898
+ labelModalCtxRef.current = {
1899
+ toggleLabelAtIndex,
1900
+ setLabelModal,
1901
+ filteredCount: filterLabels(labelModal.availableLabels, labelModal.query).length,
1902
+ }
1903
+ const moveLabelSelection = (delta: -1 | 1) => labelModalCtxRef.current.setLabelModal((current) => {
1904
+ const max = Math.max(0, labelModalCtxRef.current.filteredCount - 1)
1905
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1906
+ })
1907
+ useBindings(() => ({
1908
+ enabled: () => labelModalActiveRef.current,
1909
+ bindings: [
1910
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1911
+ { key: "return", cmd: () => labelModalCtxRef.current.toggleLabelAtIndex() },
1912
+ { key: "up", cmd: () => moveLabelSelection(-1) },
1913
+ { key: "k", cmd: () => moveLabelSelection(-1) },
1914
+ { key: "down", cmd: () => moveLabelSelection(1) },
1915
+ { key: "j", cmd: () => moveLabelSelection(1) },
1916
+ ],
1917
+ }), [])
1918
+
1919
+ // ThemeModal: nav + filter-mode toggle. j/k only navigate when not in filter mode
1920
+ // (so users can type those letters into the query).
1921
+ const themeModalActiveRef = useRef(false)
1922
+ themeModalActiveRef.current = themeModalActive
1923
+ const themeModalCtxRef = useRef({
1924
+ filterMode: false,
1925
+ hasResults: true,
1926
+ closeThemeModal,
1927
+ updateThemeQuery,
1928
+ moveThemeSelection,
1929
+ })
1930
+ themeModalCtxRef.current = {
1931
+ filterMode: themeModal.filterMode,
1932
+ hasResults: filterThemeDefinitions(themeModal.query).length > 0,
1933
+ closeThemeModal,
1934
+ updateThemeQuery,
1935
+ moveThemeSelection,
1936
+ }
1937
+ useBindings(() => ({
1938
+ enabled: () => themeModalActiveRef.current,
1939
+ bindings: [
1940
+ { key: "escape", cmd: () => {
1941
+ if (themeModalCtxRef.current.filterMode) themeModalCtxRef.current.updateThemeQuery("", { filterMode: false })
1942
+ else themeModalCtxRef.current.closeThemeModal(false)
1943
+ } },
1944
+ { key: "/", cmd: () => themeModalCtxRef.current.updateThemeQuery("", { filterMode: true }) },
1945
+ { key: "return", cmd: () => {
1946
+ if (themeModalCtxRef.current.filterMode && !themeModalCtxRef.current.hasResults) return
1947
+ themeModalCtxRef.current.closeThemeModal(true)
1948
+ } },
1949
+ { key: "up", cmd: () => themeModalCtxRef.current.moveThemeSelection(-1) },
1950
+ { key: "down", cmd: () => themeModalCtxRef.current.moveThemeSelection(1) },
1951
+ { key: "k", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(-1) } },
1952
+ { key: "j", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(1) } },
1953
+ ],
1954
+ }), [])
1955
+
1956
+ // OpenRepositoryModal: escape closes, return submits.
1957
+ const openRepositoryModalActiveRef = useRef(false)
1958
+ openRepositoryModalActiveRef.current = openRepositoryModalActive
1959
+ const openRepositoryFromInputRef = useRef(openRepositoryFromInput)
1960
+ openRepositoryFromInputRef.current = openRepositoryFromInput
1961
+ useBindings(() => ({
1962
+ enabled: () => openRepositoryModalActiveRef.current,
1963
+ bindings: [
1964
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1965
+ { key: "return", cmd: () => openRepositoryFromInputRef.current() },
1966
+ ],
1967
+ }), [])
1968
+
1969
+ // CommentModal: full text editor — escape, submit, all the cursor/edit bindings.
1970
+ const commentModalActiveRef = useRef(false)
1971
+ commentModalActiveRef.current = commentModalActive
1972
+ const commentModalCtxRef = useRef({ submitDiffComment, editComment })
1973
+ commentModalCtxRef.current = { submitDiffComment, editComment }
1974
+ const editComm = (transform: Parameters<typeof editComment>[0]) => commentModalCtxRef.current.editComment(transform)
1975
+ useBindings(() => ({
1976
+ enabled: () => commentModalActiveRef.current,
1977
+ bindings: [
1978
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
1979
+ { key: "ctrl+s", cmd: () => commentModalCtxRef.current.submitDiffComment() },
1980
+ { key: "ctrl+a", cmd: () => editComm(moveLineStart) },
1981
+ { key: "ctrl+e", cmd: () => editComm(moveLineEnd) },
1982
+ { key: "ctrl+b", cmd: () => editComm(editorMoveLeft) },
1983
+ { key: "ctrl+f", cmd: () => editComm(editorMoveRight) },
1984
+ { key: "ctrl+w", cmd: () => editComm(deleteWordBackward) },
1985
+ { key: "ctrl+u", cmd: () => editComm(deleteToLineStart) },
1986
+ { key: "ctrl+k", cmd: () => editComm(deleteToLineEnd) },
1987
+ { key: "ctrl+d", cmd: () => editComm(editorDeleteForward) },
1988
+ { key: "meta+b", cmd: () => editComm(moveWordBackward) },
1989
+ { key: "meta+left", cmd: () => editComm(moveWordBackward) },
1990
+ { key: "meta+f", cmd: () => editComm(moveWordForward) },
1991
+ { key: "meta+right", cmd: () => editComm(moveWordForward) },
1992
+ { key: "meta+backspace", cmd: () => editComm(deleteWordBackward) },
1993
+ { key: "meta+delete", cmd: () => editComm(deleteWordForward) },
1994
+ { key: "backspace", cmd: () => editComm(editorBackspace) },
1995
+ { key: "delete", cmd: () => editComm(editorDeleteForward) },
1996
+ { key: "left", cmd: () => editComm(editorMoveLeft) },
1997
+ { key: "right", cmd: () => editComm(editorMoveRight) },
1998
+ { key: "up", cmd: () => editComm((state) => moveVertically(state, -1)) },
1999
+ { key: "down", cmd: () => editComm((state) => moveVertically(state, 1)) },
2000
+ { key: "home", cmd: () => editComm(moveLineStart) },
2001
+ { key: "end", cmd: () => editComm(moveLineEnd) },
2002
+ { key: "shift+return", cmd: () => editComm((state) => insertText(state, "\n")) },
2003
+ { key: "return", cmd: () => commentModalCtxRef.current.submitDiffComment() },
2004
+ ],
2005
+ }), [])
2006
+
2007
+ // CommandPalette: escape closes, return runs, up/k & down/j navigate.
2008
+ const commandPaletteActiveRef = useRef(false)
2009
+ commandPaletteActiveRef.current = commandPaletteActive
2010
+ const commandPaletteCtxRef = useRef({
2011
+ runSelected: () => {},
2012
+ setCommandPalette,
2013
+ paletteCommands: commandPaletteCommands,
2014
+ })
2015
+ commandPaletteCtxRef.current = {
2016
+ runSelected: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2017
+ setCommandPalette,
2018
+ paletteCommands: commandPaletteCommands,
2019
+ }
2020
+ const moveCommandPaletteSelection = (delta: -1 | 1) => commandPaletteCtxRef.current.setCommandPalette((current) => {
2021
+ const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCtxRef.current.paletteCommands)
2022
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2023
+ })
2024
+ useBindings(() => ({
2025
+ enabled: () => commandPaletteActiveRef.current,
2026
+ bindings: [
2027
+ { key: "escape", cmd: () => closeActiveModalRef.current() },
2028
+ { key: "ctrl+c", cmd: () => closeActiveModalRef.current() },
2029
+ { key: "return", cmd: () => commandPaletteCtxRef.current.runSelected() },
2030
+ { key: "up", cmd: () => moveCommandPaletteSelection(-1) },
2031
+ { key: "down", cmd: () => moveCommandPaletteSelection(1) },
2032
+ ],
2033
+ }), [])
2034
+
2035
+ // FilterMode: escape cancels, return commits.
2036
+ const filterModeRef = useRef(false)
2037
+ filterModeRef.current = filterMode
2038
+ const filterCtxRef = useRef({ filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode })
2039
+ filterCtxRef.current = { filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode }
2040
+ useBindings(() => ({
2041
+ enabled: () => filterModeRef.current,
2042
+ bindings: [
2043
+ { key: "escape", cmd: () => {
2044
+ filterCtxRef.current.setFilterDraft(filterCtxRef.current.filterQuery)
2045
+ filterCtxRef.current.setFilterMode(false)
2046
+ } },
2047
+ { key: "return", cmd: () => {
2048
+ filterCtxRef.current.setFilterQuery(filterCtxRef.current.filterDraft)
2049
+ filterCtxRef.current.setFilterMode(false)
2050
+ } },
2051
+ ],
2052
+ }), [])
2053
+
2054
+ useKeyboard((key) => {
2055
+ if (commandPaletteActive) {
2056
+ if (isSingleLineInputKey(key)) {
2057
+ setCommandPalette((current) => {
2058
+ const query = editSingleLineInput(current.query, key) ?? current.query
2059
+ return current.query === query && current.selectedIndex === 0 ? current : { ...current, query, selectedIndex: 0 }
2060
+ })
1478
2061
  }
1479
2062
  return
1480
2063
  }
1481
2064
 
1482
- if (commentModalActive) {
1483
- if (key.name === "escape") {
1484
- closeActiveModal()
1485
- return
1486
- }
1487
- if (key.ctrl && key.name === "s") {
1488
- submitDiffComment()
1489
- return
1490
- }
1491
- if (key.ctrl && key.name === "a") {
1492
- editComment(moveLineStart)
1493
- return
1494
- }
1495
- if (key.ctrl && key.name === "e") {
1496
- editComment(moveLineEnd)
1497
- return
1498
- }
1499
- if (key.ctrl && key.name === "b") {
1500
- editComment(editorMoveLeft)
1501
- return
1502
- }
1503
- if (key.ctrl && key.name === "f") {
1504
- editComment(editorMoveRight)
1505
- return
1506
- }
1507
- if (key.ctrl && key.name === "w") {
1508
- editComment(deleteWordBackward)
1509
- return
1510
- }
1511
- if (key.ctrl && key.name === "u") {
1512
- editComment(deleteToLineStart)
1513
- return
1514
- }
1515
- if (key.ctrl && key.name === "k") {
1516
- editComment(deleteToLineEnd)
1517
- return
1518
- }
1519
- if (key.ctrl && key.name === "d") {
1520
- editComment(editorDeleteForward)
1521
- return
1522
- }
1523
- if ((key.meta || key.option) && (key.name === "b" || key.name === "left")) {
1524
- editComment(moveWordBackward)
1525
- return
1526
- }
1527
- if ((key.meta || key.option) && (key.name === "f" || key.name === "right")) {
1528
- editComment(moveWordForward)
1529
- return
1530
- }
1531
- if ((key.meta || key.option) && (key.name === "backspace" || key.name === "delete")) {
1532
- editComment(key.name === "delete" ? deleteWordForward : deleteWordBackward)
1533
- return
1534
- }
1535
- if (key.name === "backspace") {
1536
- editComment(editorBackspace)
1537
- return
1538
- }
1539
- if (key.name === "delete") {
1540
- editComment(editorDeleteForward)
1541
- return
1542
- }
1543
- if (key.name === "left") {
1544
- editComment(editorMoveLeft)
1545
- return
1546
- }
1547
- if (key.name === "right") {
1548
- editComment(editorMoveRight)
1549
- return
1550
- }
1551
- if (key.name === "up") {
1552
- editComment((state) => moveVertically(state, -1))
1553
- return
1554
- }
1555
- if (key.name === "down") {
1556
- editComment((state) => moveVertically(state, 1))
1557
- return
1558
- }
1559
- if (key.name === "home") {
1560
- editComment(moveLineStart)
1561
- return
1562
- }
1563
- if (key.name === "end") {
1564
- editComment(moveLineEnd)
1565
- return
1566
- }
1567
- if ((key.name === "return" || key.name === "enter") && key.shift) {
1568
- editComment((state) => insertText(state, "\n"))
1569
- return
1570
- }
1571
- if (key.name === "return" || key.name === "enter") {
1572
- submitDiffComment()
1573
- return
1574
- }
1575
- if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1576
- editComment((state) => insertText(state, key.sequence))
1577
- return
2065
+ if (openRepositoryModalActive) {
2066
+ if (isSingleLineInputKey(key)) {
2067
+ setOpenRepositoryModal((current) => ({
2068
+ ...current,
2069
+ query: editSingleLineInput(current.query, key) ?? current.query,
2070
+ error: null,
2071
+ }))
1578
2072
  }
1579
2073
  return
1580
2074
  }
1581
2075
 
1582
- if (commentThreadModalActive) {
1583
- if (key.name === "escape") {
1584
- closeActiveModal()
1585
- return
1586
- }
1587
- if (key.name === "return" || key.name === "enter" || key.name === "a" || key.name === "c") {
1588
- openDiffCommentModal()
1589
- return
1590
- }
1591
- if (key.name === "up" || key.name === "k") {
1592
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - 1) }))
1593
- return
1594
- }
1595
- if (key.name === "down" || key.name === "j") {
1596
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + 1 }))
1597
- return
1598
- }
1599
- if (key.name === "pageup" || key.ctrl && key.name === "u") {
1600
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - halfPage) }))
2076
+ if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
2077
+ if (themeModalActive) {
2078
+ closeThemeModal(false)
1601
2079
  return
1602
2080
  }
1603
- if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1604
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + halfPage }))
2081
+ if (activeModal._tag !== "None") {
2082
+ closeActiveModal()
1605
2083
  return
1606
2084
  }
2085
+ runCommandById("app.quit")
1607
2086
  return
1608
2087
  }
1609
2088
 
1610
- if (closeModalActive) {
1611
- if (key.name === "escape") {
1612
- closeActiveModal()
1613
- return
1614
- }
1615
- if (key.name === "return" || key.name === "enter") {
1616
- confirmClosePullRequest()
1617
- return
2089
+ if (themeModalActive) {
2090
+ if (themeModal.filterMode && isSingleLineInputKey(key)) {
2091
+ editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
1618
2092
  }
1619
2093
  return
1620
2094
  }
1621
2095
 
1622
- if (mergeModalActive) {
1623
- const options = availableMergeActions(mergeModal.info)
1624
- if (key.name === "escape") {
1625
- closeActiveModal()
1626
- return
1627
- }
1628
- if ((key.name === "return" || key.name === "enter") && options.length > 0) {
1629
- confirmMergeAction()
1630
- return
1631
- }
1632
- if (key.name === "up" || key.name === "k") {
1633
- setMergeModal((current) => ({
1634
- ...current,
1635
- selectedIndex: Math.max(0, current.selectedIndex - 1),
1636
- }))
1637
- return
1638
- }
1639
- if (key.name === "down" || key.name === "j") {
1640
- setMergeModal((current) => ({
1641
- ...current,
1642
- selectedIndex: Math.min(Math.max(0, options.length - 1), current.selectedIndex + 1),
1643
- }))
1644
- return
1645
- }
2096
+ if (commentModalActive) {
2097
+ const text = printableKeyText(key)
2098
+ if (text) editComment((state) => insertText(state, text))
1646
2099
  return
1647
2100
  }
1648
2101
 
1649
- // Label modal takes priority over everything else
2102
+
2103
+
2104
+
1650
2105
  if (labelModalActive) {
1651
- if (key.name === "escape") {
1652
- closeActiveModal()
1653
- return
1654
- }
1655
- if (key.name === "return" || key.name === "enter") {
1656
- toggleLabelAtIndex()
1657
- return
1658
- }
1659
- if (key.name === "up" || key.name === "k") {
2106
+ if (isSingleLineInputKey(key)) {
1660
2107
  setLabelModal((current) => ({
1661
2108
  ...current,
1662
- selectedIndex: Math.max(0, current.selectedIndex - 1),
1663
- }))
1664
- return
1665
- }
1666
- 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
- )
1670
- setLabelModal((current) => ({
1671
- ...current,
1672
- selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
1673
- }))
1674
- return
1675
- }
1676
- if (key.name === "backspace") {
1677
- setLabelModal((current) => ({
1678
- ...current,
1679
- query: current.query.slice(0, -1),
2109
+ query: editSingleLineInput(current.query, key) ?? current.query,
1680
2110
  selectedIndex: 0,
1681
2111
  }))
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,
1692
- selectedIndex: 0,
1693
- }))
1694
- return
1695
2112
  }
1696
2113
  return
1697
2114
  }
@@ -1703,7 +2120,7 @@ export const App = () => {
1703
2120
  return
1704
2121
  }
1705
2122
  if (key.name === "c") {
1706
- setDiffCommentMode(false)
2123
+ runCommandById("diff.comment-mode")
1707
2124
  return
1708
2125
  }
1709
2126
  if (key.name === "return" || key.name === "enter") {
@@ -1712,7 +2129,7 @@ export const App = () => {
1712
2129
  return
1713
2130
  }
1714
2131
  if (key.name === "a") {
1715
- openDiffCommentModal()
2132
+ runCommandById("diff.add-comment")
1716
2133
  return
1717
2134
  }
1718
2135
  if (key.name === "pageup" || key.ctrl && key.name === "u") {
@@ -1748,23 +2165,22 @@ export const App = () => {
1748
2165
  return
1749
2166
  }
1750
2167
  if (key.name === "]" && selectedDiffState?._tag === "Ready") {
1751
- jumpDiffFile(1)
2168
+ runCommandById("diff.next-file")
1752
2169
  return
1753
2170
  }
1754
2171
  if (key.name === "[" && selectedDiffState?._tag === "Ready") {
1755
- jumpDiffFile(-1)
2172
+ runCommandById("diff.previous-file")
1756
2173
  return
1757
2174
  }
1758
2175
  return
1759
2176
  }
1760
2177
 
1761
2178
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1762
- setDiffFullView(false)
1763
- setDiffCommentMode(false)
2179
+ runCommandById("diff.close")
1764
2180
  return
1765
2181
  }
1766
2182
  if (key.name === "c" && selectedDiffState?._tag === "Ready") {
1767
- enterDiffCommentMode()
2183
+ runCommandById("diff.comment-mode")
1768
2184
  return
1769
2185
  }
1770
2186
  if (key.name === "home") {
@@ -1801,67 +2217,64 @@ export const App = () => {
1801
2217
  return
1802
2218
  }
1803
2219
  if (key.name === "v") {
1804
- setDiffRenderView((current) => current === "unified" ? "split" : "unified")
2220
+ runCommandById("diff.toggle-view")
1805
2221
  return
1806
2222
  }
1807
2223
  if (key.name === "w") {
1808
- setDiffWrapMode((current) => current === "none" ? "word" : "none")
2224
+ runCommandById("diff.toggle-wrap")
1809
2225
  return
1810
2226
  }
1811
2227
  if (key.name === "r" && selectedPullRequest) {
1812
- loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1813
- flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
2228
+ runCommandById("diff.reload")
1814
2229
  return
1815
2230
  }
1816
2231
  if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
1817
- jumpDiffFile(1)
2232
+ runCommandById("diff.next-file")
1818
2233
  return
1819
2234
  }
1820
2235
  if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
1821
- jumpDiffFile(-1)
2236
+ runCommandById("diff.previous-file")
1822
2237
  return
1823
2238
  }
1824
2239
  if (key.name === "o" && selectedPullRequest) {
1825
- openSelectedPullRequestInBrowser(selectedPullRequest)
2240
+ runCommandById("pull.open-browser")
1826
2241
  return
1827
2242
  }
1828
2243
  return
1829
2244
  }
1830
2245
 
1831
- // Fullscreen detail mode handles its own navigation keys.
1832
2246
  if (detailFullView) {
1833
2247
  const plainKey = !key.ctrl && !key.meta && !key.option
1834
2248
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1835
- setDetailFullView(false)
1836
- setDetailScrollOffset(0)
2249
+ runCommandById("detail.close")
1837
2250
  return
1838
2251
  }
1839
2252
  if (isThemeKey(key)) {
1840
- openThemeModal()
2253
+ runCommandById("theme.open")
1841
2254
  return
1842
2255
  }
1843
2256
  if (plainKey && key.name === "d" && selectedPullRequest) {
1844
- openDiffView()
2257
+ runCommandById("diff.open")
1845
2258
  return
1846
2259
  }
1847
2260
  if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
1848
- openCloseModal()
2261
+ runCommandById("pull.close")
1849
2262
  return
1850
2263
  }
1851
2264
  if (plainKey && key.name === "l" && selectedPullRequest) {
1852
- openLabelModal()
2265
+ runCommandById("pull.labels")
1853
2266
  return
1854
2267
  }
1855
2268
  if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
1856
- openMergeModal()
2269
+ runCommandById("pull.merge")
1857
2270
  return
1858
2271
  }
1859
2272
  if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
1860
- toggleSelectedPullRequestDraftStatus()
2273
+ runCommandById("pull.toggle-draft")
1861
2274
  return
1862
2275
  }
1863
2276
  if (plainKey && key.name === "r") {
1864
- refreshPullRequests("Refreshed")
2277
+ runCommandById("pull.refresh")
1865
2278
  return
1866
2279
  }
1867
2280
  if (key.name === "home") {
@@ -1909,43 +2322,21 @@ export const App = () => {
1909
2322
  return
1910
2323
  }
1911
2324
  if (plainKey && key.name === "o" && selectedPullRequest) {
1912
- openSelectedPullRequestInBrowser(selectedPullRequest)
2325
+ runCommandById("pull.open-browser")
1913
2326
  return
1914
2327
  }
1915
2328
  if (plainKey && key.name === "y" && selectedPullRequest) {
1916
- copySelectedPullRequestMetadata()
2329
+ runCommandById("pull.copy-metadata")
1917
2330
  return
1918
2331
  }
1919
2332
  return
1920
2333
  }
1921
2334
 
1922
2335
  if (filterMode) {
1923
- if (key.name === "escape") {
1924
- setFilterDraft(filterQuery)
1925
- setFilterMode(false)
1926
- return
1927
- }
1928
- if (key.name === "enter") {
1929
- setFilterQuery(filterDraft)
1930
- setFilterMode(false)
1931
- return
1932
- }
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)
1947
- return
2336
+ if (isSingleLineInputKey(key)) {
2337
+ setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
1948
2338
  }
2339
+ return
1949
2340
  }
1950
2341
 
1951
2342
  if (key.name === "tab") {
@@ -1953,25 +2344,27 @@ export const App = () => {
1953
2344
  return
1954
2345
  }
1955
2346
 
1956
- if (isThemeKey(key)) {
1957
- openThemeModal()
1958
- return
1959
- }
1960
-
1961
- if (key.name === "/") {
1962
- setFilterDraft(filterQuery)
1963
- setFilterMode(true)
1964
- return
1965
- }
1966
2347
  if (key.name === "escape" && filterQuery.length > 0) {
1967
- setFilterQuery("")
1968
- setFilterDraft("")
1969
- setFilterMode(false)
2348
+ runCommandById("filter.clear")
1970
2349
  return
1971
2350
  }
1972
- if (key.name === "r") {
1973
- refreshPullRequests("Refreshed")
1974
- return
2351
+ if (isWideLayout && selectedPullRequest && !detailFullView && !diffFullView) {
2352
+ if (key.name === "home") {
2353
+ scrollDetailPreviewTo(0)
2354
+ return
2355
+ }
2356
+ if (key.name === "end") {
2357
+ scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER)
2358
+ return
2359
+ }
2360
+ if (key.name === "pageup") {
2361
+ scrollDetailPreviewBy(-halfPage)
2362
+ return
2363
+ }
2364
+ if (key.name === "pagedown") {
2365
+ scrollDetailPreviewBy(halfPage)
2366
+ return
2367
+ }
1975
2368
  }
1976
2369
  if (
1977
2370
  key.name === "[" ||
@@ -2023,6 +2416,10 @@ export const App = () => {
2023
2416
  return
2024
2417
  }
2025
2418
  if (key.name === "down" || key.name === "j") {
2419
+ if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2420
+ loadMorePullRequests()
2421
+ return
2422
+ }
2026
2423
  setSelectedIndex((current) => {
2027
2424
  if (visiblePullRequests.length === 0) return 0
2028
2425
  return current >= visiblePullRequests.length - 1 ? 0 : current + 1
@@ -2033,39 +2430,6 @@ export const App = () => {
2033
2430
  () => setSelectedIndex(0),
2034
2431
  () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2035
2432
  )) return
2036
- if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2037
- setDetailFullView(true)
2038
- setDetailScrollOffset(0)
2039
- return
2040
- }
2041
- if (key.name === "d" && selectedPullRequest) {
2042
- openDiffView()
2043
- return
2044
- }
2045
- if (key.name === "x" && selectedPullRequest?.state === "open") {
2046
- openCloseModal()
2047
- return
2048
- }
2049
- if (key.name === "l" && selectedPullRequest) {
2050
- openLabelModal()
2051
- return
2052
- }
2053
- if (key.name === "m" || key.name === "M") {
2054
- if (selectedPullRequest) openMergeModal()
2055
- return
2056
- }
2057
- if (key.name === "o" && selectedPullRequest) {
2058
- openSelectedPullRequestInBrowser(selectedPullRequest)
2059
- return
2060
- }
2061
- if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
2062
- toggleSelectedPullRequestDraftStatus()
2063
- return
2064
- }
2065
- if (key.name === "y" && selectedPullRequest) {
2066
- copySelectedPullRequestMetadata()
2067
- return
2068
- }
2069
2433
  })
2070
2434
 
2071
2435
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
@@ -2073,19 +2437,20 @@ export const App = () => {
2073
2437
  const wideFullscreenDetailScrollable = getDetailsPaneHeight({
2074
2438
  pullRequest: selectedPullRequest,
2075
2439
  contentWidth: fullscreenContentWidth,
2076
- bodyLines: fullscreenBodyLines,
2440
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2077
2441
  paneWidth: contentWidth,
2078
2442
  showChecks: true,
2079
2443
  }) > wideBodyHeight
2080
2444
  const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
2081
2445
  pullRequest: selectedPullRequest,
2082
2446
  contentWidth: fullscreenContentWidth,
2083
- bodyLines: fullscreenBodyLines,
2447
+ bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2084
2448
  paneWidth: contentWidth,
2085
2449
  }) > wideBodyHeight
2086
2450
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2087
2451
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2088
- const wideDetailBodyScrollable = getDetailBodyHeight(selectedPullRequest, rightContentWidth, wideDetailLines) > wideDetailBodyViewportHeight
2452
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2453
+ const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2089
2454
 
2090
2455
  const prListProps = {
2091
2456
  groups: visibleGroups,
@@ -2095,6 +2460,9 @@ export const App = () => {
2095
2460
  filterText: visibleFilterText,
2096
2461
  showFilterBar: filterMode || filterQuery.length > 0,
2097
2462
  isFilterEditing: filterMode,
2463
+ loadedCount: loadedPullRequestCount,
2464
+ hasMore: hasMorePullRequests,
2465
+ isLoadingMore: isLoadingMorePullRequests,
2098
2466
  onSelectPullRequest: selectPullRequestByUrl,
2099
2467
  } as const
2100
2468
 
@@ -2103,29 +2471,49 @@ export const App = () => {
2103
2471
  const labelModalHeight = Math.min(20, terminalHeight - 4)
2104
2472
  const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2105
2473
  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)
2474
+ const sizedModal = (minW: number, maxW: number, padX: number, maxH: number) => {
2475
+ const w = Math.min(maxW, Math.max(minW, contentWidth - padX))
2476
+ const h = Math.min(maxH, terminalHeight - 4)
2477
+ return { width: w, height: h, left: centeredOffset(contentWidth, w), top: centeredOffset(terminalHeight, h) }
2478
+ }
2479
+ const closeLayout = sizedModal(46, 68, 12, 12)
2480
+ const closeModalWidth = closeLayout.width
2481
+ const closeModalHeight = closeLayout.height
2482
+ const closeModalLeft = closeLayout.left
2483
+ const closeModalTop = closeLayout.top
2484
+ const commentLayout = sizedModal(46, 76, 8, 16)
2485
+ const commentModalWidth = commentLayout.width
2486
+ const commentModalHeight = commentLayout.height
2487
+ const commentModalLeft = commentLayout.left
2488
+ const commentModalTop = commentLayout.top
2489
+ const commentThreadLayout = sizedModal(50, 86, 8, 22)
2490
+ const commentThreadModalWidth = commentThreadLayout.width
2491
+ const commentThreadModalHeight = commentThreadLayout.height
2492
+ const commentThreadModalLeft = commentThreadLayout.left
2493
+ const commentThreadModalTop = commentThreadLayout.top
2118
2494
  const commentAnchorLabel = selectedDiffCommentAnchor
2119
2495
  ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2120
2496
  : "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)
2497
+ const mergeLayout = sizedModal(46, 68, 12, 16)
2498
+ const mergeModalWidth = mergeLayout.width
2499
+ const mergeModalHeight = mergeLayout.height
2500
+ const mergeModalLeft = mergeLayout.left
2501
+ const mergeModalTop = mergeLayout.top
2502
+ const themeLayout = sizedModal(38, 58, 12, 16)
2503
+ const themeModalWidth = themeLayout.width
2504
+ const themeModalHeight = themeLayout.height
2505
+ const themeModalLeft = themeLayout.left
2506
+ const themeModalTop = themeLayout.top
2507
+ const openRepositoryLayout = sizedModal(46, 76, 8, 8)
2508
+ const openRepositoryModalWidth = openRepositoryLayout.width
2509
+ const openRepositoryModalHeight = openRepositoryLayout.height
2510
+ const openRepositoryModalLeft = openRepositoryLayout.left
2511
+ const openRepositoryModalTop = openRepositoryLayout.top
2512
+ const commandPaletteLayout = sizedModal(50, 88, 8, 24)
2513
+ const commandPaletteWidth = commandPaletteLayout.width
2514
+ const commandPaletteHeight = commandPaletteLayout.height
2515
+ const commandPaletteLeft = commandPaletteLayout.left
2516
+ const commandPaletteTop = commandPaletteLayout.top
2129
2517
 
2130
2518
  return (
2131
2519
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -2158,6 +2546,11 @@ export const App = () => {
2158
2546
  onSelectCommentLine={selectDiffCommentLine}
2159
2547
  themeId={themeId}
2160
2548
  />
2549
+ ) : detailFullView && isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2550
+ <box flexGrow={1} flexDirection="column">
2551
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks={isWideLayout} />
2552
+ <LoadingPane content={detailLoadingContent} width={contentWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout))} />
2553
+ </box>
2161
2554
  ) : isWideLayout && detailFullView ? (
2162
2555
  <box flexGrow={1} flexDirection="column">
2163
2556
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
@@ -2166,6 +2559,7 @@ export const App = () => {
2166
2559
  viewerUsername={username}
2167
2560
  contentWidth={fullscreenContentWidth}
2168
2561
  bodyLines={fullscreenBodyLines}
2562
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2169
2563
  paneWidth={contentWidth}
2170
2564
  showChecks
2171
2565
  placeholderContent={detailPlaceholderContent}
@@ -2175,19 +2569,26 @@ export const App = () => {
2175
2569
  </scrollbox>
2176
2570
  </box>
2177
2571
  ) : 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} />
2572
+ <box key="wide-main" flexGrow={1} flexDirection="row">
2573
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
2574
+ <scrollbox ref={prListScrollRef} focusable={false} height={wideBodyHeight} flexGrow={0}>
2575
+ <box paddingLeft={sectionPadding} paddingRight={0}>
2576
+ <PullRequestList key={`wide-${leftContentWidth}`} {...prListProps} contentWidth={leftContentWidth} />
2577
+ </box>
2182
2578
  </scrollbox>
2183
2579
  </box>
2184
2580
  <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
2185
2581
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
2186
- {selectedPullRequest ? (
2582
+ {isSelectedPullRequestDetailLoading && selectedPullRequest ? (
2583
+ <>
2584
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2585
+ <LoadingPane content={detailLoadingContent} width={rightPaneWidth} height={Math.max(1, wideBodyHeight - getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true))} />
2586
+ </>
2587
+ ) : selectedPullRequest ? (
2187
2588
  <>
2188
2589
  <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} />
2590
+ <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2591
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2191
2592
  </scrollbox>
2192
2593
  </>
2193
2594
  ) : (
@@ -2203,6 +2604,7 @@ export const App = () => {
2203
2604
  viewerUsername={username}
2204
2605
  contentWidth={fullscreenContentWidth}
2205
2606
  bodyLines={fullscreenBodyLines}
2607
+ bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2206
2608
  paneWidth={contentWidth}
2207
2609
  placeholderContent={detailPlaceholderContent}
2208
2610
  loadingIndicator={loadingIndicator}
@@ -2215,7 +2617,7 @@ export const App = () => {
2215
2617
  <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2216
2618
  <Divider width={contentWidth} />
2217
2619
  <box flexGrow={1} flexDirection="column">
2218
- <scrollbox flexGrow={1}>
2620
+ <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
2219
2621
  <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
2220
2622
  <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
2221
2623
  </box>
@@ -2310,6 +2712,26 @@ export const App = () => {
2310
2712
  offsetTop={themeModalTop}
2311
2713
  />
2312
2714
  ) : null}
2715
+ {openRepositoryModalActive ? (
2716
+ <OpenRepositoryModal
2717
+ state={openRepositoryModal}
2718
+ modalWidth={openRepositoryModalWidth}
2719
+ modalHeight={openRepositoryModalHeight}
2720
+ offsetLeft={openRepositoryModalLeft}
2721
+ offsetTop={openRepositoryModalTop}
2722
+ />
2723
+ ) : null}
2724
+ {commandPaletteActive ? (
2725
+ <CommandPalette
2726
+ commands={commandPaletteCommands}
2727
+ query={commandPalette.query}
2728
+ selectedIndex={selectedCommandIndex}
2729
+ modalWidth={commandPaletteWidth}
2730
+ modalHeight={commandPaletteHeight}
2731
+ offsetLeft={commandPaletteLeft}
2732
+ offsetTop={commandPaletteTop}
2733
+ />
2734
+ ) : null}
2313
2735
  </box>
2314
2736
  )
2315
2737
  }