@kitlangton/ghui 0.1.21 → 0.2.0

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,6 +1,8 @@
1
1
  import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useBindings } from "@opentui/keymap/react"
3
+ import { useKeymap } from "@ghui/keymap/react"
4
+ import { appKeymap, type AppCtx } from "./keymap/all.js"
5
+ import { useOpenTuiSubscribe } from "./keyboard/opentuiAdapter.js"
4
6
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
5
7
  import { Cause, Effect, Layer, Schedule } from "effect"
6
8
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -11,7 +13,7 @@ import { buildAppCommands } from "./appCommands.js"
11
13
  import type { AppCommand } from "./commands.js"
12
14
  import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
13
15
  import { config } from "./config.js"
14
- import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestConversationItem, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
15
17
  import { formatShortDate, formatTimestamp } from "./date.js"
16
18
  import { errorMessage } from "./errors.js"
17
19
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
@@ -22,12 +24,13 @@ import { BrowserOpener } from "./services/BrowserOpener.js"
22
24
  import { Clipboard } from "./services/Clipboard.js"
23
25
  import { CommandRunner } from "./services/CommandRunner.js"
24
26
  import { GitHubService } from "./services/GitHubService.js"
25
- import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
27
+ import { loadStoredDiffWhitespaceMode, loadStoredThemeId, saveStoredDiffWhitespaceMode, saveStoredThemeId } from "./themeStore.js"
26
28
  import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
27
29
  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"
28
- import { buildStackedDiffFiles, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
29
- import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
30
+ import { buildStackedDiffFiles, diffAnchorOnSide, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, minimizeWhitespaceDiffFiles, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileIndexAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type StackedDiffCommentAnchor, verticalDiffAnchor } from "./ui/diff.js"
31
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailConversationStatus, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
30
32
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
33
+ import { LoadingLogoPane } from "./ui/LoadingLogo.js"
31
34
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
32
35
  import { CommandPalette } from "./ui/CommandPalette.js"
33
36
  import { CloseModal, CommentModal, CommentThreadModal, filterLabels, initialCloseModalState, initialCommandPaletteState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialOpenRepositoryModalState, initialThemeModalState, LabelModal, MergeModal, Modal, OpenRepositoryModal, ThemeModal, type CloseModalState, type CommandPaletteState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type OpenRepositoryModalState, type ThemeModalState } from "./ui/modals.js"
@@ -35,6 +38,7 @@ import { groupBy, reviewLabel } from "./ui/pullRequests.js"
35
38
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
36
39
  import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
37
40
  import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
41
+ import { SPINNER_FRAMES } from "./ui/spinner.js"
38
42
 
39
43
  const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
40
44
  if (value === undefined) return fallback
@@ -54,7 +58,10 @@ const githubRuntime = Atom.runtime(
54
58
  Layer.provideMerge(Observability.layer),
55
59
  ),
56
60
  )
57
- const initialThemeId = await Effect.runPromise(loadStoredThemeId)
61
+ const [initialThemeId, initialDiffWhitespaceMode] = await Promise.all([
62
+ Effect.runPromise(loadStoredThemeId),
63
+ Effect.runPromise(loadStoredDiffWhitespaceMode),
64
+ ])
58
65
 
59
66
  interface PullRequestLoad {
60
67
  readonly view: PullRequestView
@@ -96,6 +103,11 @@ interface AppliedDiffLineColorState {
96
103
  readonly entries: readonly AppliedDiffLineColor[]
97
104
  }
98
105
 
106
+ interface DiffCommentRangeSelection {
107
+ readonly start: StackedDiffCommentAnchor
108
+ readonly end: StackedDiffCommentAnchor
109
+ }
110
+
99
111
  interface DetailHydration {
100
112
  readonly token: symbol
101
113
  notifyError: boolean
@@ -106,7 +118,6 @@ const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
106
118
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
107
119
  const AUTO_REFRESH_JITTER_MS = 10_000
108
120
  const DIFF_STICKY_HEADER_LINES = 2
109
- const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
110
121
  const MAX_REPOSITORY_CACHE_ENTRIES = 8
111
122
  const LOAD_MORE_SELECTION_THRESHOLD = 8
112
123
  const LOAD_MORE_SCROLL_THRESHOLD = 3
@@ -114,7 +125,6 @@ const DETAIL_PREFETCH_BEHIND = 1
114
125
  const DETAIL_PREFETCH_AHEAD = 3
115
126
  const DETAIL_PREFETCH_CONCURRENCY = 3
116
127
  const DETAIL_PREFETCH_DELAY_MS = 120
117
-
118
128
  const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
119
129
  const seen = new Set(existing.map((pullRequest) => pullRequest.url))
120
130
  const mergedIncoming = mergeCachedDetails(incoming, existing)
@@ -179,7 +189,6 @@ const noticeAtom = Atom.make<string | null>(null)
179
189
  const filterQueryAtom = Atom.make("")
180
190
  const filterDraftAtom = Atom.make("")
181
191
  const filterModeAtom = Atom.make(false)
182
- const pendingGAtom = Atom.make(false)
183
192
  const detailFullViewAtom = Atom.make(false)
184
193
  const detailScrollOffsetAtom = Atom.make(0)
185
194
  const diffFullViewAtom = Atom.make(false)
@@ -187,10 +196,14 @@ const diffFileIndexAtom = Atom.make(0)
187
196
  const diffScrollTopAtom = Atom.make(0)
188
197
  const diffRenderViewAtom = Atom.make<DiffView>("split")
189
198
  const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
190
- const diffCommentModeAtom = Atom.make(false)
199
+ const diffWhitespaceModeAtom = Atom.make<DiffWhitespaceMode>(initialDiffWhitespaceMode)
191
200
  const diffCommentAnchorIndexAtom = Atom.make(0)
201
+ const diffPreferredSideAtom = Atom.make<DiffCommentSide | null>(null)
202
+ const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
192
203
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
193
204
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
205
+ const pullRequestConversationAtom = Atom.make<Record<string, readonly PullRequestConversationItem[]>>({}).pipe(Atom.keepAlive)
206
+ const pullRequestConversationLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
194
207
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
195
208
 
196
209
  const activeModalAtom = Atom.make<Modal>(initialModal)
@@ -324,6 +337,9 @@ const pullRequestDiffAtom = Atom.family((key: string) => {
324
337
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
325
338
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
326
339
  )
340
+ const listPullRequestConversationAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
341
+ GitHubService.use((github) => github.listPullRequestConversation(input.repository, input.number))
342
+ )
327
343
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
328
344
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
329
345
  )
@@ -381,12 +397,13 @@ const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
381
397
  const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
382
398
  const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
383
399
 
384
- const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
385
400
 
386
- const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
401
+
402
+ const diffCommentThreadMapKey = (diffKey: string, location: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
403
+ `${diffKey}:${diffCommentLocationKey(location)}`
387
404
 
388
405
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
389
- `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
406
+ diffCommentThreadMapKey(pullRequestDiffKey(pullRequest), comment)
390
407
 
391
408
  const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
392
409
  const threads: Record<string, PullRequestReviewComment[]> = {}
@@ -411,13 +428,43 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
411
428
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
412
429
  }
413
430
 
414
- const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
415
- const accent = kind === "thread"
416
- ? colors.status.pending
417
- : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
418
- return mixHex(originalDiffLineColor(anchor).gutter, accent, 0.45)
431
+ const selectedDiffCommentAccentByKind = {
432
+ addition: () => colors.status.passing,
433
+ deletion: () => colors.status.failing,
434
+ context: () => colors.muted,
435
+ } satisfies Record<DiffCommentKind, () => string>
436
+
437
+ const selectedDiffCommentAccent = (kind: DiffCommentKind) => selectedDiffCommentAccentByKind[kind]()
438
+
439
+ const mixDiffLineContentColor = (base: string, accent: string, amount: number) =>
440
+ mixHex(base === "transparent" ? colors.background : base, accent, amount)
441
+
442
+ const diffCommentLineColor = (anchor: DiffCommentAnchor, kind: "selected" | "range" | "thread"): DiffLineColorConfig => {
443
+ const original = originalDiffLineColor(anchor)
444
+ const accent = kind === "thread" ? colors.status.pending : selectedDiffCommentAccent(anchor.kind)
445
+ if (kind === "thread") return { ...original, gutter: mixHex(original.gutter, accent, 0.45) }
446
+ return {
447
+ gutter: mixHex(original.gutter, accent, kind === "selected" ? 0.68 : 0.42),
448
+ content: mixDiffLineContentColor(original.content, accent, kind === "selected" ? 0.2 : 0.1),
449
+ }
450
+ }
451
+
452
+ const sameDiffCommentTarget = (left: DiffCommentAnchor, right: DiffCommentAnchor) =>
453
+ left.path === right.path && left.side === right.side
454
+
455
+ const diffCommentRangeSelection = (start: StackedDiffCommentAnchor | null, end: StackedDiffCommentAnchor | null): DiffCommentRangeSelection | null => {
456
+ if (!start || !end || !sameDiffCommentTarget(start, end)) return null
457
+ return start.line <= end.line ? { start, end } : { start: end, end: start }
419
458
  }
420
459
 
460
+ const diffCommentRangeContains = (range: DiffCommentRangeSelection, anchor: StackedDiffCommentAnchor) =>
461
+ sameDiffCommentTarget(range.start, anchor) && anchor.line >= range.start.line && anchor.line <= range.end.line
462
+
463
+ const diffCommentRangeLabel = (range: DiffCommentRangeSelection) =>
464
+ range.start.line === range.end.line
465
+ ? diffCommentAnchorLabel(range.end)
466
+ : `${diffCommentSideLabel(range.end)} ${diffCommentLineLabel(range.start)}-${diffCommentLineLabel(range.end)}`
467
+
421
468
  const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
422
469
  const withSides = diff as unknown as DiffRenderableRuntimeSides
423
470
  if (view === "split") {
@@ -488,7 +535,6 @@ export const App = () => {
488
535
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
489
536
  const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
490
537
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
491
- const [pendingG, setPendingG] = useAtom(pendingGAtom)
492
538
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
493
539
  const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
494
540
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
@@ -496,10 +542,14 @@ export const App = () => {
496
542
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
497
543
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
498
544
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
499
- const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
545
+ const [diffWhitespaceMode, setDiffWhitespaceMode] = useAtom(diffWhitespaceModeAtom)
500
546
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
547
+ const [diffPreferredSide, setDiffPreferredSide] = useAtom(diffPreferredSideAtom)
548
+ const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
501
549
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
502
550
  const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
551
+ const setPullRequestConversation = useAtomSet(pullRequestConversationAtom)
552
+ const setPullRequestConversationLoaded = useAtomSet(pullRequestConversationLoadedAtom)
503
553
  const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
504
554
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
505
555
  const [themeId, setThemeId] = useAtom(themeIdAtom)
@@ -551,6 +601,7 @@ export const App = () => {
551
601
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
552
602
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
553
603
  const [terminalFocused, setTerminalFocused] = useState(true)
604
+ const [startupLoadComplete, setStartupLoadComplete] = useState(false)
554
605
  const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
555
606
  const usernameResult = useAtomValue(usernameAtom)
556
607
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
@@ -559,6 +610,7 @@ export const App = () => {
559
610
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
560
611
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
561
612
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
613
+ const listPullRequestConversation = useAtomSet(listPullRequestConversationAtom, { mode: "promise" })
562
614
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
563
615
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
564
616
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
@@ -579,7 +631,6 @@ export const App = () => {
579
631
  const wideDetailLines = Math.max(8, terminalHeight - 8)
580
632
  const wideBodyHeight = Math.max(8, terminalHeight - 4)
581
633
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
582
- const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
583
634
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
584
635
  const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
585
636
  const detailHydrationRef = useRef(new Map<string, DetailHydration>())
@@ -589,7 +640,7 @@ export const App = () => {
589
640
  const terminalFocusedRef = useRef(true)
590
641
  const terminalWasBlurredRef = useRef(false)
591
642
  const pullRequestStatusRef = useRef<LoadStatus>("loading")
592
- const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
643
+ const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {})
593
644
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
594
645
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
595
646
  const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
@@ -620,9 +671,6 @@ export const App = () => {
620
671
  if (noticeTimeoutRef.current !== null) {
621
672
  clearTimeout(noticeTimeoutRef.current)
622
673
  }
623
- if (pendingGTimeoutRef.current !== null) {
624
- clearTimeout(pendingGTimeoutRef.current)
625
- }
626
674
  if (diffPrefetchTimeoutRef.current !== null) {
627
675
  clearTimeout(diffPrefetchTimeoutRef.current)
628
676
  }
@@ -634,7 +682,7 @@ export const App = () => {
634
682
  const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
635
683
  const pullRequests = useAtomValue(displayedPullRequestsAtom)
636
684
  const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
637
- const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
685
+ const isInitialLoading = !startupLoadComplete && pullRequestStatus === "loading" && pullRequests.length === 0
638
686
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
639
687
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
640
688
  pullRequestStatusRef.current = pullRequestStatus
@@ -644,6 +692,8 @@ export const App = () => {
644
692
  const visibleGroups = useAtomValue(visibleGroupsAtom)
645
693
  const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
646
694
  const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
695
+ const pullRequestConversation = useAtomValue(pullRequestConversationAtom)
696
+ const pullRequestConversationLoaded = useAtomValue(pullRequestConversationLoadedAtom)
647
697
  const selectedRepository = viewRepository(activeView)
648
698
  const activeViews = activePullRequestViews(activeView)
649
699
  const currentQueueCacheKey = viewCacheKey(activeView)
@@ -662,22 +712,60 @@ export const App = () => {
662
712
  }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
663
713
  const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
664
714
  const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
715
+ const selectedConversationItems = selectedDiffKey ? pullRequestConversation[selectedDiffKey] ?? [] : []
716
+ const selectedConversationStatus: DetailConversationStatus = selectedDiffKey ? pullRequestConversationLoaded[selectedDiffKey] ?? "idle" : "idle"
665
717
  const selectedDiffState = useAtomValue(selectedDiffStateAtom)
666
718
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
667
- const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
719
+ const readyDiffFiles = useMemo(
720
+ () => selectedDiffState?._tag === "Ready"
721
+ ? diffWhitespaceMode === "ignore" ? minimizeWhitespaceDiffFiles(selectedDiffState.files) : selectedDiffState.files
722
+ : [],
723
+ [selectedDiffState, diffWhitespaceMode],
724
+ )
725
+ const displayedDiffState = useMemo(
726
+ () => selectedDiffState?._tag === "Ready"
727
+ ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
728
+ : selectedDiffState,
729
+ [selectedDiffState, readyDiffFiles],
730
+ )
668
731
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
669
732
  const diffCommentAnchors = useMemo(
670
733
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
671
734
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
672
735
  )
673
- const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
674
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentLocationKey(selectedDiffCommentAnchor)}` : null
736
+ const selectedDiffCommentAnchorIndex = Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))
737
+ const selectedDiffCommentAnchor = diffCommentAnchors[selectedDiffCommentAnchorIndex] ?? null
738
+ const diffCommentRangeStartAnchor = diffCommentRangeStartIndex === null
739
+ ? null
740
+ : diffCommentAnchors[Math.max(0, Math.min(diffCommentRangeStartIndex, diffCommentAnchors.length - 1))] ?? null
741
+ const selectedDiffCommentRange = useMemo(
742
+ () => diffCommentRangeSelection(diffCommentRangeStartAnchor, selectedDiffCommentAnchor),
743
+ [diffCommentRangeStartAnchor, selectedDiffCommentAnchor],
744
+ )
745
+ const selectedDiffCommentRangeAnchors = useMemo(
746
+ () => selectedDiffCommentRange
747
+ ? diffCommentAnchors.filter((anchor) => diffCommentRangeContains(selectedDiffCommentRange, anchor))
748
+ : [],
749
+ [diffCommentAnchors, selectedDiffCommentRange],
750
+ )
751
+ const diffCommentRangeActive = selectedDiffCommentRange !== null
752
+ const selectedDiffCommentLabel = selectedDiffCommentRange
753
+ ? diffCommentRangeLabel(selectedDiffCommentRange)
754
+ : selectedDiffCommentAnchor ? diffCommentAnchorLabel(selectedDiffCommentAnchor) : null
755
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? diffCommentThreadMapKey(selectedDiffKey, selectedDiffCommentAnchor) : null
675
756
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
676
757
  const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
