@kitlangton/ghui 0.1.16 → 0.1.18

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,4 +1,4 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
1
+ import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
3
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
4
  import { Cause, Effect, Layer, Schedule } from "effect"
@@ -6,18 +6,19 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
6
6
  import * as Atom from "effect/unstable/reactivity/Atom"
7
7
  import { useEffect, useMemo, useRef, useState } from "react"
8
8
  import { config } from "./config.js"
9
- import type { PullRequestItem, PullRequestLabel, PullRequestMergeAction } from "./domain.js"
9
+ import { pullRequestQueueLabels, pullRequestQueueModes, type CreatePullRequestCommentInput, type DiffCommentSide, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestQueueMode, type PullRequestReviewComment } from "./domain.js"
10
10
  import { formatShortDate, formatTimestamp } from "./date.js"
11
11
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
12
12
  import { Observability } from "./observability.js"
13
13
  import { GitHubService } from "./services/GitHubService.js"
14
14
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
15
15
  import { colors, filterThemeDefinitions, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
16
- import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
16
+ import { backspace as editorBackspace, deleteForward as editorDeleteForward, deleteToLineEnd, deleteToLineStart, deleteWordBackward, deleteWordForward, insertText, moveLeft as editorMoveLeft, moveLineEnd, moveLineStart, moveRight as editorMoveRight, moveVertically, moveWordBackward, moveWordForward, type CommentEditorValue } from "./ui/commentEditor.js"
17
+ import { buildStackedDiffFiles, diffCommentAnchorKey, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffCommentAnchor } from "./ui/diff.js"
17
18
  import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
18
19
  import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
19
20
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
20
- import { initialLabelModalState, initialMergeModalState, initialThemeModalState, LabelModal, MergeModal, ThemeModal } from "./ui/modals.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"
21
22
  import { groupBy, reviewLabel } from "./ui/pullRequests.js"
22
23
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
23
24
  import { PullRequestList } from "./ui/PullRequestList.js"
@@ -28,8 +29,10 @@ const initialThemeId = await Effect.runPromise(loadStoredThemeId)
28
29
  type LoadStatus = "loading" | "ready" | "error"
29
30
 
30
31
  interface PullRequestLoad {
32
+ readonly queueMode: PullRequestQueueMode
31
33
  readonly data: readonly PullRequestItem[]
32
34
  readonly fetchedAt: Date | null
35
+ readonly detailsFetchedAt: Date | null
33
36
  }
34
37
 
35
38
  interface DetailPlaceholderInput {
@@ -40,18 +43,67 @@ interface DetailPlaceholderInput {
40
43
  readonly filterText: string
41
44
  }
42
45
 
46
+ type DiffLineColorConfig = {
47
+ readonly gutter: string
48
+ readonly content: string
49
+ }
50
+
51
+ type DiffSideRenderable = {
52
+ readonly setLineColor: (line: number, color: DiffLineColorConfig) => void
53
+ }
54
+
55
+ type DiffRenderableRuntimeSides = {
56
+ readonly leftSide?: DiffSideRenderable
57
+ readonly rightSide?: DiffSideRenderable
58
+ }
59
+
60
+ interface AppliedDiffLineColor {
61
+ readonly anchor: StackedDiffCommentAnchor
62
+ readonly view: "unified" | "split"
63
+ }
64
+
65
+ interface AppliedDiffLineColorState {
66
+ readonly contextKey: string | null
67
+ readonly entries: readonly AppliedDiffLineColor[]
68
+ }
69
+
43
70
  const PR_FETCH_RETRIES = 6
44
71
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
45
72
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
46
73
  const AUTO_REFRESH_JITTER_MS = 10_000
47
74
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
48
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
+ })
95
+ }
96
+
49
97
  const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
