@kitlangton/ghui 0.1.17 → 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 { 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"
@@ -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,13 +139,14 @@ 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 closeModalAtom = Atom.make(initialCloseModalState).pipe(Atom.keepAlive)
86
- const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
148
+ const activeModalAtom = Atom.make<Modal>(initialModal).pipe(Atom.keepAlive)
87
149
  const themeIdAtom = Atom.make<ThemeId>(initialThemeId).pipe(Atom.keepAlive)
88
- const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
89
150
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
90
151
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
91
152
  const recentlyCompletedPullRequestsAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
@@ -98,8 +159,8 @@ const usernameAtom = githubRuntime.atom(
98
159
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
99
160
  GitHubService.use((github) => github.listRepoLabels(repository))
100
161
  )
101
- const listOpenPullRequestDetailsAtom = githubRuntime.fn<void>()(() =>
102
- GitHubService.use((github) => github.listOpenPullRequestDetails())
162
+ const listOpenPullRequestDetailsAtom = githubRuntime.fn<PullRequestQueueMode>()((queueMode) =>
163
+ GitHubService.use((github) => github.listOpenPullRequestDetails(queueMode))
103
164
  )
104
165
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
105
166
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
@@ -113,6 +174,9 @@ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly
113
174
  const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
114
175
  GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
115
176
  )
177
+ const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
178
+ GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
179
+ )
116
180
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
117
181
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
118
182
  )
@@ -122,11 +186,29 @@ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
122
186
  const closePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
123
187
  GitHubService.use((github) => github.closePullRequest(input.repository, input.number))
124
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)
125
192
 
126
193
  const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
127
194
 
