@kitlangton/ghui 0.1.21 → 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,6 +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 { useBindings } from "@opentui/keymap/react"
3
+ import { useAppCommandRegistry } from "./keyboard/useAppCommandRegistry.js"
4
+ import { scrollBindings, useScopedBindings, type ScopedBindingAction } from "./keyboard/useScopedBindings.js"
4
5
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
5
6
  import { Cause, Effect, Layer, Schedule } from "effect"
6
7
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -25,8 +26,8 @@ import { GitHubService } from "./services/GitHubService.js"
25
26
  import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
26
27
  import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
27
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"
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"
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"
30
31
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
31
32
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
32
33
  import { CommandPalette } from "./ui/CommandPalette.js"
@@ -96,6 +97,11 @@ interface AppliedDiffLineColorState {
96
97
  readonly entries: readonly AppliedDiffLineColor[]
97
98
  }
98
99
 
100
+ interface DiffCommentRangeSelection {
101
+ readonly start: StackedDiffCommentAnchor
102
+ readonly end: StackedDiffCommentAnchor
103
+ }
104
+
99
105
  interface DetailHydration {
100
106
  readonly token: symbol
101
107
  notifyError: boolean
@@ -114,6 +120,23 @@ const DETAIL_PREFETCH_BEHIND = 1
114
120
  const DETAIL_PREFETCH_AHEAD = 3
115
121
  const DETAIL_PREFETCH_CONCURRENCY = 3
116
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
+ }
117
140
 
118
141
  const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
119
142
  const seen = new Set(existing.map((pullRequest) => pullRequest.url))
@@ -179,7 +202,6 @@ const noticeAtom = Atom.make<string | null>(null)
179
202
  const filterQueryAtom = Atom.make("")
180
203
  const filterDraftAtom = Atom.make("")
181
204
  const filterModeAtom = Atom.make(false)
182
- const pendingGAtom = Atom.make(false)
183
205
  const detailFullViewAtom = Atom.make(false)
184
206
  const detailScrollOffsetAtom = Atom.make(0)
185
207
  const diffFullViewAtom = Atom.make(false)
@@ -187,8 +209,8 @@ const diffFileIndexAtom = Atom.make(0)
187
209
  const diffScrollTopAtom = Atom.make(0)
188
210
  const diffRenderViewAtom = Atom.make<DiffView>("split")
189
211
  const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
190
- const diffCommentModeAtom = Atom.make(false)
191
212
  const diffCommentAnchorIndexAtom = Atom.make(0)
213
+ const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
192
214
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
193
215
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
194
216
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
@@ -381,12 +403,13 @@ const pullRequestDiffAtomKey = pullRequestRevisionAtomKey
381
403
  const parsePullRequestDetailAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "detail")
382
404
  const parsePullRequestDiffAtomKey = (key: string) => parsePullRequestRevisionAtomKey(key, "diff")
383
405
 
384
- const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
385
406
 
