@kitlangton/ghui 0.1.20 → 0.1.22

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,5 +1,7 @@
1
1
  import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
+ import { useAppCommandRegistry } from "./keyboard/useAppCommandRegistry.js"
4
+ import { scrollBindings, useScopedBindings, type ScopedBindingAction } from "./keyboard/useScopedBindings.js"
3
5
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
4
6
  import { Cause, Effect, Layer, Schedule } from "effect"
5
7
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -8,7 +10,7 @@ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"
8
10
  import { useContext, useEffect, useMemo, useRef, useState } from "react"
9
11
  import { buildAppCommands } from "./appCommands.js"
10
12
  import type { AppCommand } from "./commands.js"
11
- import { clampCommandIndex, commandEnabled, filterCommands } from "./commands.js"
13
+ import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
12
14
  import { config } from "./config.js"
13
15
  import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
14
16
  import { formatShortDate, formatTimestamp } from "./date.js"
@@ -24,8 +26,8 @@ import { GitHubService } from "./services/GitHubService.js"
24
26
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
25
27
  import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
26
28
  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"
27
- import { buildStackedDiffFiles, diffCommentLocationKey, getStackedDiffCommentAnchors, nearestDiffCommentAnchorIndex, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
28
- import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
29
+ import { buildStackedDiffFiles, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWrapMode, type StackedDiffCommentAnchor } from "./ui/diff.js"
30
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
29
31
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
30
32
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
31
33
  import { CommandPalette } from "./ui/CommandPalette.js"
@@ -95,6 +97,11 @@ interface AppliedDiffLineColorState {
95
97
  readonly entries: readonly AppliedDiffLineColor[]
96
98
  }
97
99
 
100
+ interface DiffCommentRangeSelection {
101
+ readonly start: StackedDiffCommentAnchor
102
+ readonly end: StackedDiffCommentAnchor
103
+ }
104
+
98
105
  interface DetailHydration {
99
106
  readonly token: symbol
100
107
  notifyError: boolean
@@ -108,10 +115,28 @@ const DIFF_STICKY_HEADER_LINES = 2
108
115
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
109
116
  const MAX_REPOSITORY_CACHE_ENTRIES = 8
110
117
  const LOAD_MORE_SELECTION_THRESHOLD = 8
118
+ const LOAD_MORE_SCROLL_THRESHOLD = 3
111
119
  const DETAIL_PREFETCH_BEHIND = 1
112
120
  const DETAIL_PREFETCH_AHEAD = 3
113
121
  const DETAIL_PREFETCH_CONCURRENCY = 3
114
122
  const DETAIL_PREFETCH_DELAY_MS = 120
123
+ const MAX_KEYMAP_COUNT_PREFIX = 99
124
+
125
+ const countSequence = (count: number, key: string) => `${String(count).split("").join(" ")} ${key}`
126
+
127
+ const countedVerticalBindings = (
128
+ moveUp: (count: number) => void,
129
+ moveDown: (count: number) => void,
130
+ ): Record<string, ScopedBindingAction> => {
131
+ const bindings: Record<string, ScopedBindingAction> = {}
132
+ for (let count = 1; count <= MAX_KEYMAP_COUNT_PREFIX; count++) {
133
+ bindings[countSequence(count, "k")] = () => moveUp(count)
134
+ bindings[countSequence(count, "up")] = () => moveUp(count)
135
+ bindings[countSequence(count, "j")] = () => moveDown(count)
136
+ bindings[countSequence(count, "down")] = () => moveDown(count)
137
+ }
138
+ return bindings
139
+ }
115
140
 
116
141
  const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
117
142
  const seen = new Set(existing.map((pullRequest) => pullRequest.url))
@@ -177,7 +202,6 @@ const noticeAtom = Atom.make<string | null>(null)
177
202
  const filterQueryAtom = Atom.make("")
178
203
  const filterDraftAtom = Atom.make("")
179
204
  const filterModeAtom = Atom.make(false)
180
- const pendingGAtom = Atom.make(false)
181
205
  const detailFullViewAtom = Atom.make(false)
182
206
  const detailScrollOffsetAtom = Atom.make(0)
183
207
  const diffFullViewAtom = Atom.make(false)
@@ -185,8 +209,8 @@ const diffFileIndexAtom = Atom.make(0)
185
209
  const diffScrollTopAtom = Atom.make(0)
186
210
  const diffRenderViewAtom = Atom.make<DiffView>("split")
187
211
  const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
188
- const diffCommentModeAtom = Atom.make(false)
189
212
  const diffCommentAnchorIndexAtom = Atom.make(0)
213
+ const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
190
214
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
191
215
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
192
216
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
@@ -379,12 +403,13 @@ const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
379
403
  const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
380
404
  const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
381
405
 
382
- const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
383
406
 
384
- const isThemeKey = (key: { readonly name: string; readonly ctrl?: boolean; readonly meta?: boolean }) => !key.ctrl && !key.meta && key.name.toLowerCase() === "t"
407
+
408
+ const diffCommentThreadMapKey = (diffKey: string, location: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
409
+ `${diffKey}:${diffCommentLocationKey(location)}`
385
410
 
386
411
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
387
- `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
412
+ diffCommentThreadMapKey(pullRequestDiffKey(pullRequest), comment)
388
413
 
389
414
  const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
390
415
  const threads: Record<string, PullRequestReviewComment[]> = {}
@@ -409,13 +434,43 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
409
434
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
410
435
  }
411
436
 
412
- const diffCommentGutterColor = (anchor: DiffCommentAnchor, kind: "selected" | "thread") => {
413
- const accent = kind === "thread"
414
- ? colors.status.pending
415
- : anchor.side === "RIGHT" ? colors.status.passing : colors.status.failing
416
- return mixHex(originalDiffLineColor(anchor).gutter, accent, 0.45)
437
+ const selectedDiffCommentAccentByKind = {
438
+ addition: () => colors.status.passing,
439
+ deletion: () => colors.status.failing,
440
+ context: () => colors.muted,
441
+ } satisfies Record<DiffCommentKind, () => string>
442
+
443
+ const selectedDiffCommentAccent = (kind: DiffCommentKind) => selectedDiffCommentAccentByKind[kind]()
444
+
445
+ const mixDiffLineContentColor = (base: string, accent: string, amount: number) =>
446
+ mixHex(base === "transparent" ? colors.background : base, accent, amount)
447
+
448
+ const diffCommentLineColor = (anchor: DiffCommentAnchor, kind: "selected" | "range" | "thread"): DiffLineColorConfig => {
449
+ const original = originalDiffLineColor(anchor)
450
+ const accent = kind === "thread" ? colors.status.pending : selectedDiffCommentAccent(anchor.kind)
451
+ if (kind === "thread") return { ...original, gutter: mixHex(original.gutter, accent, 0.45) }
452
+ return {
453
+ gutter: mixHex(original.gutter, accent, kind === "selected" ? 0.68 : 0.42),
454
+ content: mixDiffLineContentColor(original.content, accent, kind === "selected" ? 0.2 : 0.1),
455
+ }
456
+ }
457
+
458
+ const sameDiffCommentTarget = (left: DiffCommentAnchor, right: DiffCommentAnchor) =>
459
+ left.path === right.path && left.side === right.side
460
+
461
+ const diffCommentRangeSelection = (start: StackedDiffCommentAnchor | null, end: StackedDiffCommentAnchor | null): DiffCommentRangeSelection | null => {
462
+ if (!start || !end || !sameDiffCommentTarget(start, end)) return null
463
+ return start.line <= end.line ? { start, end } : { start: end, end: start }
417
464
  }
418
465
 
466
+ const diffCommentRangeContains = (range: DiffCommentRangeSelection, anchor: StackedDiffCommentAnchor) =>
467
+ sameDiffCommentTarget(range.start, anchor) && anchor.line >= range.start.line && anchor.line <= range.end.line
468
+
469
+ const diffCommentRangeLabel = (range: DiffCommentRangeSelection) =>
470
+ range.start.line === range.end.line
471
+ ? diffCommentAnchorLabel(range.end)
472
+ : `${diffCommentSideLabel(range.end)} ${diffCommentLineLabel(range.start)}-${diffCommentLineLabel(range.end)}`
473
+
419
474
  const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
420
475
  const withSides = diff as unknown as DiffRenderableRuntimeSides
421
476
  if (view === "split") {
@@ -486,7 +541,6 @@ export const App = () => {
486
541
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
487
542
  const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
488
543
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
489
- const [pendingG, setPendingG] = useAtom(pendingGAtom)
490
544
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
491
545
  const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
492
546
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
@@ -494,8 +548,8 @@ export const App = () => {
494
548
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
495
549
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
496
550
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
497
- const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
498
551
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
552
+ const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
499
553
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
500
554
  const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
501
555
  const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
@@ -577,7 +631,6 @@ export const App = () => {
577
631
  const wideDetailLines = Math.max(8, terminalHeight - 8)
578
632
  const wideBodyHeight = Math.max(8, terminalHeight - 4)
579
633
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
580
- const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
581
634
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
582
635
  const detailPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
583
636
  const detailHydrationRef = useRef(new Map<string, DetailHydration>())
@@ -618,9 +671,6 @@ export const App = () => {
618
671
  if (noticeTimeoutRef.current !== null) {
619
672
  clearTimeout(noticeTimeoutRef.current)
620
673
  }
621
- if (pendingGTimeoutRef.current !== null) {
622
- clearTimeout(pendingGTimeoutRef.current)
623
- }
624
674
  if (diffPrefetchTimeoutRef.current !== null) {
625
675
  clearTimeout(diffPrefetchTimeoutRef.current)
626
676
  }
@@ -668,14 +718,39 @@ export const App = () => {
668
718
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
669
719
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
670
720
  )
671
- const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
672
- const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? `${selectedDiffKey}:${diffCommentLocationKey(selectedDiffCommentAnchor)}` : null
721
+ const selectedDiffCommentAnchorIndex = Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))
722
+ const selectedDiffCommentAnchor = diffCommentAnchors[selectedDiffCommentAnchorIndex] ?? null
723
+ const diffCommentRangeStartAnchor = diffCommentRangeStartIndex === null
724
+ ? null
725
+ : diffCommentAnchors[Math.max(0, Math.min(diffCommentRangeStartIndex, diffCommentAnchors.length - 1))] ?? null
726
+ const selectedDiffCommentRange = useMemo(
727
+ () => diffCommentRangeSelection(diffCommentRangeStartAnchor, selectedDiffCommentAnchor),
728
+ [diffCommentRangeStartAnchor, selectedDiffCommentAnchor],
729
+ )
730
+ const selectedDiffCommentRangeAnchors = useMemo(
731
+ () => selectedDiffCommentRange
732
+ ? diffCommentAnchors.filter((anchor) => diffCommentRangeContains(selectedDiffCommentRange, anchor))
733
+ : [],
734
+ [diffCommentAnchors, selectedDiffCommentRange],
735
+ )
736
+ const diffCommentRangeActive = selectedDiffCommentRange !== null
737
+ const selectedDiffCommentLabel = selectedDiffCommentRange
738
+ ? diffCommentRangeLabel(selectedDiffCommentRange)
739
+ : selectedDiffCommentAnchor ? diffCommentAnchorLabel(selectedDiffCommentAnchor) : null
740
+ const selectedDiffCommentThreadKey = selectedDiffKey && selectedDiffCommentAnchor ? diffCommentThreadMapKey(selectedDiffKey, selectedDiffCommentAnchor) : null
673
741
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
674
742
  const diffLineColorContextKey = selectedDiffKey ? `${selectedDiffKey}:${effectiveDiffRenderView}:${diffWrapMode}` : null
675
- const diffCommentRows = useMemo(
676
- () => [...new Set(diffCommentAnchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right),
677
- [diffCommentAnchors],
678
- )
743
+ const diffCommentThreadAnchors = useMemo(() => {
744
+ if (!selectedDiffKey) return [] as readonly StackedDiffCommentAnchor[]
745
+ const seen = new Set<string>()
746
+ return diffCommentAnchors.filter((anchor) => {
747
+ const key = diffCommentLocationKey(anchor)
748
+ if (seen.has(key)) return false
749
+ if ((diffCommentThreads[diffCommentThreadMapKey(selectedDiffKey, anchor)]?.length ?? 0) === 0) return false
750
+ seen.add(key)
751
+ return true
752
+ })
753
+ }, [diffCommentAnchors, diffCommentThreads, selectedDiffKey])
679
754
  const groupStarts = useAtomValue(groupStartsAtom)
680
755
  const getCurrentGroupIndex = (current: number) => {
681
756
  if (groupStarts.length === 0) return 0
@@ -734,7 +809,7 @@ export const App = () => {
734
809
  setLoadingMoreKey(null)
735
810
  setDetailFullView(false)
736
811
  setDiffFullView(false)
737
- setDiffCommentMode(false)
812
+ setDiffCommentRangeStartIndex(null)
738
813
  setFilterDraft(filterQuery)
739
814
  setNotice(null)
740
815
  setRefreshCompletionMessage(null)
@@ -904,6 +979,20 @@ export const App = () => {
904
979
  if (selectedIndex >= thresholdIndex) loadMorePullRequests()
905
980
  }, [selectedIndex, visiblePullRequests.length, filterMode, filterQuery, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
906
981
 
982
+ useEffect(() => {
983
+ if (filterMode || filterQuery.length > 0 || visiblePullRequests.length === 0 || detailFullView || diffFullView) return
984
+ if (!hasMorePullRequests || isLoadingMorePullRequests) return
985
+ const checkScroll = () => {
986
+ const scroll = prListScrollRef.current
987
+ if (!scroll || scroll.viewport.height <= 0) return
988
+ const bottom = scroll.scrollTop + scroll.viewport.height
989
+ if (bottom >= scroll.scrollHeight - LOAD_MORE_SCROLL_THRESHOLD) loadMorePullRequests()
990
+ }
991
+ checkScroll()
992
+ const interval = globalThis.setInterval(checkScroll, 120)
993
+ return () => globalThis.clearInterval(interval)
994
+ }, [visiblePullRequests.length, filterMode, filterQuery, detailFullView, diffFullView, hasMorePullRequests, isLoadingMorePullRequests, currentQueueCacheKey])
995
+
907
996
  useEffect(() => {
908
997
  const scroll = prListScrollRef.current
909
998
  if (!scroll || selectedPullRequestRowIndex === null) return
@@ -917,6 +1006,7 @@ export const App = () => {
917
1006
  setDiffFileIndex(0)
918
1007
  setDiffScrollTop(0)
919
1008
  setDiffCommentAnchorIndex(0)
1009
+ setDiffCommentRangeStartIndex(null)
920
1010
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
921
1011
  }, [selectedIndex])
922
1012
 
@@ -925,12 +1015,16 @@ export const App = () => {
925
1015
  if (diffCommentAnchors.length === 0) return 0
926
1016
  return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
927
1017
  })
1018
+ setDiffCommentRangeStartIndex((current) => {
1019
+ if (current === null || diffCommentAnchors.length === 0) return null
1020
+ return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
1021
+ })
928
1022
  }, [diffCommentAnchors.length])
929
1023
 
930
1024
  useEffect(() => {
931
- if (!diffCommentMode || !selectedDiffCommentAnchor) return
1025
+ if (!diffFullView || !selectedDiffCommentAnchor) return
932
1026
  setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
933
- }, [diffCommentMode, selectedDiffCommentAnchor?.fileIndex])
1027
+ }, [diffFullView, selectedDiffCommentAnchor?.fileIndex])
934
1028
 
935
1029
  useEffect(() => {
936
1030
  const previous = diffCommentLineColorsRef.current
@@ -943,27 +1037,28 @@ export const App = () => {
943
1037
 
944
1038
  const nextEntries: AppliedDiffLineColor[] = []
945
1039
  const appliedKeys = new Set<string>()
946
- const applyLineColor = (anchor: StackedDiffCommentAnchor, gutter: string, override = false) => {
1040
+ const applyLineColor = (anchor: StackedDiffCommentAnchor, color: DiffLineColorConfig, override = false) => {
947
1041
  const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
948
1042
  if (appliedKeys.has(key) && !override) return
949
1043
  appliedKeys.add(key)
950
1044
  const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
951
1045
  const diff = diffRenderableRefs.current.get(anchor.fileIndex)
952
- if (diff) setDiffCommentLineColor(diff, entry, { ...originalDiffLineColor(anchor), gutter })
1046
+ if (diff) setDiffCommentLineColor(diff, entry, color)
953
1047
  if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
954
1048
  nextEntries.push(entry)
955
1049
  }
956
1050
  }
957
1051
 
958
- if (selectedDiffKey) {
959
- for (const anchor of diffCommentAnchors) {
960
- if ((diffCommentThreads[`${selectedDiffKey}:${diffCommentLocationKey(anchor)}`]?.length ?? 0) > 0) {
961
- applyLineColor(anchor, diffCommentGutterColor(anchor, "thread"))
962
- }
1052
+ for (const anchor of diffCommentThreadAnchors) {
1053
+ applyLineColor(anchor, diffCommentLineColor(anchor, "thread"))
1054
+ }
1055
+ if (selectedDiffCommentRangeAnchors.length > 0) {
1056
+ for (const anchor of selectedDiffCommentRangeAnchors) {
1057
+ applyLineColor(anchor, diffCommentLineColor(anchor, "range"), true)
963
1058
  }
964
1059
  }
965
- if (diffCommentMode && selectedDiffCommentAnchor) {
966
- applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
1060
+ if (selectedDiffCommentAnchor) {
1061
+ applyLineColor(selectedDiffCommentAnchor, diffCommentLineColor(selectedDiffCommentAnchor, "selected"), true)
967
1062
  if (suppressNextDiffCommentScrollRef.current) {
968
1063
  suppressNextDiffCommentScrollRef.current = false
969
1064
  } else {
@@ -973,10 +1068,10 @@ export const App = () => {
973
1068
  suppressNextDiffCommentScrollRef.current = false
974
1069
  }
975
1070
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
976
- }, [diffCommentMode, selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, diffLineColorContextKey, effectiveDiffRenderView, diffCommentAnchors, diffCommentThreads])
1071
+ }, [selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, selectedDiffCommentRangeAnchors, diffLineColorContextKey, effectiveDiffRenderView, diffCommentThreadAnchors])
977
1072
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
978
1073
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
979
- const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
1074
+ const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
980
1075
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
981
1076
 
982
1077
  useEffect(() => {
@@ -1119,9 +1214,10 @@ export const App = () => {
1119
1214
  diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
1120
1215
  setDiffFullView(true)
1121
1216
  setDetailFullView(false)
1122
- setDiffCommentMode(false)
1123
1217
  setDiffFileIndex(0)
1124
1218
  setDiffScrollTop(0)
1219
+ setDiffCommentAnchorIndex(0)
1220
+ setDiffCommentRangeStartIndex(null)
1125
1221
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1126
1222
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1127
1223
  loadPullRequestDiff(selectedPullRequest, { includeComments: true })
@@ -1146,49 +1242,9 @@ export const App = () => {
1146
1242
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1147
1243
  }
1148
1244
 
1149
- const scrollDiffBy = (y: number) => {
1150
- diffScrollRef.current?.scrollBy({ x: 0, y })
1151
- syncDiffScrollState()
1152
- }
1153
-
1154
- const scrollDiffTo = (y: number) => {
1155
- diffScrollRef.current?.scrollTo({ x: 0, y })
1156
- syncDiffScrollState()
1157
- }
1158
1245
  const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1159
1246
  const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1160
1247
 
1161
- const clearPendingGTimeout = () => {
1162
- if (pendingGTimeoutRef.current !== null) {
1163
- clearTimeout(pendingGTimeoutRef.current)
1164
- pendingGTimeoutRef.current = null
1165
- }
1166
- }
1167
-
1168
- const handleVimGoto = (key: { readonly name: string; readonly shift?: boolean }, gotoStart: () => void, gotoEnd: () => void): boolean => {
1169
- if (isShiftG(key)) {
1170
- gotoEnd()
1171
- setPendingG(false)
1172
- clearPendingGTimeout()
1173
- return true
1174
- }
1175
- if (key.name === "g") {
1176
- if (pendingG) {
1177
- gotoStart()
1178
- setPendingG(false)
1179
- clearPendingGTimeout()
1180
- } else {
1181
- setPendingG(true)
1182
- pendingGTimeoutRef.current = setTimeout(() => {
1183
- setPendingG(false)
1184
- pendingGTimeoutRef.current = null
1185
- }, 500)
1186
- }
1187
- return true
1188
- }
1189
- return false
1190
- }
1191
-
1192
1248
  const ensureDiffLineVisible = (line: number) => {
1193
1249
  const scroll = diffScrollRef.current
1194
1250
  if (!scroll) return
@@ -1210,37 +1266,72 @@ export const App = () => {
1210
1266
  if (readyDiffFiles.length === 0) return
1211
1267
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1212
1268
  setDiffFileIndex(nextIndex)
1213
- if (diffCommentMode) {
1214
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1215
- ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1216
- if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1217
- }
1269
+ setDiffCommentRangeStartIndex(null)
1270
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1271
+ ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1272
+ if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1218
1273
  scrollToDiffFile(nextIndex)
1219
1274
  }
1220
1275
 
1221
- const enterDiffCommentMode = () => {
1222
- const scrollTop = diffScrollRef.current?.scrollTop ?? 0
1223
- suppressNextDiffCommentScrollRef.current = true
1224
- setDiffCommentAnchorIndex(nearestDiffCommentAnchorIndex(diffCommentAnchors, scrollTop + DIFF_STICKY_HEADER_LINES))
1225
- setDiffCommentMode(true)
1226
- }
1276
+ const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1277
+ ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1278
+ : diffCommentAnchors
1227
1279
 
1228
- const moveDiffCommentAnchor = (delta: number) => {
1229
- if (diffCommentAnchors.length === 0) return
1230
- const currentAnchor = selectedDiffCommentAnchor ?? diffCommentAnchors[0]
1231
- const currentRowIndex = Math.max(0, currentAnchor ? diffCommentRows.indexOf(currentAnchor.renderLine) : 0)
1232
- const nextRow = diffCommentRows[Math.max(0, Math.min(diffCommentRows.length - 1, currentRowIndex + delta))]
1280
+ const moveDiffCommentAnchor = (delta: number, options: { readonly preserveViewportRow?: boolean } = {}) => {
1281
+ const anchors = navigableDiffCommentAnchors()
1282
+ if (anchors.length === 0) return
1283
+ const rows = [...new Set(anchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right)
1284
+ const currentAnchor = selectedDiffCommentAnchor && anchors.includes(selectedDiffCommentAnchor) ? selectedDiffCommentAnchor : anchors[0]
1285
+ const currentRowIndex = Math.max(0, currentAnchor ? rows.indexOf(currentAnchor.renderLine) : 0)
1286
+ const nextRow = rows[Math.max(0, Math.min(rows.length - 1, currentRowIndex + delta))]
1233
1287
  if (nextRow === undefined) return
1234
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1235
- ?? diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow)
1288
+ const nextAnchor = anchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1289
+ ?? anchors.find((anchor) => anchor.renderLine === nextRow)
1290
+ if (!nextAnchor) return
1291
+ if (options.preserveViewportRow) {
1292
+ const scroll = diffScrollRef.current
1293
+ if (scroll && currentAnchor) {
1294
+ const maxScreenOffset = Math.max(DIFF_STICKY_HEADER_LINES, scroll.viewport.height - 2)
1295
+ const screenOffset = Math.max(DIFF_STICKY_HEADER_LINES, Math.min(maxScreenOffset, currentAnchor.renderLine - scroll.scrollTop))
1296
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
1297
+ const nextTop = Math.max(0, Math.min(maxScrollTop, nextAnchor.renderLine - screenOffset))
1298
+ suppressNextDiffCommentScrollRef.current = true
1299
+ scroll.scrollTo({ x: 0, y: nextTop })
1300
+ syncDiffScrollState()
1301
+ }
1302
+ }
1303
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1304
+ }
1305
+
1306
+ const moveDiffCommentToBoundary = (boundary: "first" | "last") => {
1307
+ const anchors = navigableDiffCommentAnchors()
1308
+ const nextAnchor = boundary === "first" ? anchors[0] : anchors[anchors.length - 1]
1236
1309
  if (!nextAnchor) return
1237
1310
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1311
+ setDiffFileIndex(nextAnchor.fileIndex)
1312
+ }
1313
+
1314
+ const alignSelectedDiffCommentAnchor = (position: "top" | "center" | "bottom") => {
1315
+ if (!selectedDiffCommentAnchor) return
1316
+ const scroll = diffScrollRef.current
1317
+ if (!scroll) return
1318
+ const viewportHeight = Math.max(1, scroll.viewport.height)
1319
+ const offset = position === "top"
1320
+ ? DIFF_STICKY_HEADER_LINES
1321
+ : position === "center"
1322
+ ? Math.max(DIFF_STICKY_HEADER_LINES, Math.floor(viewportHeight / 2))
1323
+ : Math.max(DIFF_STICKY_HEADER_LINES, viewportHeight - 2)
1324
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - viewportHeight)
1325
+ const nextTop = Math.max(0, Math.min(maxScrollTop, selectedDiffCommentAnchor.renderLine - offset))
1326
+ scroll.scrollTo({ x: 0, y: nextTop })
1327
+ syncDiffScrollState()
1238
1328
  }
1239
1329
 
1240
1330
  const selectDiffCommentSide = (side: DiffCommentSide) => {
1241
1331
  if (!selectedDiffCommentAnchor) return
1242
1332
  const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1243
1333
  if (!nextAnchor) return
1334
+ setDiffCommentRangeStartIndex(null)
1244
1335
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1245
1336
  }
1246
1337
 
@@ -1249,9 +1340,11 @@ export const App = () => {
1249
1340
  const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1250
1341
  if (!nextAnchor) return
1251
1342
  suppressNextDiffCommentScrollRef.current = true
1343
+ if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1344
+ setDiffCommentRangeStartIndex(null)
1345
+ }
1252
1346
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1253
1347
  setDiffFileIndex(nextAnchor.fileIndex)
1254
- setDiffCommentMode(true)
1255
1348
  }
1256
1349
 
1257
1350
  const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
@@ -1272,6 +1365,39 @@ export const App = () => {
1272
1365
  setCommentThreadModal({ scrollOffset: 0 })
1273
1366
  }
1274
1367
 
1368
+ const openSelectedDiffComment = () => {
1369
+ if (diffCommentRangeActive) {
1370
+ openDiffCommentModal()
1371
+ return
1372
+ }
1373
+ if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
1374
+ else openDiffCommentModal()
1375
+ }
1376
+
1377
+ const toggleDiffCommentRange = () => {
1378
+ if (!selectedDiffCommentAnchor) return
1379
+ setDiffCommentRangeStartIndex((current) => current === null ? selectedDiffCommentAnchorIndex : null)
1380
+ }
1381
+
1382
+ const moveDiffCommentThread = (delta: 1 | -1) => {
1383
+ if (diffCommentThreadAnchors.length === 0) {
1384
+ flashNotice("No diff comments")
1385
+ return
1386
+ }
1387
+ const currentIndex = selectedDiffCommentAnchor
1388
+ ? diffCommentThreadAnchors.findIndex((anchor) => diffCommentLocationKey(anchor) === diffCommentLocationKey(selectedDiffCommentAnchor))
1389
+ : -1
1390
+ const nextAnchor = currentIndex >= 0
1391
+ ? diffCommentThreadAnchors[(currentIndex + delta + diffCommentThreadAnchors.length) % diffCommentThreadAnchors.length]
1392
+ : delta > 0
1393
+ ? diffCommentThreadAnchors.find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine > selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[0]
1394
+ : [...diffCommentThreadAnchors].reverse().find((anchor) => !selectedDiffCommentAnchor || anchor.renderLine < selectedDiffCommentAnchor.renderLine) ?? diffCommentThreadAnchors[diffCommentThreadAnchors.length - 1]
1395
+ if (!nextAnchor) return
1396
+ setDiffCommentRangeStartIndex(null)
1397
+ setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1398
+ setDiffFileIndex(nextAnchor.fileIndex)
1399
+ }
1400
+
1275
1401
  const submitDiffComment = () => {
1276
1402
  if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1277
1403
  const body = commentModal.body.trim()
@@ -1280,8 +1406,9 @@ export const App = () => {
1280
1406
  return
1281
1407
  }
1282
1408
 
1283
- const threadKey = selectedDiffCommentThreadKey
1284
- const target = selectedDiffCommentAnchor
1409
+ const targetRange = selectedDiffCommentRange
1410
+ const target = targetRange?.end ?? selectedDiffCommentAnchor
1411
+ const threadKey = selectedDiffKey ? diffCommentThreadMapKey(selectedDiffKey, target) : null
1285
1412
  const optimisticComment = {
1286
1413
  id: `local:${Date.now()}`,
1287
1414
  path: target.path,
@@ -1292,6 +1419,9 @@ export const App = () => {
1292
1419
  createdAt: new Date(),
1293
1420
  url: null,
1294
1421
  } satisfies PullRequestReviewComment
1422
+ const rangeInput = targetRange && targetRange.start.line !== targetRange.end.line
1423
+ ? { startLine: targetRange.start.line, startSide: targetRange.start.side }
1424
+ : {}
1295
1425
  const input = {
1296
1426
  repository: selectedPullRequest.repository,
1297
1427
  number: selectedPullRequest.number,
@@ -1300,6 +1430,7 @@ export const App = () => {
1300
1430
  line: target.line,
1301
1431
  side: target.side,
1302
1432
  body,
1433
+ ...rangeInput,
1303
1434
  } satisfies CreatePullRequestCommentInput
1304
1435
 
1305
1436
  if (threadKey) {
@@ -1309,6 +1440,7 @@ export const App = () => {
1309
1440
  }))
1310
1441
  }
1311
1442
  closeActiveModal()
1443
+ setDiffCommentRangeStartIndex(null)
1312
1444
  flashNotice(`Commenting on ${target.path}:${target.line}`)
1313
1445
  void createPullRequestComment(input).then((comment) => {
1314
1446
  if (threadKey) {
@@ -1671,8 +1803,10 @@ export const App = () => {
1671
1803
  diffWrapMode,
1672
1804
  readyDiffFileCount: readyDiffFiles.length,
1673
1805
  diffFileIndex,
1674
- diffCommentMode,
1675
- selectedDiffCommentAnchorLabel: selectedDiffCommentAnchor ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line}` : null,
1806
+ diffRangeActive: diffCommentRangeActive,
1807
+ selectedDiffCommentAnchorLabel: selectedDiffCommentLabel,
1808
+ selectedDiffCommentThreadCount: selectedDiffCommentThread.length,
1809
+ hasDiffCommentThreads: diffCommentThreadAnchors.length > 0,
1676
1810
  actions: {
1677
1811
  openCommandPalette,
1678
1812
  refreshPullRequests,
@@ -1700,7 +1834,7 @@ export const App = () => {
1700
1834
  openDiffView,
1701
1835
  closeDiffView: () => {
1702
1836
  setDiffFullView(false)
1703
- setDiffCommentMode(false)
1837
+ setDiffCommentRangeStartIndex(null)
1704
1838
  },
1705
1839
  reloadDiff: () => {
1706
1840
  if (!selectedPullRequest) return
@@ -1710,10 +1844,9 @@ export const App = () => {
1710
1844
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1711
1845
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1712
1846
  jumpDiffFile,
1713
- toggleDiffCommentMode: () => {
1714
- if (diffCommentMode) setDiffCommentMode(false)
1715
- else enterDiffCommentMode()
1716
- },
1847
+ openSelectedDiffComment,
1848
+ toggleDiffCommentRange,
1849
+ moveDiffCommentThread,
1717
1850
  openDiffCommentModal,
1718
1851
  togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1719
1852
  openLabelModal,
@@ -1739,69 +1872,377 @@ export const App = () => {
1739
1872
  const command = appCommands.find((entry) => entry.id === id)
1740
1873
  return command ? runCommand(command, options) : false
1741
1874
  }
1742
- const commandPaletteCommands = commandPaletteActive ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query) : []
1875
+ const runCommandByIdRef = useRef(runCommandById)
1876
+ runCommandByIdRef.current = runCommandById
1877
+ useAppCommandRegistry(appCommands, runCommandByIdRef)
1878
+ const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1879
+ if (!commandPaletteActive) return []
1880
+ const repository = parseRepositoryInput(commandPalette.query)
1881
+ if (!repository || repository === selectedRepository) return []
1882
+ return [defineCommand({
1883
+ id: `view.repository.dynamic:${repository}`,
1884
+ title: `Open ${repository}`,
1885
+ scope: "View",
1886
+ subtitle: "Switch to this repository",
1887
+ run: () => switchViewTo({ _tag: "Repository", repository }),
1888
+ })]
1889
+ })()
1890
+ // Dynamic commands always pin to the top of the palette; they came directly from the
1891
+ // user's typed input so they shouldn't be filtered by fuzzy score against themselves.
1892
+ const staticPaletteCommands = commandPaletteActive
1893
+ ? filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)
1894
+ : []
1895
+ const commandPaletteCommands = commandPaletteActive
1896
+ ? [
1897
+ ...dynamicPaletteCommands,
1898
+ ...(commandPalette.query.trim().length > 0 ? staticPaletteCommands : sortCommandsByScope(staticPaletteCommands)),
1899
+ ]
1900
+ : []
1743
1901
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1744
1902
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1745
1903
 
1904
+ const noModalActive = activeModal._tag === "None"
1905
+ const globalLayerActive = noModalActive
1906
+ && !diffFullView
1907
+ && !detailFullView
1908
+ && !filterMode
1909
+ useScopedBindings({
1910
+ when: true,
1911
+ bindings: {
1912
+ "ctrl+p": "command.open",
1913
+ "meta+k": "command.open",
1914
+ },
1915
+ })
1916
+
1917
+ useScopedBindings({
1918
+ when: closeModalActive,
1919
+ bindings: {
1920
+ escape: closeActiveModal,
1921
+ return: confirmClosePullRequest,
1922
+ },
1923
+ })
1924
+
1925
+ const moveMergeSelection = (delta: -1 | 1) => setMergeModal((current) => {
1926
+ const max = Math.max(0, availableMergeActions(mergeModal.info).length - 1)
1927
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1928
+ })
1929
+ useScopedBindings({
1930
+ when: mergeModalActive,
1931
+ bindings: {
1932
+ escape: closeActiveModal,
1933
+ return: () => {
1934
+ if (availableMergeActions(mergeModal.info).length > 0) confirmMergeAction()
1935
+ },
1936
+ up: () => moveMergeSelection(-1),
1937
+ k: () => moveMergeSelection(-1),
1938
+ down: () => moveMergeSelection(1),
1939
+ j: () => moveMergeSelection(1),
1940
+ },
1941
+ })
1942
+
1943
+ const scrollCommentThread = (delta: number) => setCommentThreadModal((current) => ({
1944
+ ...current,
1945
+ scrollOffset: Math.max(0, current.scrollOffset + delta),
1946
+ }))
1947
+ useScopedBindings({
1948
+ when: commentThreadModalActive,
1949
+ bindings: {
1950
+ escape: closeActiveModal,
1951
+ return: openDiffCommentModal,
1952
+ a: openDiffCommentModal,
1953
+ c: openDiffCommentModal,
1954
+ up: () => scrollCommentThread(-1),
1955
+ k: () => scrollCommentThread(-1),
1956
+ down: () => scrollCommentThread(1),
1957
+ j: () => scrollCommentThread(1),
1958
+ pageup: () => scrollCommentThread(-halfPage),
1959
+ "ctrl+u": () => scrollCommentThread(-halfPage),
1960
+ pagedown: () => scrollCommentThread(halfPage),
1961
+ "ctrl+d": () => scrollCommentThread(halfPage),
1962
+ "ctrl+v": () => scrollCommentThread(halfPage),
1963
+ },
1964
+ })
1965
+
1966
+ const moveLabelSelection = (delta: -1 | 1) => setLabelModal((current) => {
1967
+ const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
1968
+ return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1969
+ })
1970
+ useScopedBindings({
1971
+ when: labelModalActive,
1972
+ bindings: {
1973
+ escape: closeActiveModal,
1974
+ return: toggleLabelAtIndex,
1975
+ up: () => moveLabelSelection(-1),
1976
+ k: () => moveLabelSelection(-1),
1977
+ down: () => moveLabelSelection(1),
1978
+ j: () => moveLabelSelection(1),
1979
+ },
1980
+ })
1981
+
1982
+ useScopedBindings({
1983
+ when: themeModalActive,
1984
+ bindings: {
1985
+ escape: () => {
1986
+ if (themeModal.filterMode) updateThemeQuery("", { filterMode: false })
1987
+ else closeThemeModal(false)
1988
+ },
1989
+ "/": () => updateThemeQuery("", { filterMode: true }),
1990
+ return: () => {
1991
+ if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
1992
+ closeThemeModal(true)
1993
+ },
1994
+ up: () => moveThemeSelection(-1),
1995
+ down: () => moveThemeSelection(1),
1996
+ k: () => { if (!themeModal.filterMode) moveThemeSelection(-1) },
1997
+ j: () => { if (!themeModal.filterMode) moveThemeSelection(1) },
1998
+ },
1999
+ })
2000
+
2001
+ useScopedBindings({
2002
+ when: openRepositoryModalActive,
2003
+ bindings: {
2004
+ escape: closeActiveModal,
2005
+ return: openRepositoryFromInput,
2006
+ },
2007
+ })
2008
+
2009
+ useScopedBindings({
2010
+ when: commentModalActive,
2011
+ bindings: {
2012
+ escape: closeActiveModal,
2013
+ "ctrl+s": submitDiffComment,
2014
+ "ctrl+a": () => editComment(moveLineStart),
2015
+ "ctrl+e": () => editComment(moveLineEnd),
2016
+ "ctrl+b": () => editComment(editorMoveLeft),
2017
+ "ctrl+f": () => editComment(editorMoveRight),
2018
+ "ctrl+w": () => editComment(deleteWordBackward),
2019
+ "ctrl+u": () => editComment(deleteToLineStart),
2020
+ "ctrl+k": () => editComment(deleteToLineEnd),
2021
+ "ctrl+d": () => editComment(editorDeleteForward),
2022
+ "meta+b": () => editComment(moveWordBackward),
2023
+ "meta+left": () => editComment(moveWordBackward),
2024
+ "meta+f": () => editComment(moveWordForward),
2025
+ "meta+right": () => editComment(moveWordForward),
2026
+ "meta+backspace": () => editComment(deleteWordBackward),
2027
+ "meta+delete": () => editComment(deleteWordForward),
2028
+ backspace: () => editComment(editorBackspace),
2029
+ delete: () => editComment(editorDeleteForward),
2030
+ left: () => editComment(editorMoveLeft),
2031
+ right: () => editComment(editorMoveRight),
2032
+ up: () => editComment((state) => moveVertically(state, -1)),
2033
+ down: () => editComment((state) => moveVertically(state, 1)),
2034
+ home: () => editComment(moveLineStart),
2035
+ end: () => editComment(moveLineEnd),
2036
+ "shift+return": () => editComment((state) => insertText(state, "\n")),
2037
+ return: submitDiffComment,
2038
+ },
2039
+ })
2040
+
2041
+ const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
2042
+ const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
2043
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2044
+ })
2045
+ useScopedBindings({
2046
+ when: commandPaletteActive,
2047
+ bindings: {
2048
+ escape: closeActiveModal,
2049
+ "ctrl+c": closeActiveModal,
2050
+ return: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2051
+ up: () => moveCommandPaletteSelection(-1),
2052
+ down: () => moveCommandPaletteSelection(1),
2053
+ },
2054
+ })
2055
+
2056
+ useScopedBindings({
2057
+ when: filterMode,
2058
+ bindings: {
2059
+ escape: () => { setFilterDraft(filterQuery); setFilterMode(false) },
2060
+ return: () => { setFilterQuery(filterDraft); setFilterMode(false) },
2061
+ },
2062
+ })
2063
+
2064
+ useScopedBindings({
2065
+ when: diffFullView && noModalActive,
2066
+ bindings: {
2067
+ escape: () => {
2068
+ if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2069
+ else runCommandById("diff.close")
2070
+ },
2071
+ return: openSelectedDiffComment,
2072
+ a: "diff.add-comment",
2073
+ v: "diff.toggle-range",
2074
+ "shift+v": "diff.toggle-view",
2075
+ w: "diff.toggle-wrap",
2076
+ r: "diff.reload",
2077
+ n: "diff.next-thread",
2078
+ p: "diff.previous-thread",
2079
+ pageup: () => moveDiffCommentAnchor(-halfPage, { preserveViewportRow: true }),
2080
+ "ctrl+u": () => moveDiffCommentAnchor(-halfPage, { preserveViewportRow: true }),
2081
+ pagedown: () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2082
+ "ctrl+d": () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2083
+ "ctrl+v": () => moveDiffCommentAnchor(halfPage, { preserveViewportRow: true }),
2084
+ "shift+up": () => moveDiffCommentAnchor(-8),
2085
+ "shift+k": () => moveDiffCommentAnchor(-8),
2086
+ "meta+up": () => moveDiffCommentAnchor(-8),
2087
+ "meta+k": () => moveDiffCommentAnchor(-8),
2088
+ "shift+down": () => moveDiffCommentAnchor(8),
2089
+ "shift+j": () => moveDiffCommentAnchor(8),
2090
+ "meta+down": () => moveDiffCommentAnchor(8),
2091
+ "meta+j": () => moveDiffCommentAnchor(8),
2092
+ ...countedVerticalBindings(
2093
+ (count) => moveDiffCommentAnchor(-count),
2094
+ (count) => moveDiffCommentAnchor(count),
2095
+ ),
2096
+ up: () => moveDiffCommentAnchor(-1),
2097
+ k: () => moveDiffCommentAnchor(-1),
2098
+ down: () => moveDiffCommentAnchor(1),
2099
+ j: () => moveDiffCommentAnchor(1),
2100
+ left: () => selectDiffCommentSide("LEFT"),
2101
+ h: () => selectDiffCommentSide("LEFT"),
2102
+ right: () => selectDiffCommentSide("RIGHT"),
2103
+ l: () => selectDiffCommentSide("RIGHT"),
2104
+ "]": "diff.next-file",
2105
+ "[": "diff.previous-file",
2106
+ "g g": () => moveDiffCommentToBoundary("first"),
2107
+ "shift+g": () => moveDiffCommentToBoundary("last"),
2108
+ "z z": () => alignSelectedDiffCommentAnchor("center"),
2109
+ "z t": () => alignSelectedDiffCommentAnchor("top"),
2110
+ "z b": () => alignSelectedDiffCommentAnchor("bottom"),
2111
+ o: "pull.open-browser",
2112
+ },
2113
+ })
2114
+
2115
+ const scrollDetailFullViewBy = (delta: number) => {
2116
+ detailScrollRef.current?.scrollBy({ x: 0, y: delta })
2117
+ setDetailScrollOffset((current) => Math.max(0, current + delta))
2118
+ }
2119
+ const scrollDetailFullViewTo = (y: number) => {
2120
+ detailScrollRef.current?.scrollTo({ x: 0, y })
2121
+ setDetailScrollOffset(y)
2122
+ }
2123
+ useScopedBindings({
2124
+ when: detailFullView && noModalActive,
2125
+ bindings: {
2126
+ ...scrollBindings(scrollDetailFullViewBy, halfPage, scrollDetailFullViewTo),
2127
+ escape: "detail.close",
2128
+ return: "detail.close",
2129
+ t: "theme.open",
2130
+ d: "diff.open",
2131
+ x: "pull.close",
2132
+ l: "pull.labels",
2133
+ m: "pull.merge",
2134
+ "shift+m": "pull.merge",
2135
+ s: "pull.toggle-draft",
2136
+ "shift+s": "pull.toggle-draft",
2137
+ r: "pull.refresh",
2138
+ o: "pull.open-browser",
2139
+ y: "pull.copy-metadata",
2140
+ },
2141
+ })
2142
+
2143
+ const moveSelectedToPreviousGroup = () => setSelectedIndex((current) => {
2144
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2145
+ const currentGroup = getCurrentGroupIndex(current)
2146
+ if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
2147
+ return groupStarts[currentGroup - 1]!
2148
+ })
2149
+ const moveSelectedToNextGroup = () => setSelectedIndex((current) => {
2150
+ if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2151
+ const currentGroup = getCurrentGroupIndex(current)
2152
+ if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
2153
+ return groupStarts[currentGroup + 1]!
2154
+ })
2155
+ const stepSelected = (delta: number) => setSelectedIndex((current) => {
2156
+ if (visiblePullRequests.length === 0) return 0
2157
+ return Math.max(0, Math.min(visiblePullRequests.length - 1, current + delta))
2158
+ })
2159
+ const stepSelectedDown = (count = 1) => {
2160
+ if (visiblePullRequests.length === 0) return
2161
+ if (selectedIndex + count >= visiblePullRequests.length && hasMorePullRequests) {
2162
+ loadMorePullRequests()
2163
+ }
2164
+ stepSelected(count)
2165
+ }
2166
+ const stepSelectedUp = (count = 1) => stepSelected(-count)
2167
+ const stepSelectedDownWithLoadMore = () => {
2168
+ if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2169
+ loadMorePullRequests()
2170
+ return
2171
+ }
2172
+ setSelectedIndex((current) => {
2173
+ if (visiblePullRequests.length === 0) return 0
2174
+ return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2175
+ })
2176
+ }
2177
+ const stepSelectedUpWrap = () => setSelectedIndex((current) => {
2178
+ if (visiblePullRequests.length === 0) return 0
2179
+ return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2180
+ })
2181
+ useScopedBindings({
2182
+ when: globalLayerActive,
2183
+ bindings: {
2184
+ "/": "filter.open",
2185
+ r: "pull.refresh",
2186
+ t: "theme.open",
2187
+ d: "diff.open",
2188
+ l: "pull.labels",
2189
+ m: "pull.merge",
2190
+ "shift+m": "pull.merge",
2191
+ x: "pull.close",
2192
+ o: "pull.open-browser",
2193
+ s: "pull.toggle-draft",
2194
+ "shift+s": "pull.toggle-draft",
2195
+ y: "pull.copy-metadata",
2196
+ return: "detail.open",
2197
+ tab: () => switchQueueMode(1),
2198
+ "shift+tab": () => switchQueueMode(-1),
2199
+ escape: () => { if (filterQuery.length > 0) runCommandByIdRef.current("filter.clear") },
2200
+ home: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewTo(0) },
2201
+ end: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER) },
2202
+ pageup: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewBy(-halfPage) },
2203
+ pagedown: () => { if (isWideLayout && selectedPullRequest) scrollDetailPreviewBy(halfPage) },
2204
+ "[": moveSelectedToPreviousGroup,
2205
+ "meta+up": moveSelectedToPreviousGroup,
2206
+ "meta+k": moveSelectedToPreviousGroup,
2207
+ "shift+k": moveSelectedToPreviousGroup,
2208
+ "]": moveSelectedToNextGroup,
2209
+ "meta+down": moveSelectedToNextGroup,
2210
+ "meta+j": moveSelectedToNextGroup,
2211
+ "shift+j": moveSelectedToNextGroup,
2212
+ "ctrl+u": () => stepSelected(-halfPage),
2213
+ "ctrl+d": () => stepSelected(halfPage),
2214
+ ...countedVerticalBindings(stepSelectedUp, stepSelectedDown),
2215
+ up: stepSelectedUpWrap,
2216
+ k: stepSelectedUpWrap,
2217
+ down: stepSelectedDownWithLoadMore,
2218
+ j: stepSelectedDownWithLoadMore,
2219
+ "g g": () => setSelectedIndex(0),
2220
+ "shift+g": () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2221
+ },
2222
+ })
2223
+
1746
2224
  useKeyboard((key) => {
1747
2225
  if (commandPaletteActive) {
1748
- if (key.name === "escape" || key.ctrl && key.name === "c") {
1749
- closeActiveModal()
1750
- return
1751
- }
1752
- if (key.name === "return" || key.name === "enter") {
1753
- if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true })
1754
- return
1755
- }
1756
- if (key.name === "up" || key.name === "k" && !key.ctrl && !key.meta) {
1757
- setCommandPalette((current) => {
1758
- const selectedIndex = clampCommandIndex(current.selectedIndex - 1, commandPaletteCommands)
1759
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1760
- })
1761
- return
1762
- }
1763
- if (key.name === "down" || key.name === "j" && !key.ctrl && !key.meta) {
1764
- setCommandPalette((current) => {
1765
- const selectedIndex = clampCommandIndex(current.selectedIndex + 1, commandPaletteCommands)
1766
- return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
1767
- })
1768
- return
1769
- }
1770
2226
  if (isSingleLineInputKey(key)) {
1771
2227
  setCommandPalette((current) => {
1772
2228
  const query = editSingleLineInput(current.query, key) ?? current.query
1773
2229
  return current.query === query && current.selectedIndex === 0 ? current : { ...current, query, selectedIndex: 0 }
1774
2230
  })
1775
- return
1776
2231
  }
1777
2232
  return
1778
2233
  }