677
- const diffCommentRows = useMemo(
678
- () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
679
- [diffCommentAnchors],
680
- )
758
+ const diffCommentThreadAnchors = useMemo(() => {
759
+ if (!selectedDiffKey) return [] as readonly StackedDiffCommentAnchor[]
760
+ const seen = new Set<string>()
761
+ return diffCommentAnchors.filter((anchor) => {
762
+ const key = diffCommentLocationKey(anchor)
763
+ if (seen.has(key)) return false
764
+ if ((diffCommentThreads[diffCommentThreadMapKey(selectedDiffKey, anchor)]?.length ?? 0) === 0) return false
765
+ seen.add(key)
766
+ return true
767
+ })
768
+ }, [diffCommentAnchors, diffCommentThreads, selectedDiffKey])
681
769
  const groupStarts = useAtomValue(groupStartsAtom)
682
770
  const getCurrentGroupIndex = (current: number) => {
683
771
  if (groupStarts.length === 0) return 0
@@ -710,12 +798,36 @@ export const App = () => {
710
798
  if (!pullRequest) return
711
799
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
712
800
  }
713
- const refreshPullRequests = (message?: string) => {
801
+ const markPullRequestCompleted = (pullRequest: PullRequestItem, state: "closed" | "merged") => {
802
+ setRecentlyCompletedPullRequests((current) => ({
803
+ ...current,
804
+ [pullRequest.url]: {
805
+ ...pullRequest,
806
+ state,
807
+ autoMergeEnabled: false,
808
+ },
809
+ }))
810
+ }
811
+ const restoreOptimisticPullRequest = (pullRequest: PullRequestItem) => {
812
+ setRecentlyCompletedPullRequests((current) => {
813
+ if (!(pullRequest.url in current)) return current
814
+ const next = { ...current }
815
+ delete next[pullRequest.url]
816
+ return next
817
+ })
818
+ updatePullRequest(pullRequest.url, () => pullRequest)
819
+ }
820
+ const refreshPullRequests = (message?: string, options: { readonly resetTransientState?: boolean } = {}) => {
714
821
  refreshGenerationRef.current += 1
715
822
  detailHydrationRef.current.clear()
716
823
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
717
824
  setLoadingMoreKey(null)
718
825
  setPullRequestOverrides({})
826
+ if (options.resetTransientState) {
827
+ setRecentlyCompletedPullRequests({})
828
+ setPullRequestConversation({})
829
+ setPullRequestConversationLoaded({})
830
+ }
719
831
  if (message) {
720
832
  setNotice(null)
721
833
  setRefreshCompletionMessage(message)
@@ -736,7 +848,7 @@ export const App = () => {
736
848
  setLoadingMoreKey(null)
737
849
  setDetailFullView(false)
738
850
  setDiffFullView(false)
739
- setDiffCommentMode(false)
851
+ setDiffCommentRangeStartIndex(null)
740
852
  setFilterDraft(filterQuery)
741
853
  setNotice(null)
742
854
  setRefreshCompletionMessage(null)
@@ -818,6 +930,29 @@ export const App = () => {
818
930
  })
819
931
  return true
820
932
  }
933
+ const loadPullRequestConversation = (pullRequest: PullRequestItem, force = false) => {
934
+ const key = pullRequestDiffKey(pullRequest)
935
+ const previousLoadState = registry.get(pullRequestConversationLoadedAtom)[key]
936
+ if (!force && previousLoadState) return
937
+ const generation = refreshGenerationRef.current
938
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "loading" }))
939
+ void listPullRequestConversation({ repository: pullRequest.repository, number: pullRequest.number })
940
+ .then((items) => {
941
+ if (generation !== refreshGenerationRef.current) return
942
+ setPullRequestConversation((current) => ({ ...current, [key]: items }))
943
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "ready" }))
944
+ })
945
+ .catch((error) => {
946
+ if (generation !== refreshGenerationRef.current) return
947
+ setPullRequestConversationLoaded((current) => {
948
+ if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
949
+ const next = { ...current }
950
+ delete next[key]
951
+ return next
952
+ })
953
+ flashNotice(errorMessage(error))
954
+ })
955
+ }
821
956
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
822
957
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
823
958
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -933,20 +1068,30 @@ export const App = () => {
933
1068
  setDiffFileIndex(0)
934
1069
  setDiffScrollTop(0)
935
1070
  setDiffCommentAnchorIndex(0)
1071
+ setDiffPreferredSide(null)
1072
+ setDiffCommentRangeStartIndex(null)
936
1073
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
937
1074
  }, [selectedIndex])
938
1075
 
1076
+ useEffect(() => {
1077
+ setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current))
1078
+ }, [readyDiffFiles.length])
1079
+
939
1080
  useEffect(() => {
940
1081
  setDiffCommentAnchorIndex((current) => {
941
1082
  if (diffCommentAnchors.length === 0) return 0
942
1083
  return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
943
1084
  })
1085
+ setDiffCommentRangeStartIndex((current) => {
1086
+ if (current === null || diffCommentAnchors.length === 0) return null
1087
+ return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
1088
+ })
944
1089
  }, [diffCommentAnchors.length])
945
1090
 
946
1091
  useEffect(() => {
947
- if (!diffCommentMode || !selectedDiffCommentAnchor) return
1092
+ if (!diffFullView || !selectedDiffCommentAnchor) return
948
1093
  setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
949
- }, [diffCommentMode, selectedDiffCommentAnchor?.fileIndex])
1094
+ }, [diffFullView, selectedDiffCommentAnchor?.fileIndex])
950
1095
 