98
+ const queueModeAtom = Atom.make<PullRequestQueueMode>("authored").pipe(Atom.keepAlive)
99
+ const queueLoadCacheAtom = Atom.make<Partial<Record<PullRequestQueueMode, PullRequestLoad>>>({}).pipe(Atom.keepAlive)
100
+ const queueSelectionAtom = Atom.make<Partial<Record<PullRequestQueueMode, number>>>({}).pipe(Atom.keepAlive)
50
101
  const pullRequestsAtom = githubRuntime.atom(
51
102
  GitHubService.use((github) =>
52
103
  Effect.gen(function*() {
104
+ const queueMode = yield* Atom.get(queueModeAtom)
53
105
  yield* Atom.set(retryProgressAtom, null)
54
- const data = yield* github.listOpenPullRequests().pipe(
106
+ const data = yield* github.listOpenPullRequests(queueMode).pipe(
55
107
  Effect.tapError(() =>
56
108
  Atom.update(retryProgressAtom, (current) => ({
57
109
  attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
@@ -63,7 +115,15 @@ const pullRequestsAtom = githubRuntime.atom(
63
115
  )
64
116
 
65
117
  yield* Atom.set(retryProgressAtom, null)
66
- return { data, fetchedAt: new Date() } satisfies PullRequestLoad
118
+ const cache = yield* Atom.get(queueLoadCacheAtom)
119
+ const load = {
120
+ queueMode,
121
+ data: mergeCachedDetails(data, cache[queueMode]?.data),
122
+ fetchedAt: new Date(),
123
+ detailsFetchedAt: null,
124
+ } satisfies PullRequestLoad
125
+ yield* Atom.set(queueLoadCacheAtom, { ...cache, [queueMode]: load })
126
+ return load
67
127
  })
68
128
  ),
69
129
  ).pipe(Atom.keepAlive)
@@ -79,14 +139,17 @@ const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
79
139
  const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
80
140
  const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
81
141
  const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
142
+ const diffCommentModeAtom = Atom.make(false).pipe(Atom.keepAlive)
143
+ const diffCommentAnchorIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
144
+ const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
145
+ const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
82
146
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
83
147
 
84
- const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
85
- const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
148
+ const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
86
149
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
87
- const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
88
150
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
89
151
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
152
+ const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
90
153
  const usernameAtom = githubRuntime.atom(
91
154
  config.author === "@me"
92
155
  ? GitHubService.use((github) => github.getAuthenticatedUser())
@@ -96,8 +159,8 @@ const usernameAtom = githubRuntime.atom(
96
159
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
97
160
  GitHubService.use((github) => github.listRepoLabels(repository))
98
161
  )
99
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<void>()(() =>
100
- GitHubService.use((github) => github.listOpenPullRequestDetails())
162
+ const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
163
+ GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
101
164
  )
102
165
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
103
166
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
@@ -111,17 +174,41 @@ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly
111
174
  const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
112
175
  GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
113
176
  )
177
+ const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
178
+ GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
179
+ )
114
180
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
115
181
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
116
182
  )
117
183
  const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
118
184
  GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
119
185
  )
186
+ const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
187
+ GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
188
+ )
189
+ const createPullRequestCommentAtom = githubRuntime.fn<CreatePullRequestCommentInput>()((input) => GitHubService.use((github) => github.createPullRequestComment(input)))
190
+
191
+ const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
120
192
 
121
193
  const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
122
194
 
123
195
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
124
196
 
197
+ const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
198
+ const normalized = query.trim().toLowerCase()
199
+ if (normalized.length === 0) return 0
200
+ const fields = [
201
+ pullRequest.title.toLowerCase(),
202
+ pullRequest.repository.toLowerCase(),
203
+ String(pullRequest.number),
204
+ ]
205
+ const scores = fields.flatMap((field, index) => {
206
+ const matchIndex = field.indexOf(normalized)
207
+ return matchIndex >= 0 ? [index * 1000 + matchIndex] : []
208
+ })
209
+ return scores.length > 0 ? Math.min(...scores) : null
210
+ }
211
+
125
212
  const clipboardCommands = (): readonly (readonly string[])[] => {
126
213
  if (process.platform === "darwin") return [["pbcopy"]]
127
214
  if (process.platform === "linux") {
@@ -202,6 +289,52 @@ const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => k
202
289
 
203
290
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
204
291
 
292
+ const nextQueueMode = (mode: PullRequestQueueMode, delta: 1 | -1) => {
293
+ const index = pullRequestQueueModes.indexOf(mode)
294
+ return pullRequestQueueModes[(index + delta + pullRequestQueueModes.length) % pullRequestQueueModes.length]!
295
+ }
296
+
297
+ const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
298
+ `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
299
+
300
+ const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
301
+ const threads: Record<string, PullRequestReviewComment[]> = {}
302
+ for (const comment of comments) {
303
+ const key = diffCommentThreadKey(pullRequest, comment)
304
+ const thread = threads[key]
305
+ if (thread) thread.push(comment)
306
+ else threads[key] = [comment]
307
+ }
308
+ return threads
309
+ }
310
+
311
+ const isLocalDiffComment = (comment: PullRequestReviewComment) => comment.id.startsWith("local:")
312
+
313
+ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig => {
314
+ if (anchor.kind === "addition") {
315
+ return { gutter: colors.diff.addedLineNumberBg, content: colors.diff.addedBg }
316
+ }
317
+ if (anchor.kind === "deletion") {
318
+ return { gutter: colors.diff.removedLineNumberBg, content: colors.diff.removedBg }
319
+ }
320
+ return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
321
+ }
322
+
323
+ const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: "unified" | "split") => {
324
+ const withSides = diff as unknown as DiffRenderableRuntimeSides
325
+ if (view === "split") {
326
+ const target = anchor.side === "LEFT" ? withSides.leftSide : withSides.rightSide
327
+ return target ? [target] : []
328
+ }
329
+ return withSides.leftSide ? [withSides.leftSide] : []
330
+ }
331
+
332
+ const setDiffCommentLineColor = (diff: DiffRenderable, entry: AppliedDiffLineColor, color: DiffLineColorConfig) => {
333
+ for (const target of diffSideTargets(diff, entry.anchor, entry.view)) {
334
+ target.setLineColor(entry.anchor.localRenderLine, color)
335
+ }
336
+ }
337
+
205
338
  const getDetailPlaceholderContent = ({
206
339
  status,
207
340
  retryProgress,
@@ -248,6 +381,9 @@ export const App = () => {
248
381
  const { width, height } = useTerminalDimensions()
249
382
  const pullRequestResult = useAtomValue(pullRequestsAtom)
250
383
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
384
+ const [queueMode, setQueueMode] = useAtom(queueModeAtom)
385
+ const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
386
+ const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
251
387
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
252
388
  const [notice, setNotice] = useAtom(noticeAtom)
253
389
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -260,11 +396,43 @@ export const App = () => {
260
396
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
261
397
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
262
398
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
399
+ const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
400
+ const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
401
+ const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
402
+ const [diffCommentsLoaded, setDiffCommentsLoaded] = useAtom(diffCommentsLoadedAtom)
263
403
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
264
- const [labelModal, setLabelModal] = useAtom(labelModalAtom)
265
- const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
404
+ const [activeModal, setActiveModal] = useAtom(activeModalAtom)
266
405
  const [themeId, setThemeId] = useAtom(themeIdAtom)
267
- const [themeModal, setThemeModal] = useAtom(themeModalAtom)
406
+ const closeActiveModal = () => setActiveModal(initialModal)
407
+ const labelModalActive = Modal.$is("Label")(activeModal)
408
+ const closeModalActive = Modal.$is("Close")(activeModal)
409
+ const mergeModalActive = Modal.$is("Merge")(activeModal)
410
+ const commentModalActive = Modal.$is("Comment")(activeModal)
411
+ const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
412
+ const themeModalActive = Modal.$is("Theme")(activeModal)
413
+ const labelModal: LabelModalState = labelModalActive ? activeModal.state : initialLabelModalState
414
+ const closeModal: CloseModalState = closeModalActive ? activeModal.state : initialCloseModalState
415
+ const mergeModal: MergeModalState = mergeModalActive ? activeModal.state : initialMergeModalState
416
+ const commentModal: CommentModalState = commentModalActive ? activeModal.state : initialCommentModalState
417
+ const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal.state : initialCommentThreadModalState
418
+ const themeModal: ThemeModalState = themeModalActive ? activeModal.state : initialThemeModalState
419
+ const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
420
+ (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
421
+ const ctor = Modal[tag] as (args: { state: ModalState<Tag> }) => Modal
422
+ if (typeof next === "function") {
423
+ const updater = next as (prev: ModalState<Tag>) => ModalState<Tag>
424
+ if (current._tag !== tag) return current
425
+ const prev = (current as unknown as { readonly state: ModalState<Tag> }).state
426
+ return ctor({ state: updater(prev) })
427
+ }
428
+ return ctor({ state: next })
429
+ })
430
+ const setLabelModal = makeModalSetter("Label")
431
+ const setCloseModal = makeModalSetter("Close")
432
+ const setMergeModal = makeModalSetter("Merge")
433
+ const setCommentModal = makeModalSetter("Comment")
434
+ const setCommentThreadModal = makeModalSetter("CommentThread")
435
+ const setThemeModal = makeModalSetter("Theme")
268
436
  setActiveTheme(themeId)
269
437
  const themeIdRef = useRef(themeId)
270
438
  const themeModalRef = useRef(themeModal)
@@ -272,8 +440,11 @@ export const App = () => {
272
440
  themeModalRef.current = themeModal
273
441
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
274
442
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
443
+ const [recentlyCompletedPullRequests, setRecentlyCompletedPullRequests] = useAtom(recentlyCompletedPullRequestsAtom)
275
444
  const retryProgress = useAtomValue(retryProgressAtom)
276
445
  const [loadingFrame, setLoadingFrame] = useState(0)
446
+ const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
447
+ const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
277
448
  const [terminalFocused, setTerminalFocused] = useState(true)
278
449
  const usernameResult = useAtomValue(usernameAtom)
279
450
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
@@ -282,8 +453,11 @@ export const App = () => {
282
453
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
283
454
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
284
455
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
456
+ const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
285
457
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
286
458
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
459
+ const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
460
+ const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
287
461
  const terminalWidth = width ?? 100
288
462
  const terminalHeight = height ?? 24
289
463
  const contentWidth = Math.max(1, terminalWidth)
@@ -301,6 +475,8 @@ export const App = () => {
301
475
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
302
476
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
303
477
  const detailHydrationRef = useRef<number | null>(null)
478
+ const refreshGenerationRef = useRef(0)
479
+ const didMountQueueModeRef = useRef(false)
304
480
  const lastPullRequestRefreshAtRef = useRef(0)
305
481
  const terminalFocusedRef = useRef(true)
306
482
  const terminalWasBlurredRef = useRef(false)
@@ -309,6 +485,9 @@ export const App = () => {
309
485
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
310
486
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
311
487
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
488
+ const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
489
+ const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
490
+ const suppressNextDiffCommentScrollRef = useRef(false)
312
491
  const headerFooterWidth = Math.max(24, contentWidth - 2)
313
492
 
314
493
  const flashNotice = (message: string) => {
@@ -337,12 +516,23 @@ export const App = () => {
337
516
  }
338
517
  }, [])
339
518
 
340
- const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
341
- const pullRequests = useMemo(
342
- () => pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? [],
343
- [pullRequestLoad?.data, pullRequestOverrides],
344
- )
345
- const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
519
+ const resolvedPullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
520
+ const pullRequestLoad = queueLoadCache[queueMode] ?? (resolvedPullRequestLoad?.queueMode === queueMode ? resolvedPullRequestLoad : null)
521
+ const isLoadingQueueMode = resolvedPullRequestLoad !== null && resolvedPullRequestLoad.queueMode !== queueMode
522
+ const pullRequests = useMemo(() => {
523
+ const source = pullRequestLoad?.data ?? []
524
+ const seenUrls = new Set<string>()
525
+ const openPullRequests = source.map((pullRequest) => {
526
+ seenUrls.add(pullRequest.url)
527
+ return recentlyCompletedPullRequests[pullRequest.url] ?? pullRequestOverrides[pullRequest.url] ?? pullRequest
528
+ })
529
+
530
+ return [
531
+ ...openPullRequests,
532
+ ...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
533
+ ]
534
+ }, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
535
+ const pullRequestStatus: LoadStatus = (pullRequestResult.waiting || isLoadingQueueMode) && pullRequestLoad === null
346
536
  ? "loading"
347
537
  : AsyncResult.isFailure(pullRequestResult)
348
538
  ? "error"
@@ -355,18 +545,47 @@ export const App = () => {
355
545
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
356
546
  const visibleFilterText = filterMode ? filterDraft : filterQuery
357
547
 
358
- const filteredPullRequests = useMemo(() => pullRequests.filter((pullRequest) => {
359
- const query = effectiveFilterQuery
360
- if (query.length === 0) return true
361
- return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
362
- .some((value) => value.toLowerCase().includes(query))
363
- }), [pullRequests, effectiveFilterQuery])
364
-
548
+ const filteredPullRequests = useMemo(() => {
549
+ if (effectiveFilterQuery.length === 0) return pullRequests
550
+ return pullRequests.flatMap((pullRequest) => {
551
+ const score = pullRequestFilterScore(pullRequest, effectiveFilterQuery)
552
+ return score === null ? [] : [{ pullRequest, score }]
553
+ }).sort((left, right) =>
554
+ left.score - right.score || right.pullRequest.createdAt.getTime() - left.pullRequest.createdAt.getTime()
555
+ ).map(({ pullRequest }) => pullRequest)
556
+ }, [pullRequests, effectiveFilterQuery])
557
+
558
+ const visibleRepoOrder = useMemo(() => effectiveFilterQuery.length > 0
559
+ ? [...new Set(filteredPullRequests.map((pullRequest) => pullRequest.repository))]
560
+ : [], [filteredPullRequests, effectiveFilterQuery.length])
365
561
  const visibleGroups = useMemo(
366
- () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository),
367
- [filteredPullRequests],
562
+ () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository, visibleRepoOrder),
563
+ [filteredPullRequests, visibleRepoOrder],
368
564
  )
369
565
  const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
566
+ const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
567
+ const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
568
+ const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
569
+ const readyDiffFiles = selectedDiffState?.status === "ready" ? selectedDiffState.files : []
570
+ const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
571
+ const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
572
+ const selectedDiffCommentCount = useMemo(() => selectedDiffKey
573
+ ? Object.entries(diffCommentThreads)
574
+ .filter(([key, comments]) => key.startsWith(`${selectedDiffKey}:`) && comments.length > 0)
575
+ .reduce((count, [, comments]) => count + comments.length, 0)
576
+ : 0, [diffCommentThreads, selectedDiffKey])
577
+ const diffCommentAnchors = useMemo(
578
+ () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
579
+ [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
580
+ )
581
+ const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
582
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentAnchorKey(selectedDiffCommentAnchor)}` : null
583
+ const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
584
+ const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
585
+ const diffCommentRows = useMemo(
586
+ () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
587
+ [diffCommentAnchors],
588
+ )
370
589
  const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
371
590
  if (index === 0) {
372
591
  starts.push(0)
@@ -386,12 +605,15 @@ export const App = () => {
386
605
  : pullRequestStatus === "loading"
387
606
  ? "loading pull requests..."
388
607
  : ""
389
- const headerLeft = username ? `GHUI ${username}` : "GHUI"
608
+ const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
390
609
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
391
610
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
392
611
  const selectPullRequestByUrl = (url: string) => {
393
612
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
394
- if (index >= 0) setSelectedIndex(index)
613
+ if (index >= 0) {
614
+ setSelectedIndex(index)
615
+ setQueueSelection((current) => ({ ...current, [queueMode]: index }))
616
+ }
395
617
  }
396
618
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
397
619
  const pullRequest = pullRequests.find((item) => item.url === url)
@@ -399,10 +621,33 @@ export const App = () => {
399
621
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
400
622
  }
401
623
  const refreshPullRequests = (message?: string) => {
624
+ refreshGenerationRef.current += 1
625
+ setPullRequestOverrides({})
626
+ if (message) {
627
+ setNotice(null)
628
+ setRefreshCompletionMessage(message)
629
+ setRefreshStartedAt(lastPullRequestRefreshAtRef.current)
630
+ }
402
631
  refreshPullRequestsAtom()
403
- if (message) flashNotice(message)
404
632
  }
405
633
  refreshPullRequestsRef.current = refreshPullRequests
634
+ const switchQueueMode = (delta: 1 | -1) => {
635
+ const mode = nextQueueMode(queueMode, delta)
636
+ if (mode === queueMode) return
637
+ refreshGenerationRef.current += 1
638
+ setQueueSelection((current) => ({ ...current, [queueMode]: selectedIndex }))
639
+ setQueueMode(mode)
640
+ setSelectedIndex(queueSelection[mode] ?? 0)
641
+ setRecentlyCompletedPullRequests({})
642
+ detailHydrationRef.current = null
643
+ setDetailFullView(false)
644
+ setDiffFullView(false)
645
+ setDiffCommentMode(false)
646
+ setFilterDraft(filterQuery)
647
+ setNotice(null)
648
+ setRefreshCompletionMessage(null)
649
+ setRefreshStartedAt(null)
650
+ }
406
651
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
407
652
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
408
653
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -417,6 +662,30 @@ export const App = () => {
417
662
  }
418
663
  }, [pullRequestLoad?.fetchedAt])
419
664
 
665
+ useEffect(() => {
666
+ if (!didMountQueueModeRef.current) {
667
+ didMountQueueModeRef.current = true
668
+ return
669
+ }
670
+ if (queueLoadCache[queueMode]) return
671
+ refreshPullRequestsAtom()
672
+ }, [queueMode, queueLoadCache, refreshPullRequestsAtom])
673
+
674
+ useEffect(() => {
675
+ if (!refreshCompletionMessage || refreshStartedAt === null) return
676
+ const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
677
+ const isHydratingDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
678
+ if (pullRequestStatus === "ready" && fetchedAt !== undefined && fetchedAt !== refreshStartedAt && !isHydratingDetails) {
679
+ flashNotice(`✓ ${refreshCompletionMessage}`)
680
+ setRefreshCompletionMessage(null)
681
+ setRefreshStartedAt(null)
682
+ } else if (pullRequestStatus === "error") {
683
+ flashNotice("Refresh failed")
684
+ setRefreshCompletionMessage(null)
685
+ setRefreshStartedAt(null)
686
+ }
687
+ }, [refreshCompletionMessage, refreshStartedAt, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests])
688
+
420
689
  useEffect(() => {
421
690
  const handleFocus = () => {
422
691
  terminalFocusedRef.current = true
@@ -457,15 +726,72 @@ export const App = () => {
457
726
  })
458
727
  }, [visiblePullRequests.length])
459
728
 
729
+ useEffect(() => {
730
+ setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
731
+ }, [queueMode, selectedIndex])
732
+
460
733
  useEffect(() => {
461
734
  setDiffFileIndex(0)
735
+ setDiffCommentAnchorIndex(0)
462
736
  }, [selectedIndex])
463
737
 
464
- const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
465
- const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
466
- const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
467
- const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => !pullRequest.detailLoaded)
468
- const hasActiveLoadingIndicator = pullRequestStatus === "loading" || isHydratingPullRequestDetails || labelModal.loading || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
738
+ useEffect(() => {
739
+ setDiffCommentAnchorIndex((current) => {
740
+ if (diffCommentAnchors.length === 0) return 0
741
+ return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
742
+ })
743
+ }, [diffCommentAnchors.length])
744
+
745
+ useEffect(() => {
746
+ if (!diffCommentMode || !selectedDiffCommentAnchor) return
747
+ setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
748
+ }, [diffCommentMode, selectedDiffCommentAnchor?.fileIndex])
749
+
750
+ useEffect(() => {
751
+ const previous = diffCommentLineColorsRef.current
752
+ if (previous.contextKey === diffLineColorContextKey) {
753
+ for (const entry of previous.entries) {
754
+ const diff = diffRenderableRefs.current.get(entry.anchor.fileIndex)
755
+ if (diff) setDiffCommentLineColor(diff, entry, originalDiffLineColor(entry.anchor))
756
+ }
757
+ }
758
+
759
+ const nextEntries: AppliedDiffLineColor[] = []
760
+ const appliedKeys = new Set<string>()
761
+ const applyLineColor = (anchor: StackedDiffCommentAnchor, gutter: string, override = false) => {
762
+ const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
763
+ if (appliedKeys.has(key) && !override) return
764
+ appliedKeys.add(key)
765
+ const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
766
+ const diff = diffRenderableRefs.current.get(anchor.fileIndex)
767
+ if (diff) setDiffCommentLineColor(diff, entry, { ...originalDiffLineColor(anchor), gutter })
768
+ if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
769
+ nextEntries.push(entry)
770
+ }
771
+ }
772
+
773
+ if (selectedDiffKey) {
774
+ for (const anchor of diffCommentAnchors) {
775
+ if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
776
+ applyLineColor(anchor, colors.status.pending)
777
+ }
778
+ }
779
+ }
780
+ if (diffCommentMode && selectedDiffCommentAnchor) {
781
+ applyLineColor(selectedDiffCommentAnchor, selectedDiffCommentAnchor.side === "RIGHT" ? colors.status.passing : colors.status.failing, true)
782
+ if (suppressNextDiffCommentScrollRef.current) {
783
+ suppressNextDiffCommentScrollRef.current = false
784
+ } else {
785
+ ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
786
+ }
787
+ } else {
788
+ suppressNextDiffCommentScrollRef.current = false
789
+ }
790
+ diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
791
+ }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
792
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
793
+ const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
794
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
469
795
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
470
796
 
471
797
  useEffect(() => {
@@ -479,21 +805,29 @@ export const App = () => {
479
805
  useEffect(() => {
480
806
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
481
807
  if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
482
- if (detailHydrationRef.current === fetchedAt) return
483
- if (!pullRequests.some((pullRequest) => !pullRequest.detailLoaded)) return
808
+ if (detailHydrationRef.current === fetchedAt || pullRequestLoad?.detailsFetchedAt?.getTime() === fetchedAt) return
809
+ if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
484
810
  detailHydrationRef.current = fetchedAt
485
- void loadPullRequestDetails().then((details) => {
486
- setPullRequestOverrides((current) => {
487
- const next = { ...current }
488
- for (const detail of details) {
489
- next[detail.url] = current[detail.url]?.detailLoaded ? current[detail.url]! : detail
811
+ const generation = refreshGenerationRef.current
812
+ void loadPullRequestDetails(queueMode).then((details) => {
813
+ if (generation !== refreshGenerationRef.current) return
814
+ setQueueLoadCache((current) => {
815
+ const load = current[queueMode]
816
+ if (!load) return current
817
+ const detailsByUrl = new Map(details.map((detail) => [detail.url, detail]))
818
+ return {
819
+ ...current,
820
+ [queueMode]: {
821
+ ...load,
822
+ data: load.data.map((pullRequest) => detailsByUrl.get(pullRequest.url) ?? pullRequest),
823
+ detailsFetchedAt: load.fetchedAt,
824
+ },
490
825
  }
491
- return next
492
826
  })
493
827
  }).catch((error) => {
494
- flashNotice(error instanceof Error ? error.message : String(error))
828
+ flashNotice(errorMessage(error))
495
829
  })
496
- }, [pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
830
+ }, [queueMode, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
497
831
 
498
832
  const detailPlaceholderContent = getDetailPlaceholderContent({
499
833
  status: pullRequestStatus,
@@ -506,9 +840,53 @@ export const App = () => {
506
840
 
507
841
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
508
842
 
509
- const loadPullRequestDiff = (pullRequest: PullRequestItem, force = false) => {
843
+ const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
844
+ const key = pullRequestDiffKey(pullRequest)
845
+ const previousLoadState = diffCommentsLoaded[key]
846
+ if (!force && previousLoadState) return
847
+ setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
848
+ void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
849
+ .then((comments) => {
850
+ setDiffCommentsLoaded((current) => ({ ...current, [key]: "ready" }))
851
+ setDiffCommentThreads((current) => {
852
+ const prefix = `${key}:`
853
+ const threads = groupDiffCommentThreads(pullRequest, comments)
854
+ const next: Record<string, readonly PullRequestReviewComment[]> = Object.fromEntries(
855
+ Object.entries(current).filter(([threadKey]) => !threadKey.startsWith(prefix)),
856
+ )
857
+
858
+ for (const [threadKey, threadComments] of Object.entries(current)) {
859
+ if (!threadKey.startsWith(prefix)) continue
860
+ const localComments = threadComments.filter(isLocalDiffComment)
861
+ if (localComments.length > 0) {
862
+ next[threadKey] = [...(threads[threadKey] ?? []), ...localComments]
863
+ }
864
+ }
865
+
866
+ for (const [threadKey, threadComments] of Object.entries(threads)) {
867
+ if (!next[threadKey]) next[threadKey] = threadComments
868
+ }
869
+
870
+ return next
871
+ })
872
+ })
873
+ .catch((error) => {
874
+ setDiffCommentsLoaded((current) => {
875
+ if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
876
+ const next = { ...current }
877
+ delete next[key]
878
+ return next
879
+ })
880
+ flashNotice(errorMessage(error))
881
+ })
882
+ }
883
+
884
+ const loadPullRequestDiff = (pullRequest: PullRequestItem, options: { readonly force?: boolean; readonly includeComments?: boolean } = {}) => {
885
+ const force = options.force ?? false
886
+ const includeComments = options.includeComments ?? false
510
887
  const key = pullRequestDiffKey(pullRequest)
511
888
  const existing = pullRequestDiffCache[key]
889
+ if (includeComments) loadPullRequestComments(pullRequest, force)
512
890
  if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
513
891
 
514
892
  setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
@@ -546,12 +924,168 @@ export const App = () => {
546
924
 
547
925
  const openDiffView = () => {
548
926
  if (!selectedPullRequest) return
927
+ diffRenderableRefs.current.clear()
928
+ diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
549
929
  setDiffFullView(true)
550
930
  setDetailFullView(false)
931
+ setDiffCommentMode(false)
551
932
  setDiffFileIndex(0)
552
933
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
553
934
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
554
- loadPullRequestDiff(selectedPullRequest)
935
+ loadPullRequestDiff(selectedPullRequest, { includeComments: true })
936
+ }
937
+
938
+ const setDiffRenderableRef = (index: number, diff: DiffRenderable | null) => {
939
+ if (diff) diffRenderableRefs.current.set(index, diff)
940
+ else diffRenderableRefs.current.delete(index)
941
+ }
942
+
943
+ const scrollToDiffFile = (index: number) => {
944
+ const stackedFile = stackedDiffFiles[index]
945
+ diffScrollRef.current?.scrollTo({ x: 0, y: stackedFile?.headerLine ?? 0 })
946
+ }
947
+
948
+ const syncDiffFileIndexToScroll = () => {
949
+ const scrollTop = diffScrollRef.current?.scrollTop
950
+ if (scrollTop === undefined || stackedDiffFiles.length === 0) return
951
+ const nextIndex = stackedDiffFiles.reduce((current, file) => file.headerLine <= scrollTop + 1 ? file.index : current, 0)
952
+ setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
953
+ }
954
+
955
+ const scrollDiffBy = (y: number) => {
956
+ diffScrollRef.current?.scrollBy({ x: 0, y })
957
+ syncDiffFileIndexToScroll()
958
+ }
959
+
960
+ const scrollDiffTo = (y: number) => {
961
+ diffScrollRef.current?.scrollTo({ x: 0, y })
962
+ if (y <= 0) setDiffFileIndex(0)
963
+ else if (y === Number.MAX_SAFE_INTEGER) setDiffFileIndex(Math.max(0, readyDiffFiles.length - 1))
964
+ else syncDiffFileIndexToScroll()
965
+ }
966
+
967
+ const ensureDiffLineVisible = (line: number) => {
968
+ const scroll = diffScrollRef.current
969
+ if (!scroll) return
970
+ const viewportHeight = Math.max(1, wideBodyHeight - (selectedDiffCommentThread.length > 0 ? 6 : 3))
971
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line)
972
+ if (nextTop !== scroll.scrollTop) scroll.scrollTo({ x: 0, y: nextTop })
973
+ }
974
+
975
+ const jumpDiffFile = (delta: 1 | -1) => {
976
+ if (readyDiffFiles.length === 0) return
977
+ const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
978
+ setDiffFileIndex(nextIndex)
979
+ if (diffCommentMode) {
980
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
981
+ ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
982
+ if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
983
+ }
984
+ scrollToDiffFile(nextIndex)
985
+ }
986
+
987
+ const enterDiffCommentMode = () => {
988
+ const scrollTop = diffScrollRef.current?.scrollTop ?? 0
989
+ suppressNextDiffCommentScrollRef.current = true
990
+ setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop))
991
+ setDiffCommentMode(true)
992
+ }
993
+
994
+ const moveDiffCommentAnchor = (delta: number) => {
995
+ if (diffCommentAnchors.length === 0) return
996
+ const currentAnchor = selectedDiffCommentAnchor ?? diffCommentAnchors[0]
997
+ const currentRowIndex = Math.max(0, currentAnchor ? diffCommentRows.indexOf(currentAnchor.renderLine) : 0)
998
+ const nextRow = diffCommentRows[Math.max(0, Math.min(diffCommentRows.length - 1, currentRowIndex + delta))]
999
+ if (nextRow === undefined) return
1000
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1001
+ ?? diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow)
1002
+ if (!nextAnchor) return
1003
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1004
+ }
1005
+
1006
+ const selectDiffCommentSide = (side: DiffCommentSide) => {
1007
+ if (!selectedDiffCommentAnchor) return
1008
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1009
+ if (!nextAnchor) return
1010
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1011
+ }
1012
+
1013
+ const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1014
+ setCommentModal((current) => {
1015
+ const next = transform({ body: current.body, cursor: current.cursor })
1016
+ if (next.body === current.body && next.cursor === current.cursor && current.error === null) return current
1017
+ return { ...current, body: next.body, cursor: next.cursor, error: null }
1018
+ })
1019
+ }
1020
+
1021
+ const openDiffCommentModal = () => {
1022
+ if (!selectedDiffCommentAnchor || !selectedPullRequest) return
1023
+ setCommentModal(initialCommentModalState)
1024
+ }
1025
+
1026
+ const openDiffCommentThreadModal = () => {
1027
+ if (!selectedDiffCommentAnchor || selectedDiffCommentThread.length === 0) return
1028
+ setCommentThreadModal({ scrollOffset: 0 })
1029
+ }
1030
+
1031
+ const submitDiffComment = () => {
1032
+ if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1033
+ const body = commentModal.body.trim()
1034
+ if (body.length === 0) {
1035
+ setCommentModal((current) => ({ ...current, error: "Write a comment before saving." }))
1036
+ return
1037
+ }
1038
+
1039
+ const threadKey = selectedDiffCommentThreadKey
1040
+ const target = selectedDiffCommentAnchor
1041
+ const optimisticComment = {
1042
+ id: `local:${Date.now()}`,
1043
+ path: target.path,
1044
+ line: target.line,
1045
+ side: target.side,
1046
+ author: username ?? "you",
1047
+ body,
1048
+ createdAt: new Date(),
1049
+ url: null,
1050
+ } satisfies PullRequestReviewComment
1051
+ const input = {
1052
+ repository: selectedPullRequest.repository,
1053
+ number: selectedPullRequest.number,
1054
+ commitId: selectedPullRequest.headRefOid,
1055
+ path: target.path,
1056
+ line: target.line,
1057
+ side: target.side,
1058
+ body,
1059
+ } satisfies CreatePullRequestCommentInput
1060
+
1061
+ if (threadKey) {
1062
+ setDiffCommentThreads((current) => ({
1063
+ ...current,
1064
+ [threadKey]: [...(current[threadKey] ?? []), optimisticComment],
1065
+ }))
1066
+ }
1067
+ closeActiveModal()
1068
+ flashNotice(`Commenting on ${target.path}:${target.line}`)
1069
+ void createPullRequestComment(input).then((comment) => {
1070
+ if (threadKey) {
1071
+ setDiffCommentThreads((current) => ({
1072
+ ...current,
1073
+ [threadKey]: (current[threadKey] ?? []).map((existing) => existing.id === optimisticComment.id ? comment : existing),
1074
+ }))
1075
+ }
1076
+ flashNotice(`Commented on ${target.path}:${target.line}`)
1077
+ }).catch((error) => {
1078
+ if (threadKey) {
1079
+ setDiffCommentThreads((current) => {
1080
+ const next = { ...current }
1081
+ const comments = (next[threadKey] ?? []).filter((comment) => comment.id !== optimisticComment.id)
1082
+ if (comments.length > 0) next[threadKey] = comments
1083
+ else delete next[threadKey]
1084
+ return next
1085
+ })
1086
+ }
1087
+ flashNotice(errorMessage(error))
1088
+ })
555
1089
  }
556
1090
 
557
1091
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
@@ -560,11 +1094,73 @@ export const App = () => {
560
1094
  .catch((error) => flashNotice(errorMessage(error)))
561
1095
  }
562
1096
 
1097
+ const copySelectedPullRequestMetadata = () => {
1098
+ if (!selectedPullRequest) return
1099
+ void copyPullRequestMetadata(selectedPullRequest)
1100
+ .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1101
+ .catch((error) => flashNotice(errorMessage(error)))
1102
+ }
1103
+
1104
+ const toggleSelectedPullRequestDraftStatus = () => {
1105
+ if (!selectedPullRequest) return
1106
+ const previousPullRequest = selectedPullRequest
1107
+ const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1108
+ updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1109
+ ...pullRequest,
1110
+ reviewStatus: nextReviewStatus,
1111
+ }))
1112
+ void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1113
+ .then(() => {
1114
+ flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1115
+ })
1116
+ .catch((error) => {
1117
+ updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1118
+ flashNotice(errorMessage(error))
1119
+ })
1120
+ }
1121
+
1122
+ const openCloseModal = () => {
1123
+ if (!selectedPullRequest || selectedPullRequest.state !== "open") return
1124
+ setCloseModal({
1125
+ repository: selectedPullRequest.repository,
1126
+ number: selectedPullRequest.number,
1127
+ title: selectedPullRequest.title,
1128
+ url: selectedPullRequest.url,
1129
+ running: false,
1130
+ error: null,
1131
+ })
1132
+ }
1133
+
1134
+ const confirmClosePullRequest = () => {
1135
+ if (!closeModal.repository || closeModal.number === null || !closeModal.url || closeModal.running) return
1136
+ const { repository, number, url } = closeModal
1137
+ const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.url === url)
1138
+ const previousPullRequest = targetPullRequest ?? null
1139
+
1140
+ setCloseModal((current) => ({ ...current, running: true, error: null }))
1141
+ void closePullRequest({ repository, number })
1142
+ .then(() => {
1143
+ if (previousPullRequest) {
1144
+ setRecentlyCompletedPullRequests((current) => ({
1145
+ ...current,
1146
+ [previousPullRequest.url]: {
1147
+ ...previousPullRequest,
1148
+ state: "closed",
1149
+ autoMergeEnabled: false,
1150
+ },
1151
+ }))
1152
+ }
1153
+ closeActiveModal()
1154
+ refreshPullRequests(`Closed #${number}`)
1155
+ })
1156
+ .catch((error) => {
1157
+ setCloseModal((current) => ({ ...current, running: false, error: errorMessage(error) }))
1158
+ flashNotice(errorMessage(error))
1159
+ })
1160
+ }
1161
+
563
1162
  const openThemeModal = () => {
564
- setLabelModal(initialLabelModalState)
565
- setMergeModal(initialMergeModalState)
566
1163
  setThemeModal({
567
- open: true,
568
1164
  query: "",
569
1165
  filterMode: false,
570
1166
  initialThemeId: themeId,
@@ -579,7 +1175,7 @@ export const App = () => {
579
1175
  void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
580
1176
  flashNotice(`Theme: ${selectedTheme.name}`)
581
1177
  }
582
- setThemeModal(initialThemeModalState)
1178
+ closeActiveModal()
583
1179
  }
584
1180
 
585
1181
  const previewTheme = (id: ThemeId) => {
@@ -622,13 +1218,10 @@ export const App = () => {
622
1218
 
623
1219
  const openLabelModal = () => {
624
1220
  if (!selectedPullRequest) return
625
- setMergeModal(initialMergeModalState)
626
- setThemeModal(initialThemeModalState)
627
1221
  const repository = selectedPullRequest.repository
628
1222
  const cachedLabels = labelCache[repository]
629
1223
  if (cachedLabels) {
630
1224
  setLabelModal({
631
- open: true,
632
1225
  repository,
633
1226
  query: "",
634
1227
  selectedIndex: 0,
@@ -638,7 +1231,7 @@ export const App = () => {
638
1231
  return
639
1232
  }
640
1233
 
641
- setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1234
+ setLabelModal({ repository, query: "", selectedIndex: 0, availableLabels: [], loading: true })
642
1235
  void loadRepoLabels(repository)
643
1236
  .then((labels) => {
644
1237
  setLabelCache((current) => ({ ...current, [repository]: labels }))
@@ -646,19 +1239,16 @@ export const App = () => {
646
1239
  })
647
1240
  .catch((error) => {
648
1241
  setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
649
- flashNotice(error instanceof Error ? error.message : String(error))
1242
+ flashNotice(errorMessage(error))
650
1243
  })
651
1244
  }
652
1245
 
653
1246
  const openMergeModal = () => {
654
1247
  if (!selectedPullRequest) return
655
- setThemeModal(initialThemeModalState)
656
1248
  const repository = selectedPullRequest.repository
657
1249
  const number = selectedPullRequest.number
658
1250
  const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
659
- setLabelModal(initialLabelModalState)
660
1251
  setMergeModal({
661
- open: true,
662
1252
  repository,
663
1253
  number,
664
1254
  selectedIndex: 0,
@@ -702,7 +1292,17 @@ export const App = () => {
702
1292
  setMergeModal((current) => ({ ...current, running: true, error: null }))
703
1293
  void mergePullRequest({ repository, number, action: option.action })
704
1294
  .then(() => {
705
- setMergeModal(initialMergeModalState)
1295
+ if (option.refreshOnSuccess && previousPullRequest) {
1296
+ setRecentlyCompletedPullRequests((current) => ({
1297
+ ...current,
1298
+ [previousPullRequest.url]: {
1299
+ ...previousPullRequest,
1300
+ state: "merged",
1301
+ autoMergeEnabled: false,
1302
+ },
1303
+ }))
1304
+ }
1305
+ closeActiveModal()
706
1306
  if (option.refreshOnSuccess) {
707
1307
  refreshPullRequests(`${option.pastTense} #${number}`)
708
1308
  } else {
@@ -736,7 +1336,7 @@ export const App = () => {
736
1336
  .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
737
1337
  .catch((error) => {
738
1338
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
739
- flashNotice(error instanceof Error ? error.message : String(error))
1339
+ flashNotice(errorMessage(error))
740
1340
  })
741
1341
  } else {
742
1342
  updatePullRequest(selectedPullRequest.url, (pr) => ({
@@ -747,30 +1347,26 @@ export const App = () => {
747
1347
  .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
748
1348
  .catch((error) => {
749
1349
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
750
- flashNotice(error instanceof Error ? error.message : String(error))
1350
+ flashNotice(errorMessage(error))
751
1351
  })
752
1352
  }
753
1353
  }
754
1354
 
755
1355
  useKeyboard((key) => {
756
- if ((key.name === "q" && !(themeModal.open && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
757
- if (themeModal.open) {
1356
+ if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1357
+ if (themeModalActive) {
758
1358
  closeThemeModal(false)
759
1359
  return
760
1360
  }
761
- if (mergeModal.open) {
762
- setMergeModal(initialMergeModalState)
763
- return
764
- }
765
- if (labelModal.open) {
766
- setLabelModal(initialLabelModalState)
1361
+ if (activeModal._tag !== "None") {
1362
+ closeActiveModal()
767
1363
  return
768
1364
  }
769
1365
  renderer.destroy()
770
1366
  return
771
1367
  }
772
1368
 
773
- if (themeModal.open) {
1369
+ if (themeModalActive) {
774
1370
  if (key.name === "escape") {
775
1371
  if (themeModal.filterMode) {
776
1372
  updateThemeQuery("", { filterMode: false })
@@ -811,10 +1407,150 @@ export const App = () => {
811
1407
  return
812
1408
  }
813
1409
 
814
- if (mergeModal.open) {
1410
+ if (commentModalActive) {
1411
+ if (key.name === "escape") {
1412
+ closeActiveModal()
1413
+ return
1414
+ }
1415
+ if (key.ctrl && key.name === "s") {
1416
+ submitDiffComment()
1417
+ return
1418
+ }
1419
+ if (key.ctrl && key.name === "a") {
1420
+ editComment(moveLineStart)
1421
+ return
1422
+ }
1423
+ if (key.ctrl && key.name === "e") {
1424
+ editComment(moveLineEnd)
1425
+ return
1426
+ }
1427
+ if (key.ctrl && key.name === "b") {
1428
+ editComment(editorMoveLeft)
1429
+ return
1430
+ }
1431
+ if (key.ctrl && key.name === "f") {
1432
+ editComment(editorMoveRight)
1433
+ return
1434
+ }
1435
+ if (key.ctrl && key.name === "w") {
1436
+ editComment(deleteWordBackward)
1437
+ return
1438
+ }
1439
+ if (key.ctrl && key.name === "u") {
1440
+ editComment(deleteToLineStart)
1441
+ return
1442
+ }
1443
+ if (key.ctrl && key.name === "k") {
1444
+ editComment(deleteToLineEnd)
1445
+ return
1446
+ }
1447
+ if (key.ctrl && key.name === "d") {
1448
+ editComment(editorDeleteForward)
1449
+ return
1450
+ }
1451
+ if ((key.meta || key.option) && (key.name === "b" || key.name === "left")) {
1452
+ editComment(moveWordBackward)
1453
+ return
1454
+ }
1455
+ if ((key.meta || key.option) && (key.name === "f" || key.name === "right")) {
1456
+ editComment(moveWordForward)
1457
+ return
1458
+ }
1459
+ if ((key.meta || key.option) && (key.name === "backspace" || key.name === "delete")) {
1460
+ editComment(key.name === "delete" ? deleteWordForward : deleteWordBackward)
1461
+ return
1462
+ }
1463
+ if (key.name === "backspace") {
1464
+ editComment(editorBackspace)
1465
+ return
1466
+ }
1467
+ if (key.name === "delete") {
1468
+ editComment(editorDeleteForward)
1469
+ return
1470
+ }
1471
+ if (key.name === "left") {
1472
+ editComment(editorMoveLeft)
1473
+ return
1474
+ }
1475
+ if (key.name === "right") {
1476
+ editComment(editorMoveRight)
1477
+ return
1478
+ }
1479
+ if (key.name === "up") {
1480
+ editComment((state) => moveVertically(state, -1))
1481
+ return
1482
+ }
1483
+ if (key.name === "down") {
1484
+ editComment((state) => moveVertically(state, 1))
1485
+ return
1486
+ }
1487
+ if (key.name === "home") {
1488
+ editComment(moveLineStart)
1489
+ return
1490
+ }
1491
+ if (key.name === "end") {
1492
+ editComment(moveLineEnd)
1493
+ return
1494
+ }
1495
+ if ((key.name === "return" || key.name === "enter") && key.shift) {
1496
+ editComment((state) => insertText(state, "\n"))
1497
+ return
1498
+ }
1499
+ if (key.name === "return" || key.name === "enter") {
1500
+ submitDiffComment()
1501
+ return
1502
+ }
1503
+ if (!key.ctrl && !key.meta && key.sequence.length === 1) {
1504
+ editComment((state) => insertText(state, key.sequence))
1505
+ return
1506
+ }
1507
+ return
1508
+ }
1509
+
1510
+ if (commentThreadModalActive) {
1511
+ if (key.name === "escape") {
1512
+ closeActiveModal()
1513
+ return
1514
+ }
1515
+ if (key.name === "return" || key.name === "enter" || key.name === "a" || key.name === "c") {
1516
+ openDiffCommentModal()
1517
+ return
1518
+ }
1519
+ if (key.name === "up" || key.name === "k") {
1520
+ setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - 1) }))
1521
+ return
1522
+ }
1523
+ if (key.name === "down" || key.name === "j") {
1524
+ setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + 1 }))
1525
+ return
1526
+ }
1527
+ if (key.name === "pageup" || key.ctrl && key.name === "u") {
1528
+ setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - halfPage) }))
1529
+ return
1530
+ }
1531
+ if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1532
+ setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + halfPage }))
1533
+ return
1534
+ }
1535
+ return
1536
+ }
1537
+
1538
+ if (closeModalActive) {
1539
+ if (key.name === "escape") {
1540
+ closeActiveModal()
1541
+ return
1542
+ }
1543
+ if (key.name === "return" || key.name === "enter") {
1544
+ confirmClosePullRequest()
1545
+ return
1546
+ }
1547
+ return
1548
+ }
1549
+
1550
+ if (mergeModalActive) {
815
1551
  const options = availableMergeActions(mergeModal.info)
816
1552
  if (key.name === "escape") {
817
- setMergeModal(initialMergeModalState)
1553
+ closeActiveModal()
818
1554
  return
819
1555
  }
820
1556
  if ((key.name === "return" || key.name === "enter") && options.length > 0) {
@@ -839,9 +1575,9 @@ export const App = () => {
839
1575
  }
840
1576
 
841
1577
  // Label modal takes priority over everything else
842
- if (labelModal.open) {
1578
+ if (labelModalActive) {
843
1579
  if (key.name === "escape") {
844
- setLabelModal(initialLabelModalState)
1580
+ closeActiveModal()
845
1581
  return
846
1582
  }
847
1583
  if (key.name === "return" || key.name === "enter") {
@@ -889,28 +1625,94 @@ export const App = () => {
889
1625
  }
890
1626
 
891
1627
  if (diffFullView) {
1628
+ if (diffCommentMode) {
1629
+ if (key.name === "escape") {
1630
+ setDiffCommentMode(false)
1631
+ return
1632
+ }
1633
+ if (key.name === "c") {
1634
+ setDiffCommentMode(false)
1635
+ return
1636
+ }
1637
+ if (key.name === "return" || key.name === "enter") {
1638
+ if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
1639
+ else openDiffCommentModal()
1640
+ return
1641
+ }
1642
+ if (key.name === "a") {
1643
+ openDiffCommentModal()
1644
+ return
1645
+ }
1646
+ if (key.name === "pageup" || key.ctrl && key.name === "u") {
1647
+ moveDiffCommentAnchor(-halfPage)
1648
+ return
1649
+ }
1650
+ if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1651
+ moveDiffCommentAnchor(halfPage)
1652
+ return
1653
+ }
1654
+ if ((key.shift || key.option || key.meta) && (key.name === "up" || key.name === "k") || key.name === "K") {
1655
+ moveDiffCommentAnchor(-8)
1656
+ return
1657
+ }
1658
+ if ((key.shift || key.option || key.meta) && (key.name === "down" || key.name === "j") || key.name === "J") {
1659
+ moveDiffCommentAnchor(8)
1660
+ return
1661
+ }
1662
+ if (key.name === "up" || key.name === "k") {
1663
+ moveDiffCommentAnchor(-1)
1664
+ return
1665
+ }
1666
+ if (key.name === "down" || key.name === "j") {
1667
+ moveDiffCommentAnchor(1)
1668
+ return
1669
+ }
1670
+ if (key.name === "left" || key.name === "h") {
1671
+ selectDiffCommentSide("LEFT")
1672
+ return
1673
+ }
1674
+ if (key.name === "right" || key.name === "l") {
1675
+ selectDiffCommentSide("RIGHT")
1676
+ return
1677
+ }
1678
+ if (key.name === "]" && selectedDiffState?.status === "ready") {
1679
+ jumpDiffFile(1)
1680
+ return
1681
+ }
1682
+ if (key.name === "[" && selectedDiffState?.status === "ready") {
1683
+ jumpDiffFile(-1)
1684
+ return
1685
+ }
1686
+ return
1687
+ }
1688
+
892
1689
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
893
1690
  setDiffFullView(false)
1691
+ setDiffCommentMode(false)
1692
+ return
1693
+ }
1694
+ if (key.name === "c" && selectedDiffState?.status === "ready") {
1695
+ enterDiffCommentMode()
894
1696
  return
895
1697
  }
896
1698
  if (key.name === "home") {
897
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1699
+ scrollDiffTo(0)
898
1700
  return
899
1701
  }
900
1702
  if (key.name === "end") {
901
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1703
+ scrollDiffTo(Number.MAX_SAFE_INTEGER)
902
1704
  return
903
1705
  }
904
1706
  if (key.name === "pageup") {
905
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1707
+ scrollDiffBy(-halfPage)
906
1708
  return
907
1709
  }
908
1710
  if (key.name === "pagedown") {
909
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1711
+ scrollDiffBy(halfPage)
910
1712
  return
911
1713
  }
912
1714
  if (isShiftG(key)) {
913
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1715
+ scrollDiffTo(Number.MAX_SAFE_INTEGER)
914
1716
  setPendingG(false)
915
1717
  if (pendingGTimeoutRef.current !== null) {
916
1718
  clearTimeout(pendingGTimeoutRef.current)
@@ -920,7 +1722,7 @@ export const App = () => {
920
1722
  }
921
1723
  if (key.name === "g") {
922
1724
  if (pendingG) {
923
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1725
+ scrollDiffTo(0)
924
1726
  setPendingG(false)
925
1727
  if (pendingGTimeoutRef.current !== null) {
926
1728
  clearTimeout(pendingGTimeoutRef.current)
@@ -936,19 +1738,19 @@ export const App = () => {
936
1738
  return
937
1739
  }
938
1740
  if (key.name === "up" || key.name === "k") {
939
- diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
1741
+ scrollDiffBy(-1)
940
1742
  return
941
1743
  }
942
1744
  if (key.name === "down" || key.name === "j") {
943
- diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
1745
+ scrollDiffBy(1)
944
1746
  return
945
1747
  }
946
1748
  if (key.ctrl && key.name === "u") {
947
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1749
+ scrollDiffBy(-halfPage)
948
1750
  return
949
1751
  }
950
1752
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
951
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1753
+ scrollDiffBy(halfPage)
952
1754
  return
953
1755
  }
954
1756
  if (key.name === "v") {
@@ -960,18 +1762,16 @@ export const App = () => {
960
1762
  return
961
1763
  }
962
1764
  if (key.name === "r" && selectedPullRequest) {
963
- loadPullRequestDiff(selectedPullRequest, true)
1765
+ loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
964
1766
  flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
965
1767
  return
966
1768
  }
967
1769
  if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
968
- setDiffFileIndex((current) => Math.min(Math.max(0, selectedDiffState.files.length - 1), current + 1))
969
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1770
+ jumpDiffFile(1)
970
1771
  return
971
1772
  }
972
1773
  if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
973
- setDiffFileIndex((current) => Math.max(0, current - 1))
974
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1774
+ jumpDiffFile(-1)
975
1775
  return
976
1776
  }
977
1777
  if (key.name === "o" && selectedPullRequest) {
@@ -983,11 +1783,40 @@ export const App = () => {
983
1783
 
984
1784
  // Fullscreen detail mode handles its own navigation keys.
985
1785
  if (detailFullView) {
1786
+ const plainKey = !key.ctrl && !key.meta && !key.option
986
1787
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
987
1788
  setDetailFullView(false)
988
1789
  setDetailScrollOffset(0)
989
1790
  return
990
1791
  }
1792
+ if (isThemeKey(key)) {
1793
+ openThemeModal()
1794
+ return
1795
+ }
1796
+ if (plainKey && key.name === "d" && selectedPullRequest) {
1797
+ openDiffView()
1798
+ return
1799
+ }
1800
+ if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
1801
+ openCloseModal()
1802
+ return
1803
+ }
1804
+ if (plainKey && key.name === "l" && selectedPullRequest) {
1805
+ openLabelModal()
1806
+ return
1807
+ }
1808
+ if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
1809
+ openMergeModal()
1810
+ return
1811
+ }
1812
+ if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
1813
+ toggleSelectedPullRequestDraftStatus()
1814
+ return
1815
+ }
1816
+ if (plainKey && key.name === "r") {
1817
+ refreshPullRequests("Refreshed")
1818
+ return
1819
+ }
991
1820
  if (key.name === "home") {
992
1821
  detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
993
1822
  setDetailScrollOffset(0)
@@ -1051,14 +1880,12 @@ export const App = () => {
1051
1880
  setDetailScrollOffset((current) => current + halfPage)
1052
1881
  return
1053
1882
  }
1054
- if (key.name === "o" && selectedPullRequest) {
1883
+ if (plainKey && key.name === "o" && selectedPullRequest) {
1055
1884
  openSelectedPullRequestInBrowser(selectedPullRequest)
1056
1885
  return
1057
1886
  }
1058
- if (key.name === "y" && selectedPullRequest) {
1059
- void copyPullRequestMetadata(selectedPullRequest)
1060
- .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1061
- .catch((error) => flashNotice(error instanceof Error ? error.message : String(error)))
1887
+ if (plainKey && key.name === "y" && selectedPullRequest) {
1888
+ copySelectedPullRequestMetadata()
1062
1889
  return
1063
1890
  }
1064
1891
  return
@@ -1093,6 +1920,11 @@ export const App = () => {
1093
1920
  }
1094
1921
  }
1095
1922
 
1923
+ if (key.name === "tab") {
1924
+ switchQueueMode(key.shift ? -1 : 1)
1925
+ return
1926
+ }
1927
+
1096
1928
  if (isThemeKey(key)) {
1097
1929
  openThemeModal()
1098
1930
  return
@@ -1110,7 +1942,7 @@ export const App = () => {
1110
1942
  return
1111
1943
  }
1112
1944
  if (key.name === "r") {
1113
- refreshPullRequests("Refreshing pull requests...")
1945
+ refreshPullRequests("Refreshed")
1114
1946
  return
1115
1947
  }
1116
1948
  if (
@@ -1199,10 +2031,14 @@ export const App = () => {
1199
2031
  setDetailScrollOffset(0)
1200
2032
  return
1201
2033
  }
1202
- if ((key.name === "d" || key.name === "p") && selectedPullRequest) {
2034
+ if (key.name === "d" && selectedPullRequest) {
1203
2035
  openDiffView()
1204
2036
  return
1205
2037
  }
2038
+ if (key.name === "x" && selectedPullRequest?.state === "open") {
2039
+ openCloseModal()
2040
+ return
2041
+ }
1206
2042
  if (key.name === "l" && selectedPullRequest) {
1207
2043
  openLabelModal()
1208
2044
  return
@@ -1216,30 +2052,12 @@ export const App = () => {
1216
2052
  return
1217
2053
  }
1218
2054
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
1219
- const previousPullRequest = selectedPullRequest
1220
- const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1221
- updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1222
- ...pullRequest,
1223
- reviewStatus: nextReviewStatus,
1224
- }))
1225
- void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1226
- .then(() => {
1227
- flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1228
- })
1229
- .catch((error) => {
1230
- updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1231
- flashNotice(error instanceof Error ? error.message : String(error))
1232
- })
2055
+ toggleSelectedPullRequestDraftStatus()
1233
2056
  return
1234
2057
  }
1235
2058
  if (key.name === "y" && selectedPullRequest) {
1236
- void copyPullRequestMetadata(selectedPullRequest)
1237
- .then(() => {
1238
- flashNotice(`Copied #${selectedPullRequest.number} metadata`)
1239
- })
1240
- .catch((error) => {
1241
- flashNotice(error instanceof Error ? error.message : String(error))
1242
- })
2059
+ copySelectedPullRequestMetadata()
2060
+ return
1243
2061
  }
1244
2062
  })
1245
2063
 
@@ -1276,16 +2094,31 @@ export const App = () => {
1276
2094
  const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
1277
2095
  const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1278
2096
  const labelModalHeight = Math.min(20, terminalHeight - 4)
1279
- const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1280
- const labelModalTop = Math.floor((terminalHeight - labelModalHeight) / 2)
2097
+ const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2098
+ const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
2099
+ const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
2100
+ const closeModalHeight = Math.min(12, terminalHeight - 4)
2101
+ const closeModalLeft = centeredOffset(contentWidth, closeModalWidth)
2102
+ const closeModalTop = centeredOffset(terminalHeight, closeModalHeight)
2103
+ const commentModalWidth = Math.min(76, Math.max(46, contentWidth - 8))
2104
+ const commentModalHeight = Math.min(16, terminalHeight - 4)
2105
+ const commentModalLeft = centeredOffset(contentWidth, commentModalWidth)
2106
+ const commentModalTop = centeredOffset(terminalHeight, commentModalHeight)
2107
+ const commentThreadModalWidth = Math.min(86, Math.max(50, contentWidth - 8))
2108
+ const commentThreadModalHeight = Math.min(22, terminalHeight - 4)
2109
+ const commentThreadModalLeft = centeredOffset(contentWidth, commentThreadModalWidth)
2110
+ const commentThreadModalTop = centeredOffset(terminalHeight, commentThreadModalHeight)
2111
+ const commentAnchorLabel = selectedDiffCommentAnchor
2112
+ ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2113
+ : "No diff line selected"
1281
2114
  const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1282
2115
  const mergeModalHeight = Math.min(16, terminalHeight - 4)
1283
- const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1284
- const mergeModalTop = Math.floor((terminalHeight - mergeModalHeight) / 2)
2116
+ const mergeModalLeft = centeredOffset(contentWidth, mergeModalWidth)
2117
+ const mergeModalTop = centeredOffset(terminalHeight, mergeModalHeight)
1285
2118
  const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
1286
2119
  const themeModalHeight = Math.min(16, terminalHeight - 4)
1287
- const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
1288
- const themeModalTop = Math.floor((terminalHeight - themeModalHeight) / 2)
2120
+ const themeModalLeft = centeredOffset(contentWidth, themeModalWidth)
2121
+ const themeModalTop = centeredOffset(terminalHeight, themeModalHeight)
1289
2122
 
1290
2123
  return (
1291
2124
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -1303,6 +2136,7 @@ export const App = () => {
1303
2136
  <PullRequestDiffPane
1304
2137
  pullRequest={selectedPullRequest}
1305
2138
  diffState={selectedDiffState}
2139
+ stackedFiles={stackedDiffFiles}
1306
2140
  fileIndex={diffFileIndex}
1307
2141
  view={effectiveDiffRenderView}
1308
2142
  wrapMode={diffWrapMode}
@@ -1310,6 +2144,11 @@ export const App = () => {
1310
2144
  height={wideBodyHeight}
1311
2145
  loadingIndicator={loadingIndicator}
1312
2146
  scrollRef={diffScrollRef}
2147
+ setDiffRef={setDiffRenderableRef}
2148
+ commentMode={diffCommentMode}
2149
+ selectedCommentAnchor={selectedDiffCommentAnchor}
2150
+ selectedCommentThread={selectedDiffCommentThread}
2151
+ commentCount={selectedDiffCommentCount}
1313
2152
  themeId={themeId}
1314
2153
  />
1315
2154
  ) : isWideLayout && detailFullView ? (
@@ -1317,6 +2156,7 @@ export const App = () => {
1317
2156
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
1318
2157
  <DetailsPane
1319
2158
  pullRequest={selectedPullRequest}
2159
+ viewerUsername={username}
1320
2160
  contentWidth={fullscreenContentWidth}
1321
2161
  bodyLines={fullscreenBodyLines}
1322
2162
  paneWidth={contentWidth}
@@ -1338,7 +2178,7 @@ export const App = () => {
1338
2178
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
1339
2179
  {selectedPullRequest ? (
1340
2180
  <>
1341
- <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2181
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1342
2182
  <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
1343
2183
  <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
1344
2184
  </scrollbox>
@@ -1353,6 +2193,7 @@ export const App = () => {
1353
2193
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
1354
2194
  <DetailsPane
1355
2195
  pullRequest={selectedPullRequest}
2196
+ viewerUsername={username}
1356
2197
  contentWidth={fullscreenContentWidth}
1357
2198
  bodyLines={fullscreenBodyLines}
1358
2199
  paneWidth={contentWidth}
@@ -1364,7 +2205,7 @@ export const App = () => {
1364
2205
  </box>
1365
2206
  ) : (
1366
2207
  <box height={wideBodyHeight} flexDirection="column">
1367
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2208
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
1368
2209
  <Divider width={contentWidth} />
1369
2210
  <box flexGrow={1} flexDirection="column">
1370
2211
  <scrollbox flexGrow={1}>
@@ -1390,15 +2231,17 @@ export const App = () => {
1390
2231
  showFilterClear={filterMode || filterQuery.length > 0}
1391
2232
  detailFullView={detailFullView}
1392
2233
  diffFullView={diffFullView}
2234
+ diffCommentMode={diffCommentMode}
1393
2235
  hasSelection={selectedPullRequest !== null}
2236
+ canCloseSelection={selectedPullRequest?.state === "open"}
1394
2237
  hasError={pullRequestStatus === "error"}
1395
- isLoading={pullRequestStatus === "loading"}
2238
+ isLoading={pullRequestStatus === "loading" || isRefreshingPullRequests || isHydratingPullRequestDetails || closeModal.running || mergeModal.running}
1396
2239
  loadingIndicator={loadingIndicator}
1397
2240
  retryProgress={retryProgress}
1398
2241
  />
1399
2242
  )}
1400
2243
  </box>
1401
- {labelModal.open ? (
2244
+ {labelModalActive ? (
1402
2245
  <LabelModal
1403
2246
  state={labelModal}
1404
2247
  currentLabels={selectedPullRequest?.labels ?? []}
@@ -1409,7 +2252,38 @@ export const App = () => {
1409
2252
  loadingIndicator={loadingIndicator}
1410
2253
  />
1411
2254
  ) : null}
1412
- {mergeModal.open ? (
2255
+ {closeModalActive ? (
2256
+ <CloseModal
2257
+ state={closeModal}
2258
+ modalWidth={closeModalWidth}
2259
+ modalHeight={closeModalHeight}
2260
+ offsetLeft={closeModalLeft}
2261
+ offsetTop={closeModalTop}
2262
+ loadingIndicator={loadingIndicator}
2263
+ />
2264
+ ) : null}
2265
+ {commentModalActive ? (
2266
+ <CommentModal
2267
+ state={commentModal}
2268
+ anchorLabel={commentAnchorLabel}
2269
+ modalWidth={commentModalWidth}
2270
+ modalHeight={commentModalHeight}
2271
+ offsetLeft={commentModalLeft}
2272
+ offsetTop={commentModalTop}
2273
+ />
2274
+ ) : null}
2275
+ {commentThreadModalActive ? (
2276
+ <CommentThreadModal
2277
+ state={commentThreadModal}
2278
+ anchorLabel={commentAnchorLabel}
2279
+ comments={selectedDiffCommentThread}
2280
+ modalWidth={commentThreadModalWidth}
2281
+ modalHeight={commentThreadModalHeight}
2282
+ offsetLeft={commentThreadModalLeft}
2283
+ offsetTop={commentThreadModalTop}
2284
+ />
2285
+ ) : null}
2286
+ {mergeModalActive ? (
1413
2287
  <MergeModal
1414
2288
  state={mergeModal}
1415
2289
  modalWidth={mergeModalWidth}
@@ -1419,7 +2293,7 @@ export const App = () => {
1419
2293
  loadingIndicator={loadingIndicator}
1420
2294
  />
1421
2295
  ) : null}
1422
- {themeModal.open ? (
2296
+ {themeModalActive ? (
1423
2297
  <ThemeModal
1424
2298
  state={themeModal}
1425
2299
  activeThemeId={themeId}