@kitlangton/ghui 0.1.17 → 0.1.19

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 LoadStatus, 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, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, 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
- import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
19
+ import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
19
20
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
20
- import { CloseModal, initialCloseModalState, 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"
@@ -25,45 +26,104 @@ import { PullRequestList } from "./ui/PullRequestList.js"
25
26
  const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
26
27
  const initialThemeId = await Effect.runPromise(loadStoredThemeId)
27
28
 
28
- type LoadStatus = "loading" | "ready" | "error"
29
29
 
30
30
  interface PullRequestLoad {
31
+ readonly queueMode: PullRequestQueueMode
31
32
  readonly data: readonly PullRequestItem[]
32
33
  readonly fetchedAt: Date | null
34
+ readonly detailsFetchedAt: Date | null
33
35
  }
34
36
 
35
37
  interface DetailPlaceholderInput {
36
38
  readonly status: LoadStatus
37
- readonly retryProgress: RetryProgress | null
39
+ readonly retryProgress: RetryProgress
38
40
  readonly loadingIndicator: string
39
41
  readonly visibleCount: number
40
42
  readonly filterText: string
41
43
  }
42
44
 
45
+ type DiffLineColorConfig = {
46
+ readonly gutter: string
47
+ readonly content: string
48
+ }
49
+
50
+ type DiffSideRenderable = {
51
+ readonly setLineColor: (line: number, color: DiffLineColorConfig) => void
52
+ }
53
+
54
+ type DiffRenderableRuntimeSides = {
55
+ readonly leftSide?: DiffSideRenderable
56
+ readonly rightSide?: DiffSideRenderable
57
+ }
58
+
59
+ interface AppliedDiffLineColor {
60
+ readonly anchor: StackedDiffCommentAnchor
61
+ readonly view: DiffView
62
+ }
63
+
64
+ interface AppliedDiffLineColorState {
65
+ readonly contextKey: string | null
66
+ readonly entries: readonly AppliedDiffLineColor[]
67
+ }
68
+
43
69
  const PR_FETCH_RETRIES = 6
44
70
  const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
45
71
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
46
72
  const AUTO_REFRESH_JITTER_MS = 10_000
73
+ const DIFF_STICKY_HEADER_LINES = 2
47
74
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
48
75
 
49
- const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
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
+
97
+ 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)
50
101
  const pullRequestsAtom = githubRuntime.atom(
51
102
  GitHubService.use((github) =>
52
103
  Effect.gen(function*() {
53
- yield* Atom.set(retryProgressAtom, null)
54
- const data = yield* github.listOpenPullRequests().pipe(
104
+ const queueMode = yield* Atom.get(queueModeAtom)
105
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
106
+ const data = yield* github.listOpenPullRequests(queueMode).pipe(
55
107
  Effect.tapError(() =>
56
- Atom.update(retryProgressAtom, (current) => ({
57
- attempt: Math.min((current?.attempt ?? 0) + 1, PR_FETCH_RETRIES),
108
+ Atom.update(retryProgressAtom, (current) => RetryProgress.Retrying({
109
+ attempt: Math.min(RetryProgress.$match(current, { Idle: () => 0, Retrying: ({ attempt }) => attempt }) + 1, PR_FETCH_RETRIES),
58
110
  max: PR_FETCH_RETRIES,
59
111
  }))
60
112
  ),
61
113
  Effect.retry({ times: PR_FETCH_RETRIES, schedule: Schedule.exponential("300 millis", 2) }),
62
- Effect.tapError(() => Atom.set(retryProgressAtom, null)),
114
+ Effect.tapError(() => Atom.set(retryProgressAtom, initialRetryProgress)),
63
115
  )
64
116
 
65
- yield* Atom.set(retryProgressAtom, null)
66
- return { data, fetchedAt: new Date() } satisfies PullRequestLoad
117
+ yield* Atom.set(retryProgressAtom, initialRetryProgress)
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)
@@ -77,15 +137,17 @@ const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
77
137
  const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
78
138
  const diffFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
79
139
  const diffFileIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
80
- const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.keepAlive)
81
- const diffWrapModeAtom = Atom.make<"none" | "word">("none").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)
145
+ const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
146
+ const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
82
147
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
83
148
 
84
- const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
85
- const closeModalAtom = Atom.make(initialCloseModalState).pipe(Atom.keepAlive)
86
- const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
149
+ const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
87
150
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
88
- const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
89
151
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
90
152
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
91
153
  const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -98,8 +160,8 @@ const usernameAtom = githubRuntime.atom(
98
160
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
99
161
  GitHubService.use((github) => github.listRepoLabels(repository))
100
162
  )
101
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<void>()(() =>
102
- GitHubService.use((github) => github.listOpenPullRequestDetails())
163
+ const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
164
+ GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
103
165
  )
104
166
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
105
167
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
@@ -113,6 +175,9 @@ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly
113
175
  const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
114
176
  GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
115
177
  )
178
+ const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
179
+ GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
180
+ )
116
181
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
117
182
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
118
183
  )
@@ -122,11 +187,29 @@ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
122
187
  const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
123
188
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
124
189
  )
190
+ const createPullRequestCommentAtom = githubRuntime.fn<CreatePullRequestCommentInput>()((input) => GitHubService.use((github) => github.createPullRequestComment(input)))
191
+
192
+ const centeredOffset = (outer: number, inner: number) => Math.floor((outer - inner) / 2)
125
193
 
126
194
  const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
127
195
 
128
196
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
129
197
 
198
+ const pullRequestFilterScore = (pullRequest: PullRequestItem, query: string) => {
199
+ const normalized = query.trim().toLowerCase()
200
+ if (normalized.length === 0) return 0
201
+ const fields = [
202
+ pullRequest.title.toLowerCase(),
203
+ pullRequest.repository.toLowerCase(),
204
+ String(pullRequest.number),
205
+ ]
206
+ const scores = fields.flatMap((field, index) => {
207
+ const matchIndex = field.indexOf(normalized)
208
+ return matchIndex >= 0 ? [index * 1000 + matchIndex] : []
209
+ })
210
+ return scores.length > 0 ? Math.min(...scores) : null
211
+ }
212
+
130
213
  const clipboardCommands = (): readonly (readonly string[])[] => {
131
214
  if (process.platform === "darwin") return [["pbcopy"]]
132
215
  if (process.platform === "linux") {
@@ -207,6 +290,76 @@ const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => k
207
290
 
208
291
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
209
292
 
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
+ const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
299
+ `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
300
+
301
+ const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
302
+ const threads: Record<string, PullRequestReviewComment[]> = {}
303
+ for (const comment of comments) {
304
+ const key = diffCommentThreadKey(pullRequest, comment)
305
+ const thread = threads[key]
306
+ if (thread) thread.push(comment)
307
+ else threads[key] = [comment]
308
+ }
309
+ return threads
310
+ }
311
+
312
+ const isLocalDiffComment = (comment: PullRequestReviewComment) => comment.id.startsWith("local:")
313
+
314
+ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig => {
315
+ if (anchor.kind === "addition") {
316
+ return { gutter: colors.diff.addedLineNumberBg, content: colors.diff.addedBg }
317
+ }
318
+ if (anchor.kind === "deletion") {
319
+ return { gutter: colors.diff.removedLineNumberBg, content: colors.diff.removedBg }
320
+ }
321
+ return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
322
+ }
323
+
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
+ const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
342
+ const accent = kind === "thread"
343
+ ? colors.status.pending
344
+ : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
345
+ return mixHexColor(accent, originalDiffLineColor(anchor).gutter, 0.45)
346
+ }
347
+
348
+ const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
349
+ const withSides = diff as unknown as DiffRenderableRuntimeSides
350
+ if (view === "split") {
351
+ const target = anchor.side === "LEFT" ? withSides.leftSide : withSides.rightSide
352
+ return target ? [target] : []
353
+ }
354
+ return withSides.leftSide ? [withSides.leftSide] : []
355
+ }
356
+
357
+ const setDiffCommentLineColor = (diff: DiffRenderable, entry: AppliedDiffLineColor, color: DiffLineColorConfig) => {
358
+ for (const target of diffSideTargets(diff, entry.anchor, entry.view)) {
359
+ target.setLineColor(entry.anchor.localRenderLine, color)
360
+ }
361
+ }
362
+
210
363
  const getDetailPlaceholderContent = ({
211
364
  status,
212
365
  retryProgress,
@@ -217,7 +370,7 @@ const getDetailPlaceholderContent = ({
217
370
  if (status === "loading") {
218
371
  return {
219
372
  title: `${loadingIndicator} Loading pull requests`,
220
- hint: retryProgress ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
373
+ hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs",
221
374
  }
222
375
  }
223
376
 
@@ -253,6 +406,9 @@ export const App = () => {
253
406
  const { width, height } = useTerminalDimensions()
254
407
  const pullRequestResult = useAtomValue(pullRequestsAtom)
255
408
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
409
+ const [queueMode, setQueueMode] = useAtom(queueModeAtom)
410
+ const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
411
+ const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
256
412
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
257
413
  const [notice, setNotice] = useAtom(noticeAtom)
258
414
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -263,14 +419,45 @@ export const App = () => {
263
419
  const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
264
420
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
265
421
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
422
+ const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
266
423
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
267
424
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
425
+ const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
426
+ const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
427
+ const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
428
+ const [diffCommentsLoaded, setDiffCommentsLoaded] = useAtom(diffCommentsLoadedAtom)
268
429
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
269
- const [labelModal, setLabelModal] = useAtom(labelModalAtom)
270
- const [closeModal, setCloseModal] = useAtom(closeModalAtom)
271
- const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
430
+ const [activeModal, setActiveModal] = useAtom(activeModalAtom)
272
431
  const [themeId, setThemeId] = useAtom(themeIdAtom)
273
- const [themeModal, setThemeModal] = useAtom(themeModalAtom)
432
+ const closeActiveModal = () => setActiveModal(initialModal)
433
+ const labelModalActive = Modal.$is("Label")(activeModal)
434
+ const closeModalActive = Modal.$is("Close")(activeModal)
435
+ const mergeModalActive = Modal.$is("Merge")(activeModal)
436
+ const commentModalActive = Modal.$is("Comment")(activeModal)
437
+ const commentThreadModalActive = Modal.$is("CommentThread")(activeModal)
438
+ const themeModalActive = Modal.$is("Theme")(activeModal)
439
+ const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState
440
+ const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState
441
+ const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState
442
+ const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState
443
+ const commentThreadModal: CommentThreadModalState = commentThreadModalActive ? activeModal : initialCommentThreadModalState
444
+ const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState
445
+ const makeModalSetter = <Tag extends Exclude<ModalTag, "None">>(tag: Tag) =>
446
+ (next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) => setActiveModal((current) => {
447
+ const ctor = Modal[tag] as unknown as (args: ModalState<Tag>) => Modal
448
+ if (typeof next === "function") {
449
+ const updater = next as (prev: ModalState<Tag>) => ModalState<Tag>
450
+ if (current._tag !== tag) return current
451
+ return ctor(updater(current as unknown as ModalState<Tag>))
452
+ }
453
+ return ctor(next)
454
+ })
455
+ const setLabelModal = makeModalSetter("Label")
456
+ const setCloseModal = makeModalSetter("Close")
457
+ const setMergeModal = makeModalSetter("Merge")
458
+ const setCommentModal = makeModalSetter("Comment")
459
+ const setCommentThreadModal = makeModalSetter("CommentThread")
460
+ const setThemeModal = makeModalSetter("Theme")
274
461
  setActiveTheme(themeId)
275
462
  const themeIdRef = useRef(themeId)
276
463
  const themeModalRef = useRef(themeModal)
@@ -291,9 +478,11 @@ export const App = () => {
291
478
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
292
479
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
293
480
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
481
+ const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
294
482
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
295
483
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
296
484
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
485
+ const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
297
486
  const terminalWidth = width ?? 100
298
487
  const terminalHeight = height ?? 24
299
488
  const contentWidth = Math.max(1, terminalWidth)
@@ -312,6 +501,7 @@ export const App = () => {
312
501
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
313
502
  const detailHydrationRef = useRef<number | null>(null)
314
503
  const refreshGenerationRef = useRef(0)
504
+ const didMountQueueModeRef = useRef(false)
315
505
  const lastPullRequestRefreshAtRef = useRef(0)
316
506
  const terminalFocusedRef = useRef(true)
317
507
  const terminalWasBlurredRef = useRef(false)
@@ -320,6 +510,9 @@ export const App = () => {
320
510
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
321
511
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
322
512
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
513
+ const diffRenderableRefs = useRef(new Map<number, DiffRenderable>())
514
+ const diffCommentLineColorsRef = useRef<AppliedDiffLineColorState>({ contextKey: null, entries: [] })
515
+ const suppressNextDiffCommentScrollRef = useRef(false)
323
516
  const headerFooterWidth = Math.max(24, contentWidth - 2)
324
517
 
325
518
  const flashNotice = (message: string) => {
@@ -348,7 +541,9 @@ export const App = () => {
348
541
  }
349
542
  }, [])
350
543
 
351
- const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
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
352
547
  const pullRequests = useMemo(() => {
353
548
  const source = pullRequestLoad?.data ?? []
354
549
  const seenUrls = new Set<string>()
@@ -362,7 +557,7 @@ export const App = () => {
362
557
  ...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
363
558
  ]
364
559
  }, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
365
- const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
560
+ const pullRequestStatus: LoadStatus = (pullRequestResult.waiting || isLoadingQueueMode) && pullRequestLoad === null
366
561
  ? "loading"
367
562
  : AsyncResult.isFailure(pullRequestResult)
368
563
  ? "error"
@@ -375,18 +570,42 @@ export const App = () => {
375
570
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
376
571
  const visibleFilterText = filterMode ? filterDraft : filterQuery
377
572
 
378
- const filteredPullRequests = useMemo(() => pullRequests.filter((pullRequest) => {
379
- const query = effectiveFilterQuery
380
- if (query.length === 0) return true
381
- return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
382
- .some((value) => value.toLowerCase().includes(query))
383
- }), [pullRequests, effectiveFilterQuery])
384
-
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])
385
586
  const visibleGroups = useMemo(
386
- () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository),
387
- [filteredPullRequests],
587
+ () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository, visibleRepoOrder),
588
+ [filteredPullRequests, visibleRepoOrder],
388
589
  )
389
590
  const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
591
+ const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
592
+ const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
593
+ const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
594
+ const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
595
+ const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
596
+ const selectedDiffKey = selectedPullRequest ? pullRequestDiffKey(selectedPullRequest) : null
597
+ const diffCommentAnchors = useMemo(
598
+ () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
599
+ [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
600
+ )
601
+ const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
602
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentAnchorKey(selectedDiffCommentAnchor)}` : null
603
+ const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
604
+ const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
605
+ const diffCommentRows = useMemo(
606
+ () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
607
+ [diffCommentAnchors],
608
+ )
390
609
  const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
391
610
  if (index === 0) {
392
611
  starts.push(0)
@@ -406,12 +625,15 @@ export const App = () => {
406
625
  : pullRequestStatus === "loading"
407
626
  ? "loading pull requests..."
408
627
  : ""
409
- const headerLeft = username ? `GHUI ${username}` : "GHUI"
628
+ const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
410
629
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
411
630
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
412
631
  const selectPullRequestByUrl = (url: string) => {
413
632
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
414
- if (index >= 0) setSelectedIndex(index)
633
+ if (index >= 0) {
634
+ setSelectedIndex(index)
635
+ setQueueSelection((current) => ({ ...current, [queueMode]: index }))
636
+ }
415
637
  }
416
638
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
417
639
  const pullRequest = pullRequests.find((item) => item.url === url)
@@ -429,6 +651,23 @@ export const App = () => {
429
651
  refreshPullRequestsAtom()
430
652
  }
431
653
  refreshPullRequestsRef.current = refreshPullRequests
654
+ const switchQueueMode = (delta: 1 | -1) => {
655
+ const mode = nextQueueMode(queueMode, delta)
656
+ if (mode === queueMode) return
657
+ refreshGenerationRef.current += 1
658
+ setQueueSelection((current) => ({ ...current, [queueMode]: selectedIndex }))
659
+ setQueueMode(mode)
660
+ setSelectedIndex(queueSelection[mode] ?? 0)
661
+ setRecentlyCompletedPullRequests({})
662
+ detailHydrationRef.current = null
663
+ setDetailFullView(false)
664
+ setDiffFullView(false)
665
+ setDiffCommentMode(false)
666
+ setFilterDraft(filterQuery)
667
+ setNotice(null)
668
+ setRefreshCompletionMessage(null)
669
+ setRefreshStartedAt(null)
670
+ }
432
671
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
433
672
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
434
673
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -443,6 +682,15 @@ export const App = () => {
443
682
  }
444
683
  }, [pullRequestLoad?.fetchedAt])
445
684
 
685
+ useEffect(() => {
686
+ if (!didMountQueueModeRef.current) {
687
+ didMountQueueModeRef.current = true
688
+ return
689
+ }
690
+ if (queueLoadCache[queueMode]) return
691
+ refreshPullRequestsAtom()
692
+ }, [queueMode, queueLoadCache, refreshPullRequestsAtom])
693
+
446
694
  useEffect(() => {
447
695
  if (!refreshCompletionMessage || refreshStartedAt === null) return
448
696
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
@@ -498,16 +746,73 @@ export const App = () => {
498
746
  })
499
747
  }, [visiblePullRequests.length])
500
748
 
749
+ useEffect(() => {
750
+ setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
751
+ }, [queueMode, selectedIndex])
752
+
501
753
  useEffect(() => {
502
754
  setDiffFileIndex(0)
755
+ setDiffScrollTop(0)
756
+ setDiffCommentAnchorIndex(0)
503
757
  }, [selectedIndex])
504
758
 
505
- const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
506
- const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
507
- const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
759
+ useEffect(() => {
760
+ setDiffCommentAnchorIndex((current) => {
761
+ if (diffCommentAnchors.length === 0) return 0
762
+ return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
763
+ })
764
+ }, [diffCommentAnchors.length])
765
+
766
+ useEffect(() => {
767
+ if (!diffCommentMode || !selectedDiffCommentAnchor) return
768
+ setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
769
+ }, [diffCommentMode, selectedDiffCommentAnchor?.fileIndex])
770
+
771
+ useEffect(() => {
772
+ const previous = diffCommentLineColorsRef.current
773
+ if (previous.contextKey === diffLineColorContextKey) {
774
+ for (const entry of previous.entries) {
775
+ const diff = diffRenderableRefs.current.get(entry.anchor.fileIndex)
776
+ if (diff) setDiffCommentLineColor(diff, entry, originalDiffLineColor(entry.anchor))
777
+ }
778
+ }
779
+
780
+ const nextEntries: AppliedDiffLineColor[] = []
781
+ const appliedKeys = new Set<string>()
782
+ const applyLineColor = (anchor: StackedDiffCommentAnchor, gutter: string, override = false) => {
783
+ const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
784
+ if (appliedKeys.has(key) && !override) return
785
+ appliedKeys.add(key)
786
+ const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
787
+ const diff = diffRenderableRefs.current.get(anchor.fileIndex)
788
+ if (diff) setDiffCommentLineColor(diff, entry, { ...originalDiffLineColor(anchor), gutter })
789
+ if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
790
+ nextEntries.push(entry)
791
+ }
792
+ }
793
+
794
+ if (selectedDiffKey) {
795
+ for (const anchor of diffCommentAnchors) {
796
+ if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentAnchorKey(anchor)}`]?.length ?? 0) > 0) {
797
+ applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
798
+ }
799
+ }
800
+ }
801
+ if (diffCommentMode && selectedDiffCommentAnchor) {
802
+ applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
803
+ if (suppressNextDiffCommentScrollRef.current) {
804
+ suppressNextDiffCommentScrollRef.current = false
805
+ } else {
806
+ ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
807
+ }
808
+ } else {
809
+ suppressNextDiffCommentScrollRef.current = false
810
+ }
811
+ diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
812
+ }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
508
813
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
509
814
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
510
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
815
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
511
816
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
512
817
 
513
818
  useEffect(() => {
@@ -521,23 +826,29 @@ export const App = () => {
521
826
  useEffect(() => {
522
827
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
523
828
  if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
524
- if (detailHydrationRef.current === fetchedAt) return
829
+ if (detailHydrationRef.current === fetchedAt || pullRequestLoad?.detailsFetchedAt?.getTime() === fetchedAt) return
525
830
  if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
526
831
  detailHydrationRef.current = fetchedAt
527
832
  const generation = refreshGenerationRef.current
528
- void loadPullRequestDetails().then((details) => {
833
+ void loadPullRequestDetails(queueMode).then((details) => {
529
834
  if (generation !== refreshGenerationRef.current) return
530
- setPullRequestOverrides((current) => {
531
- const next = { ...current }
532
- for (const detail of details) {
533
- next[detail.url] = current[detail.url]?.detailLoaded ? current[detail.url]! : detail
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
+ },
534
846
  }
535
- return next
536
847
  })
537
848
  }).catch((error) => {
538
- flashNotice(error instanceof Error ? error.message : String(error))
849
+ flashNotice(errorMessage(error))
539
850
  })
540
- }, [pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
851
+ }, [queueMode, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
541
852
 
542
853
  const detailPlaceholderContent = getDetailPlaceholderContent({
543
854
  status: pullRequestStatus,
@@ -550,23 +861,67 @@ export const App = () => {
550
861
 
551
862
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
552
863
 
553
- const loadPullRequestDiff = (pullRequest: PullRequestItem, force = false) => {
864
+ const loadPullRequestComments = (pullRequest: PullRequestItem, force = false) => {
865
+ const key = pullRequestDiffKey(pullRequest)
866
+ const previousLoadState = diffCommentsLoaded[key]
867
+ if (!force && previousLoadState) return
868
+ setDiffCommentsLoaded((current) => ({ ...current, [key]: "loading" }))
869
+ void listPullRequestComments({ repository: pullRequest.repository, number: pullRequest.number })
870
+ .then((comments) => {
871
+ setDiffCommentsLoaded((current) => ({ ...current, [key]: "ready" }))
872
+ setDiffCommentThreads((current) => {
873
+ const prefix = `${key}:`
874
+ const threads = groupDiffCommentThreads(pullRequest, comments)
875
+ const next: Record<string, readonly PullRequestReviewComment[]> = Object.fromEntries(
876
+ Object.entries(current).filter(([threadKey]) => !threadKey.startsWith(prefix)),
877
+ )
878
+
879
+ for (const [threadKey, threadComments] of Object.entries(current)) {
880
+ if (!threadKey.startsWith(prefix)) continue
881
+ const localComments = threadComments.filter(isLocalDiffComment)
882
+ if (localComments.length > 0) {
883
+ next[threadKey] = [...(threads[threadKey] ?? []), ...localComments]
884
+ }
885
+ }
886
+
887
+ for (const [threadKey, threadComments] of Object.entries(threads)) {
888
+ if (!next[threadKey]) next[threadKey] = threadComments
889
+ }
890
+
891
+ return next
892
+ })
893
+ })
894
+ .catch((error) => {
895
+ setDiffCommentsLoaded((current) => {
896
+ if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
897
+ const next = { ...current }
898
+ delete next[key]
899
+ return next
900
+ })
901
+ flashNotice(errorMessage(error))
902
+ })
903
+ }
904
+
905
+ const loadPullRequestDiff = (pullRequest: PullRequestItem, options: { readonly force?: boolean; readonly includeComments?: boolean } = {}) => {
906
+ const force = options.force ?? false
907
+ const includeComments = options.includeComments ?? false
554
908
  const key = pullRequestDiffKey(pullRequest)
555
909
  const existing = pullRequestDiffCache[key]
556
- if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
910
+ if (includeComments) loadPullRequestComments(pullRequest, force)
911
+ if (!force && existing && (existing._tag === "Ready" || existing._tag === "Loading")) return
557
912
 
558
- setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
913
+ setPullRequestDiffCache((current) => ({ ...current, [key]: PullRequestDiffState.Loading() }))
559
914
  void getPullRequestDiff({ repository: pullRequest.repository, number: pullRequest.number })
560
915
  .then((patch) => {
561
916
  setPullRequestDiffCache((current) => ({
562
917
  ...current,
563
- [key]: { status: "ready", patch, files: splitPatchFiles(patch) },
918
+ [key]: PullRequestDiffState.Ready({ patch, files: splitPatchFiles(patch) }),
564
919
  }))
565
920
  })
566
921
  .catch((error) => {
567
922
  setPullRequestDiffCache((current) => ({
568
923
  ...current,
569
- [key]: { status: "error", error: errorMessage(error) },
924
+ [key]: PullRequestDiffState.Error({ error: errorMessage(error) }),
570
925
  }))
571
926
  flashNotice(errorMessage(error))
572
927
  })
@@ -590,12 +945,219 @@ export const App = () => {
590
945
 
591
946
  const openDiffView = () => {
592
947
  if (!selectedPullRequest) return
948
+ diffRenderableRefs.current.clear()
949
+ diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
593
950
  setDiffFullView(true)
594
951
  setDetailFullView(false)
952
+ setDiffCommentMode(false)
595
953
  setDiffFileIndex(0)
954
+ setDiffScrollTop(0)
596
955
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
597
956
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
598
- loadPullRequestDiff(selectedPullRequest)
957
+ loadPullRequestDiff(selectedPullRequest, { includeComments: true })
958
+ }
959
+
960
+ const setDiffRenderableRef = (index: number, diff: DiffRenderable | null) => {
961
+ if (diff) diffRenderableRefs.current.set(index, diff)
962
+ else diffRenderableRefs.current.delete(index)
963
+ }
964
+
965
+ const scrollToDiffFile = (index: number) => {
966
+ const stackedFile = stackedDiffFiles[index]
967
+ diffScrollRef.current?.scrollTo({ x: 0, y: stackedFile?.headerLine ?? 0 })
968
+ syncDiffScrollState()
969
+ }
970
+
971
+ const syncDiffScrollState = () => {
972
+ const scrollTop = diffScrollRef.current?.scrollTop
973
+ if (scrollTop === undefined || stackedDiffFiles.length === 0) return
974
+ setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
975
+ const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
976
+ setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
977
+ }
978
+
979
+ const scrollDiffBy = (y: number) => {
980
+ diffScrollRef.current?.scrollBy({ x: 0, y })
981
+ syncDiffScrollState()
982
+ }
983
+
984
+ const scrollDiffTo = (y: number) => {
985
+ diffScrollRef.current?.scrollTo({ x: 0, y })
986
+ syncDiffScrollState()
987
+ }
988
+
989
+ const clearPendingGTimeout = () => {
990
+ if (pendingGTimeoutRef.current !== null) {
991
+ clearTimeout(pendingGTimeoutRef.current)
992
+ pendingGTimeoutRef.current = null
993
+ }
994
+ }
995
+
996
+ const handleVimGoto = (key: { readonly name: string; readonly shift?: boolean }, gotoStart: () => void, gotoEnd: () => void): boolean => {
997
+ if (isShiftG(key)) {
998
+ gotoEnd()
999
+ setPendingG(false)
1000
+ clearPendingGTimeout()
1001
+ return true
1002
+ }
1003
+ if (key.name === "g") {
1004
+ if (pendingG) {
1005
+ gotoStart()
1006
+ setPendingG(false)
1007
+ clearPendingGTimeout()
1008
+ } else {
1009
+ setPendingG(true)
1010
+ pendingGTimeoutRef.current = setTimeout(() => {
1011
+ setPendingG(false)
1012
+ pendingGTimeoutRef.current = null
1013
+ }, 500)
1014
+ }
1015
+ return true
1016
+ }
1017
+ return false
1018
+ }
1019
+
1020
+ const ensureDiffLineVisible = (line: number) => {
1021
+ const scroll = diffScrollRef.current
1022
+ if (!scroll) return
1023
+ const viewportHeight = Math.max(1, wideBodyHeight - (selectedDiffCommentThread.length > 0 ? 6 : 3))
1024
+ const nextTop = scrollTopForVisibleLine(scroll.scrollTop, viewportHeight, line, DIFF_STICKY_HEADER_LINES)
1025
+ if (nextTop !== scroll.scrollTop) {
1026
+ scroll.scrollTo({ x: 0, y: nextTop })
1027
+ syncDiffScrollState()
1028
+ }
1029
+ }
1030
+
1031
+ useEffect(() => {
1032
+ if (!diffFullView) return
1033
+ const interval = globalThis.setInterval(syncDiffScrollState, 80)
1034
+ return () => globalThis.clearInterval(interval)
1035
+ }, [diffFullView, stackedDiffFiles])
1036
+
1037
+ const jumpDiffFile = (delta: 1 | -1) => {
1038
+ if (readyDiffFiles.length === 0) return
1039
+ const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1040
+ setDiffFileIndex(nextIndex)
1041
+ if (diffCommentMode) {
1042
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1043
+ ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1044
+ if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1045
+ }
1046
+ scrollToDiffFile(nextIndex)
1047
+ }
1048
+
1049
+ const enterDiffCommentMode = () => {
1050
+ const scrollTop = diffScrollRef.current?.scrollTop ?? 0
1051
+ suppressNextDiffCommentScrollRef.current = true
1052
+ setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop + DIFF_STICKY_HEADER_LINES))
1053
+ setDiffCommentMode(true)
1054
+ }
1055
+
1056
+ const moveDiffCommentAnchor = (delta: number) => {
1057
+ if (diffCommentAnchors.length === 0) return
1058
+ const currentAnchor = selectedDiffCommentAnchor ?? diffCommentAnchors[0]
1059
+ const currentRowIndex = Math.max(0, currentAnchor ? diffCommentRows.indexOf(currentAnchor.renderLine) : 0)
1060
+ const nextRow = diffCommentRows[Math.max(0, Math.min(diffCommentRows.length - 1, currentRowIndex + delta))]
1061
+ if (nextRow === undefined) return
1062
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1063
+ ?? diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow)
1064
+ if (!nextAnchor) return
1065
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1066
+ }
1067
+
1068
+ const selectDiffCommentSide = (side: DiffCommentSide) => {
1069
+ if (!selectedDiffCommentAnchor) return
1070
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1071
+ if (!nextAnchor) return
1072
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1073
+ }
1074
+
1075
+ const selectDiffCommentLine = (renderLine: number, side: DiffCommentSide | null) => {
1076
+ const lineAnchors = diffCommentAnchors.filter((anchor) => anchor.renderLine === renderLine)
1077
+ const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1078
+ if (!nextAnchor) return
1079
+ suppressNextDiffCommentScrollRef.current = true
1080
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1081
+ setDiffFileIndex(nextAnchor.fileIndex)
1082
+ setDiffCommentMode(true)
1083
+ }
1084
+
1085
+ const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
1086
+ setCommentModal((current) => {
1087
+ const next = transform({ body: current.body, cursor: current.cursor })
1088
+ if (next.body === current.body && next.cursor === current.cursor && current.error === null) return current
1089
+ return { ...current, body: next.body, cursor: next.cursor, error: null }
1090
+ })
1091
+ }
1092
+
1093
+ const openDiffCommentModal = () => {
1094
+ if (!selectedDiffCommentAnchor || !selectedPullRequest) return
1095
+ setCommentModal(initialCommentModalState)
1096
+ }
1097
+
1098
+ const openDiffCommentThreadModal = () => {
1099
+ if (!selectedDiffCommentAnchor || selectedDiffCommentThread.length === 0) return
1100
+ setCommentThreadModal({ scrollOffset: 0 })
1101
+ }
1102
+
1103
+ const submitDiffComment = () => {
1104
+ if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1105
+ const body = commentModal.body.trim()
1106
+ if (body.length === 0) {
1107
+ setCommentModal((current) => ({ ...current, error: "Write a comment before saving." }))
1108
+ return
1109
+ }
1110
+
1111
+ const threadKey = selectedDiffCommentThreadKey
1112
+ const target = selectedDiffCommentAnchor
1113
+ const optimisticComment = {
1114
+ id: `local:${Date.now()}`,
1115
+ path: target.path,
1116
+ line: target.line,
1117
+ side: target.side,
1118
+ author: username ?? "you",
1119
+ body,
1120
+ createdAt: new Date(),
1121
+ url: null,
1122
+ } satisfies PullRequestReviewComment
1123
+ const input = {
1124
+ repository: selectedPullRequest.repository,
1125
+ number: selectedPullRequest.number,
1126
+ commitId: selectedPullRequest.headRefOid,
1127
+ path: target.path,
1128
+ line: target.line,
1129
+ side: target.side,
1130
+ body,
1131
+ } satisfies CreatePullRequestCommentInput
1132
+
1133
+ if (threadKey) {
1134
+ setDiffCommentThreads((current) => ({
1135
+ ...current,
1136
+ [threadKey]: [...(current[threadKey] ?? []), optimisticComment],
1137
+ }))
1138
+ }
1139
+ closeActiveModal()
1140
+ flashNotice(`Commenting on ${target.path}:${target.line}`)
1141
+ void createPullRequestComment(input).then((comment) => {
1142
+ if (threadKey) {
1143
+ setDiffCommentThreads((current) => ({
1144
+ ...current,
1145
+ [threadKey]: (current[threadKey] ?? []).map((existing) => existing.id === optimisticComment.id ? comment : existing),
1146
+ }))
1147
+ }
1148
+ flashNotice(`Commented on ${target.path}:${target.line}`)
1149
+ }).catch((error) => {
1150
+ if (threadKey) {
1151
+ setDiffCommentThreads((current) => {
1152
+ const next = { ...current }
1153
+ const comments = (next[threadKey] ?? []).filter((comment) => comment.id !== optimisticComment.id)
1154
+ if (comments.length > 0) next[threadKey] = comments
1155
+ else delete next[threadKey]
1156
+ return next
1157
+ })
1158
+ }
1159
+ flashNotice(errorMessage(error))
1160
+ })
599
1161
  }
600
1162
 
601
1163
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
@@ -604,13 +1166,34 @@ export const App = () => {
604
1166
  .catch((error) => flashNotice(errorMessage(error)))
605
1167
  }
606
1168
 
1169
+ const copySelectedPullRequestMetadata = () => {
1170
+ if (!selectedPullRequest) return
1171
+ void copyPullRequestMetadata(selectedPullRequest)
1172
+ .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1173
+ .catch((error) => flashNotice(errorMessage(error)))
1174
+ }
1175
+
1176
+ const toggleSelectedPullRequestDraftStatus = () => {
1177
+ if (!selectedPullRequest) return
1178
+ const previousPullRequest = selectedPullRequest
1179
+ const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1180
+ updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1181
+ ...pullRequest,
1182
+ reviewStatus: nextReviewStatus,
1183
+ }))
1184
+ void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1185
+ .then(() => {
1186
+ flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1187
+ })
1188
+ .catch((error) => {
1189
+ updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1190
+ flashNotice(errorMessage(error))
1191
+ })
1192
+ }
1193
+
607
1194
  const openCloseModal = () => {
608
1195
  if (!selectedPullRequest || selectedPullRequest.state !== "open") return
609
- setLabelModal(initialLabelModalState)
610
- setMergeModal(initialMergeModalState)
611
- setThemeModal(initialThemeModalState)
612
1196
  setCloseModal({
613
- open: true,
614
1197
  repository: selectedPullRequest.repository,
615
1198
  number: selectedPullRequest.number,
616
1199
  title: selectedPullRequest.title,
@@ -639,7 +1222,7 @@ export const App = () => {
639
1222
  },
640
1223
  }))
641
1224
  }
642
- setCloseModal(initialCloseModalState)
1225
+ closeActiveModal()
643
1226
  refreshPullRequests(`Closed #${number}`)
644
1227
  })
645
1228
  .catch((error) => {
@@ -649,11 +1232,7 @@ export const App = () => {
649
1232
  }
650
1233
 
651
1234
  const openThemeModal = () => {
652
- setLabelModal(initialLabelModalState)
653
- setCloseModal(initialCloseModalState)
654
- setMergeModal(initialMergeModalState)
655
1235
  setThemeModal({
656
- open: true,
657
1236
  query: "",
658
1237
  filterMode: false,
659
1238
  initialThemeId: themeId,
@@ -668,7 +1247,7 @@ export const App = () => {
668
1247
  void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
669
1248
  flashNotice(`Theme: ${selectedTheme.name}`)
670
1249
  }
671
- setThemeModal(initialThemeModalState)
1250
+ closeActiveModal()
672
1251
  }
673
1252
 
674
1253
  const previewTheme = (id: ThemeId) => {
@@ -711,14 +1290,10 @@ export const App = () => {
711
1290
 
712
1291
  const openLabelModal = () => {
713
1292
  if (!selectedPullRequest) return
714
- setCloseModal(initialCloseModalState)
715
- setMergeModal(initialMergeModalState)
716
- setThemeModal(initialThemeModalState)
717
1293
  const repository = selectedPullRequest.repository
718
1294
  const cachedLabels = labelCache[repository]
719
1295
  if (cachedLabels) {
720
1296
  setLabelModal({
721
- open: true,
722
1297
  repository,
723
1298
  query: "",
724
1299
  selectedIndex: 0,
@@ -728,7 +1303,7 @@ export const App = () => {
728
1303
  return
729
1304
  }
730
1305
 
731
- setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1306
+ setLabelModal({ repository, query: "", selectedIndex: 0, availableLabels: [], loading: true })
732
1307
  void loadRepoLabels(repository)
733
1308
  .then((labels) => {
734
1309
  setLabelCache((current) => ({ ...current, [repository]: labels }))
@@ -736,20 +1311,16 @@ export const App = () => {
736
1311
  })
737
1312
  .catch((error) => {
738
1313
  setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
739
- flashNotice(error instanceof Error ? error.message : String(error))
1314
+ flashNotice(errorMessage(error))
740
1315
  })
741
1316
  }
742
1317
 
743
1318
  const openMergeModal = () => {
744
1319
  if (!selectedPullRequest) return
745
- setCloseModal(initialCloseModalState)
746
- setThemeModal(initialThemeModalState)
747
1320
  const repository = selectedPullRequest.repository
748
1321
  const number = selectedPullRequest.number
749
1322
  const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
750
- setLabelModal(initialLabelModalState)
751
1323
  setMergeModal({
752
- open: true,
753
1324
  repository,
754
1325
  number,
755
1326
  selectedIndex: 0,
@@ -803,7 +1374,7 @@ export const App = () => {
803
1374
  },
804
1375
  }))
805
1376
  }
806
- setMergeModal(initialMergeModalState)
1377
+ closeActiveModal()
807
1378
  if (option.refreshOnSuccess) {
808
1379
  refreshPullRequests(`${option.pastTense} #${number}`)
809
1380
  } else {
@@ -837,7 +1408,7 @@ export const App = () => {
837
1408
  .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
838
1409
  .catch((error) => {
839
1410
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
840
- flashNotice(error instanceof Error ? error.message : String(error))
1411
+ flashNotice(errorMessage(error))
841
1412
  })
842
1413
  } else {
843
1414
  updatePullRequest(selectedPullRequest.url, (pr) => ({
@@ -848,34 +1419,26 @@ export const App = () => {
848
1419
  .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
849
1420
  .catch((error) => {
850
1421
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
851
- flashNotice(error instanceof Error ? error.message : String(error))
1422
+ flashNotice(errorMessage(error))
852
1423
  })
853
1424
  }
854
1425
  }
855
1426
 
856
1427
  useKeyboard((key) => {
857
- if ((key.name === "q" && !(themeModal.open && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
858
- if (themeModal.open) {
1428
+ if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1429
+ if (themeModalActive) {
859
1430
  closeThemeModal(false)
860
1431
  return
861
1432
  }
862
- if (closeModal.open) {
863
- setCloseModal(initialCloseModalState)
864
- return
865
- }
866
- if (mergeModal.open) {
867
- setMergeModal(initialMergeModalState)
868
- return
869
- }
870
- if (labelModal.open) {
871
- setLabelModal(initialLabelModalState)
1433
+ if (activeModal._tag !== "None") {
1434
+ closeActiveModal()
872
1435
  return
873
1436
  }
874
1437
  renderer.destroy()
875
1438
  return
876
1439
  }
877
1440
 
878
- if (themeModal.open) {
1441
+ if (themeModalActive) {
879
1442
  if (key.name === "escape") {
880
1443
  if (themeModal.filterMode) {
881
1444
  updateThemeQuery("", { filterMode: false })
@@ -916,9 +1479,137 @@ export const App = () => {
916
1479
  return
917
1480
  }
918
1481
 
919
- if (closeModal.open) {
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
1578
+ }
1579
+ return
1580
+ }
1581
+
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) }))
1601
+ return
1602
+ }
1603
+ if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1604
+ setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + halfPage }))
1605
+ return
1606
+ }
1607
+ return
1608
+ }
1609
+
1610
+ if (closeModalActive) {
920
1611
  if (key.name === "escape") {
921
- setCloseModal(initialCloseModalState)
1612
+ closeActiveModal()
922
1613
  return
923
1614
  }
924
1615
  if (key.name === "return" || key.name === "enter") {
@@ -928,10 +1619,10 @@ export const App = () => {
928
1619
  return
929
1620
  }
930
1621
 
931
- if (mergeModal.open) {
1622
+ if (mergeModalActive) {
932
1623
  const options = availableMergeActions(mergeModal.info)
933
1624
  if (key.name === "escape") {
934
- setMergeModal(initialMergeModalState)
1625
+ closeActiveModal()
935
1626
  return
936
1627
  }
937
1628
  if ((key.name === "return" || key.name === "enter") && options.length > 0) {
@@ -956,9 +1647,9 @@ export const App = () => {
956
1647
  }
957
1648
 
958
1649
  // Label modal takes priority over everything else
959
- if (labelModal.open) {
1650
+ if (labelModalActive) {
960
1651
  if (key.name === "escape") {
961
- setLabelModal(initialLabelModalState)
1652
+ closeActiveModal()
962
1653
  return
963
1654
  }
964
1655
  if (key.name === "return" || key.name === "enter") {
@@ -1006,66 +1697,107 @@ export const App = () => {
1006
1697
  }
1007
1698
 
1008
1699
  if (diffFullView) {
1700
+ if (diffCommentMode) {
1701
+ if (key.name === "escape") {
1702
+ setDiffCommentMode(false)
1703
+ return
1704
+ }
1705
+ if (key.name === "c") {
1706
+ setDiffCommentMode(false)
1707
+ return
1708
+ }
1709
+ if (key.name === "return" || key.name === "enter") {
1710
+ if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
1711
+ else openDiffCommentModal()
1712
+ return
1713
+ }
1714
+ if (key.name === "a") {
1715
+ openDiffCommentModal()
1716
+ return
1717
+ }
1718
+ if (key.name === "pageup" || key.ctrl && key.name === "u") {
1719
+ moveDiffCommentAnchor(-halfPage)
1720
+ return
1721
+ }
1722
+ if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1723
+ moveDiffCommentAnchor(halfPage)
1724
+ return
1725
+ }
1726
+ if ((key.shift || key.option || key.meta) && (key.name === "up" || key.name === "k") || key.name === "K") {
1727
+ moveDiffCommentAnchor(-8)
1728
+ return
1729
+ }
1730
+ if ((key.shift || key.option || key.meta) && (key.name === "down" || key.name === "j") || key.name === "J") {
1731
+ moveDiffCommentAnchor(8)
1732
+ return
1733
+ }
1734
+ if (key.name === "up" || key.name === "k") {
1735
+ moveDiffCommentAnchor(-1)
1736
+ return
1737
+ }
1738
+ if (key.name === "down" || key.name === "j") {
1739
+ moveDiffCommentAnchor(1)
1740
+ return
1741
+ }
1742
+ if (key.name === "left" || key.name === "h") {
1743
+ selectDiffCommentSide("LEFT")
1744
+ return
1745
+ }
1746
+ if (key.name === "right" || key.name === "l") {
1747
+ selectDiffCommentSide("RIGHT")
1748
+ return
1749
+ }
1750
+ if (key.name === "]" && selectedDiffState?._tag === "Ready") {
1751
+ jumpDiffFile(1)
1752
+ return
1753
+ }
1754
+ if (key.name === "[" && selectedDiffState?._tag === "Ready") {
1755
+ jumpDiffFile(-1)
1756
+ return
1757
+ }
1758
+ return
1759
+ }
1760
+
1009
1761
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1010
1762
  setDiffFullView(false)
1763
+ setDiffCommentMode(false)
1764
+ return
1765
+ }
1766
+ if (key.name === "c" && selectedDiffState?._tag === "Ready") {
1767
+ enterDiffCommentMode()
1011
1768
  return
1012
1769
  }
1013
1770
  if (key.name === "home") {
1014
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1771
+ scrollDiffTo(0)
1015
1772
  return
1016
1773
  }
1017
1774
  if (key.name === "end") {
1018
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1775
+ scrollDiffTo(Number.MAX_SAFE_INTEGER)
1019
1776
  return
1020
1777
  }
1021
1778
  if (key.name === "pageup") {
1022
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1779
+ scrollDiffBy(-halfPage)
1023
1780
  return
1024
1781
  }
1025
1782
  if (key.name === "pagedown") {
1026
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1027
- return
1028
- }
1029
- if (isShiftG(key)) {
1030
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1031
- setPendingG(false)
1032
- if (pendingGTimeoutRef.current !== null) {
1033
- clearTimeout(pendingGTimeoutRef.current)
1034
- pendingGTimeoutRef.current = null
1035
- }
1036
- return
1037
- }
1038
- if (key.name === "g") {
1039
- if (pendingG) {
1040
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1041
- setPendingG(false)
1042
- if (pendingGTimeoutRef.current !== null) {
1043
- clearTimeout(pendingGTimeoutRef.current)
1044
- pendingGTimeoutRef.current = null
1045
- }
1046
- } else {
1047
- setPendingG(true)
1048
- pendingGTimeoutRef.current = setTimeout(() => {
1049
- setPendingG(false)
1050
- pendingGTimeoutRef.current = null
1051
- }, 500)
1052
- }
1783
+ scrollDiffBy(halfPage)
1053
1784
  return
1054
1785
  }
1786
+ if (handleVimGoto(key, () => scrollDiffTo(0), () => scrollDiffTo(Number.MAX_SAFE_INTEGER))) return
1055
1787
  if (key.name === "up" || key.name === "k") {
1056
- diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
1788
+ scrollDiffBy(-1)
1057
1789
  return
1058
1790
  }
1059
1791
  if (key.name === "down" || key.name === "j") {
1060
- diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
1792
+ scrollDiffBy(1)
1061
1793
  return
1062
1794
  }
1063
1795
  if (key.ctrl && key.name === "u") {
1064
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1796
+ scrollDiffBy(-halfPage)
1065
1797
  return
1066
1798
  }
1067
1799
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
1068
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1800
+ scrollDiffBy(halfPage)
1069
1801
  return
1070
1802
  }
1071
1803
  if (key.name === "v") {
@@ -1077,18 +1809,16 @@ export const App = () => {
1077
1809
  return
1078
1810
  }
1079
1811
  if (key.name === "r" && selectedPullRequest) {
1080
- loadPullRequestDiff(selectedPullRequest, true)
1812
+ loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1081
1813
  flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1082
1814
  return
1083
1815
  }
1084
- if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?.status === "ready") {
1085
- setDiffFileIndex((current) => Math.min(Math.max(0, selectedDiffState.files.length - 1), current + 1))
1086
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1816
+ if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
1817
+ jumpDiffFile(1)
1087
1818
  return
1088
1819
  }
1089
- if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?.status === "ready") {
1090
- setDiffFileIndex((current) => Math.max(0, current - 1))
1091
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1820
+ if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
1821
+ jumpDiffFile(-1)
1092
1822
  return
1093
1823
  }
1094
1824
  if (key.name === "o" && selectedPullRequest) {
@@ -1100,24 +1830,48 @@ export const App = () => {
1100
1830
 
1101
1831
  // Fullscreen detail mode handles its own navigation keys.
1102
1832
  if (detailFullView) {
1833
+ const plainKey = !key.ctrl && !key.meta && !key.option
1103
1834
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1104
1835
  setDetailFullView(false)
1105
1836
  setDetailScrollOffset(0)
1106
1837
  return
1107
1838
  }
1839
+ if (isThemeKey(key)) {
1840
+ openThemeModal()
1841
+ return
1842
+ }
1843
+ if (plainKey && key.name === "d" && selectedPullRequest) {
1844
+ openDiffView()
1845
+ return
1846
+ }
1847
+ if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
1848
+ openCloseModal()
1849
+ return
1850
+ }
1851
+ if (plainKey && key.name === "l" && selectedPullRequest) {
1852
+ openLabelModal()
1853
+ return
1854
+ }
1855
+ if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
1856
+ openMergeModal()
1857
+ return
1858
+ }
1859
+ if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
1860
+ toggleSelectedPullRequestDraftStatus()
1861
+ return
1862
+ }
1863
+ if (plainKey && key.name === "r") {
1864
+ refreshPullRequests("Refreshed")
1865
+ return
1866
+ }
1108
1867
  if (key.name === "home") {
1109
1868
  detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1110
1869
  setDetailScrollOffset(0)
1111
1870
  return
1112
1871
  }
1113
- if (key.name === "end" || isShiftG(key)) {
1872
+ if (key.name === "end") {
1114
1873
  detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1115
1874
  setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
1116
- setPendingG(false)
1117
- if (pendingGTimeoutRef.current !== null) {
1118
- clearTimeout(pendingGTimeoutRef.current)
1119
- pendingGTimeoutRef.current = null
1120
- }
1121
1875
  return
1122
1876
  }
1123
1877
  if (key.name === "pageup") {
@@ -1130,24 +1884,10 @@ export const App = () => {
1130
1884
  setDetailScrollOffset((current) => current + halfPage)
1131
1885
  return
1132
1886
  }
1133
- if (key.name === "g") {
1134
- if (pendingG) {
1135
- detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1136
- setDetailScrollOffset(0)
1137
- setPendingG(false)
1138
- if (pendingGTimeoutRef.current !== null) {
1139
- clearTimeout(pendingGTimeoutRef.current)
1140
- pendingGTimeoutRef.current = null
1141
- }
1142
- } else {
1143
- setPendingG(true)
1144
- pendingGTimeoutRef.current = setTimeout(() => {
1145
- setPendingG(false)
1146
- pendingGTimeoutRef.current = null
1147
- }, 500)
1148
- }
1149
- return
1150
- }
1887
+ if (handleVimGoto(key,
1888
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: 0 }); setDetailScrollOffset(0) },
1889
+ () => { detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER }); setDetailScrollOffset(Number.MAX_SAFE_INTEGER) },
1890
+ )) return
1151
1891
  if (key.name === "up" || key.name === "k") {
1152
1892
  detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
1153
1893
  setDetailScrollOffset((current) => Math.max(0, current - 1))
@@ -1168,14 +1908,12 @@ export const App = () => {
1168
1908
  setDetailScrollOffset((current) => current + halfPage)
1169
1909
  return
1170
1910
  }
1171
- if (key.name === "o" && selectedPullRequest) {
1911
+ if (plainKey && key.name === "o" && selectedPullRequest) {
1172
1912
  openSelectedPullRequestInBrowser(selectedPullRequest)
1173
1913
  return
1174
1914
  }
1175
- if (key.name === "y" && selectedPullRequest) {
1176
- void copyPullRequestMetadata(selectedPullRequest)
1177
- .then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
1178
- .catch((error) => flashNotice(error instanceof Error ? error.message : String(error)))
1915
+ if (plainKey && key.name === "y" && selectedPullRequest) {
1916
+ copySelectedPullRequestMetadata()
1179
1917
  return
1180
1918
  }
1181
1919
  return
@@ -1210,6 +1948,11 @@ export const App = () => {
1210
1948
  }
1211
1949
  }
1212
1950
 
1951
+ if (key.name === "tab") {
1952
+ switchQueueMode(key.shift ? -1 : 1)
1953
+ return
1954
+ }
1955
+
1213
1956
  if (isThemeKey(key)) {
1214
1957
  openThemeModal()
1215
1958
  return
@@ -1286,37 +2029,16 @@ export const App = () => {
1286
2029
  })
1287
2030
  return
1288
2031
  }
1289
- // Vim-style navigation: gg to go to top, G to go to bottom
1290
- if (isShiftG(key)) {
1291
- setSelectedIndex((_current) => {
1292
- if (visiblePullRequests.length === 0) return 0
1293
- return visiblePullRequests.length - 1
1294
- })
1295
- return
1296
- }
1297
- if (key.name === "g") {
1298
- if (pendingG) {
1299
- setSelectedIndex(0)
1300
- setPendingG(false)
1301
- if (pendingGTimeoutRef.current !== null) {
1302
- clearTimeout(pendingGTimeoutRef.current)
1303
- pendingGTimeoutRef.current = null
1304
- }
1305
- } else {
1306
- setPendingG(true)
1307
- pendingGTimeoutRef.current = setTimeout(() => {
1308
- setPendingG(false)
1309
- pendingGTimeoutRef.current = null
1310
- }, 500)
1311
- }
1312
- return
1313
- }
2032
+ if (handleVimGoto(key,
2033
+ () => setSelectedIndex(0),
2034
+ () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2035
+ )) return
1314
2036
  if ((key.name === "return" || key.name === "enter") && !detailFullView) {
1315
2037
  setDetailFullView(true)
1316
2038
  setDetailScrollOffset(0)
1317
2039
  return
1318
2040
  }
1319
- if ((key.name === "d" || key.name === "p") && selectedPullRequest) {
2041
+ if (key.name === "d" && selectedPullRequest) {
1320
2042
  openDiffView()
1321
2043
  return
1322
2044
  }
@@ -1337,30 +2059,12 @@ export const App = () => {
1337
2059
  return
1338
2060
  }
1339
2061
  if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
1340
- const previousPullRequest = selectedPullRequest
1341
- const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
1342
- updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
1343
- ...pullRequest,
1344
- reviewStatus: nextReviewStatus,
1345
- }))
1346
- void toggleDraftStatus({ repository: selectedPullRequest.repository, number: selectedPullRequest.number, isDraft: selectedPullRequest.reviewStatus === "draft" })
1347
- .then(() => {
1348
- flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
1349
- })
1350
- .catch((error) => {
1351
- updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
1352
- flashNotice(error instanceof Error ? error.message : String(error))
1353
- })
2062
+ toggleSelectedPullRequestDraftStatus()
1354
2063
  return
1355
2064
  }
1356
2065
  if (key.name === "y" && selectedPullRequest) {
1357
- void copyPullRequestMetadata(selectedPullRequest)
1358
- .then(() => {
1359
- flashNotice(`Copied #${selectedPullRequest.number} metadata`)
1360
- })
1361
- .catch((error) => {
1362
- flashNotice(error instanceof Error ? error.message : String(error))
1363
- })
2066
+ copySelectedPullRequestMetadata()
2067
+ return
1364
2068
  }
1365
2069
  })
1366
2070
 
@@ -1397,20 +2101,31 @@ export const App = () => {
1397
2101
  const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
1398
2102
  const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1399
2103
  const labelModalHeight = Math.min(20, terminalHeight - 4)
1400
- const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1401
- const labelModalTop = Math.floor((terminalHeight - labelModalHeight) / 2)
2104
+ const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2105
+ const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
1402
2106
  const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1403
2107
  const closeModalHeight = Math.min(12, terminalHeight - 4)
1404
- const closeModalLeft = Math.floor((contentWidth - closeModalWidth) / 2)
1405
- const closeModalTop = Math.floor((terminalHeight - closeModalHeight) / 2)
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)
2118
+ const commentAnchorLabel = selectedDiffCommentAnchor
2119
+ ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2120
+ : "No diff line selected"
1406
2121
  const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1407
2122
  const mergeModalHeight = Math.min(16, terminalHeight - 4)
1408
- const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1409
- const mergeModalTop = Math.floor((terminalHeight - mergeModalHeight) / 2)
2123
+ const mergeModalLeft = centeredOffset(contentWidth, mergeModalWidth)
2124
+ const mergeModalTop = centeredOffset(terminalHeight, mergeModalHeight)
1410
2125
  const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
1411
2126
  const themeModalHeight = Math.min(16, terminalHeight - 4)
1412
- const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
1413
- const themeModalTop = Math.floor((terminalHeight - themeModalHeight) / 2)
2127
+ const themeModalLeft = centeredOffset(contentWidth, themeModalWidth)
2128
+ const themeModalTop = centeredOffset(terminalHeight, themeModalHeight)
1414
2129
 
1415
2130
  return (
1416
2131
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -1428,13 +2143,19 @@ export const App = () => {
1428
2143
  <PullRequestDiffPane
1429
2144
  pullRequest={selectedPullRequest}
1430
2145
  diffState={selectedDiffState}
1431
- fileIndex={diffFileIndex}
2146
+ stackedFiles={stackedDiffFiles}
2147
+ scrollTop={diffScrollTop}
1432
2148
  view={effectiveDiffRenderView}
1433
2149
  wrapMode={diffWrapMode}
1434
2150
  paneWidth={contentWidth}
1435
2151
  height={wideBodyHeight}
1436
2152
  loadingIndicator={loadingIndicator}
1437
2153
  scrollRef={diffScrollRef}
2154
+ setDiffRef={setDiffRenderableRef}
2155
+ commentMode={diffCommentMode}
2156
+ selectedCommentAnchor={selectedDiffCommentAnchor}
2157
+ selectedCommentThread={selectedDiffCommentThread}
2158
+ onSelectCommentLine={selectDiffCommentLine}
1438
2159
  themeId={themeId}
1439
2160
  />
1440
2161
  ) : isWideLayout && detailFullView ? (
@@ -1442,6 +2163,7 @@ export const App = () => {
1442
2163
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
1443
2164
  <DetailsPane
1444
2165
  pullRequest={selectedPullRequest}
2166
+ viewerUsername={username}
1445
2167
  contentWidth={fullscreenContentWidth}
1446
2168
  bodyLines={fullscreenBodyLines}
1447
2169
  paneWidth={contentWidth}
@@ -1453,17 +2175,17 @@ export const App = () => {
1453
2175
  </scrollbox>
1454
2176
  </box>
1455
2177
  ) : isWideLayout ? (
1456
- <box flexGrow={1} flexDirection="row">
2178
+ <box key="wide-main" flexGrow={1} flexDirection="row">
1457
2179
  <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
1458
2180
  <scrollbox height={wideBodyHeight} flexGrow={0}>
1459
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2181
+ <PullRequestList key={`wide-${leftContentWidth}`} {...prListProps} contentWidth={leftContentWidth} />
1460
2182
  </scrollbox>
1461
2183
  </box>
1462
2184
  <SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
1463
2185
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
1464
2186
  {selectedPullRequest ? (
1465
2187
  <>
1466
- <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2188
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1467
2189
  <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
1468
2190
  <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
1469
2191
  </scrollbox>
@@ -1478,6 +2200,7 @@ export const App = () => {
1478
2200
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
1479
2201
  <DetailsPane
1480
2202
  pullRequest={selectedPullRequest}
2203
+ viewerUsername={username}
1481
2204
  contentWidth={fullscreenContentWidth}
1482
2205
  bodyLines={fullscreenBodyLines}
1483
2206
  paneWidth={contentWidth}
@@ -1488,13 +2211,13 @@ export const App = () => {
1488
2211
  </scrollbox>
1489
2212
  </box>
1490
2213
  ) : (
1491
- <box height={wideBodyHeight} flexDirection="column">
1492
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2214
+ <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2215
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
1493
2216
  <Divider width={contentWidth} />
1494
2217
  <box flexGrow={1} flexDirection="column">
1495
2218
  <scrollbox flexGrow={1}>
1496
2219
  <box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
1497
- <PullRequestList {...prListProps} contentWidth={leftContentWidth} />
2220
+ <PullRequestList key={`narrow-${fullscreenContentWidth}`} {...prListProps} contentWidth={fullscreenContentWidth} />
1498
2221
  </box>
1499
2222
  </scrollbox>
1500
2223
  </box>
@@ -1515,6 +2238,7 @@ export const App = () => {
1515
2238
  showFilterClear={filterMode || filterQuery.length > 0}
1516
2239
  detailFullView={detailFullView}
1517
2240
  diffFullView={diffFullView}
2241
+ diffCommentMode={diffCommentMode}
1518
2242
  hasSelection={selectedPullRequest !== null}
1519
2243
  canCloseSelection={selectedPullRequest?.state === "open"}
1520
2244
  hasError={pullRequestStatus === "error"}
@@ -1524,7 +2248,7 @@ export const App = () => {
1524
2248
  />
1525
2249
  )}
1526
2250
  </box>
1527
- {labelModal.open ? (
2251
+ {labelModalActive ? (
1528
2252
  <LabelModal
1529
2253
  state={labelModal}
1530
2254
  currentLabels={selectedPullRequest?.labels ?? []}
@@ -1535,7 +2259,7 @@ export const App = () => {
1535
2259
  loadingIndicator={loadingIndicator}
1536
2260
  />
1537
2261
  ) : null}
1538
- {closeModal.open ? (
2262
+ {closeModalActive ? (
1539
2263
  <CloseModal
1540
2264
  state={closeModal}
1541
2265
  modalWidth={closeModalWidth}
@@ -1545,7 +2269,28 @@ export const App = () => {
1545
2269
  loadingIndicator={loadingIndicator}
1546
2270
  />
1547
2271
  ) : null}
1548
- {mergeModal.open ? (
2272
+ {commentModalActive ? (
2273
+ <CommentModal
2274
+ state={commentModal}
2275
+ anchorLabel={commentAnchorLabel}
2276
+ modalWidth={commentModalWidth}
2277
+ modalHeight={commentModalHeight}
2278
+ offsetLeft={commentModalLeft}
2279
+ offsetTop={commentModalTop}
2280
+ />
2281
+ ) : null}
2282
+ {commentThreadModalActive ? (
2283
+ <CommentThreadModal
2284
+ state={commentThreadModal}
2285
+ anchorLabel={commentAnchorLabel}
2286
+ comments={selectedDiffCommentThread}
2287
+ modalWidth={commentThreadModalWidth}
2288
+ modalHeight={commentThreadModalHeight}
2289
+ offsetLeft={commentThreadModalLeft}
2290
+ offsetTop={commentThreadModalTop}
2291
+ />
2292
+ ) : null}
2293
+ {mergeModalActive ? (
1549
2294
  <MergeModal
1550
2295
  state={mergeModal}
1551
2296
  modalWidth={mergeModalWidth}
@@ -1555,7 +2300,7 @@ export const App = () => {
1555
2300
  loadingIndicator={loadingIndicator}
1556
2301
  />
1557
2302
  ) : null}
1558
- {themeModal.open ? (
2303
+ {themeModalActive ? (
1559
2304
  <ThemeModal
1560
2305
  state={themeModal}
1561
2306
  activeThemeId={themeId}