951
1096
  useEffect(() => {
952
1097
  const previous = diffCommentLineColorsRef.current
@@ -959,27 +1104,28 @@ export const App = () => {
959
1104
 
960
1105
  const nextEntries: AppliedDiffLineColor[] = []
961
1106
  const appliedKeys = new Set<string>()
962
- const applyLineColor = (anchor: StackedDiffCommentAnchor, gutter: string, override = false) => {
1107
+ const applyLineColor = (anchor: StackedDiffCommentAnchor, color: DiffLineColorConfig, override = false) => {
963
1108
  const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
964
1109
  if (appliedKeys.has(key) && !override) return
965
1110
  appliedKeys.add(key)
966
1111
  const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
967
1112
  const diff = diffRenderableRefs.current.get(anchor.fileIndex)
968
- if (diff) setDiffCommentLineColor(diff, entry, { ...originalDiffLineColor(anchor), gutter })
1113
+ if (diff) setDiffCommentLineColor(diff, entry, color)
969
1114
  if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
970
1115
  nextEntries.push(entry)
971
1116
  }
972
1117
  }
973
1118
 
974
- if (selectedDiffKey) {
975
- for (const anchor of diffCommentAnchors) {
976
- if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentLocationKey(anchor)}`]?.length ?? 0) > 0) {
977
- applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
978
- }
1119
+ for (const anchor of diffCommentThreadAnchors) {
1120
+ applyLineColor(anchor, diffCommentLineColor(anchor, "thread"))
1121
+ }
1122
+ if (selectedDiffCommentRangeAnchors.length > 0) {
1123
+ for (const anchor of selectedDiffCommentRangeAnchors) {
1124
+ applyLineColor(anchor, diffCommentLineColor(anchor, "range"), true)
979
1125
  }
980
1126
  }
981
- if (diffCommentMode && selectedDiffCommentAnchor) {
982
- applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
1127
+ if (selectedDiffCommentAnchor) {
1128
+ applyLineColor(selectedDiffCommentAnchor, diffCommentLineColor(selectedDiffCommentAnchor, "selected"), true)
983
1129
  if (suppressNextDiffCommentScrollRef.current) {
984
1130
  suppressNextDiffCommentScrollRef.current = false
985
1131
  } else {
@@ -989,25 +1135,49 @@ export const App = () => {
989
1135
  suppressNextDiffCommentScrollRef.current = false
990
1136
  }
991
1137
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
992
- }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
1138
+ }, [selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, selectedDiffCommentRangeAnchors, diffLineColorContextKey, effectiveDiffRenderView, diffCommentThreadAnchors])
1139
+
1140
+ // Scroll the selected line into view when the diff view is opened. Previously
1141
+ // opentui's `focused` scrollbox did this auto-scroll on mount; with the keymap
1142
+ // migration the scrollbox is `focusable={false}` so we have to scroll explicitly.
1143
+ useEffect(() => {
1144
+ if (!diffFullView) return
1145
+ if (!selectedDiffCommentAnchor) return
1146
+ ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
1147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1148
+ }, [diffFullView])
993
1149
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
994
1150
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
995
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
996
- const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1151
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
1152
+ const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
997
1153
 
998
1154
  useEffect(() => {
999
1155
  if (!hasActiveLoadingIndicator) return
1000
1156
  const interval = globalThis.setInterval(() => {
1001
- setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1157
+ setLoadingFrame((current) => current + 1)
1002
1158
  }, 120)
1003
1159
  return () => globalThis.clearInterval(interval)
1004
1160
  }, [hasActiveLoadingIndicator])
1005
1161
 
1162
+ useEffect(() => {
1163
+ if (isInitialLoading) setLoadingFrame(0)
1164
+ }, [isInitialLoading])
1165
+
1166
+ useEffect(() => {
1167
+ if (startupLoadComplete || pullRequestStatus === "loading") return
1168
+ setStartupLoadComplete(true)
1169
+ }, [startupLoadComplete, pullRequestStatus])
1170
+
1006
1171
  useEffect(() => {
1007
1172
  if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1008
1173
  hydratePullRequestDetails(selectedPullRequest, true)
1009
1174
  }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
1010
1175
 
1176
+ useEffect(() => {
1177
+ if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1178
+ loadPullRequestConversation(selectedPullRequest)
1179
+ }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.repository, selectedPullRequest?.number])
1180
+
1011
1181
  useEffect(() => {
1012
1182
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1013
1183
  if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
@@ -1041,7 +1211,7 @@ export const App = () => {
1041
1211
  title: `${loadingIndicator} Loading pull request details`,
1042
1212
  hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1043
1213
  } : detailPlaceholderContent
1044
- const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1214
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true, rightContentWidth, selectedConversationItems, selectedConversationStatus)
1045
1215
 
1046
1216
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1047
1217
 
@@ -1135,9 +1305,11 @@ export const App = () => {
1135
1305
  diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
1136
1306
  setDiffFullView(true)
1137
1307
  setDetailFullView(false)
1138
- setDiffCommentMode(false)
1139
1308
  setDiffFileIndex(0)
1140
1309
  setDiffScrollTop(0)
1310
+ setDiffCommentAnchorIndex(0)
1311
+ setDiffPreferredSide(null)
1312
+ setDiffCommentRangeStartIndex(null)
1141
1313
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1142
1314
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1143
1315
  loadPullRequestDiff(selectedPullRequest, { includeComments: true })
@@ -1158,53 +1330,13 @@ export const App = () => {
1158
1330
  const scrollTop = diffScrollRef.current?.scrollTop
1159
1331
  if (scrollTop === undefined || stackedDiffFiles.length === 0) return
1160
1332
  setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
1161
- const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
1333
+ const nextIndex = Math.max(0, stackedDiffFileIndexAtLine(stackedDiffFiles, scrollTop))
1162
1334
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1163
1335
  }
1164
1336
 
1165
- const scrollDiffBy = (y: number) => {
1166
- diffScrollRef.current?.scrollBy({ x: 0, y })
1167
- syncDiffScrollState()
1168
- }
1169
-
1170
- const scrollDiffTo = (y: number) => {
1171
- diffScrollRef.current?.scrollTo({ x: 0, y })
1172
- syncDiffScrollState()
1173
- }
1174
1337
  const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1175
1338
  const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1176
1339
 
1177
- const clearPendingGTimeout = () => {
1178
- if (pendingGTimeoutRef.current !== null) {
1179
- clearTimeout(pendingGTimeoutRef.current)
1180
- pendingGTimeoutRef.current = null
1181
- }
1182
- }
1183
-
1184
- const handleVimGoto = (key: { readonly name: string; readonly shift?: boolean }, gotoStart: () => void, gotoEnd: () => void): boolean => {
1185
- if (isShiftG(key)) {
1186
- gotoEnd()
1187
- setPendingG(false)
1188
- clearPendingGTimeout()
1189
- return true
1190
- }
1191
- if (key.name === "g") {
1192
- if (pendingG) {
1193
- gotoStart()
1194
- setPendingG(false)
1195
- clearPendingGTimeout()
1196
- } else {
1197
- setPendingG(true)
1198
- pendingGTimeoutRef.current = setTimeout(() => {
1199
- setPendingG(false)
1200
- pendingGTimeoutRef.current = null
1201
- }, 500)
1202
- }
1203
- return true
1204
- }
1205
- return false
1206
- }
1207
-
1208
1340
  const ensureDiffLineVisible = (line: number) => {
1209
1341
  const scroll = diffScrollRef.current
1210
1342
  if (!scroll) return
@@ -1226,37 +1358,69 @@ export const App = () => {
1226
1358
  if (readyDiffFiles.length === 0) return
1227
1359
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1228
1360
  setDiffFileIndex(nextIndex)
1229
- if (diffCommentMode) {
1230
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1231
- ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1232
- if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1233
- }
1361
+ setDiffCommentRangeStartIndex(null)
1362
+ const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
1363
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === targetSide)
1364
+ ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1365
+ if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1234
1366
  scrollToDiffFile(nextIndex)
1235
1367
  }
1236
1368
 
1237
- const enterDiffCommentMode = () => {
1238
- const scrollTop = diffScrollRef.current?.scrollTop ?? 0
1239
- suppressNextDiffCommentScrollRef.current = true
1240
- setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop + DIFF_STICKY_HEADER_LINES))
1241
- setDiffCommentMode(true)
1242
- }
1243
-
1244
- const moveDiffCommentAnchor = (delta: number) => {
1245
- if (diffCommentAnchors.length === 0) return
1246
- const currentAnchor = selectedDiffCommentAnchor ?? diffCommentAnchors[0]
1247
- const currentRowIndex = Math.max(0, currentAnchor ? diffCommentRows.indexOf(currentAnchor.renderLine) : 0)
1248
- const nextRow = diffCommentRows[Math.max(0, Math.min(diffCommentRows.length - 1, currentRowIndex + delta))]
1249
- if (nextRow === undefined) return
1250
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1251
- ?? diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow)
1369
+ const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1370
+ ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1371
+ : diffCommentAnchors
1372
+
1373
+ const moveDiffCommentAnchor = (delta: number, options: { readonly preserveViewportRow?: boolean } = {}) => {
1374
+ const anchors = navigableDiffCommentAnchors()
1375
+ if (anchors.length === 0) return
1376
+ const currentAnchor = selectedDiffCommentAnchor && anchors.includes(selectedDiffCommentAnchor) ? selectedDiffCommentAnchor : anchors[0]
1377
+ const nextAnchor = verticalDiffAnchor(anchors, currentAnchor ?? null, delta, diffPreferredSide)
1252
1378
  if (!nextAnchor) return
1379
+ if (options.preserveViewportRow) {
1380
+ const scroll = diffScrollRef.current
1381
+ if (scroll && currentAnchor) {
1382
+ const maxScreenOffset = Math.max(DIFF_STICKY_HEADER_LINES, scroll.viewport.height - 2)
1383
+ const screenOffset = Math.max(DIFF_STICKY_HEADER_LINES, Math.min(maxScreenOffset, currentAnchor.renderLine - scroll.scrollTop))
1384
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
1385
+ const nextTop = Math.max(0, Math.min(maxScrollTop, nextAnchor.renderLine - screenOffset))
1386
+ suppressNextDiffCommentScrollRef.current = true
1387
+ scroll.scrollTo({ x: 0, y: nextTop })
1388
+ syncDiffScrollState()
1389
+ }
1390
+ }
1253
1391
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1254
1392
  }
1255
1393
 
1394
+ const moveDiffCommentToBoundary = (boundary: "first" | "last") => {
1395
+ const anchors = navigableDiffCommentAnchors()
1396
+ const nextAnchor = boundary === "first" ? anchors[0] : anchors[anchors.length - 1]
1397
+ if (!nextAnchor) return
1398
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1399
+ setDiffFileIndex(nextAnchor.fileIndex)
1400
+ }
1401
+
1402
+ const alignSelectedDiffCommentAnchor = (position: "top" | "center" | "bottom") => {
1403
+ if (!selectedDiffCommentAnchor) return
1404
+ const scroll = diffScrollRef.current
1405
+ if (!scroll) return
1406
+ const viewportHeight = Math.max(1, scroll.viewport.height)
1407
+ const offset = position === "top"
1408
+ ? DIFF_STICKY_HEADER_LINES
1409
+ : position === "center"
1410
+ ? Math.max(DIFF_STICKY_HEADER_LINES, Math.floor(viewportHeight / 2))
1411
+ : Math.max(DIFF_STICKY_HEADER_LINES, viewportHeight - 2)
1412
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - viewportHeight)
1413
+ const nextTop = Math.max(0, Math.min(maxScrollTop, selectedDiffCommentAnchor.renderLine - offset))
1414
+ scroll.scrollTo({ x: 0, y: nextTop })
1415
+ syncDiffScrollState()
1416
+ }
1417
+
1256
1418
  const selectDiffCommentSide = (side: DiffCommentSide) => {
1419
+ setDiffPreferredSide(side)
1257
1420
  if (!selectedDiffCommentAnchor) return
1258
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1421
+ const nextAnchor = diffAnchorOnSide(diffCommentAnchors, selectedDiffCommentAnchor, side)
1259
1422
  if (!nextAnchor) return
1423
+ setDiffCommentRangeStartIndex(null)
1260
1424
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1261
1425
  }
1262
1426
 
@@ -1265,9 +1429,12 @@ export const App = () => {
1265
1429
  const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1266
1430
  if (!nextAnchor) return
1267
1431
  suppressNextDiffCommentScrollRef.current = true
1432
+ setDiffPreferredSide(side ?? nextAnchor.side)
1433
+ if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1434
+ setDiffCommentRangeStartIndex(null)
1435
+ }
1268
1436
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1269
1437
  setDiffFileIndex(nextAnchor.fileIndex)
1270
- setDiffCommentMode(true)
1271
1438
  }
1272
1439
 
1273
1440
  const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
@@ -1288,6 +1455,39 @@ export const App = () => {
1288
1455
  setCommentThreadModal({ scrollOffset: 0 })
1289
1456
  }
1290
1457
 
1458
+ const openSelectedDiffComment = () => {
1459
+ if (diffCommentRangeActive) {
1460
+ openDiffCommentModal()
1461
+ return
1462
+ }
1463
+ if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
1464
+ else openDiffCommentModal()
1465
+ }
1466
+
1467
+ const toggleDiffCommentRange = () => {
1468
+ if (!selectedDiffCommentAnchor) return
1469
+ setDiffCommentRangeStartIndex((current) => current === null ? selectedDiffCommentAnchorIndex : null)
1470
+ }
1471
+
1472
+ const moveDiffCommentThread = (delta: 1 | -1) => {
1473
+ if (diffCommentThreadAnchors.length === 0) {
1474
+ flashNotice("No diff comments")
1475
+ return
1476
+ }
1477
+ const currentIndex = selectedDiffCommentAnchor
1478
+ ? diffCommentThreadAnchors.findIndex((anchor) => diffCommentLocationKey(anchor) === diffCommentLocationKey(selectedDiffCommentAnchor))
1479
+ : -1
1480
+ const nextAnchor = currentIndex >= 0
1481
+ ? diffCommentThreadAnchors[(currentIndex + delta + diffCommentThreadAnchors.length) % diffCommentThreadAnchors.length]
1482
+ : delta > 0
1483
+ ? diffCommentThreadAnchors.find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine > selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[0]
1484
+ : [...diffCommentThreadAnchors].reverse().find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine < selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[diffCommentThreadAnchors.length - 1]
1485
+ if (!nextAnchor) return
1486
+ setDiffCommentRangeStartIndex(null)
1487
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1488
+ setDiffFileIndex(nextAnchor.fileIndex)
1489
+ }
1490
+
1291
1491
  const submitDiffComment = () => {
1292
1492
  if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1293
1493
  const body = commentModal.body.trim()
@@ -1296,8 +1496,9 @@ export const App = () => {
1296
1496
  return
1297
1497
  }
1298
1498
 
1299
- const threadKey = selectedDiffCommentThreadKey
1300
- const target = selectedDiffCommentAnchor
1499
+ const targetRange = selectedDiffCommentRange
1500
+ const target = targetRange?.end ?? selectedDiffCommentAnchor
1501
+ const threadKey = selectedDiffKey ? diffCommentThreadMapKey(selectedDiffKey, target) : null
1301
1502
  const optimisticComment = {
1302
1503
  id: `local:${Date.now()}`,
1303
1504
  path: target.path,
@@ -1308,6 +1509,9 @@ export const App = () => {
1308
1509
  createdAt: new Date(),
1309
1510
  url: null,
1310
1511
  } satisfies PullRequestReviewComment
1512
+ const rangeInput = targetRange && targetRange.start.line !== targetRange.end.line
1513
+ ? { startLine: targetRange.start.line, startSide: targetRange.start.side }
1514
+ : {}
1311
1515
  const input = {
1312
1516
  repository: selectedPullRequest.repository,
1313
1517
  number: selectedPullRequest.number,
@@ -1316,6 +1520,7 @@ export const App = () => {
1316
1520
  line: target.line,
1317
1521
  side: target.side,
1318
1522
  body,
1523
+ ...rangeInput,
1319
1524
  } satisfies CreatePullRequestCommentInput
1320
1525
 
1321
1526
  if (threadKey) {
@@ -1325,6 +1530,7 @@ export const App = () => {
1325
1530
  }))
1326
1531
  }
1327
1532
  closeActiveModal()
1533
+ setDiffCommentRangeStartIndex(null)
1328
1534
  flashNotice(`Commenting on ${target.path}:${target.line}`)
1329
1535
  void createPullRequestComment(input).then((comment) => {
1330
1536
  if (threadKey) {
@@ -1400,16 +1606,7 @@ export const App = () => {
1400
1606
  setCloseModal((current) => ({ ...current, running: true, error: null }))
1401
1607
  void closePullRequest({ repository, number })
1402
1608
  .then(() => {
1403
- if (previousPullRequest) {
1404
- setRecentlyCompletedPullRequests((current) => ({
1405
- ...current,
1406
- [previousPullRequest.url]: {
1407
- ...previousPullRequest,
1408
- state: "closed",
1409
- autoMergeEnabled: false,
1410
- },
1411
- }))
1412
- }
1609
+ if (previousPullRequest) markPullRequestCompleted(previousPullRequest, "closed")
1413
1610
  closeActiveModal()
1414
1611
  refreshPullRequests(`Closed #${number}`)
1415
1612
  })
@@ -1444,6 +1641,12 @@ export const App = () => {
1444
1641
  setThemeId(id)
1445
1642
  }
1446
1643
 
1644
+ const toggleDiffWhitespaceMode = () => {
1645
+ const next = diffWhitespaceMode === "ignore" ? "show" : "ignore"
1646
+ setDiffWhitespaceMode(next)
1647
+ void Effect.runPromise(saveStoredDiffWhitespaceMode(next)).catch((error) => flashNotice(errorMessage(error)))
1648
+ }
1649
+
1447
1650
  const moveThemeSelection = (delta: number) => {
1448
1651
  const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
1449
1652
  if (filteredThemes.length === 0) return
@@ -1539,30 +1742,15 @@ export const App = () => {
1539
1742
  const { repository, number } = mergeModal.info
1540
1743
  const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
1541
1744
  const previousPullRequest = targetPullRequest ?? null
1542
- const previousMergeInfo = mergeModal.info
1543
1745
 
1544
1746
  if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
1545
1747
  updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
1546
- setMergeModal((current) => ({
1547
- ...current,
1548
- info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
1549
- }))
1550
1748
  }
1749
+ if (targetPullRequest && option.optimisticState === "merged") markPullRequestCompleted(targetPullRequest, "merged")
1551
1750
 
1552
- setMergeModal((current) => ({ ...current, running: true, error: null }))
1751
+ closeActiveModal()
1553
1752
  void mergePullRequest({ repository, number, action: option.action })
1554
1753
  .then(() => {
1555
- if (option.refreshOnSuccess && previousPullRequest) {
1556
- setRecentlyCompletedPullRequests((current) => ({
1557
- ...current,
1558
- [previousPullRequest.url]: {
1559
- ...previousPullRequest,
1560
- state: "merged",
1561
- autoMergeEnabled: false,
1562
- },
1563
- }))
1564
- }
1565
- closeActiveModal()
1566
1754
  if (option.refreshOnSuccess) {
1567
1755
  refreshPullRequests(`${option.pastTense} #${number}`)
1568
1756
  } else {
@@ -1570,8 +1758,7 @@ export const App = () => {
1570
1758
  }
1571
1759
  })
1572
1760
  .catch((error) => {
1573
- if (previousPullRequest) updatePullRequest(previousPullRequest.url, () => previousPullRequest)
1574
- setMergeModal((current) => ({ ...current, running: false, info: previousMergeInfo, error: errorMessage(error) }))
1761
+ if (previousPullRequest) restoreOptimisticPullRequest(previousPullRequest)
1575
1762
  flashNotice(errorMessage(error))
1576
1763
  })
1577
1764
  }
@@ -1685,10 +1872,13 @@ export const App = () => {
1685
1872
  diffReady: selectedDiffState?._tag === "Ready",
1686
1873
  effectiveDiffRenderView,
1687
1874
  diffWrapMode,
1875
+ diffWhitespaceMode,
1688
1876
  readyDiffFileCount: readyDiffFiles.length,
1689
1877
  diffFileIndex,
1690
- diffCommentMode,
1691
- selectedDiffCommentAnchorLabel: selectedDiffCommentAnchor ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line}` : null,
1878
+ diffRangeActive: diffCommentRangeActive,
1879
+ selectedDiffCommentAnchorLabel: selectedDiffCommentLabel,
1880
+ selectedDiffCommentThreadCount: selectedDiffCommentThread.length,
1881
+ hasDiffCommentThreads: diffCommentThreadAnchors.length > 0,
1692
1882
  actions: {
1693
1883
  openCommandPalette,
1694
1884
  refreshPullRequests,
@@ -1716,7 +1906,7 @@ export const App = () => {
1716
1906
  openDiffView,
1717
1907
  closeDiffView: () => {
1718
1908
  setDiffFullView(false)
1719
- setDiffCommentMode(false)
1909
+ setDiffCommentRangeStartIndex(null)
1720
1910
  },
1721
1911
  reloadDiff: () => {
1722
1912
  if (!selectedPullRequest) return
@@ -1725,11 +1915,11 @@ export const App = () => {
1725
1915
  },
1726
1916
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1727
1917
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1918
+ toggleDiffWhitespaceMode,
1728
1919
  jumpDiffFile,
1729
- toggleDiffCommentMode: () => {
1730
- if (diffCommentMode) setDiffCommentMode(false)
1731
- else enterDiffCommentMode()
1732
- },
1920
+ openSelectedDiffComment,
1921
+ toggleDiffCommentRange,
1922
+ moveDiffCommentThread,
1733
1923
  openDiffCommentModal,
1734
1924
  togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1735
1925
  openLabelModal,
@@ -1755,6 +1945,8 @@ export const App = () => {
1755
1945
  const command = appCommands.find((entry) => entry.id === id)
1756
1946
  return command ? runCommand(command, options) : false
1757
1947
  }
1948
+ const runCommandByIdRef = useRef(runCommandById)
1949
+ runCommandByIdRef.current = runCommandById
1758
1950
  const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1759
1951
  if (!commandPaletteActive) return []
1760
1952
  const repository = parseRepositoryInput(commandPalette.query)
@@ -1769,287 +1961,250 @@ export const App = () => {
1769
1961
  })()
1770
1962
  // Dynamic commands always pin to the top of the palette; they came directly from the
1771
1963
  // user's typed input so they shouldn't be filtered by fuzzy score against themselves.
1964
+ const staticPaletteCommands = commandPaletteActive
1965
+ ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)
1966
+ : []
1772
1967
  const commandPaletteCommands = commandPaletteActive
1773
1968
  ? [
1774
1969
  ...dynamicPaletteCommands,
1775
- ...sortCommandsByScope(filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)),
1970
+ ...(commandPalette.query.trim().length > 0 ? staticPaletteCommands : sortCommandsByScope(staticPaletteCommands)),
1776
1971
  ]
1777
1972
  : []
1778
1973
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1779
1974
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1780
1975
 
1781
- // Keymap migration phase 2: simple cmd-id bindings move out of useKeyboard.
1782
- // Gated to "global mode" no modal active, no full-view, not in filter editing —
1783
- // so these don't dispatch on top of modal-specific handlers below.
1784
- const globalKeymapActiveRef = useRef(false)
1785
- globalKeymapActiveRef.current = !commandPaletteActive
1786
- && !openRepositoryModalActive
1787
- && !labelModalActive
1788
- && !commentModalActive
1789
- && !commentThreadModalActive
1790
- && !closeModalActive
1791
- && !mergeModalActive
1792
- && !themeModalActive
1793
- && !diffFullView
1794
- && !detailFullView
1795
- && !filterMode
1796
- const runCommandByIdRef = useRef(runCommandById)
1797
- runCommandByIdRef.current = runCommandById
1798
- useBindings(() => ({
1799
- enabled: () => globalKeymapActiveRef.current,
1800
- bindings: [
1801
- { key: "/", cmd: () => runCommandByIdRef.current("filter.open") },
1802
- { key: "r", cmd: () => runCommandByIdRef.current("pull.refresh") },
1803
- { key: "t", cmd: () => runCommandByIdRef.current("theme.open") },
1804
- { key: "d", cmd: () => runCommandByIdRef.current("diff.open") },
1805
- { key: "l", cmd: () => runCommandByIdRef.current("pull.labels") },
1806
- { key: "m", cmd: () => runCommandByIdRef.current("pull.merge") },
1807
- { key: "shift+m", cmd: () => runCommandByIdRef.current("pull.merge") },
1808
- { key: "x", cmd: () => runCommandByIdRef.current("pull.close") },
1809
- { key: "o", cmd: () => runCommandByIdRef.current("pull.open-browser") },
1810
- { key: "s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1811
- { key: "shift+s", cmd: () => runCommandByIdRef.current("pull.toggle-draft") },
1812
- { key: "y", cmd: () => runCommandByIdRef.current("pull.copy-metadata") },
1813
- { key: "return", cmd: () => runCommandByIdRef.current("detail.open") },
1814
- ],
1815
- }), [])
1816
- // Always-on bindings — work even while modals are open.
1817
- useBindings(() => ({
1818
- bindings: [
1819
- { key: "ctrl+p", cmd: () => runCommandByIdRef.current("command.open") },
1820
- { key: "meta+k", cmd: () => runCommandByIdRef.current("command.open") },
1821
- ],
1822
- }), [])
1823
-
1824
- // CloseModal: escape closes, enter confirms.
1825
- const closeModalActiveRef = useRef(false)
1826
- closeModalActiveRef.current = closeModalActive
1827
- const closeActiveModalRef = useRef(closeActiveModal)
1828
- closeActiveModalRef.current = closeActiveModal
1829
- const confirmClosePullRequestRef = useRef(confirmClosePullRequest)
1830
- confirmClosePullRequestRef.current = confirmClosePullRequest
1831
- useBindings(() => ({
1832
- enabled: () => closeModalActiveRef.current,
1833
- bindings: [
1834
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1835
- { key: "return", cmd: () => confirmClosePullRequestRef.current() },
1836
- ],
1837
- }), [])
1838
-
1839
- // MergeModal: escape, enter (when options>0), up/down/j/k navigation.
1840
- const mergeModalActiveRef = useRef(false)
1841
- mergeModalActiveRef.current = mergeModalActive
1842
- const mergeModalContextRef = useRef({ availableCount: 0, confirm: confirmMergeAction, setMergeModal })
1843
- mergeModalContextRef.current = {
1844
- availableCount: availableMergeActions(mergeModal.info).length,
1845
- confirm: confirmMergeAction,
1846
- setMergeModal,
1847
- }
1848
- const moveMergeSelection = (delta: -1 | 1) => mergeModalContextRef.current.setMergeModal((current) => {
1849
- const max = Math.max(0, mergeModalContextRef.current.availableCount - 1)
1976
+ // === Helpers used by the keymap layers ===
1977
+ const moveMergeSelection = (delta: -1 | 1) => setMergeModal((current) => {
1978
+ const max = Math.max(0, availableMergeActions(mergeModal.info).length - 1)
1850
1979
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1851
1980
  })
1852
- useBindings(() => ({
1853
- enabled: () => mergeModalActiveRef.current,
1854
- bindings: [
1855
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1856
- { key: "return", cmd: () => {
1857
- if (mergeModalContextRef.current.availableCount > 0) mergeModalContextRef.current.confirm()
1858
- } },
1859
- { key: "up", cmd: () => moveMergeSelection(-1) },
1860
- { key: "k", cmd: () => moveMergeSelection(-1) },
1861
- { key: "down", cmd: () => moveMergeSelection(1) },
1862
- { key: "j", cmd: () => moveMergeSelection(1) },
1863
- ],
1864
- }), [])
1865
-
1866
- // CommentThreadModal: scroll the thread, shortcut to compose a reply.
1867
- const commentThreadModalActiveRef = useRef(false)
1868
- commentThreadModalActiveRef.current = commentThreadModalActive
1869
- const commentThreadCtxRef = useRef({ openDiffCommentModal, setCommentThreadModal, halfPage })
1870
- commentThreadCtxRef.current = { openDiffCommentModal, setCommentThreadModal, halfPage }
1871
- const scrollCommentThread = (delta: number) => commentThreadCtxRef.current.setCommentThreadModal((current) => ({
1981
+ const scrollCommentThread = (delta: number) => setCommentThreadModal((current) => ({
1872
1982
  ...current,
1873
1983
  scrollOffset: Math.max(0, current.scrollOffset + delta),
1874
1984
  }))
1875
- useBindings(() => ({
1876
- enabled: () => commentThreadModalActiveRef.current,
1877
- bindings: [
1878
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1879
- { key: "return", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1880
- { key: "a", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1881
- { key: "c", cmd: () => commentThreadCtxRef.current.openDiffCommentModal() },
1882
- { key: "up", cmd: () => scrollCommentThread(-1) },
1883
- { key: "k", cmd: () => scrollCommentThread(-1) },
1884
- { key: "down", cmd: () => scrollCommentThread(1) },
1885
- { key: "j", cmd: () => scrollCommentThread(1) },
1886
- { key: "pageup", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1887
- { key: "ctrl+u", cmd: () => scrollCommentThread(-commentThreadCtxRef.current.halfPage) },
1888
- { key: "pagedown", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1889
- { key: "ctrl+d", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1890
- { key: "ctrl+v", cmd: () => scrollCommentThread(commentThreadCtxRef.current.halfPage) },
1891
- ],
1892
- }), [])
1893
-
1894
- // LabelModal: nav keys via keymap; text input stays in useKeyboard fallback.
1895
- const labelModalActiveRef = useRef(false)
1896
- labelModalActiveRef.current = labelModalActive
1897
- const labelModalCtxRef = useRef({ toggleLabelAtIndex, setLabelModal, filteredCount: 0 })
1898
- labelModalCtxRef.current = {
1899
- toggleLabelAtIndex,
1900
- setLabelModal,
1901
- filteredCount: filterLabels(labelModal.availableLabels, labelModal.query).length,
1902
- }
1903
- const moveLabelSelection = (delta: -1 | 1) => labelModalCtxRef.current.setLabelModal((current) => {
1904
- const max = Math.max(0, labelModalCtxRef.current.filteredCount - 1)
1985
+ const moveLabelSelection = (delta: -1 | 1) => setLabelModal((current) => {
1986
+ const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
1905
1987
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1906
1988
  })
1907
- useBindings(() => ({
1908
- enabled: () => labelModalActiveRef.current,
1909
- bindings: [
1910
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1911
- { key: "return", cmd: () => labelModalCtxRef.current.toggleLabelAtIndex() },
1912
- { key: "up", cmd: () => moveLabelSelection(-1) },
1913
- { key: "k", cmd: () => moveLabelSelection(-1) },
1914
- { key: "down", cmd: () => moveLabelSelection(1) },
1915
- { key: "j", cmd: () => moveLabelSelection(1) },
1916
- ],
1917
- }), [])
1918
-
1919
- // ThemeModal: nav + filter-mode toggle. j/k only navigate when not in filter mode
1920
- // (so users can type those letters into the query).
1921
- const themeModalActiveRef = useRef(false)
1922
- themeModalActiveRef.current = themeModalActive
1923
- const themeModalCtxRef = useRef({
1924
- filterMode: false,
1925
- hasResults: true,
1926
- closeThemeModal,
1927
- updateThemeQuery,
1928
- moveThemeSelection,
1989
+ const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
1990
+ const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
1991
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1992
+ })
1993
+ const selectCommandPaletteIndex = (index: number) => setCommandPalette((current) => {
1994
+ const selectedIndex = clampCommandIndex(index, commandPaletteCommands)
1995
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1996
+ })
1997
+ const runCommandPaletteCommand = (command: AppCommand) => {
1998
+ runCommand(command, { notifyDisabled: true, closePalette: true })
1999
+ }
2000
+ const scrollDetailFullViewBy = (delta: number) => {
2001
+ detailScrollRef.current?.scrollBy({ x: 0, y: delta })
2002
+ setDetailScrollOffset((current) => Math.max(0, current + delta))
2003
+ }
2004
+ const scrollDetailFullViewTo = (y: number) => {
2005
+ detailScrollRef.current?.scrollTo({ x: 0, y })
2006
+ setDetailScrollOffset(y)
2007
+ }
2008
+ const moveSelectedToPreviousGroup = () => setSelectedIndex((current) => {
2009
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2010
+ const currentGroup = getCurrentGroupIndex(current)
2011
+ if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
2012
+ return groupStarts[currentGroup - 1]!
2013
+ })
2014
+ const moveSelectedToNextGroup = () => setSelectedIndex((current) => {
2015
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2016
+ const currentGroup = getCurrentGroupIndex(current)
2017
+ if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
2018
+ return groupStarts[currentGroup + 1]!
1929
2019
  })
1930
- themeModalCtxRef.current = {
1931
- filterMode: themeModal.filterMode,
1932
- hasResults: filterThemeDefinitions(themeModal.query).length > 0,
1933
- closeThemeModal,
1934
- updateThemeQuery,
1935
- moveThemeSelection,
1936
- }
1937
- useBindings(() => ({
1938
- enabled: () => themeModalActiveRef.current,
1939
- bindings: [
1940
- { key: "escape", cmd: () => {
1941
- if (themeModalCtxRef.current.filterMode) themeModalCtxRef.current.updateThemeQuery("", { filterMode: false })
1942
- else themeModalCtxRef.current.closeThemeModal(false)
1943
- } },
1944
- { key: "/", cmd: () => themeModalCtxRef.current.updateThemeQuery("", { filterMode: true }) },
1945
- { key: "return", cmd: () => {
1946
- if (themeModalCtxRef.current.filterMode && !themeModalCtxRef.current.hasResults) return
1947
- themeModalCtxRef.current.closeThemeModal(true)
1948
- } },
1949
- { key: "up", cmd: () => themeModalCtxRef.current.moveThemeSelection(-1) },
1950
- { key: "down", cmd: () => themeModalCtxRef.current.moveThemeSelection(1) },
1951
- { key: "k", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(-1) } },
1952
- { key: "j", cmd: () => { if (!themeModalCtxRef.current.filterMode) themeModalCtxRef.current.moveThemeSelection(1) } },
1953
- ],
1954
- }), [])
1955
-
1956
- // OpenRepositoryModal: escape closes, return submits.
1957
- const openRepositoryModalActiveRef = useRef(false)
1958
- openRepositoryModalActiveRef.current = openRepositoryModalActive
1959
- const openRepositoryFromInputRef = useRef(openRepositoryFromInput)
1960
- openRepositoryFromInputRef.current = openRepositoryFromInput
1961
- useBindings(() => ({
1962
- enabled: () => openRepositoryModalActiveRef.current,
1963
- bindings: [
1964
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1965
- { key: "return", cmd: () => openRepositoryFromInputRef.current() },
1966
- ],
1967
- }), [])
1968
-
1969
- // CommentModal: full text editor — escape, submit, all the cursor/edit bindings.
1970
- const commentModalActiveRef = useRef(false)
1971
- commentModalActiveRef.current = commentModalActive
1972
- const commentModalCtxRef = useRef({ submitDiffComment, editComment })
1973
- commentModalCtxRef.current = { submitDiffComment, editComment }
1974
- const editComm = (transform: Parameters<typeof editComment>[0]) => commentModalCtxRef.current.editComment(transform)
1975
- useBindings(() => ({
1976
- enabled: () => commentModalActiveRef.current,
1977
- bindings: [
1978
- { key: "escape", cmd: () => closeActiveModalRef.current() },
1979
- { key: "ctrl+s", cmd: () => commentModalCtxRef.current.submitDiffComment() },
1980
- { key: "ctrl+a", cmd: () => editComm(moveLineStart) },
1981
- { key: "ctrl+e", cmd: () => editComm(moveLineEnd) },
1982
- { key: "ctrl+b", cmd: () => editComm(editorMoveLeft) },
1983
- { key: "ctrl+f", cmd: () => editComm(editorMoveRight) },
1984
- { key: "ctrl+w", cmd: () => editComm(deleteWordBackward) },
1985
- { key: "ctrl+u", cmd: () => editComm(deleteToLineStart) },
1986
- { key: "ctrl+k", cmd: () => editComm(deleteToLineEnd) },
1987
- { key: "ctrl+d", cmd: () => editComm(editorDeleteForward) },
1988
- { key: "meta+b", cmd: () => editComm(moveWordBackward) },
1989
- { key: "meta+left", cmd: () => editComm(moveWordBackward) },
1990
- { key: "meta+f", cmd: () => editComm(moveWordForward) },
1991
- { key: "meta+right", cmd: () => editComm(moveWordForward) },
1992
- { key: "meta+backspace", cmd: () => editComm(deleteWordBackward) },
1993
- { key: "meta+delete", cmd: () => editComm(deleteWordForward) },
1994
- { key: "backspace", cmd: () => editComm(editorBackspace) },
1995
- { key: "delete", cmd: () => editComm(editorDeleteForward) },
1996
- { key: "left", cmd: () => editComm(editorMoveLeft) },
1997
- { key: "right", cmd: () => editComm(editorMoveRight) },
1998
- { key: "up", cmd: () => editComm((state) => moveVertically(state, -1)) },
1999
- { key: "down", cmd: () => editComm((state) => moveVertically(state, 1)) },
2000
- { key: "home", cmd: () => editComm(moveLineStart) },
2001
- { key: "end", cmd: () => editComm(moveLineEnd) },
2002
- { key: "shift+return", cmd: () => editComm((state) => insertText(state, "\n")) },
2003
- { key: "return", cmd: () => commentModalCtxRef.current.submitDiffComment() },
2004
- ],
2005
- }), [])
2006
-
2007
- // CommandPalette: escape closes, return runs, up/k & down/j navigate.
2008
- const commandPaletteActiveRef = useRef(false)
2009
- commandPaletteActiveRef.current = commandPaletteActive
2010
- const commandPaletteCtxRef = useRef({
2011
- runSelected: () => {},
2012
- setCommandPalette,
2013
- paletteCommands: commandPaletteCommands,
2020
+ const stepSelected = (delta: number) => setSelectedIndex((current) => {
2021
+ if (visiblePullRequests.length === 0) return 0
2022
+ return Math.max(0, Math.min(visiblePullRequests.length - 1, current + delta))
2014
2023
  })
2015
- commandPaletteCtxRef.current = {
2016
- runSelected: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2017
- setCommandPalette,
2018
- paletteCommands: commandPaletteCommands,
2024
+ const stepSelectedDown = (count = 1) => {
2025
+ if (visiblePullRequests.length === 0) return
2026
+ if (selectedIndex + count >= visiblePullRequests.length && hasMorePullRequests) {
2027
+ loadMorePullRequests()
2028
+ }
2029
+ stepSelected(count)
2019
2030
  }
2020
- const moveCommandPaletteSelection = (delta: -1 | 1) => commandPaletteCtxRef.current.setCommandPalette((current) => {
2021
- const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCtxRef.current.paletteCommands)
2022
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2031
+ const stepSelectedUp = (count = 1) => stepSelected(-count)
2032
+ const stepSelectedDownWithLoadMore = () => {
2033
+ if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2034
+ loadMorePullRequests()
2035
+ return
2036
+ }
2037
+ setSelectedIndex((current) => {
2038
+ if (visiblePullRequests.length === 0) return 0
2039
+ return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2040
+ })
2041
+ }
2042
+ const stepSelectedUpWrap = () => setSelectedIndex((current) => {
2043
+ if (visiblePullRequests.length === 0) return 0
2044
+ return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2023
2045
  })
2024
- useBindings(() => ({
2025
- enabled: () => commandPaletteActiveRef.current,
2026
- bindings: [
2027
- { key: "escape", cmd: () => closeActiveModalRef.current() },
2028
- { key: "ctrl+c", cmd: () => closeActiveModalRef.current() },
2029
- { key: "return", cmd: () => commandPaletteCtxRef.current.runSelected() },
2030
- { key: "up", cmd: () => moveCommandPaletteSelection(-1) },
2031
- { key: "down", cmd: () => moveCommandPaletteSelection(1) },
2032
- ],
2033
- }), [])
2034
-
2035
- // FilterMode: escape cancels, return commits.
2036
- const filterModeRef = useRef(false)
2037
- filterModeRef.current = filterMode
2038
- const filterCtxRef = useRef({ filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode })
2039
- filterCtxRef.current = { filterQuery, filterDraft, setFilterQuery, setFilterDraft, setFilterMode }
2040
- useBindings(() => ({
2041
- enabled: () => filterModeRef.current,
2042
- bindings: [
2043
- { key: "escape", cmd: () => {
2044
- filterCtxRef.current.setFilterDraft(filterCtxRef.current.filterQuery)
2045
- filterCtxRef.current.setFilterMode(false)
2046
- } },
2047
- { key: "return", cmd: () => {
2048
- filterCtxRef.current.setFilterQuery(filterCtxRef.current.filterDraft)
2049
- filterCtxRef.current.setFilterMode(false)
2050
- } },
2051
- ],
2052
- }), [])
2046
+ const handleQuitOrClose = () => {
2047
+ if (themeModalActive) {
2048
+ closeThemeModal(false)
2049
+ return
2050
+ }
2051
+ if (activeModal._tag !== "None") {
2052
+ closeActiveModal()
2053
+ return
2054
+ }
2055
+ runCommandById("app.quit")
2056
+ }
2057
+
2058
+ // === Build the keymap context ===
2059
+ const appCtx: AppCtx = {
2060
+ closeModalActive,
2061
+ mergeModalActive,
2062
+ commentThreadModalActive,
2063
+ labelModalActive,
2064
+ themeModalActive,
2065
+ openRepositoryModalActive,
2066
+ commentModalActive,
2067
+ commandPaletteActive,
2068
+ filterMode,
2069
+ diffFullView,
2070
+ detailFullView,
2071
+ textInputActive: commentModalActive
2072
+ || commandPaletteActive
2073
+ || openRepositoryModalActive
2074
+ || labelModalActive
2075
+ || filterMode
2076
+ || (themeModalActive && themeModal.filterMode),
2077
+ closeModal: {
2078
+ closeModal: closeActiveModal,
2079
+ confirmClose: confirmClosePullRequest,
2080
+ },
2081
+ mergeModal: {
2082
+ availableActionCount: availableMergeActions(mergeModal.info).length,
2083
+ closeModal: closeActiveModal,
2084
+ confirmMerge: confirmMergeAction,
2085
+ moveSelection: moveMergeSelection,
2086
+ },
2087
+ commentThreadModal: {
2088
+ halfPage,
2089
+ closeModal: closeActiveModal,
2090
+ openInlineComment: openDiffCommentModal,
2091
+ scrollBy: scrollCommentThread,
2092
+ },
2093
+ labelModal: {
2094
+ closeModal: closeActiveModal,
2095
+ toggleSelected: toggleLabelAtIndex,
2096
+ moveSelection: moveLabelSelection,
2097
+ },
2098
+ themeModal: {
2099
+ filterMode: themeModal.filterMode,
2100
+ hasFilteredResults: filterThemeDefinitions(themeModal.query).length > 0,
2101
+ closeWithoutSaving: () => closeThemeModal(false),
2102
+ clearFilter: () => updateThemeQuery("", { filterMode: false }),
2103
+ enterFilterMode: () => updateThemeQuery("", { filterMode: true }),
2104
+ confirmSelection: () => closeThemeModal(true),
2105
+ moveSelection: moveThemeSelection,
2106
+ },
2107
+ openRepositoryModal: {
2108
+ closeModal: closeActiveModal,
2109
+ openFromInput: openRepositoryFromInput,
2110
+ },
2111
+ commentModal: {
2112
+ closeModal: closeActiveModal,
2113
+ submit: submitDiffComment,
2114
+ insertNewline: () => editComment((state) => insertText(state, "\n")),
2115
+ moveLeft: () => editComment(editorMoveLeft),
2116
+ moveRight: () => editComment(editorMoveRight),
2117
+ moveUp: () => editComment((state) => moveVertically(state, -1)),
2118
+ moveDown: () => editComment((state) => moveVertically(state, 1)),
2119
+ moveLineStart: () => editComment(moveLineStart),
2120
+ moveLineEnd: () => editComment(moveLineEnd),
2121
+ moveWordBackward: () => editComment(moveWordBackward),
2122
+ moveWordForward: () => editComment(moveWordForward),
2123
+ backspace: () => editComment(editorBackspace),
2124
+ deleteForward: () => editComment(editorDeleteForward),
2125
+ deleteWordBackward: () => editComment(deleteWordBackward),
2126
+ deleteWordForward: () => editComment(deleteWordForward),
2127
+ deleteToLineStart: () => editComment(deleteToLineStart),
2128
+ deleteToLineEnd: () => editComment(deleteToLineEnd),
2129
+ },
2130
+ commandPalette: {
2131
+ closeModal: closeActiveModal,
2132
+ runSelected: () => {
2133
+ if (selectedCommand) runCommandPaletteCommand(selectedCommand)
2134
+ },
2135
+ moveSelection: moveCommandPaletteSelection,
2136
+ },
2137
+ filterModeCtx: {
2138
+ cancel: () => {
2139
+ setFilterDraft(filterQuery)
2140
+ setFilterMode(false)
2141
+ },
2142
+ commit: () => {
2143
+ setFilterQuery(filterDraft)
2144
+ setFilterMode(false)
2145
+ },
2146
+ },
2147
+ diff: {
2148
+ halfPage,
2149
+ handleEscape: () => {
2150
+ if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2151
+ else runCommandById("diff.close")
2152
+ },
2153
+ openSelectedComment: openSelectedDiffComment,
2154
+ addComment: () => runCommandById("diff.add-comment"),
2155
+ toggleRange: () => runCommandById("diff.toggle-range"),
2156
+ toggleView: () => runCommandById("diff.toggle-view"),
2157
+ toggleWrap: () => runCommandById("diff.toggle-wrap"),
2158
+ reload: () => runCommandById("diff.reload"),
2159
+ nextThread: () => runCommandById("diff.next-thread"),
2160
+ previousThread: () => runCommandById("diff.previous-thread"),
2161
+ moveAnchor: moveDiffCommentAnchor,
2162
+ moveAnchorToBoundary: moveDiffCommentToBoundary,
2163
+ alignAnchor: alignSelectedDiffCommentAnchor,
2164
+ selectSide: selectDiffCommentSide,
2165
+ nextFile: () => runCommandById("diff.next-file"),
2166
+ previousFile: () => runCommandById("diff.previous-file"),
2167
+ openInBrowser: () => runCommandById("pull.open-browser"),
2168
+ },
2169
+ detail: {
2170
+ halfPage,
2171
+ scrollBy: scrollDetailFullViewBy,
2172
+ scrollTo: scrollDetailFullViewTo,
2173
+ closeDetail: () => runCommandById("detail.close"),
2174
+ openTheme: () => runCommandById("theme.open"),
2175
+ openDiff: () => runCommandById("diff.open"),
2176
+ closePullRequest: () => runCommandById("pull.close"),
2177
+ openLabels: () => runCommandById("pull.labels"),
2178
+ openMerge: () => runCommandById("pull.merge"),
2179
+ toggleDraft: () => runCommandById("pull.toggle-draft"),
2180
+ refresh: () => runCommandById("pull.refresh"),
2181
+ openInBrowser: () => runCommandById("pull.open-browser"),
2182
+ copyMetadata: () => runCommandById("pull.copy-metadata"),
2183
+ },
2184
+ listNav: {
2185
+ halfPage,
2186
+ visibleCount: visiblePullRequests.length,
2187
+ hasFilter: filterQuery.length > 0,
2188
+ canScrollDetailPreview: isWideLayout && selectedPullRequest !== null,
2189
+ runCommandById: (id) => { runCommandById(id) },
2190
+ switchQueueMode,
2191
+ scrollDetailPreviewBy,
2192
+ scrollDetailPreviewTo,
2193
+ clearFilter: () => { runCommandById("filter.clear") },
2194
+ stepSelected,
2195
+ stepSelectedUp,
2196
+ stepSelectedDown,
2197
+ stepSelectedUpWrap,
2198
+ stepSelectedDownWithLoadMore,
2199
+ moveSelectedToPreviousGroup,
2200
+ moveSelectedToNextGroup,
2201
+ setSelected: (index) => setSelectedIndex(index),
2202
+ },
2203
+ openCommandPalette: () => { runCommandById("command.open") },
2204
+ handleQuitOrClose,
2205
+ }
2206
+
2207
+ useKeymap(appKeymap, appCtx, useOpenTuiSubscribe())
2053
2208
 
2054
2209
  useKeyboard((key) => {
2055
2210
  if (commandPaletteActive) {
@@ -2073,18 +2228,9 @@ export const App = () => {
2073
2228
  return
2074
2229
  }
2075
2230
 
2076
- if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
2077
- if (themeModalActive) {
2078
- closeThemeModal(false)
2079
- return
2080
- }
2081
- if (activeModal._tag !== "None") {
2082
- closeActiveModal()
2083
- return
2084
- }
2085
- runCommandById("app.quit")
2086
- return
2087
- }
2231
+ // q / ctrl+c quit/close-modal logic now lives in the keymap layer
2232
+ // (handleQuitOrClose). This useKeyboard callback only handles raw text
2233
+ // input for modals that need character-by-character accumulation.
2088
2234
 
2089
2235
  if (themeModalActive) {
2090
2236
  if (themeModal.filterMode && isSingleLineInputKey(key)) {
@@ -2113,343 +2259,30 @@ export const App = () => {
2113
2259
  return
2114
2260
  }
2115
2261
 
2116
- if (diffFullView) {
2117
- if (diffCommentMode) {
2118
- if (key.name === "escape") {
2119
- setDiffCommentMode(false)
2120
- return
2121
- }
2122
- if (key.name === "c") {
2123
- runCommandById("diff.comment-mode")
2124
- return
2125
- }
2126
- if (key.name === "return" || key.name === "enter") {
2127
- if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
2128
- else openDiffCommentModal()
2129
- return
2130
- }
2131
- if (key.name === "a") {
2132
- runCommandById("diff.add-comment")
2133
- return
2134
- }
2135
- if (key.name === "pageup" || key.ctrl && key.name === "u") {
2136
- moveDiffCommentAnchor(-halfPage)
2137
- return
2138
- }
2139
- if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
2140
- moveDiffCommentAnchor(halfPage)
2141
- return
2142
- }
2143
- if ((key.shift || key.option || key.meta) && (key.name === "up" || key.name === "k") || key.name === "K") {
2144
- moveDiffCommentAnchor(-8)
2145
- return
2146
- }
2147
- if ((key.shift || key.option || key.meta) && (key.name === "down" || key.name === "j") || key.name === "J") {
2148
- moveDiffCommentAnchor(8)
2149
- return
2150
- }
2151
- if (key.name === "up" || key.name === "k") {
2152
- moveDiffCommentAnchor(-1)
2153
- return
2154
- }
2155
- if (key.name === "down" || key.name === "j") {
2156
- moveDiffCommentAnchor(1)
2157
- return
2158
- }
2159
- if (key.name === "left" || key.name === "h") {
2160
- selectDiffCommentSide("LEFT")
2161
- return
2162
- }
2163
- if (key.name === "right" || key.name === "l") {
2164
- selectDiffCommentSide("RIGHT")
2165
- return
2166
- }
2167
- if (key.name === "]" && selectedDiffState?._tag === "Ready") {
2168
- runCommandById("diff.next-file")
2169
- return
2170
- }
2171
- if (key.name === "[" && selectedDiffState?._tag === "Ready") {
2172
- runCommandById("diff.previous-file")
2173
- return
2174
- }
2175
- return
2176
- }
2177
-
2178
- if (key.name === "escape" || key.name === "return" || key.name === "enter") {
2179
- runCommandById("diff.close")
2180
- return
2181
- }
2182
- if (key.name === "c" && selectedDiffState?._tag === "Ready") {
2183
- runCommandById("diff.comment-mode")
2184
- return
2185
- }
2186
- if (key.name === "home") {
2187
- scrollDiffTo(0)
2188
- return
2189
- }
2190
- if (key.name === "end") {
2191
- scrollDiffTo(Number.MAX_SAFE_INTEGER)
2192
- return
2193
- }
2194
- if (key.name === "pageup") {
2195
- scrollDiffBy(-halfPage)
2196
- return
2197
- }
2198
- if (key.name === "pagedown") {
2199
- scrollDiffBy(halfPage)
2200
- return
2201
- }
2202
- if (handleVimGoto(key, () => scrollDiffTo(0), () => scrollDiffTo(Number.MAX_SAFE_INTEGER))) return
2203
- if (key.name === "up" || key.name === "k") {
2204
- scrollDiffBy(-1)
2205
- return
2206
- }
2207
- if (key.name === "down" || key.name === "j") {
2208
- scrollDiffBy(1)
2209
- return
2210
- }
2211
- if (key.ctrl && key.name === "u") {
2212
- scrollDiffBy(-halfPage)
2213
- return
2214
- }
2215
- if (key.ctrl && (key.name === "d" || key.name === "v")) {
2216
- scrollDiffBy(halfPage)
2217
- return
2218
- }
2219
- if (key.name === "v") {
2220
- runCommandById("diff.toggle-view")
2221
- return
2222
- }
2223
- if (key.name === "w") {
2224
- runCommandById("diff.toggle-wrap")
2225
- return
2226
- }
2227
- if (key.name === "r" && selectedPullRequest) {
2228
- runCommandById("diff.reload")
2229
- return
2230
- }
2231
- if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
2232
- runCommandById("diff.next-file")
2233
- return
2234
- }
2235
- if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
2236
- runCommandById("diff.previous-file")
2237
- return
2238
- }
2239
- if (key.name === "o" && selectedPullRequest) {
2240
- runCommandById("pull.open-browser")
2241
- return
2242
- }
2243
- return
2244
- }
2245
-
2246
- if (detailFullView) {
2247
- const plainKey = !key.ctrl && !key.meta && !key.option
2248
- if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
2249
- runCommandById("detail.close")
2250
- return
2251
- }
2252
- if (isThemeKey(key)) {
2253
- runCommandById("theme.open")
2254
- return
2255
- }
2256
- if (plainKey && key.name === "d" && selectedPullRequest) {
2257
- runCommandById("diff.open")
2258
- return
2259
- }
2260
- if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
2261
- runCommandById("pull.close")
2262
- return
2263
- }
2264
- if (plainKey && key.name === "l" && selectedPullRequest) {
2265
- runCommandById("pull.labels")
2266
- return
2267
- }
2268
- if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
2269
- runCommandById("pull.merge")
2270
- return
2271
- }
2272
- if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
2273
- runCommandById("pull.toggle-draft")
2274
- return
2275
- }
2276
- if (plainKey && key.name === "r") {
2277
- runCommandById("pull.refresh")
2278
- return
2279
- }
2280
- if (key.name === "home") {
2281
- detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
2282
- setDetailScrollOffset(0)
2283
- return
2284
- }
2285
- if (key.name === "end") {
2286
- detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
2287
- setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
2288
- return
2289
- }
2290
- if (key.name === "pageup") {
2291
- detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
2292
- setDetailScrollOffset((current) => Math.max(0, current - halfPage))
2293
- return
2294
- }
2295
- if (key.name === "pagedown") {
2296
- detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
2297
- setDetailScrollOffset((current) => current + halfPage)
2298
- return
2299
- }
2300
- if (handleVimGoto(key,
2301
- () => { detailScrollRef.current?.scrollTo({ x: 0, y: 0 }); setDetailScrollOffset(0) },
2302
- () => { detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER }); setDetailScrollOffset(Number.MAX_SAFE_INTEGER) },
2303
- )) return
2304
- if (key.name === "up" || key.name === "k") {
2305
- detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
2306
- setDetailScrollOffset((current) => Math.max(0, current - 1))
2307
- return
2308
- }
2309
- if (key.name === "down" || key.name === "j") {
2310
- detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
2311
- setDetailScrollOffset((current) => current + 1)
2312
- return
2313
- }
2314
- if (key.ctrl && key.name === "u") {
2315
- detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
2316
- setDetailScrollOffset((current) => Math.max(0, current - halfPage))
2317
- return
2318
- }
2319
- if (key.ctrl && (key.name === "d" || key.name === "v")) {
2320
- detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
2321
- setDetailScrollOffset((current) => current + halfPage)
2322
- return
2323
- }
2324
- if (plainKey && key.name === "o" && selectedPullRequest) {
2325
- runCommandById("pull.open-browser")
2326
- return
2327
- }
2328
- if (plainKey && key.name === "y" && selectedPullRequest) {
2329
- runCommandById("pull.copy-metadata")
2330
- return
2331
- }
2332
- return
2333
- }
2334
-
2335
2262
  if (filterMode) {
2336
2263
  if (isSingleLineInputKey(key)) {
2337
2264
  setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
2338
2265
  }
2339
- return
2340
- }
2341
-
2342
- if (key.name === "tab") {
2343
- switchQueueMode(key.shift ? -1 : 1)
2344
- return
2345
- }
2346
-
2347
- if (key.name === "escape" && filterQuery.length > 0) {
2348
- runCommandById("filter.clear")
2349
- return
2350
- }
2351
- if (isWideLayout && selectedPullRequest && !detailFullView && !diffFullView) {
2352
- if (key.name === "home") {
2353
- scrollDetailPreviewTo(0)
2354
- return
2355
- }
2356
- if (key.name === "end") {
2357
- scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER)
2358
- return
2359
- }
2360
- if (key.name === "pageup") {
2361
- scrollDetailPreviewBy(-halfPage)
2362
- return
2363
- }
2364
- if (key.name === "pagedown") {
2365
- scrollDetailPreviewBy(halfPage)
2366
- return
2367
- }
2368
- }
2369
- if (
2370
- key.name === "[" ||
2371
- ((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
2372
- (key.shift && key.name === "k") ||
2373
- key.name === "K"
2374
- ) {
2375
- setSelectedIndex((current) => {
2376
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2377
- const currentGroup = getCurrentGroupIndex(current)
2378
- if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
2379
- return groupStarts[currentGroup - 1]!
2380
- })
2381
- return
2382
2266
  }
2383
- if (
2384
- key.name === "]" ||
2385
- ((key.option || key.meta) && (key.name === "down" || key.name === "j")) ||
2386
- (key.shift && key.name === "j") ||
2387
- key.name === "J"
2388
- ) {
2389
- setSelectedIndex((current) => {
2390
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2391
- const currentGroup = getCurrentGroupIndex(current)
2392
- if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
2393
- return groupStarts[currentGroup + 1]!
2394
- })
2395
- return
2396
- }
2397
- if (key.ctrl && key.name === "u") {
2398
- setSelectedIndex((current) => {
2399
- if (visiblePullRequests.length === 0) return 0
2400
- return Math.max(0, current - halfPage)
2401
- })
2402
- return
2403
- }
2404
- if (key.ctrl && key.name === "d") {
2405
- setSelectedIndex((current) => {
2406
- if (visiblePullRequests.length === 0) return 0
2407
- return Math.min(visiblePullRequests.length - 1, current + halfPage)
2408
- })
2409
- return
2410
- }
2411
- if (key.name === "up" || key.name === "k") {
2412
- setSelectedIndex((current) => {
2413
- if (visiblePullRequests.length === 0) return 0
2414
- return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2415
- })
2416
- return
2417
- }
2418
- if (key.name === "down" || key.name === "j") {
2419
- if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2420
- loadMorePullRequests()
2421
- return
2422
- }
2423
- setSelectedIndex((current) => {
2424
- if (visiblePullRequests.length === 0) return 0
2425
- return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2426
- })
2427
- return
2428
- }
2429
- if (handleVimGoto(key,
2430
- () => setSelectedIndex(0),
2431
- () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2432
- )) return
2433
2267
  })
2434
2268
 
2269
+ if (isInitialLoading) {
2270
+ return (
2271
+ <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
2272
+ <LoadingLogoPane content={detailPlaceholderContent} width={contentWidth} height={terminalHeight} frame={loadingFrame} />
2273
+ </box>
2274
+ )
2275
+ }
2276
+
2435
2277
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2436
2278
  const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
2437
- const wideFullscreenDetailScrollable = getDetailsPaneHeight({
2438
- pullRequest: selectedPullRequest,
2439
- contentWidth: fullscreenContentWidth,
2440
- bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2441
- paneWidth: contentWidth,
2442
- showChecks: true,
2443
- }) > wideBodyHeight
2444
- const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
2445
- pullRequest: selectedPullRequest,
2446
- contentWidth: fullscreenContentWidth,
2447
- bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2448
- paneWidth: contentWidth,
2449
- }) > wideBodyHeight
2279
+ const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout)
2280
+ const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight)
2281
+ const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth, selectedConversationItems, selectedConversationStatus)
2282
+ const fullscreenDetailBodyScrollable = fullscreenDetailBodyHeight > fullscreenDetailBodyViewportHeight
2450
2283
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2451
2284
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2452
- const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2285
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
2453
2286
  const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2454
2287
 
2455
2288
  const prListProps = {
@@ -2463,6 +2296,7 @@ export const App = () => {
2463
2296
  loadedCount: loadedPullRequestCount,
2464
2297
  hasMore: hasMorePullRequests,
2465
2298
  isLoadingMore: isLoadingMorePullRequests,
2299
+ loadingIndicator,
2466
2300
  onSelectPullRequest: selectPullRequestByUrl,
2467
2301
  } as const
2468
2302
 
@@ -2491,8 +2325,8 @@ export const App = () => {
2491
2325
  const commentThreadModalHeight = commentThreadLayout.height
2492
2326
  const commentThreadModalLeft = commentThreadLayout.left
2493
2327
  const commentThreadModalTop = commentThreadLayout.top
2494
- const commentAnchorLabel = selectedDiffCommentAnchor
2495
- ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2328
+ const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2329
+ ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2496
2330
  : "No diff line selected"
2497
2331
  const mergeLayout = sizedModal(46, 68, 12, 16)
2498
2332
  const mergeModalWidth = mergeLayout.width
@@ -2520,28 +2354,27 @@ export const App = () => {
2520
2354
  <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
2521
2355
  <PlainLine text={headerLine} fg={colors.muted} bold />
2522
2356
  </box>
2523
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2357
+ {isWideLayout && !detailFullView && !diffFullView ? (
2524
2358
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
2525
2359
  ) : (
2526
2360
  <Divider width={contentWidth} />
2527
2361
  )}
2528
- {isInitialLoading ? (
2529
- <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
2530
- ) : diffFullView ? (
2362
+ {diffFullView ? (
2531
2363
  <PullRequestDiffPane
2532
2364
  pullRequest={selectedPullRequest}
2533
- diffState={selectedDiffState}
2365
+ diffState={displayedDiffState}
2534
2366
  stackedFiles={stackedDiffFiles}
2535
2367
  scrollTop={diffScrollTop}
2536
2368
  view={effectiveDiffRenderView}
2369
+ whitespaceMode={diffWhitespaceMode}
2537
2370
  wrapMode={diffWrapMode}
2538
2371
  paneWidth={contentWidth}
2539
2372
  height={wideBodyHeight}
2540
2373
  loadingIndicator={loadingIndicator}
2541
2374
  scrollRef={diffScrollRef}
2542
2375
  setDiffRef={setDiffRenderableRef}
2543
- commentMode={diffCommentMode}
2544
2376
  selectedCommentAnchor={selectedDiffCommentAnchor}
2377
+ selectedCommentLabel={selectedDiffCommentLabel}
2545
2378
  selectedCommentThread={selectedDiffCommentThread}
2546
2379
  onSelectCommentLine={selectDiffCommentLine}
2547
2380
  themeId={themeId}
@@ -2553,20 +2386,16 @@ export const App = () => {
2553
2386
  </box>
2554
2387
  ) : isWideLayout && detailFullView ? (
2555
2388
  <box flexGrow={1} flexDirection="column">
2556
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
2557
- <DetailsPane
2558
- pullRequest={selectedPullRequest}
2559
- viewerUsername={username}
2560
- contentWidth={fullscreenContentWidth}
2561
- bodyLines={fullscreenBodyLines}
2562
- bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2563
- paneWidth={contentWidth}
2564
- showChecks
2565
- placeholderContent={detailPlaceholderContent}
2566
- loadingIndicator={loadingIndicator}
2567
- themeId={themeId}
2568
- />
2569
- </scrollbox>
2389
+ {selectedPullRequest ? (
2390
+ <>
2391
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks />
2392
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2393
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2394
+ </scrollbox>
2395
+ </>
2396
+ ) : (
2397
+ <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2398
+ )}
2570
2399
  </box>
2571
2400
  ) : isWideLayout ? (
2572
2401
  <box key="wide-main" flexGrow={1} flexDirection="row">
@@ -2588,7 +2417,7 @@ export const App = () => {
2588
2417
  <>
2589
2418
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2590
2419
  <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2591
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2420
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2592
2421
  </scrollbox>
2593
2422
  </>
2594
2423
  ) : (
@@ -2598,23 +2427,20 @@ export const App = () => {
2598
2427
  </box>
2599
2428
  ) : detailFullView ? (
2600
2429
  <box flexGrow={1} flexDirection="column">
2601
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
2602
- <DetailsPane
2603
- pullRequest={selectedPullRequest}
2604
- viewerUsername={username}
2605
- contentWidth={fullscreenContentWidth}
2606
- bodyLines={fullscreenBodyLines}
2607
- bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2608
- paneWidth={contentWidth}
2609
- placeholderContent={detailPlaceholderContent}
2610
- loadingIndicator={loadingIndicator}
2611
- themeId={themeId}
2612
- />
2613
- </scrollbox>
2430
+ {selectedPullRequest ? (
2431
+ <>
2432
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} />
2433
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2434
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2435
+ </scrollbox>
2436
+ </>
2437
+ ) : (
2438
+ <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2439
+ )}
2614
2440
  </box>
2615
2441
  ) : (
2616
2442
  <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2617
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2443
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2618
2444
  <Divider width={contentWidth} />
2619
2445
  <box flexGrow={1} flexDirection="column">
2620
2446
  <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
@@ -2626,7 +2452,7 @@ export const App = () => {
2626
2452
  </box>
2627
2453
  )}
2628
2454
 
2629
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2455
+ {isWideLayout && !detailFullView && !diffFullView ? (
2630
2456
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
2631
2457
  ) : (
2632
2458
  <Divider width={contentWidth} />
@@ -2640,7 +2466,7 @@ export const App = () => {
2640
2466
  showFilterClear={filterMode || filterQuery.length > 0}
2641
2467
  detailFullView={detailFullView}
2642
2468
  diffFullView={diffFullView}
2643
- diffCommentMode={diffCommentMode}
2469
+ diffRangeActive={diffCommentRangeActive}
2644
2470
  hasSelection={selectedPullRequest !== null}
2645
2471
  canCloseSelection={selectedPullRequest?.state === "open"}
2646
2472
  hasError={pullRequestStatus === "error"}
@@ -2730,6 +2556,8 @@ export const App = () => {
2730
2556
  modalHeight={commandPaletteHeight}
2731
2557
  offsetLeft={commandPaletteLeft}
2732
2558
  offsetTop={commandPaletteTop}
2559
+ onSelectCommandIndex={selectCommandPaletteIndex}
2560
+ onRunCommand={runCommandPaletteCommand}
2733
2561
  />
2734
2562
  ) : null}
2735
2563
  </box>