1779
2234
 
1780
2235
  if (openRepositoryModalActive) {
1781
- if (key.name === "escape" || key.ctrl && key.name === "c") {
1782
- closeActiveModal()
1783
- return
1784
- }
1785
- if (key.name === "return" || key.name === "enter") {
1786
- openRepositoryFromInput()
1787
- return
1788
- }
1789
2236
  if (isSingleLineInputKey(key)) {
1790
2237
  setOpenRepositoryModal((current) => ({
1791
2238
  ...current,
1792
2239
  query: editSingleLineInput(current.query, key) ?? current.query,
1793
2240
  error: null,
1794
2241
  }))
1795
- return
1796
2242
  }
1797
2243
  return
1798
2244
  }
1799
2245
 
1800
- if ((key.ctrl && key.name === "p") || (key.meta && key.name === "k")) {
1801
- runCommandById("command.open")
1802
- return
1803
- }
1804
-
1805
2246
  if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
1806
2247
  if (themeModalActive) {
1807
2248
  closeThemeModal(false)
@@ -1816,630 +2257,45 @@ export const App = () => {
1816
2257
  }
1817
2258
 
1818
2259
  if (themeModalActive) {
1819
- if (key.name === "escape") {
1820
- if (themeModal.filterMode) {
1821
- updateThemeQuery("", { filterMode: false })
1822
- return
1823
- }
1824
- closeThemeModal(false)
1825
- return
1826
- }
1827
- if (key.name === "/") {
1828
- updateThemeQuery("", { filterMode: true })
1829
- return
1830
- }
1831
- if (key.name === "return" || key.name === "enter") {
1832
- if (themeModal.filterMode && filterThemeDefinitions(themeModal.query).length === 0) return
1833
- closeThemeModal(true)
1834
- return
1835
- }
1836
- if (key.name === "up" || (!themeModal.filterMode && key.name === "k")) {
1837
- moveThemeSelection(-1)
1838
- return
1839
- }
1840
- if (key.name === "down" || (!themeModal.filterMode && key.name === "j")) {
1841
- moveThemeSelection(1)
1842
- return
1843
- }
1844
2260
  if (themeModal.filterMode && isSingleLineInputKey(key)) {
1845
2261
  editThemeQuery((query) => editSingleLineInput(query, key) ?? query)
1846
- return
1847
2262
  }
1848
2263
  return
1849
2264
  }
1850
2265
 
1851
2266
  if (commentModalActive) {
1852
- if (key.name === "escape") {
1853
- closeActiveModal()
1854
- return
1855
- }
1856
- if (key.ctrl && key.name === "s") {
1857
- submitDiffComment()
1858
- return
1859
- }
1860
- if (key.ctrl && key.name === "a") {
1861
- editComment(moveLineStart)
1862
- return
1863
- }
1864
- if (key.ctrl && key.name === "e") {
1865
- editComment(moveLineEnd)
1866
- return
1867
- }
1868
- if (key.ctrl && key.name === "b") {
1869
- editComment(editorMoveLeft)
1870
- return
1871
- }
1872
- if (key.ctrl && key.name === "f") {
1873
- editComment(editorMoveRight)
1874
- return
1875
- }
1876
- if (key.ctrl && key.name === "w") {
1877
- editComment(deleteWordBackward)
1878
- return
1879
- }
1880
- if (key.ctrl && key.name === "u") {
1881
- editComment(deleteToLineStart)
1882
- return
1883
- }
1884
- if (key.ctrl && key.name === "k") {
1885
- editComment(deleteToLineEnd)
1886
- return
1887
- }
1888
- if (key.ctrl && key.name === "d") {
1889
- editComment(editorDeleteForward)
1890
- return
1891
- }
1892
- if ((key.meta || key.option) && (key.name === "b" || key.name === "left")) {
1893
- editComment(moveWordBackward)
1894
- return
1895
- }
1896
- if ((key.meta || key.option) && (key.name === "f" || key.name === "right")) {
1897
- editComment(moveWordForward)
1898
- return
1899
- }
1900
- if ((key.meta || key.option) && (key.name === "backspace" || key.name === "delete")) {
1901
- editComment(key.name === "delete" ? deleteWordForward : deleteWordBackward)
1902
- return
1903
- }
1904
- if (key.name === "backspace") {
1905
- editComment(editorBackspace)
1906
- return
1907
- }
1908
- if (key.name === "delete") {
1909
- editComment(editorDeleteForward)
1910
- return
1911
- }
1912
- if (key.name === "left") {
1913
- editComment(editorMoveLeft)
1914
- return
1915
- }
1916
- if (key.name === "right") {
1917
- editComment(editorMoveRight)
1918
- return
1919
- }
1920
- if (key.name === "up") {
1921
- editComment((state) => moveVertically(state, -1))
1922
- return
1923
- }
1924
- if (key.name === "down") {
1925
- editComment((state) => moveVertically(state, 1))
1926
- return
1927
- }
1928
- if (key.name === "home") {
1929
- editComment(moveLineStart)
1930
- return
1931
- }
1932
- if (key.name === "end") {
1933
- editComment(moveLineEnd)
1934
- return
1935
- }
1936
- if ((key.name === "return" || key.name === "enter") && key.shift) {
1937
- editComment((state) => insertText(state, "\n"))
1938
- return
1939
- }
1940
- if (key.name === "return" || key.name === "enter") {
1941
- submitDiffComment()
1942
- return
1943
- }
1944
2267
  const text = printableKeyText(key)
1945
- if (text) {
1946
- editComment((state) => insertText(state, text))
1947
- return
1948
- }
2268
+ if (text) editComment((state) => insertText(state, text))
1949
2269
  return
1950
2270
  }
1951
2271
 
1952
- if (commentThreadModalActive) {
1953
- if (key.name === "escape") {
1954
- closeActiveModal()
1955
- return
1956
- }
1957
- if (key.name === "return" || key.name === "enter" || key.name === "a" || key.name === "c") {
1958
- openDiffCommentModal()
1959
- return
1960
- }
1961
- if (key.name === "up" || key.name === "k") {
1962
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - 1) }))
1963
- return
1964
- }
1965
- if (key.name === "down" || key.name === "j") {
1966
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + 1 }))
1967
- return
1968
- }
1969
- if (key.name === "pageup" || key.ctrl && key.name === "u") {
1970
- setCommentThreadModal((current) => ({ ...current, scrollOffset: Math.max(0, current.scrollOffset - halfPage) }))
1971
- return
1972
- }
1973
- if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
1974
- setCommentThreadModal((current) => ({ ...current, scrollOffset: current.scrollOffset + halfPage }))
1975
- return
1976
- }
1977
- return
1978
- }
1979
2272
 