386
- 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)}`
387
410
 
388
411
  const diffCommentThreadKey = (pullRequest: PullRequestItem, comment: Pick<PullRequestReviewComment, "path" | "side" | "line">) =>
389
- `${pullRequestDiffKey(pullRequest)}:${diffCommentLocationKey(comment)}`
412
+ diffCommentThreadMapKey(pullRequestDiffKey(pullRequest), comment)
390
413
 
391
414
  const groupDiffCommentThreads = (pullRequest: PullRequestItem, comments: readonly PullRequestReviewComment[]) => {
392
415
  const threads: Record<string, PullRequestReviewComment[]> = {}
@@ -411,13 +434,43 @@ const originalDiffLineColor = (anchor: DiffCommentAnchor): DiffLineColorConfig =
411
434
  return { gutter: colors.diff.lineNumberBg, content: colors.diff.contextBg }
412
435
  }
413
436
 
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)
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
+ }
419
456
  }
420
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 }
464
+ }
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
+
421
474
  const diffSideTargets = (diff: DiffRenderable, anchor: DiffCommentAnchor, view: DiffView) => {
422
475
  const withSides = diff as unknown as DiffRenderableRuntimeSides
423
476
  if (view === "split") {
@@ -488,7 +541,6 @@ export const App = () => {
488
541
  const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
489
542
  const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
490
543
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
491
- const [pendingG, setPendingG] = useAtom(pendingGAtom)
492
544
  const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
493
545
  const setDetailScrollOffset = useAtomSet(detailScrollOffsetAtom)
494
546
  const [diffFullView, setDiffFullView] = useAtom(diffFullViewAtom)
@@ -496,8 +548,8 @@ export const App = () => {
496
548
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
497
549
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
498
550
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
499
- const [diffCommentMode, setDiffCommentMode] = useAtom(diffCommentModeAtom)
500
551
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
552
+ const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
501
553
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
502
554
  const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
503
555
  const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
@@ -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>())
@@ -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
  }
@@ -670,14 +718,39 @@ export const App = () => {
670
718
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
671
719
  [diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
672
720
  )
673
- const selectedDiffCommentAnchor = diffCommentAnchors[Math.max(0, Math.min(diffCommentAnchorIndex, diffCommentAnchors.length - 1))] ?? null
674
- 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
675
741
  const selectedDiffCommentThread = selectedDiffCommentThreadKey ? diffCommentThreads[selectedDiffCommentThreadKey] ?? [] : []
676
742
  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
- )
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])
681
754
  const groupStarts = useAtomValue(groupStartsAtom)
682
755
  const getCurrentGroupIndex = (current: number) => {
683
756
  if (groupStarts.length === 0) return 0
@@ -736,7 +809,7 @@ export const App = () => {
736
809
  setLoadingMoreKey(null)
737
810
  setDetailFullView(false)
738
811
  setDiffFullView(false)
739
- setDiffCommentMode(false)
812
+ setDiffCommentRangeStartIndex(null)
740
813
  setFilterDraft(filterQuery)
741
814
  setNotice(null)
742
815
  setRefreshCompletionMessage(null)
@@ -933,6 +1006,7 @@ export const App = () => {
933
1006
  setDiffFileIndex(0)
934
1007
  setDiffScrollTop(0)
935
1008
  setDiffCommentAnchorIndex(0)
1009
+ setDiffCommentRangeStartIndex(null)
936
1010
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
937
1011
  }, [selectedIndex])
938
1012
 
@@ -941,12 +1015,16 @@ export const App = () => {
941
1015
  if (diffCommentAnchors.length === 0) return 0
942
1016
  return Math.max(0, Math.min(current, diffCommentAnchors.length - 1))
943
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
+ })
944
1022
  }, [diffCommentAnchors.length])
945
1023
 
946
1024
  useEffect(() => {
947
- if (!diffCommentMode || !selectedDiffCommentAnchor) return
1025
+ if (!diffFullView || !selectedDiffCommentAnchor) return
948
1026
  setDiffFileIndex((current) => current === selectedDiffCommentAnchor.fileIndex ? current : selectedDiffCommentAnchor.fileIndex)
949
- }, [diffCommentMode, selectedDiffCommentAnchor?.fileIndex])
1027
+ }, [diffFullView, selectedDiffCommentAnchor?.fileIndex])
950
1028
 
951
1029
  useEffect(() => {
952
1030
  const previous = diffCommentLineColorsRef.current
@@ -959,27 +1037,28 @@ export const App = () => {
959
1037
 
960
1038
  const nextEntries: AppliedDiffLineColor[] = []
961
1039
  const appliedKeys = new Set<string>()
962
- const applyLineColor = (anchor: StackedDiffCommentAnchor, gutter: string, override = false) => {
1040
+ const applyLineColor = (anchor: StackedDiffCommentAnchor, color: DiffLineColorConfig, override = false) => {
963
1041
  const key = `${effectiveDiffRenderView}:${anchor.side}:${anchor.renderLine}`
964
1042
  if (appliedKeys.has(key) && !override) return
965
1043
  appliedKeys.add(key)
966
1044
  const entry = { anchor, view: effectiveDiffRenderView } satisfies AppliedDiffLineColor
967
1045
  const diff = diffRenderableRefs.current.get(anchor.fileIndex)
968
- if (diff) setDiffCommentLineColor(diff, entry, { ...originalDiffLineColor(anchor), gutter })
1046
+ if (diff) setDiffCommentLineColor(diff, entry, color)
969
1047
  if (!nextEntries.some((existing) => existing.view === entry.view && existing.anchor.side === anchor.side && existing.anchor.renderLine === anchor.renderLine)) {
970
1048
  nextEntries.push(entry)
971
1049
  }
972
1050
  }
973
1051
 
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
- }
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)
979
1058
  }
980
1059
  }
981
- if (diffCommentMode && selectedDiffCommentAnchor) {
982
- applyLineColor(selectedDiffCommentAnchor, diffCommentGutterColor(selectedDiffCommentAnchor, "selected"), true)
1060
+ if (selectedDiffCommentAnchor) {
1061
+ applyLineColor(selectedDiffCommentAnchor, diffCommentLineColor(selectedDiffCommentAnchor, "selected"), true)
983
1062
  if (suppressNextDiffCommentScrollRef.current) {
984
1063
  suppressNextDiffCommentScrollRef.current = false
985
1064
  } else {
@@ -989,10 +1068,10 @@ export const App = () => {
989
1068
  suppressNextDiffCommentScrollRef.current = false
990
1069
  }
991
1070
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
992
- }, [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])
993
1072
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
994
1073
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
995
- 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"
996
1075
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
997
1076
 
998
1077
  useEffect(() => {
@@ -1135,9 +1214,10 @@ export const App = () => {
1135
1214
  diffCommentLineColorsRef.current = { contextKey: null, entries: [] }
1136
1215
  setDiffFullView(true)
1137
1216
  setDetailFullView(false)
1138
- setDiffCommentMode(false)
1139
1217
  setDiffFileIndex(0)
1140
1218
  setDiffScrollTop(0)
1219
+ setDiffCommentAnchorIndex(0)
1220
+ setDiffCommentRangeStartIndex(null)
1141
1221
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1142
1222
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
1143
1223
  loadPullRequestDiff(selectedPullRequest, { includeComments: true })
@@ -1162,49 +1242,9 @@ export const App = () => {
1162
1242
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1163
1243
  }
1164
1244
 
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
1245
  const scrollDetailPreviewBy = (y: number) => detailPreviewScrollRef.current?.scrollBy({ x: 0, y })
1175
1246
  const scrollDetailPreviewTo = (y: number) => detailPreviewScrollRef.current?.scrollTo({ x: 0, y })
1176
1247
 
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
1248
  const ensureDiffLineVisible = (line: number) => {
1209
1249
  const scroll = diffScrollRef.current
1210
1250
  if (!scroll) return
@@ -1226,37 +1266,72 @@ export const App = () => {
1226
1266
  if (readyDiffFiles.length === 0) return
1227
1267
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1228
1268
  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
- }
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))
1234
1273
  scrollToDiffFile(nextIndex)
1235
1274
  }
1236
1275
 
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
- }
1276
+ const navigableDiffCommentAnchors = () => diffCommentRangeStartAnchor
1277
+ ? diffCommentAnchors.filter((anchor) => sameDiffCommentTarget(anchor, diffCommentRangeStartAnchor))
1278
+ : diffCommentAnchors
1243
1279
 
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))]
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))]
1249
1287
  if (nextRow === undefined) return
1250
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1251
- ?? 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)
1252
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
+ }
1253
1303
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1254
1304
  }
1255
1305
 
1306
+ const moveDiffCommentToBoundary = (boundary: "first" | "last") => {
1307
+ const anchors = navigableDiffCommentAnchors()
1308
+ const nextAnchor = boundary === "first" ? anchors[0] : anchors[anchors.length - 1]
1309
+ if (!nextAnchor) return
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()
1328
+ }
1329
+
1256
1330
  const selectDiffCommentSide = (side: DiffCommentSide) => {
1257
1331
  if (!selectedDiffCommentAnchor) return
1258
1332
  const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1259
1333
  if (!nextAnchor) return
1334
+ setDiffCommentRangeStartIndex(null)
1260
1335
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1261
1336
  }
1262
1337
 
@@ -1265,9 +1340,11 @@ export const App = () => {
1265
1340
  const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1266
1341
  if (!nextAnchor) return
1267
1342
  suppressNextDiffCommentScrollRef.current = true
1343
+ if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1344
+ setDiffCommentRangeStartIndex(null)
1345
+ }
1268
1346
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1269
1347
  setDiffFileIndex(nextAnchor.fileIndex)
1270
- setDiffCommentMode(true)
1271
1348
  }
1272
1349
 
1273
1350
  const editComment = (transform: (state: CommentEditorValue) => CommentEditorValue) => {
@@ -1288,6 +1365,39 @@ export const App = () => {
1288
1365
  setCommentThreadModal({ scrollOffset: 0 })
1289
1366
  }
1290
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
+
1291
1401
  const submitDiffComment = () => {
1292
1402
  if (!selectedPullRequest || !selectedDiffCommentAnchor) return
1293
1403
  const body = commentModal.body.trim()
@@ -1296,8 +1406,9 @@ export const App = () => {
1296
1406
  return
1297
1407
  }
1298
1408
 
1299
- const threadKey = selectedDiffCommentThreadKey
1300
- const target = selectedDiffCommentAnchor
1409
+ const targetRange = selectedDiffCommentRange
1410
+ const target = targetRange?.end ?? selectedDiffCommentAnchor
1411
+ const threadKey = selectedDiffKey ? diffCommentThreadMapKey(selectedDiffKey, target) : null
1301
1412
  const optimisticComment = {
1302
1413
  id: `local:${Date.now()}`,
1303
1414
  path: target.path,
@@ -1308,6 +1419,9 @@ export const App = () => {
1308
1419
  createdAt: new Date(),
1309
1420
  url: null,
1310
1421
  } satisfies PullRequestReviewComment
1422
+ const rangeInput = targetRange && targetRange.start.line !== targetRange.end.line
1423
+ ? { startLine: targetRange.start.line, startSide: targetRange.start.side }
1424
+ : {}
1311
1425
  const input = {
1312
1426
  repository: selectedPullRequest.repository,
1313
1427
  number: selectedPullRequest.number,
@@ -1316,6 +1430,7 @@ export const App = () => {
1316
1430
  line: target.line,
1317
1431
  side: target.side,
1318
1432
  body,
1433
+ ...rangeInput,
1319
1434
  } satisfies CreatePullRequestCommentInput
1320
1435
 
1321
1436
  if (threadKey) {
@@ -1325,6 +1440,7 @@ export const App = () => {
1325
1440
  }))
1326
1441
  }
1327
1442
  closeActiveModal()
1443
+ setDiffCommentRangeStartIndex(null)
1328
1444
  flashNotice(`Commenting on ${target.path}:${target.line}`)
1329
1445
  void createPullRequestComment(input).then((comment) => {
1330
1446
  if (threadKey) {
@@ -1687,8 +1803,10 @@ export const App = () => {
1687
1803
  diffWrapMode,
1688
1804
  readyDiffFileCount: readyDiffFiles.length,
1689
1805
  diffFileIndex,
1690
- diffCommentMode,
1691
- selectedDiffCommentAnchorLabel: selectedDiffCommentAnchor ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line}` : null,
1806
+ diffRangeActive: diffCommentRangeActive,
1807
+ selectedDiffCommentAnchorLabel: selectedDiffCommentLabel,
1808
+ selectedDiffCommentThreadCount: selectedDiffCommentThread.length,
1809
+ hasDiffCommentThreads: diffCommentThreadAnchors.length > 0,
1692
1810
  actions: {
1693
1811
  openCommandPalette,
1694
1812
  refreshPullRequests,
@@ -1716,7 +1834,7 @@ export const App = () => {
1716
1834
  openDiffView,
1717
1835
  closeDiffView: () => {
1718
1836
  setDiffFullView(false)
1719
- setDiffCommentMode(false)
1837
+ setDiffCommentRangeStartIndex(null)
1720
1838
  },
1721
1839
  reloadDiff: () => {
1722
1840
  if (!selectedPullRequest) return
@@ -1726,10 +1844,9 @@ export const App = () => {
1726
1844
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1727
1845
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1728
1846
  jumpDiffFile,
1729
- toggleDiffCommentMode: () => {
1730
- if (diffCommentMode) setDiffCommentMode(false)
1731
- else enterDiffCommentMode()
1732
- },
1847
+ openSelectedDiffComment,
1848
+ toggleDiffCommentRange,
1849
+ moveDiffCommentThread,
1733
1850
  openDiffCommentModal,
1734
1851
  togglePullRequestDraftStatus: toggleSelectedPullRequestDraftStatus,
1735
1852
  openLabelModal,
@@ -1755,6 +1872,9 @@ export const App = () => {
1755
1872
  const command = appCommands.find((entry) => entry.id === id)
1756
1873
  return command ? runCommand(command, options) : false
1757
1874
  }
1875
+ const runCommandByIdRef = useRef(runCommandById)
1876
+ runCommandByIdRef.current = runCommandById
1877
+ useAppCommandRegistry(appCommands, runCommandByIdRef)
1758
1878
  const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1759
1879
  if (!commandPaletteActive) return []
1760
1880
  const repository = parseRepositoryInput(commandPalette.query)
@@ -1769,287 +1889,337 @@ export const App = () => {
1769
1889
  })()
1770
1890
  // Dynamic commands always pin to the top of the palette; they came directly from the
1771
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
+ : []
1772
1895
  const commandPaletteCommands = commandPaletteActive
1773
1896
  ? [
1774
1897
  ...dynamicPaletteCommands,
1775
- ...sortCommandsByScope(filterCommands(appCommands.filter((command) => command.id !== "command.open" && commandEnabled(command)), commandPalette.query)),
1898
+ ...(commandPalette.query.trim().length > 0 ? staticPaletteCommands : sortCommandsByScope(staticPaletteCommands)),
1776
1899
  ]
1777
1900
  : []
1778
1901
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1779
1902
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1780
1903
 
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
1904
+ const noModalActive = activeModal._tag === "None"
1905
+ const globalLayerActive = noModalActive
1793
1906
  && !diffFullView
1794
1907
  && !detailFullView
1795
1908
  && !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)
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)
1850
1927
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1851
1928
  })
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) => ({
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) => ({
1872
1944
  ...current,
1873
1945
  scrollOffset: Math.max(0, current.scrollOffset + delta),
1874
1946
  }))
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)
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)
1905
1968
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1906
1969
  })
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,
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
+ },
1929
1980
  })
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,
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
+ },
2014
1999
  })