128
195
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
129
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
+
130
212
  const clipboardCommands = (): readonly (readonly string[])[] => {
131
213
  if (process.platform === "darwin") return [["pbcopy"]]
132
214
  if (process.platform === "linux") {
@@ -207,6 +289,52 @@ const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => k
207
289
 
208
290
  const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
209
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
+
210
338
  const getDetailPlaceholderContent = ({
211
339
  status,
212
340
  retryProgress,
@@ -253,6 +381,9 @@ export const App = () => {
253
381
  const { width, height } = useTerminalDimensions()
254
382
  const pullRequestResult = useAtomValue(pullRequestsAtom)
255
383
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
384
+ const [queueMode, setQueueMode] = useAtom(queueModeAtom)
385
+ const [queueLoadCache, setQueueLoadCache] = useAtom(queueLoadCacheAtom)
386
+ const [queueSelection, setQueueSelection] = useAtom(queueSelectionAtom)
256
387
  const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
257
388
  const [notice, setNotice] = useAtom(noticeAtom)
258
389
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
@@ -265,12 +396,43 @@ export const App = () => {
265
396
  const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
266
397
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
267
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)
268
403
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
269
- const [labelModal, setLabelModal] = useAtom(labelModalAtom)
270
- const [closeModal, setCloseModal] = useAtom(closeModalAtom)
271
- const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
404
+ const [activeModal, setActiveModal] = useAtom(activeModalAtom)
272
405
  const [themeId, setThemeId] = useAtom(themeIdAtom)
273
- 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")
274
436
  setActiveTheme(themeId)
275
437
  const themeIdRef = useRef(themeId)
276
438
  const themeModalRef = useRef(themeModal)
@@ -291,9 +453,11 @@ export const App = () => {
291
453
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
292
454
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
293
455
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
456
+ const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
294
457
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
295
458
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
296
459
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
460
+ const createPullRequestComment = useAtomSet(createPullRequestCommentAtom, { mode: "promise" })
297
461
  const terminalWidth = width ?? 100
298
462
  const terminalHeight = height ?? 24
299
463
  const contentWidth = Math.max(1, terminalWidth)
@@ -312,6 +476,7 @@ export const App = () => {
312
476
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
313
477
  const detailHydrationRef = useRef<number | null>(null)
314
478
  const refreshGenerationRef = useRef(0)
479
+ const didMountQueueModeRef = useRef(false)
315
480
  const lastPullRequestRefreshAtRef = useRef(0)
316
481
  const terminalFocusedRef = useRef(true)
317
482
  const terminalWasBlurredRef = useRef(false)
@@ -320,6 +485,9 @@ export const App = () => {
320
485
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
321
486
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
322
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)
323
491
  const headerFooterWidth = Math.max(24, contentWidth - 2)
324
492
 
325
493
  const flashNotice = (message: string) => {
@@ -348,7 +516,9 @@ export const App = () => {
348
516
  }
349
517
  }, [])
350
518
 
351
- const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => 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
352
522
  const pullRequests = useMemo(() => {
353
523
  const source = pullRequestLoad?.data ?? []
354
524
  const seenUrls = new Set<string>()
@@ -362,7 +532,7 @@ export const App = () => {
362
532
  ...Object.values(recentlyCompletedPullRequests).filter((pullRequest) => !seenUrls.has(pullRequest.url)),
363
533
  ]
364
534
  }, [pullRequestLoad?.data, pullRequestOverrides, recentlyCompletedPullRequests])
365
- const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
535
+ const pullRequestStatus: LoadStatus = (pullRequestResult.waiting || isLoadingQueueMode) && pullRequestLoad === null
366
536
  ? "loading"
367
537
  : AsyncResult.isFailure(pullRequestResult)
368
538
  ? "error"
@@ -375,18 +545,47 @@ export const App = () => {
375
545
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
376
546
  const visibleFilterText = filterMode ? filterDraft : filterQuery
377
547
 
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
-
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])
385
561
  const visibleGroups = useMemo(
386
- () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository),
387
- [filteredPullRequests],
562
+ () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository, visibleRepoOrder),
563
+ [filteredPullRequests, visibleRepoOrder],
388
564
  )
389
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
+ )
390
589
  const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
391
590
  if (index === 0) {
392
591
  starts.push(0)
@@ -406,12 +605,15 @@ export const App = () => {
406
605
  : pullRequestStatus === "loading"
407
606
  ? "loading pull requests..."
408
607
  : ""
409
- const headerLeft = username ? `GHUI ${username}` : "GHUI"
608
+ const headerLeft = username ? `GHUI ${username} ${pullRequestQueueLabels[queueMode]}` : `GHUI ${pullRequestQueueLabels[queueMode]}`
410
609
  const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
411
610
  const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
412
611
  const selectPullRequestByUrl = (url: string) => {
413
612
  const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
414
- if (index >= 0) setSelectedIndex(index)
613
+ if (index >= 0) {
614
+ setSelectedIndex(index)
615
+ setQueueSelection((current) => ({ ...current, [queueMode]: index }))
616
+ }
415
617
  }
416
618
  const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
417
619
  const pullRequest = pullRequests.find((item) => item.url === url)
@@ -429,6 +631,23 @@ export const App = () => {
429
631
  refreshPullRequestsAtom()
430
632
  }
431
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
+ }
432
651
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
433
652
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
434
653
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -443,6 +662,15 @@ export const App = () => {
443
662
  }
444
663
  }, [pullRequestLoad?.fetchedAt])
445
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
+
446
674
  useEffect(() => {
447
675
  if (!refreshCompletionMessage || refreshStartedAt === null) return
448
676
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
@@ -498,13 +726,69 @@ export const App = () => {
498
726
  })
499
727
  }, [visiblePullRequests.length])
500
728
 
729
+ useEffect(() => {
730
+ setQueueSelection((current) => current[queueMode] === selectedIndex ? current : { ...current, [queueMode]: selectedIndex })
731
+ }, [queueMode, selectedIndex])
732
+
501
733
  useEffect(() => {
502
734
  setDiffFileIndex(0)
735
+ setDiffCommentAnchorIndex(0)
503
736
  }, [selectedIndex])
504
737
 
505
- const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
506
- const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
507
- const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
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])
508
792
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)
509
793
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
510
794
  const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