1980
- if (closeModalActive) {
1981
- if (key.name === "escape") {
1982
- closeActiveModal()
1983
- return
1984
- }
1985
- if (key.name === "return" || key.name === "enter") {
1986
- confirmClosePullRequest()
1987
- return
1988
- }
1989
- return
1990
- }
1991
2273
 
1992
- if (mergeModalActive) {
1993
- const options = availableMergeActions(mergeModal.info)
1994
- if (key.name === "escape") {
1995
- closeActiveModal()
1996
- return
1997
- }
1998
- if ((key.name === "return" || key.name === "enter") && options.length > 0) {
1999
- confirmMergeAction()
2000
- return
2001
- }
2002
- if (key.name === "up" || key.name === "k") {
2003
- setMergeModal((current) => ({
2004
- ...current,
2005
- selectedIndex: Math.max(0, current.selectedIndex - 1),
2006
- }))
2007
- return
2008
- }
2009
- if (key.name === "down" || key.name === "j") {
2010
- setMergeModal((current) => ({
2011
- ...current,
2012
- selectedIndex: Math.min(Math.max(0, options.length - 1), current.selectedIndex + 1),
2013
- }))
2014
- return
2015
- }
2016
- return
2017
- }
2018
2274
 
2019
2275
  if (labelModalActive) {
2020
- if (key.name === "escape") {
2021
- closeActiveModal()
2022
- return
2023
- }
2024
- if (key.name === "return" || key.name === "enter") {
2025
- toggleLabelAtIndex()
2026
- return
2027
- }
2028
- if (key.name === "up" || key.name === "k") {
2029
- setLabelModal((current) => ({
2030
- ...current,
2031
- selectedIndex: Math.max(0, current.selectedIndex - 1),
2032
- }))
2033
- return
2034
- }
2035
- if (key.name === "down" || key.name === "j") {
2036
- const filtered = filterLabels(labelModal.availableLabels, labelModal.query)
2037
- setLabelModal((current) => ({
2038
- ...current,
2039
- selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
2040
- }))
2041
- return
2042
- }
2043
2276
  if (isSingleLineInputKey(key)) {
2044
2277
  setLabelModal((current) => ({
2045
2278
  ...current,
2046
2279
  query: editSingleLineInput(current.query, key) ?? current.query,
2047
2280
  selectedIndex: 0,
2048
2281
  }))
2049
- return
2050
- }
2051
- return
2052
- }
2053
-
2054
- if (diffFullView) {
2055
- if (diffCommentMode) {
2056
- if (key.name === "escape") {
2057
- setDiffCommentMode(false)
2058
- return
2059
- }
2060
- if (key.name === "c") {
2061
- runCommandById("diff.comment-mode")
2062
- return
2063
- }
2064
- if (key.name === "return" || key.name === "enter") {
2065
- if (selectedDiffCommentThread.length > 0) openDiffCommentThreadModal()
2066
- else openDiffCommentModal()
2067
- return
2068
- }
2069
- if (key.name === "a") {
2070
- runCommandById("diff.add-comment")
2071
- return
2072
- }
2073
- if (key.name === "pageup" || key.ctrl && key.name === "u") {
2074
- moveDiffCommentAnchor(-halfPage)
2075
- return
2076
- }
2077
- if (key.name === "pagedown" || key.ctrl && (key.name === "d" || key.name === "v")) {
2078
- moveDiffCommentAnchor(halfPage)
2079
- return
2080
- }
2081
- if ((key.shift || key.option || key.meta) && (key.name === "up" || key.name === "k") || key.name === "K") {
2082
- moveDiffCommentAnchor(-8)
2083
- return
2084
- }
2085
- if ((key.shift || key.option || key.meta) && (key.name === "down" || key.name === "j") || key.name === "J") {
2086
- moveDiffCommentAnchor(8)
2087
- return
2088
- }
2089
- if (key.name === "up" || key.name === "k") {
2090
- moveDiffCommentAnchor(-1)
2091
- return
2092
- }
2093
- if (key.name === "down" || key.name === "j") {
2094
- moveDiffCommentAnchor(1)
2095
- return
2096
- }
2097
- if (key.name === "left" || key.name === "h") {
2098
- selectDiffCommentSide("LEFT")
2099
- return
2100
- }
2101
- if (key.name === "right" || key.name === "l") {
2102
- selectDiffCommentSide("RIGHT")
2103
- return
2104
- }
2105
- if (key.name === "]" && selectedDiffState?._tag === "Ready") {
2106
- runCommandById("diff.next-file")
2107
- return
2108
- }
2109
- if (key.name === "[" && selectedDiffState?._tag === "Ready") {
2110
- runCommandById("diff.previous-file")
2111
- return
2112
- }
2113
- return
2114
- }
2115
-
2116
- if (key.name === "escape" || key.name === "return" || key.name === "enter") {
2117
- runCommandById("diff.close")
2118
- return
2119
- }
2120
- if (key.name === "c" && selectedDiffState?._tag === "Ready") {
2121
- runCommandById("diff.comment-mode")
2122
- return
2123
- }
2124
- if (key.name === "home") {
2125
- scrollDiffTo(0)
2126
- return
2127
- }
2128
- if (key.name === "end") {
2129
- scrollDiffTo(Number.MAX_SAFE_INTEGER)
2130
- return
2131
- }
2132
- if (key.name === "pageup") {
2133
- scrollDiffBy(-halfPage)
2134
- return
2135
- }
2136
- if (key.name === "pagedown") {
2137
- scrollDiffBy(halfPage)
2138
- return
2139
- }
2140
- if (handleVimGoto(key, () => scrollDiffTo(0), () => scrollDiffTo(Number.MAX_SAFE_INTEGER))) return
2141
- if (key.name === "up" || key.name === "k") {
2142
- scrollDiffBy(-1)
2143
- return
2144
- }
2145
- if (key.name === "down" || key.name === "j") {
2146
- scrollDiffBy(1)
2147
- return
2148
- }
2149
- if (key.ctrl && key.name === "u") {
2150
- scrollDiffBy(-halfPage)
2151
- return
2152
- }
2153
- if (key.ctrl && (key.name === "d" || key.name === "v")) {
2154
- scrollDiffBy(halfPage)
2155
- return
2156
- }
2157
- if (key.name === "v") {
2158
- runCommandById("diff.toggle-view")
2159
- return
2160
- }
2161
- if (key.name === "w") {
2162
- runCommandById("diff.toggle-wrap")
2163
- return
2164
- }
2165
- if (key.name === "r" && selectedPullRequest) {
2166
- runCommandById("diff.reload")
2167
- return
2168
- }
2169
- if ((key.name === "]" || key.name === "right" || key.name === "l") && selectedDiffState?._tag === "Ready") {
2170
- runCommandById("diff.next-file")
2171
- return
2172
- }
2173
- if ((key.name === "[" || key.name === "left" || key.name === "h") && selectedDiffState?._tag === "Ready") {
2174
- runCommandById("diff.previous-file")
2175
- return
2176
- }
2177
- if (key.name === "o" && selectedPullRequest) {
2178
- runCommandById("pull.open-browser")
2179
- return
2180
- }
2181
- return
2182
- }
2183
-
2184
- if (detailFullView) {
2185
- const plainKey = !key.ctrl && !key.meta && !key.option
2186
- if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
2187
- runCommandById("detail.close")
2188
- return
2189
- }
2190
- if (isThemeKey(key)) {
2191
- runCommandById("theme.open")
2192
- return
2193
- }
2194
- if (plainKey && key.name === "d" && selectedPullRequest) {
2195
- runCommandById("diff.open")
2196
- return
2197
- }
2198
- if (plainKey && key.name === "x" && selectedPullRequest?.state === "open") {
2199
- runCommandById("pull.close")
2200
- return
2201
- }
2202
- if (plainKey && key.name === "l" && selectedPullRequest) {
2203
- runCommandById("pull.labels")
2204
- return
2205
- }
2206
- if (plainKey && (key.name === "m" || key.name === "M") && selectedPullRequest) {
2207
- runCommandById("pull.merge")
2208
- return
2209
- }
2210
- if (plainKey && (key.name === "s" || key.name === "S") && selectedPullRequest) {
2211
- runCommandById("pull.toggle-draft")
2212
- return
2213
- }
2214
- if (plainKey && key.name === "r") {
2215
- runCommandById("pull.refresh")
2216
- return
2217
- }
2218
- if (key.name === "home") {
2219
- detailScrollRef.current?.scrollTo({ x: 0, y: 0 })
2220
- setDetailScrollOffset(0)
2221
- return
2222
- }
2223
- if (key.name === "end") {
2224
- detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER })
2225
- setDetailScrollOffset(Number.MAX_SAFE_INTEGER)
2226
- return
2227
- }
2228
- if (key.name === "pageup") {
2229
- detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
2230
- setDetailScrollOffset((current) => Math.max(0, current - halfPage))
2231
- return
2232
- }
2233
- if (key.name === "pagedown") {
2234
- detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
2235
- setDetailScrollOffset((current) => current + halfPage)
2236
- return
2237
- }
2238
- if (handleVimGoto(key,
2239
- () => { detailScrollRef.current?.scrollTo({ x: 0, y: 0 }); setDetailScrollOffset(0) },
2240
- () => { detailScrollRef.current?.scrollTo({ x: 0, y: Number.MAX_SAFE_INTEGER }); setDetailScrollOffset(Number.MAX_SAFE_INTEGER) },
2241
- )) return
2242
- if (key.name === "up" || key.name === "k") {
2243
- detailScrollRef.current?.scrollBy({ x: 0, y: -1 })
2244
- setDetailScrollOffset((current) => Math.max(0, current - 1))
2245
- return
2246
- }
2247
- if (key.name === "down" || key.name === "j") {
2248
- detailScrollRef.current?.scrollBy({ x: 0, y: 1 })
2249
- setDetailScrollOffset((current) => current + 1)
2250
- return
2251
- }
2252
- if (key.ctrl && key.name === "u") {
2253
- detailScrollRef.current?.scrollBy({ x: 0, y: -halfPage })
2254
- setDetailScrollOffset((current) => Math.max(0, current - halfPage))
2255
- return
2256
- }
2257
- if (key.ctrl && (key.name === "d" || key.name === "v")) {
2258
- detailScrollRef.current?.scrollBy({ x: 0, y: halfPage })
2259
- setDetailScrollOffset((current) => current + halfPage)
2260
- return
2261
- }
2262
- if (plainKey && key.name === "o" && selectedPullRequest) {
2263
- runCommandById("pull.open-browser")
2264
- return
2265
- }
2266
- if (plainKey && key.name === "y" && selectedPullRequest) {
2267
- runCommandById("pull.copy-metadata")
2268
- return
2269
2282
  }