2015
- commandPaletteCtxRef.current = {
2016
- runSelected: () => { if (selectedCommand) runCommand(selectedCommand, { notifyDisabled: true, closePalette: true }) },
2017
- setCommandPalette,
2018
- paletteCommands: commandPaletteCommands,
2019
- }
2020
- const moveCommandPaletteSelection = (delta: -1 | 1) => commandPaletteCtxRef.current.setCommandPalette((current) => {
2021
- const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCtxRef.current.paletteCommands)
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)
2022
2043
  return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2023
2044
  })
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
- }), [])
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
+ })
2053
2223
 
2054
2224
  useKeyboard((key) => {
2055
2225
  if (commandPaletteActive) {
@@ -2113,340 +2283,19 @@ export const App = () => {
2113
2283
  return
2114
2284
  }
2115
2285
 
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
2286
  if (filterMode) {
2336
2287
  if (isSingleLineInputKey(key)) {
2337
2288
  setFilterDraft((current) => editSingleLineInput(current, key) ?? current)
2338
2289
  }
2339
- return
2340
2290
  }
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
- }
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
2291
  })
2434
2292
 
2435
2293
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2436
2294
  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
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
2450
2299
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2451
2300
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2452
2301
  const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
@@ -2463,6 +2312,7 @@ export const App = () => {
2463
2312
  loadedCount: loadedPullRequestCount,
2464
2313
  hasMore: hasMorePullRequests,
2465
2314
  isLoadingMore: isLoadingMorePullRequests,
2315
+ loadingIndicator,
2466
2316
  onSelectPullRequest: selectPullRequestByUrl,
2467
2317
  } as const