@@ -521,23 +805,29 @@ export const App = () => {
521
805
  useEffect(() => {
522
806
  const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
523
807
  if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
524
- if (detailHydrationRef.current === fetchedAt) return
808
+ if (detailHydrationRef.current === fetchedAt || pullRequestLoad?.detailsFetchedAt?.getTime() === fetchedAt) return
525
809
  if (!pullRequests.some((pullRequest) => pullRequest.state === "open" && !pullRequest.detailLoaded)) return
526
810
  detailHydrationRef.current = fetchedAt
527
811
  const generation = refreshGenerationRef.current
528
- void loadPullRequestDetails().then((details) => {
812
+ void loadPullRequestDetails(queueMode).then((details) => {
529
813
  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
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
+ },
534
825
  }
535
- return next
536
826
  })
537
827
  }).catch((error) => {
538
- flashNotice(error instanceof Error ? error.message : String(error))
828
+ flashNotice(errorMessage(error))
539
829
  })
540
- }, [pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
830
+ }, [queueMode, pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
541
831
 
542
832
  const detailPlaceholderContent = getDetailPlaceholderContent({
543
833
  status: pullRequestStatus,
@@ -550,9 +840,53 @@ export const App = () => {
550
840
 
551
841
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
552
842
 
553
- 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
554
887
  const key = pullRequestDiffKey(pullRequest)
555
888
  const existing = pullRequestDiffCache[key]
889
+ if (includeComments) loadPullRequestComments(pullRequest, force)
556
890
  if (!force && (existing?.status === "ready" || existing?.status === "loading")) return
557
891
 
558
892
  setPullRequestDiffCache((current) => ({ ...current, [key]: { status: "loading" } }))
@@ -590,12 +924,168 @@ export const App = () => {
590
924
 
591
925
  const openDiffView = () => {
592
926
  if (!selectedPullRequest) return
927
+ diffRenderableRefs.current.clear()
928
+ diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
593
929
  setDiffFullView(true)
594
930
  setDetailFullView(false)
931
+ setDiffCommentMode(false)
595
932
  setDiffFileIndex(0)
596
933
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
597
934
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
598
- 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
+ })
599
1089
  }
600
1090
 
601
1091
  const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
@@ -604,13 +1094,34 @@ export const App = () => {
604
1094
  .catch((error) => flashNotice(errorMessage(error)))
605
1095
  }
606
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
+
607
1122
  const openCloseModal = () => {
608
1123
  if (!selectedPullRequest || selectedPullRequest.state !== "open") return
609
- setLabelModal(initialLabelModalState)
610
- setMergeModal(initialMergeModalState)
611
- setThemeModal(initialThemeModalState)
612
1124
  setCloseModal({
613
- open: true,
614
1125
  repository: selectedPullRequest.repository,
615
1126
  number: selectedPullRequest.number,
616
1127
  title: selectedPullRequest.title,
@@ -639,7 +1150,7 @@ export const App = () => {
639
1150
  },
640
1151
  }))
641
1152
  }
642
- setCloseModal(initialCloseModalState)
1153
+ closeActiveModal()
643
1154
  refreshPullRequests(`Closed #${number}`)
644
1155
  })