2270
2283
  return
2271
2284
  }
2272
2285
 
2273
2286
  if (filterMode) {
2274
- if (key.name === "escape") {
2275
- setFilterDraft(filterQuery)
2276
- setFilterMode(false)
2277
- return
2278
- }
2279
- if (key.name === "return" || key.name === "enter") {
2280
- setFilterQuery(filterDraft)
2281
- setFilterMode(false)
2282
- return
2283
- }
2284
2287
  if (isSingleLineInputKey(key)) {
2285
2288
  setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
2286
- return
2287
2289
  }
2288
2290
  }
2289
-
2290
- if (key.name === "tab") {
2291
- switchQueueMode(key.shift ? -1 : 1)
2292
- return
2293
- }
2294
-
2295
- if (isThemeKey(key)) {
2296
- runCommandById("theme.open")
2297
- return
2298
- }
2299
-
2300
- if (key.name === "/") {
2301
- runCommandById("filter.open")
2302
- return
2303
- }
2304
- if (key.name === "escape" && filterQuery.length > 0) {
2305
- runCommandById("filter.clear")
2306
- return
2307
- }
2308
- if (key.name === "r") {
2309
- runCommandById("pull.refresh")
2310
- return
2311
- }
2312
- if (isWideLayout && selectedPullRequest && !detailFullView && !diffFullView) {
2313
- if (key.name === "home") {
2314
- scrollDetailPreviewTo(0)
2315
- return
2316
- }
2317
- if (key.name === "end") {
2318
- scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER)
2319
- return
2320
- }
2321
- if (key.name === "pageup") {
2322
- scrollDetailPreviewBy(-halfPage)
2323
- return
2324
- }
2325
- if (key.name === "pagedown") {
2326
- scrollDetailPreviewBy(halfPage)
2327
- return
2328
- }
2329
- }
2330
- if (
2331
- key.name === "[" ||
2332
- ((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
2333
- (key.shift && key.name === "k") ||
2334
- key.name === "K"
2335
- ) {
2336
- setSelectedIndex((current) => {
2337
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2338
- const currentGroup = getCurrentGroupIndex(current)
2339
- if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
2340
- return groupStarts[currentGroup - 1]!
2341
- })
2342
- return
2343
- }
2344
- if (
2345
- key.name === "]" ||
2346
- ((key.option || key.meta) && (key.name === "down" || key.name === "j")) ||
2347
- (key.shift && key.name === "j") ||
2348
- key.name === "J"
2349
- ) {
2350
- setSelectedIndex((current) => {
2351
- if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2352
- const currentGroup = getCurrentGroupIndex(current)
2353
- if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
2354
- return groupStarts[currentGroup + 1]!
2355
- })
2356
- return
2357
- }
2358
- if (key.ctrl && key.name === "u") {
2359
- setSelectedIndex((current) => {
2360
- if (visiblePullRequests.length === 0) return 0
2361
- return Math.max(0, current - halfPage)
2362
- })
2363
- return
2364
- }
2365
- if (key.ctrl && key.name === "d") {
2366
- setSelectedIndex((current) => {
2367
- if (visiblePullRequests.length === 0) return 0
2368
- return Math.min(visiblePullRequests.length - 1, current + halfPage)
2369
- })
2370
- return
2371
- }
2372
- if (key.name === "up" || key.name === "k") {
2373
- setSelectedIndex((current) => {
2374
- if (visiblePullRequests.length === 0) return 0
2375
- return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2376
- })
2377
- return
2378
- }
2379
- if (key.name === "down" || key.name === "j") {
2380
- if (visiblePullRequests.length > 0 && selectedIndex >= visiblePullRequests.length - 1 && hasMorePullRequests) {
2381
- loadMorePullRequests()
2382
- return
2383
- }
2384
- setSelectedIndex((current) => {
2385
- if (visiblePullRequests.length === 0) return 0
2386
- return current >= visiblePullRequests.length - 1 ? 0 : current + 1
2387
- })
2388
- return
2389
- }
2390
- if (handleVimGoto(key,
2391
- () => setSelectedIndex(0),
2392
- () => setSelectedIndex(visiblePullRequests.length === 0 ? 0 : visiblePullRequests.length - 1),
2393
- )) return
2394
- if ((key.name === "return" || key.name === "enter") && !detailFullView) {
2395
- runCommandById("detail.open")
2396
- return
2397
- }
2398
- if (key.name === "d" && selectedPullRequest) {
2399
- runCommandById("diff.open")
2400
- return
2401
- }
2402
- if (key.name === "x" && selectedPullRequest?.state === "open") {
2403
- runCommandById("pull.close")
2404
- return
2405
- }
2406
- if (key.name === "l" && selectedPullRequest) {
2407
- runCommandById("pull.labels")
2408
- return
2409
- }
2410
- if (key.name === "m" || key.name === "M") {
2411
- if (selectedPullRequest) runCommandById("pull.merge")
2412
- return
2413
- }
2414
- if (key.name === "o" && selectedPullRequest) {
2415
- runCommandById("pull.open-browser")
2416
- return
2417
- }
2418
- if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
2419
- runCommandById("pull.toggle-draft")
2420
- return
2421
- }
2422
- if (key.name === "y" && selectedPullRequest) {
2423
- runCommandById("pull.copy-metadata")
2424
- return
2425
- }
2426
2291
  })