2468
2318
 
@@ -2491,8 +2341,8 @@ export const App = () => {
2491
2341
  const commentThreadModalHeight = commentThreadLayout.height
2492
2342
  const commentThreadModalLeft = commentThreadLayout.left
2493
2343
  const commentThreadModalTop = commentThreadLayout.top
2494
- const commentAnchorLabel = selectedDiffCommentAnchor
2495
- ? `${selectedDiffCommentAnchor.path}:${selectedDiffCommentAnchor.line} ${selectedDiffCommentAnchor.side === "RIGHT" ? "right" : "left"}`
2344
+ const commentAnchorLabel = selectedDiffCommentAnchor && selectedDiffCommentLabel
2345
+ ? `${selectedDiffCommentAnchor.path} ${selectedDiffCommentLabel}`
2496
2346
  : "No diff line selected"
2497
2347
  const mergeLayout = sizedModal(46, 68, 12, 16)
2498
2348
  const mergeModalWidth = mergeLayout.width
@@ -2540,8 +2390,8 @@ export const App = () => {
2540
2390
  loadingIndicator={loadingIndicator}
2541
2391
  scrollRef={diffScrollRef}
2542
2392
  setDiffRef={setDiffRenderableRef}
2543
- commentMode={diffCommentMode}
2544
2393
  selectedCommentAnchor={selectedDiffCommentAnchor}
2394
+ selectedCommentLabel={selectedDiffCommentLabel}
2545
2395
  selectedCommentThread={selectedDiffCommentThread}
2546
2396
  onSelectCommentLine={selectDiffCommentLine}
2547
2397
  themeId={themeId}
@@ -2553,20 +2403,16 @@ export const App = () => {
2553
2403
  </box>
2554
2404
  ) : isWideLayout && detailFullView ? (
2555
2405
  <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>
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
+ )}
2570
2416
  </box>
2571
2417
  ) : isWideLayout ? (
2572
2418
  <box key="wide-main" flexGrow={1} flexDirection="row">
@@ -2598,19 +2444,16 @@ export const App = () => {
2598
2444
  </box>
2599
2445
  ) : detailFullView ? (
2600
2446
  <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>
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
+ )}
2614
2457
  </box>
2615
2458
  ) : (
2616
2459
  <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
@@ -2640,7 +2483,7 @@ export const App = () => {
2640
2483
  showFilterClear={filterMode || filterQuery.length > 0}
2641
2484
  detailFullView={detailFullView}
2642
2485
  diffFullView={diffFullView}
2643
- diffCommentMode={diffCommentMode}
2486
+ diffRangeActive={diffCommentRangeActive}
2644
2487
  hasSelection={selectedPullRequest !== null}
2645
2488
  canCloseSelection={selectedPullRequest?.state === "open"}
2646
2489
  hasError={pullRequestStatus === "error"}