645
1156
  .catch((error) => {
@@ -649,11 +1160,7 @@ export const App = () => {
649
1160
  }
650
1161
 
651
1162
  const openThemeModal = () => {
652
- setLabelModal(initialLabelModalState)
653
- setCloseModal(initialCloseModalState)
654
- setMergeModal(initialMergeModalState)
655
1163
  setThemeModal({
656
- open: true,
657
1164
  query: "",
658
1165
  filterMode: false,
659
1166
  initialThemeId: themeId,
@@ -668,7 +1175,7 @@ export const App = () => {
668
1175
  void Effect.runPromise(saveStoredThemeId(selectedTheme.id)).catch((error) => flashNotice(errorMessage(error)))
669
1176
  flashNotice(`Theme: ${selectedTheme.name}`)
670
1177
  }
671
- setThemeModal(initialThemeModalState)
1178
+ closeActiveModal()
672
1179
  }
673
1180
 
674
1181
  const previewTheme = (id: ThemeId) => {
@@ -711,14 +1218,10 @@ export const App = () => {
711
1218
 
712
1219
  const openLabelModal = () => {
713
1220
  if (!selectedPullRequest) return
714
- setCloseModal(initialCloseModalState)
715
- setMergeModal(initialMergeModalState)
716
- setThemeModal(initialThemeModalState)
717
1221
  const repository = selectedPullRequest.repository
718
1222
  const cachedLabels = labelCache[repository]
719
1223
  if (cachedLabels) {
720
1224
  setLabelModal({
721
- open: true,
722
1225
  repository,
723
1226
  query: "",
724
1227
  selectedIndex: 0,
@@ -728,7 +1231,7 @@ export const App = () => {
728
1231
  return
729
1232
  }
730
1233
 
731
- setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
1234
+ setLabelModal({ repository, query: "", selectedIndex: 0, availableLabels: [], loading: true })
732
1235
  void loadRepoLabels(repository)
733
1236
  .then((labels) => {
734
1237
  setLabelCache((current) => ({ ...current, [repository]: labels }))
@@ -736,20 +1239,16 @@ export const App = () => {
736
1239
  })
737
1240
  .catch((error) => {
738
1241
  setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
739
- flashNotice(error instanceof Error ? error.message : String(error))
1242
+ flashNotice(errorMessage(error))
740
1243
  })
741
1244
  }
742
1245
 
743
1246
  const openMergeModal = () => {
744
1247
  if (!selectedPullRequest) return
745
- setCloseModal(initialCloseModalState)
746
- setThemeModal(initialThemeModalState)
747
1248
  const repository = selectedPullRequest.repository
748
1249
  const number = selectedPullRequest.number
749
1250
  const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
750
- setLabelModal(initialLabelModalState)
751
1251
  setMergeModal({
752
- open: true,
753
1252
  repository,
754
1253
  number,
755
1254
  selectedIndex: 0,
@@ -803,7 +1302,7 @@ export const App = () => {
803
1302
  },
804
1303
  }))
805
1304
  }
806
- setMergeModal(initialMergeModalState)
1305
+ closeActiveModal()
807
1306
  if (option.refreshOnSuccess) {
808
1307
  refreshPullRequests(`${option.pastTense} #${number}`)
809
1308
  } else {
@@ -837,7 +1336,7 @@ export const App = () => {
837
1336
  .then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
838
1337
  .catch((error) => {
839
1338
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
840
- flashNotice(error instanceof Error ? error.message : String(error))
1339
+ flashNotice(errorMessage(error))
841
1340
  })
842
1341
  } else {
843
1342
  updatePullRequest(selectedPullRequest.url, (pr) => ({
@@ -848,34 +1347,26 @@ export const App = () => {
848
1347
  .then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
849
1348
  .catch((error) => {
850
1349
  updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
851
- flashNotice(error instanceof Error ? error.message : String(error))
1350
+ flashNotice(errorMessage(error))
852
1351
  })
853
1352
  }
854
1353
  }
855
1354
 
856
1355
  useKeyboard((key) => {
857
- if ((key.name === "q" && !(themeModal.open && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
858
- if (themeModal.open) {
1356
+ if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1357
+ if (themeModalActive) {
859
1358
  closeThemeModal(false)
860
1359
  return
861
1360
  }
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)
1361
+ if (activeModal._tag !== "None") {
1362
+ closeActiveModal()
872
1363
  return
873
1364
  }
874
1365
  renderer.destroy()
875
1366
  return
876
1367
  }
877
1368
 
878
- if (themeModal.open) {
1369
+ if (themeModalActive) {
879
1370
  if (key.name === "escape") {
880
1371
  if (themeModal.filterMode) {
881
1372
  updateThemeQuery("", { filterMode: false })
@@ -916,9 +1407,137 @@ export const App = () => {
916
1407
  return
917
1408
  }
918
1409
 
919
- if (closeModal.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) {
920
1539
  if (key.name === "escape") {
921
- setCloseModal(initialCloseModalState)
1540
+ closeActiveModal()
922
1541
  return
923
1542
  }
924
1543
  if (key.name === "return" || key.name === "enter") {
@@ -928,10 +1547,10 @@ export const App = () => {
928
1547
  return
929
1548
  }
930
1549
 
931
- if (mergeModal.open) {
1550
+ if (mergeModalActive) {
932
1551
  const options = availableMergeActions(mergeModal.info)
933
1552
  if (key.name === "escape") {
934
- setMergeModal(initialMergeModalState)
1553
+ closeActiveModal()
935
1554
  return
936
1555
  }
937
1556
  if ((key.name === "return" || key.name === "enter") && options.length > 0) {
@@ -956,9 +1575,9 @@ export const App = () => {
956
1575
  }
957
1576
 
958
1577
  // Label modal takes priority over everything else
959
- if (labelModal.open) {
1578
+ if (labelModalActive) {
960
1579
  if (key.name === "escape") {
961
- setLabelModal(initialLabelModalState)
1580
+ closeActiveModal()
962
1581
  return
963
1582
  }
964
1583
  if (key.name === "return" || key.name === "enter") {
@@ -1006,28 +1625,94 @@ export const App = () => {
1006
1625
  }
1007
1626
 
1008
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
+
1009
1689
  if (key.name === "escape" || key.name === "return" || key.name === "enter") {
1010
1690
  setDiffFullView(false)
1691
+ setDiffCommentMode(false)
1692
+ return
1693
+ }
1694
+ if (key.name === "c" && selectedDiffState?.status === "ready") {
1695
+ enterDiffCommentMode()
1011
1696
  return
1012
1697
  }
1013
1698
  if (key.name === "home") {
1014
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1699
+ scrollDiffTo(0)
1015
1700
  return
1016
1701
  }
1017
1702
  if (key.name === "end") {
1018
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1703
+ scrollDiffTo(Number.MAX_SAFE_INTEGER)
1019
1704
  return
1020
1705
  }
1021
1706
  if (key.name === "pageup") {
1022
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1707
+ scrollDiffBy(-halfPage)
1023
1708
  return
1024
1709
  }
1025
1710
  if (key.name === "pagedown") {
1026
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1711
+ scrollDiffBy(halfPage)
1027
1712
  return
1028
1713
  }
1029
1714
  if (isShiftG(key)) {
1030
- diffScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
1715
+ scrollDiffTo(Number.MAX_SAFE_INTEGER)
1031
1716
  setPendingG(false)
1032
1717
  if (pendingGTimeoutRef.current !== null) {
1033
1718
  clearTimeout(pendingGTimeoutRef.current)
@@ -1037,7 +1722,7 @@ export const App = () => {
1037
1722
  }
1038
1723
  if (key.name === "g") {
1039
1724
  if (pendingG) {
1040
- diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1725
+ scrollDiffTo(0)
1041
1726
  setPendingG(false)
1042
1727
  if (pendingGTimeoutRef.current !== null) {
1043
1728
  clearTimeout(pendingGTimeoutRef.current)
@@ -1053,19 +1738,19 @@ export const App = () => {
1053
1738
  return
1054
1739
  }
1055
1740
  if (key.name === "up" || key.name === "k") {
1056
- diffScrollRef.current?.scrollBy({ x: 0, y: -1 })
1741
+ scrollDiffBy(-1)
1057
1742
  return
1058
1743
  }
1059
1744
  if (key.name === "down" || key.name === "j") {
1060
- diffScrollRef.current?.scrollBy({ x: 0, y: 1 })
1745
+ scrollDiffBy(1)
1061
1746
  return
1062
1747
  }
1063
1748
  if (key.ctrl && key.name === "u") {
1064
- diffScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
1749
+ scrollDiffBy(-halfPage)
1065
1750
  return
1066
1751
  }
1067
1752
  if (key.ctrl && (key.name === "d" || key.name === "v")) {
1068
- diffScrollRef.current?.scrollBy({ x: 0, y: halfPage })
1753
+ scrollDiffBy(halfPage)
1069
1754
  return
1070
1755
  }
1071
1756
  if (key.name === "v") {
@@ -1077,18 +1762,16 @@ export const App = () => {
1077
1762
  return
1078
1763
  }
1079
1764
  if (key.name === "r" && selectedPullRequest) {
1080
- loadPullRequestDiff(selectedPullRequest, true)
1765
+ loadPullRequestDiff(selectedPullRequest, { force: true, includeComments: true })
1081
1766
  flashNotice(`Refreshing diff for #${selectedPullRequest.number}`)
1082
1767
  return
1083
1768
  }
1084
1769
  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 })
1770
+ jumpDiffFile(1)
1087
1771
  return
1088
1772
  }
1089
1773
  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 })
1774
+ jumpDiffFile(-1)
1092
1775
  return
1093
1776
  }
1094
1777
  if (key.name === "o" && selectedPullRequest) {
@@ -1100,11 +1783,40 @@ export const App = () => {
1100
1783
 
1101
1784
  // Fullscreen detail mode handles its own navigation keys.
1102
1785
  if (detailFullView) {
1786
+ const plainKey = !key.ctrl && !key.meta && !key.option
1103
1787
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1104
1788
  setDetailFullView(false)
1105
1789
  setDetailScrollOffset(0)
1106
1790
  return
1107
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
+ }
1108
1820
  if (key.name === "home") {
1109
1821
  detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
1110
1822
  setDetailScrollOffset(0)
@@ -1168,14 +1880,12 @@ export const App = () => {
1168
1880
  setDetailScrollOffset((current) => current + halfPage)
1169
1881
  return
1170
1882
  }
1171
- if (key.name === "o" && selectedPullRequest) {
1883
+ if (plainKey && key.name === "o" && selectedPullRequest) {
1172
1884
  openSelectedPullRequestInBrowser(selectedPullRequest)
1173
1885
  return
1174
1886
  }
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)))
1887
+ if (plainKey && key.name === "y" && selectedPullRequest) {
1888
+ copySelectedPullRequestMetadata()
1179
1889
  return
1180
1890
  }
1181
1891
  return
@@ -1210,6 +1920,11 @@ export const App = () => {
1210
1920
  }
1211
1921
  }
1212
1922
 
1923
+ if (key.name === "tab") {
1924
+ switchQueueMode(key.shift ? -1 : 1)
1925
+ return
1926
+ }
1927
+
1213
1928
  if (isThemeKey(key)) {
1214
1929
  openThemeModal()
1215
1930
  return
@@ -1316,7 +2031,7 @@ export const App = () => {
1316
2031
  setDetailScrollOffset(0)
1317
2032
  return
1318
2033
  }
1319
- if ((key.name === "d" || key.name === "p") && selectedPullRequest) {
2034
+ if (key.name === "d" && selectedPullRequest) {
1320
2035
  openDiffView()
1321
2036
  return
1322
2037
  }
@@ -1337,30 +2052,12 @@ export const App = () => {
1337
2052
  return
1338
2053
  }
1339
2054
  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
- })
2055
+ toggleSelectedPullRequestDraftStatus()
1354
2056
  return
1355
2057
  }
1356
2058
  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
- })
2059
+ copySelectedPullRequestMetadata()
2060
+ return
1364
2061
  }
1365
2062
  })
1366
2063
 
@@ -1397,20 +2094,31 @@ export const App = () => {
1397
2094
  const longestLabelName = labelModal.availableLabels.reduce((max, label) => Math.max(max, label.name.length), 0)
1398
2095
  const labelModalWidth = Math.min(Math.max(42, longestLabelName + 16), 56, contentWidth - 4)
1399
2096
  const labelModalHeight = Math.min(20, terminalHeight - 4)
1400
- const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
1401
- const labelModalTop = Math.floor((terminalHeight - labelModalHeight) / 2)
2097
+ const labelModalLeft = centeredOffset(contentWidth, labelModalWidth)
2098
+ const labelModalTop = centeredOffset(terminalHeight, labelModalHeight)
1402
2099
  const closeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1403
2100
  const closeModalHeight = Math.min(12, terminalHeight - 4)
1404
- const closeModalLeft = Math.floor((contentWidth - closeModalWidth) / 2)
1405
- const closeModalTop = Math.floor((terminalHeight - closeModalHeight) / 2)
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"
1406
2114
  const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1407
2115
  const mergeModalHeight = Math.min(16, terminalHeight - 4)
1408
- const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1409
- const mergeModalTop = Math.floor((terminalHeight - mergeModalHeight) / 2)
2116
+ const mergeModalLeft = centeredOffset(contentWidth, mergeModalWidth)
2117
+ const mergeModalTop = centeredOffset(terminalHeight, mergeModalHeight)
1410
2118
  const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
1411
2119
  const themeModalHeight = Math.min(16, terminalHeight - 4)
1412
- const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
1413
- const themeModalTop = Math.floor((terminalHeight - themeModalHeight) / 2)
2120
+ const themeModalLeft = centeredOffset(contentWidth, themeModalWidth)
2121
+ const themeModalTop = centeredOffset(terminalHeight, themeModalHeight)
1414
2122
 
1415
2123
  return (
1416
2124
  <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
@@ -1428,6 +2136,7 @@ export const App = () => {
1428
2136
  <PullRequestDiffPane
1429
2137
  pullRequest={selectedPullRequest}
1430
2138
  diffState={selectedDiffState}
2139
+ stackedFiles={stackedDiffFiles}
1431
2140
  fileIndex={diffFileIndex}
1432
2141
  view={effectiveDiffRenderView}
1433
2142
  wrapMode={diffWrapMode}
@@ -1435,6 +2144,11 @@ export const App = () => {
1435
2144
  height={wideBodyHeight}
1436
2145
  loadingIndicator={loadingIndicator}
1437
2146
  scrollRef={diffScrollRef}
2147
+ setDiffRef={setDiffRenderableRef}
2148
+ commentMode={diffCommentMode}
2149
+ selectedCommentAnchor={selectedDiffCommentAnchor}
2150
+ selectedCommentThread={selectedDiffCommentThread}
2151
+ commentCount={selectedDiffCommentCount}
1438
2152
  themeId={themeId}
1439
2153
  />
1440
2154
  ) : isWideLayout && detailFullView ? (
@@ -1442,6 +2156,7 @@ export const App = () => {
1442
2156
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
1443
2157
  <DetailsPane
1444
2158
  pullRequest={selectedPullRequest}
2159
+ viewerUsername={username}
1445
2160
  contentWidth={fullscreenContentWidth}
1446
2161
  bodyLines={fullscreenBodyLines}
1447
2162
  paneWidth={contentWidth}
@@ -1463,7 +2178,7 @@ export const App = () => {
1463
2178
  <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
1464
2179
  {selectedPullRequest ? (
1465
2180
  <>
1466
- <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2181
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
1467
2182
  <scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
1468
2183
  <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} themeId={themeId} />
1469
2184
  </scrollbox>
@@ -1478,6 +2193,7 @@ export const App = () => {
1478
2193
  <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
1479
2194
  <DetailsPane
1480
2195
  pullRequest={selectedPullRequest}
2196
+ viewerUsername={username}
1481
2197
  contentWidth={fullscreenContentWidth}
1482
2198
  bodyLines={fullscreenBodyLines}
1483
2199
  paneWidth={contentWidth}
@@ -1489,7 +2205,7 @@ export const App = () => {
1489
2205
  </box>
1490
2206
  ) : (
1491
2207
  <box height={wideBodyHeight} flexDirection="column">
1492
- <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} />
1493
2209
  <Divider width={contentWidth} />
1494
2210
  <box flexGrow={1} flexDirection="column">
1495
2211
  <scrollbox flexGrow={1}>
@@ -1515,6 +2231,7 @@ export const App = () => {
1515
2231
  showFilterClear={filterMode || filterQuery.length > 0}
1516
2232
  detailFullView={detailFullView}
1517
2233
  diffFullView={diffFullView}
2234
+ diffCommentMode={diffCommentMode}
1518
2235
  hasSelection={selectedPullRequest !== null}
1519
2236
  canCloseSelection={selectedPullRequest?.state === "open"}
1520
2237
  hasError={pullRequestStatus === "error"}
@@ -1524,7 +2241,7 @@ export const App = () => {
1524
2241
  />
1525
2242
  )}
1526
2243
  </box>
1527
- {labelModal.open ? (
2244
+ {labelModalActive ? (
1528
2245
  <LabelModal
1529
2246
  state={labelModal}
1530
2247
  currentLabels={selectedPullRequest?.labels ?? []}
@@ -1535,7 +2252,7 @@ export const App = () => {
1535
2252
  loadingIndicator={loadingIndicator}
1536
2253
  />
1537
2254
  ) : null}
1538
- {closeModal.open ? (
2255
+ {closeModalActive ? (
1539
2256
  <CloseModal
1540
2257
  state={closeModal}
1541
2258
  modalWidth={closeModalWidth}
@@ -1545,7 +2262,28 @@ export const App = () => {
1545
2262
  loadingIndicator={loadingIndicator}
1546
2263
  />
1547
2264
  ) : null}
1548
- {mergeModal.open ? (
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 ? (
1549
2287
  <MergeModal
1550
2288
  state={mergeModal}
1551
2289
  modalWidth={mergeModalWidth}
@@ -1555,7 +2293,7 @@ export const App = () => {
1555
2293
  loadingIndicator={loadingIndicator}
1556
2294
  />
1557
2295
  ) : null}
1558
- {themeModal.open ? (
2296
+ {themeModalActive ? (
1559
2297
  <ThemeModal
1560
2298
  state={themeModal}
1561
2299
  activeThemeId={themeId}