2427
2292
 
2428
2293
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2429
2294
  const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
2430
- const wideFullscreenDetailScrollable = getDetailsPaneHeight({
2431
- pullRequest: selectedPullRequest,
2432
- contentWidth: fullscreenContentWidth,
2433
- bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2434
- paneWidth: contentWidth,
2435
- showChecks: true,
2436
- }) > wideBodyHeight
2437
- const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
2438
- pullRequest: selectedPullRequest,
2439
- contentWidth: fullscreenContentWidth,
2440
- bodyLines: DETAIL_BODY_SCROLL_LIMIT,
2441
- paneWidth: contentWidth,
2442
- }) > wideBodyHeight
2295
+ const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout)
2296
+ const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight)
2297
+ const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth)
2298
+ const fullscreenDetailBodyScrollable = fullscreenDetailBodyHeight > fullscreenDetailBodyViewportHeight
2443
2299
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2444
2300
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2445
2301
  const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
@@ -2456,6 +2312,7 @@ export const App = () => {
2456
2312
  loadedCount: loadedPullRequestCount,
2457
2313
  hasMore: hasMorePullRequests,
2458
2314
  isLoadingMore: isLoadingMorePullRequests,
2315
+ loadingIndicator,
2459
2316
  onSelectPullRequest: selectPullRequestByUrl,
2460
2317
  } as const
2461
2318
 
@@ -2484,8 +2341,8 @@ export const App = () => {
2484
2341
  const commentThreadModalHeight = commentThreadLayout.height
2485
2342
  const commentThreadModalLeft = commentThreadLayout.left
2486
2343
  const commentThreadModalTop = commentThreadLayout.top
2487
- const commentAnchorLabel = selectedDiffCommentAnchor
2488
- ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2344
+ const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2345
+ ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2489
2346
  : "No diff line selected"
2490
2347
  const mergeLayout = sizedModal(46, 68, 12, 16)
2491
2348
  const mergeModalWidth = mergeLayout.width
@@ -2533,8 +2390,8 @@ export const App = () => {
2533
2390
  loadingIndicator={loadingIndicator}
2534
2391
  scrollRef={diffScrollRef}
2535
2392
  setDiffRef={setDiffRenderableRef}
2536
- commentMode={diffCommentMode}
2537
2393
  selectedCommentAnchor={selectedDiffCommentAnchor}
2394
+ selectedCommentLabel={selectedDiffCommentLabel}
2538
2395
  selectedCommentThread={selectedDiffCommentThread}
2539
2396
  onSelectCommentLine={selectDiffCommentLine}
2540
2397
  themeId={themeId}
@@ -2546,20 +2403,16 @@ export const App = () => {
2546
2403
  </box>
2547
2404
  ) : isWideLayout && detailFullView ? (
2548
2405
  <box flexGrow={1} flexDirection="column">
2549
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
2550
- <DetailsPane
2551
- pullRequest={selectedPullRequest}
2552
- viewerUsername={username}
2553
- contentWidth={fullscreenContentWidth}
2554
- bodyLines={fullscreenBodyLines}
2555
- bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2556
- paneWidth={contentWidth}
2557
- showChecks
2558
- placeholderContent={detailPlaceholderContent}
2559
- loadingIndicator={loadingIndicator}
2560
- themeId={themeId}
2561
- />
2562
- </scrollbox>
2406
+ {selectedPullRequest ? (
2407
+ <>
2408
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} showChecks />
2409
+ <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2410
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2411
+ </scrollbox>
2412
+ </>
2413
+ ) : (
2414
+ <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2415
+ )}
2563
2416
  </box>
2564
2417
  ) : isWideLayout ? (
2565
2418
  <box key="wide-main" flexGrow={1} flexDirection="row">
@@ -2591,19 +2444,16 @@ export const App = () => {
2591
2444
  </box>
2592
2445
  ) : detailFullView ? (
2593
2446
  <box flexGrow={1} flexDirection="column">
2594
- <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
2595
- <DetailsPane
2596
- pullRequest={selectedPullRequest}
2597
- viewerUsername={username}
2598
- contentWidth={fullscreenContentWidth}
2599
- bodyLines={fullscreenBodyLines}
2600
- bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT}
2601
- paneWidth={contentWidth}
2602
- placeholderContent={detailPlaceholderContent}
2603
- loadingIndicator={loadingIndicator}
2604
- themeId={themeId}
2605
- />
2606
- </scrollbox>
2447
+ {selectedPullRequest ? (
2448
+ <>
2449
+ <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} />
2450
+ <scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2451
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2452
+ </scrollbox>
2453
+ </>
2454
+ ) : (
2455
+ <DetailsPane pullRequest={null} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2456
+ )}
2607
2457
  </box>
2608
2458
  ) : (
2609
2459
  <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
@@ -2633,7 +2483,7 @@ export const App = () => {
2633
2483
  showFilterClear={filterMode || filterQuery.length > 0}
2634
2484
  detailFullView={detailFullView}
2635
2485
  diffFullView={diffFullView}
2636
- diffCommentMode={diffCommentMode}
2486
+ diffRangeActive={diffCommentRangeActive}
2637
2487
  hasSelection={selectedPullRequest !== null}
2638
2488
  canCloseSelection={selectedPullRequest?.state === "open"}
2639
2489
  hasError={pullRequestStatus === "error"}