@kitlangton/ghui 0.1.22 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { DiffRenderable, PasteEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { RegistryContext, useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useAppCommandRegistry } from "./keyboard/useAppCommandRegistry.js"
4
- import { scrollBindings, useScopedBindings, type ScopedBindingAction } from "./keyboard/useScopedBindings.js"
3
+ import { useKeymap } from "@ghui/keymap/react"
4
+ import { appKeymap, type AppCtx } from "./keymap/all.js"
5
+ import { useOpenTuiSubscribe } from "./keyboard/opentuiAdapter.js"
5
6
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
6
7
  import { Cause, Effect, Layer, Schedule } from "effect"
7
8
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
@@ -12,7 +13,7 @@ import { buildAppCommands } from "./appCommands.js"
12
13
  import type { AppCommand } from "./commands.js"
13
14
  import { clampCommandIndex, commandEnabled, defineCommand, filterCommands, sortCommandsByScope } from "./commands.js"
14
15
  import { config } from "./config.js"
15
- import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
+ import { type CreatePullRequestCommentInput, type DiffCommentSide, type ListPullRequestPageInput, type LoadStatus, type PullRequestConversationItem, type PullRequestItem, type PullRequestLabel, type PullRequestMergeAction, type PullRequestReviewComment } from "./domain.js"
16
17
  import { formatShortDate, formatTimestamp } from "./date.js"
17
18
  import { errorMessage } from "./errors.js"
18
19
  import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
@@ -23,12 +24,13 @@ import { BrowserOpener } from "./services/BrowserOpener.js"
23
24
  import { Clipboard } from "./services/Clipboard.js"
24
25
  import { CommandRunner } from "./services/CommandRunner.js"
25
26
  import { GitHubService } from "./services/GitHubService.js"
26
- import { loadStoredThemeId, saveStoredThemeId } from "./themeStore.js"
27
+ import { loadStoredDiffWhitespaceMode, loadStoredThemeId, saveStoredDiffWhitespaceMode, saveStoredThemeId } from "./themeStore.js"
27
28
  import { colors, filterThemeDefinitions, mixHex, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
28
29
  import { backspace as editorBackspace, deleteForward as editorDeleteForward, deleteToLineEnd, deleteToLineStart, deleteWordBackward, deleteWordForward, insertText, moveLeft as editorMoveLeft, moveLineEnd, moveLineStart, moveRight as editorMoveRight, moveVertically, moveWordBackward, moveWordForward, type CommentEditorValue } from "./ui/commentEditor.js"
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
+ import { buildStackedDiffFiles, diffAnchorOnSide, diffCommentAnchorLabel, diffCommentLineLabel, diffCommentLocationKey, diffCommentSideLabel, getStackedDiffCommentAnchors, minimizeWhitespaceDiffFiles, PullRequestDiffState, pullRequestDiffKey, safeDiffFileIndex, scrollTopForVisibleLine, splitPatchFiles, stackedDiffFileIndexAtLine, type DiffCommentAnchor, type DiffCommentKind, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type StackedDiffCommentAnchor, verticalDiffAnchor } from "./ui/diff.js"
31
+ import { DETAIL_BODY_SCROLL_LIMIT, DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailHeaderHeight, getDetailJunctionRows, getScrollableDetailBodyHeight, LoadingPane, type DetailConversationStatus, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
31
32
  import { FooterHints, initialRetryProgress, RetryProgress } from "./ui/FooterHints.js"
33
+ import { LoadingLogoPane } from "./ui/LoadingLogo.js"
32
34
  import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
33
35
  import { CommandPalette } from "./ui/CommandPalette.js"
34
36
  import { CloseModal, CommentModal, CommentThreadModal, filterLabels, initialCloseModalState, initialCommandPaletteState, initialCommentModalState, initialCommentThreadModalState, initialLabelModalState, initialMergeModalState, initialModal, initialOpenRepositoryModalState, initialThemeModalState, LabelModal, MergeModal, Modal, OpenRepositoryModal, ThemeModal, type CloseModalState, type CommandPaletteState, type CommentModalState, type CommentThreadModalState, type LabelModalState, type MergeModalState, type ModalState, type ModalTag, type OpenRepositoryModalState, type ThemeModalState } from "./ui/modals.js"
@@ -36,6 +38,7 @@ import { groupBy, reviewLabel } from "./ui/pullRequests.js"
36
38
  import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
37
39
  import { buildPullRequestListRows, pullRequestListRowIndex, PullRequestList } from "./ui/PullRequestList.js"
38
40
  import { editSingleLineInput, isSingleLineInputKey, printableKeyText, singleLineText } from "./ui/singleLineInput.js"
41
+ import { SPINNER_FRAMES } from "./ui/spinner.js"
39
42
 
40
43
  const parseOptionalPositiveInt = (value: string | undefined, fallback: number | null) => {
41
44
  if (value === undefined) return fallback
@@ -55,7 +58,10 @@ const githubRuntime = Atom.runtime(
55
58
  Layer.provideMerge(Observability.layer),
56
59
  ),
57
60
  )
58
- const initialThemeId = await Effect.runPromise(loadStoredThemeId)
61
+ const [initialThemeId, initialDiffWhitespaceMode] = await Promise.all([
62
+ Effect.runPromise(loadStoredThemeId),
63
+ Effect.runPromise(loadStoredDiffWhitespaceMode),
64
+ ])
59
65
 
60
66
  interface PullRequestLoad {
61
67
  readonly view: PullRequestView
@@ -112,7 +118,6 @@ const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
112
118
  const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
113
119
  const AUTO_REFRESH_JITTER_MS = 10_000
114
120
  const DIFF_STICKY_HEADER_LINES = 2
115
- const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
116
121
  const MAX_REPOSITORY_CACHE_ENTRIES = 8
117
122
  const LOAD_MORE_SELECTION_THRESHOLD = 8
118
123
  const LOAD_MORE_SCROLL_THRESHOLD = 3
@@ -120,24 +125,6 @@ const DETAIL_PREFETCH_BEHIND = 1
120
125
  const DETAIL_PREFETCH_AHEAD = 3
121
126
  const DETAIL_PREFETCH_CONCURRENCY = 3
122
127
  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
- }
140
-
141
128
  const appendPullRequestPage = (existing: readonly PullRequestItem[], incoming: readonly PullRequestItem[]) => {
142
129
  const seen = new Set(existing.map((pullRequest) => pullRequest.url))
143
130
  const mergedIncoming = mergeCachedDetails(incoming, existing)
@@ -209,10 +196,14 @@ const diffFileIndexAtom = Atom.make(0)
209
196
  const diffScrollTopAtom = Atom.make(0)
210
197
  const diffRenderViewAtom = Atom.make<DiffView>("split")
211
198
  const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
199
+ const diffWhitespaceModeAtom = Atom.make<DiffWhitespaceMode>(initialDiffWhitespaceMode)
212
200
  const diffCommentAnchorIndexAtom = Atom.make(0)
201
+ const diffPreferredSideAtom = Atom.make<DiffCommentSide | null>(null)
213
202
  const diffCommentRangeStartIndexAtom = Atom.make<number | null>(null)
214
203
  const diffCommentThreadsAtom = Atom.make<Record<string, readonly PullRequestReviewComment[]>>({}).pipe(Atom.keepAlive)
215
204
  const diffCommentsLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
205
+ const pullRequestConversationAtom = Atom.make<Record<string, readonly PullRequestConversationItem[]>>({}).pipe(Atom.keepAlive)
206
+ const pullRequestConversationLoadedAtom = Atom.make<Record<string, "loading" | "ready">>({}).pipe(Atom.keepAlive)
216
207
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
217
208
 
218
209
  const activeModalAtom = Atom.make<Modal>(initialModal)
@@ -346,6 +337,9 @@ const pullRequestDiffAtom = Atom.family((key: string) => {
346
337
  const listPullRequestCommentsAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
347
338
  GitHubService.use((github) => github.listPullRequestComments(input.repository, input.number))
348
339
  )
340
+ const listPullRequestConversationAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
341
+ GitHubService.use((github) => github.listPullRequestConversation(input.repository, input.number))
342
+ )
349
343
  const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
350
344
  GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
351
345
  )
@@ -548,10 +542,14 @@ export const App = () => {
548
542
  const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
549
543
  const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
550
544
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
545
+ const [diffWhitespaceMode, setDiffWhitespaceMode] = useAtom(diffWhitespaceModeAtom)
551
546
  const [diffCommentAnchorIndex, setDiffCommentAnchorIndex] = useAtom(diffCommentAnchorIndexAtom)
547
+ const [diffPreferredSide, setDiffPreferredSide] = useAtom(diffPreferredSideAtom)
552
548
  const [diffCommentRangeStartIndex, setDiffCommentRangeStartIndex] = useAtom(diffCommentRangeStartIndexAtom)
553
549
  const [diffCommentThreads, setDiffCommentThreads] = useAtom(diffCommentThreadsAtom)
554
550
  const setDiffCommentsLoaded = useAtomSet(diffCommentsLoadedAtom)
551
+ const setPullRequestConversation = useAtomSet(pullRequestConversationAtom)
552
+ const setPullRequestConversationLoaded = useAtomSet(pullRequestConversationLoadedAtom)
555
553
  const setPullRequestDiffCache = useAtomSet(pullRequestDiffCacheAtom)
556
554
  const [activeModal, setActiveModal] = useAtom(activeModalAtom)
557
555
  const [themeId, setThemeId] = useAtom(themeIdAtom)
@@ -603,6 +601,7 @@ export const App = () => {
603
601
  const [refreshCompletionMessage, setRefreshCompletionMessage] = useState<string | null>(null)
604
602
  const [refreshStartedAt, setRefreshStartedAt] = useState<number | null>(null)
605
603
  const [terminalFocused, setTerminalFocused] = useState(true)
604
+ const [startupLoadComplete, setStartupLoadComplete] = useState(false)
606
605
  const [loadingMoreKey, setLoadingMoreKey] = useState<string | null>(null)
607
606
  const usernameResult = useAtomValue(usernameAtom)
608
607
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
@@ -611,6 +610,7 @@ export const App = () => {
611
610
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
612
611
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
613
612
  const listPullRequestComments = useAtomSet(listPullRequestCommentsAtom, { mode: "promise" })
613
+ const listPullRequestConversation = useAtomSet(listPullRequestConversationAtom, { mode: "promise" })
614
614
  const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
615
615
  const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
616
616
  const closePullRequest = useAtomSet(closePullRequestAtom, { mode: "promise" })
@@ -640,7 +640,7 @@ export const App = () => {
640
640
  const terminalFocusedRef = useRef(true)
641
641
  const terminalWasBlurredRef = useRef(false)
642
642
  const pullRequestStatusRef = useRef<LoadStatus>("loading")
643
- const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
643
+ const refreshPullRequestsRef = useRef<(message?: string, options?: { readonly resetTransientState?: boolean }) => void>(() => {})
644
644
  const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
645
645
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
646
646
  const detailPreviewScrollRef = useRef<ScrollBoxRenderable | null>(null)
@@ -682,7 +682,7 @@ export const App = () => {
682
682
  const pullRequestLoad = useAtomValue(pullRequestLoadAtom)
683
683
  const pullRequests = useAtomValue(displayedPullRequestsAtom)
684
684
  const pullRequestStatus = useAtomValue(pullRequestStatusAtom)
685
- const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
685
+ const isInitialLoading = !startupLoadComplete && pullRequestStatus === "loading" && pullRequests.length === 0
686
686
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
687
687
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
688
688
  pullRequestStatusRef.current = pullRequestStatus
@@ -692,6 +692,8 @@ export const App = () => {
692
692
  const visibleGroups = useAtomValue(visibleGroupsAtom)
693
693
  const visiblePullRequests = useAtomValue(visiblePullRequestsAtom)
694
694
  const selectedPullRequest = useAtomValue(selectedPullRequestAtom)
695
+ const pullRequestConversation = useAtomValue(pullRequestConversationAtom)
696
+ const pullRequestConversationLoaded = useAtomValue(pullRequestConversationLoadedAtom)
695
697
  const selectedRepository = viewRepository(activeView)
696
698
  const activeViews = activePullRequestViews(activeView)
697
699
  const currentQueueCacheKey = viewCacheKey(activeView)
@@ -710,9 +712,22 @@ export const App = () => {
710
712
  }), [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests])
711
713
  const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null)
712
714
  const selectedDiffKey = useAtomValue(selectedDiffKeyAtom)
715
+ const selectedConversationItems = selectedDiffKey ? pullRequestConversation[selectedDiffKey] ?? [] : []
716
+ const selectedConversationStatus: DetailConversationStatus = selectedDiffKey ? pullRequestConversationLoaded[selectedDiffKey] ?? "idle" : "idle"
713
717
  const selectedDiffState = useAtomValue(selectedDiffStateAtom)
714
718
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
715
- const readyDiffFiles = selectedDiffState?._tag === "Ready" ? selectedDiffState.files : []
719
+ const readyDiffFiles = useMemo(
720
+ () => selectedDiffState?._tag === "Ready"
721
+ ? diffWhitespaceMode === "ignore" ? minimizeWhitespaceDiffFiles(selectedDiffState.files) : selectedDiffState.files
722
+ : [],
723
+ [selectedDiffState, diffWhitespaceMode],
724
+ )
725
+ const displayedDiffState = useMemo(
726
+ () => selectedDiffState?._tag === "Ready"
727
+ ? PullRequestDiffState.Ready({ patch: readyDiffFiles.map((file) => file.patch).join("\n"), files: readyDiffFiles })
728
+ : selectedDiffState,
729
+ [selectedDiffState, readyDiffFiles],
730
+ )
716
731
  const stackedDiffFiles = useMemo(() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth), [readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth])
717
732
  const diffCommentAnchors = useMemo(
718
733
  () => diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : [],
@@ -783,12 +798,36 @@ export const App = () => {
783
798
  if (!pullRequest) return
784
799
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
785
800
  }
786
- const refreshPullRequests = (message?: string) => {
801
+ const markPullRequestCompleted = (pullRequest: PullRequestItem, state: "closed" | "merged") => {
802
+ setRecentlyCompletedPullRequests((current) => ({
803
+ ...current,
804
+ [pullRequest.url]: {
805
+ ...pullRequest,
806
+ state,
807
+ autoMergeEnabled: false,
808
+ },
809
+ }))
810
+ }
811
+ const restoreOptimisticPullRequest = (pullRequest: PullRequestItem) => {
812
+ setRecentlyCompletedPullRequests((current) => {
813
+ if (!(pullRequest.url in current)) return current
814
+ const next = { ...current }
815
+ delete next[pullRequest.url]
816
+ return next
817
+ })
818
+ updatePullRequest(pullRequest.url, () => pullRequest)
819
+ }
820
+ const refreshPullRequests = (message?: string, options: { readonly resetTransientState?: boolean } = {}) => {
787
821
  refreshGenerationRef.current += 1
788
822
  detailHydrationRef.current.clear()
789
823
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
790
824
  setLoadingMoreKey(null)
791
825
  setPullRequestOverrides({})
826
+ if (options.resetTransientState) {
827
+ setRecentlyCompletedPullRequests({})
828
+ setPullRequestConversation({})
829
+ setPullRequestConversationLoaded({})
830
+ }
792
831
  if (message) {
793
832
  setNotice(null)
794
833
  setRefreshCompletionMessage(message)
@@ -891,6 +930,29 @@ export const App = () => {
891
930
  })
892
931
  return true
893
932
  }
933
+ const loadPullRequestConversation = (pullRequest: PullRequestItem, force = false) => {
934
+ const key = pullRequestDiffKey(pullRequest)
935
+ const previousLoadState = registry.get(pullRequestConversationLoadedAtom)[key]
936
+ if (!force && previousLoadState) return
937
+ const generation = refreshGenerationRef.current
938
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "loading" }))
939
+ void listPullRequestConversation({ repository: pullRequest.repository, number: pullRequest.number })
940
+ .then((items) => {
941
+ if (generation !== refreshGenerationRef.current) return
942
+ setPullRequestConversation((current) => ({ ...current, [key]: items }))
943
+ setPullRequestConversationLoaded((current) => ({ ...current, [key]: "ready" }))
944
+ })
945
+ .catch((error) => {
946
+ if (generation !== refreshGenerationRef.current) return
947
+ setPullRequestConversationLoaded((current) => {
948
+ if (previousLoadState === "ready") return { ...current, [key]: previousLoadState }
949
+ const next = { ...current }
950
+ delete next[key]
951
+ return next
952
+ })
953
+ flashNotice(errorMessage(error))
954
+ })
955
+ }
894
956
  maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
895
957
  if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
896
958
  const lastRefreshAt = lastPullRequestRefreshAtRef.current
@@ -1006,10 +1068,15 @@ export const App = () => {
1006
1068
  setDiffFileIndex(0)
1007
1069
  setDiffScrollTop(0)
1008
1070
  setDiffCommentAnchorIndex(0)
1071
+ setDiffPreferredSide(null)
1009
1072
  setDiffCommentRangeStartIndex(null)
1010
1073
  detailPreviewScrollRef.current?.scrollTo({ x: 0, y: 0 })
1011
1074
  }, [selectedIndex])
1012
1075
 
1076
+ useEffect(() => {
1077
+ setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current))
1078
+ }, [readyDiffFiles.length])
1079
+
1013
1080
  useEffect(() => {
1014
1081
  setDiffCommentAnchorIndex((current) => {
1015
1082
  if (diffCommentAnchors.length === 0) return 0
@@ -1069,24 +1136,48 @@ export const App = () => {
1069
1136
  }
1070
1137
  diffCommentLineColorsRef.current = { contextKey: diffLineColorContextKey, entries: nextEntries }
1071
1138
  }, [selectedDiffCommentAnchor?.renderLine, selectedDiffCommentAnchor?.localRenderLine, selectedDiffCommentAnchor?.side, selectedDiffCommentAnchor?.fileIndex, selectedDiffCommentRangeAnchors, diffLineColorContextKey, effectiveDiffRenderView, diffCommentThreadAnchors])
1139
+
1140
+ // Scroll the selected line into view when the diff view is opened. Previously
1141
+ // opentui's `focused` scrollbox did this auto-scroll on mount; with the keymap
1142
+ // migration the scrollbox is `focusable={false}` so we have to scroll explicitly.
1143
+ useEffect(() => {
1144
+ if (!diffFullView) return
1145
+ if (!selectedDiffCommentAnchor) return
1146
+ ensureDiffLineVisible(selectedDiffCommentAnchor.renderLine)
1147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1148
+ }, [diffFullView])
1072
1149
  const isHydratingPullRequestDetails = pullRequestStatus === "ready" && selectedPullRequest?.state === "open" && !selectedPullRequest.detailLoaded
1073
1150
  const isRefreshingPullRequests = pullRequestResult.waiting && pullRequestLoad !== null
1074
1151
  const hasActiveLoadingIndicator = pullRequestResult.waiting || isHydratingPullRequestDetails || isLoadingMorePullRequests || labelModal.loading || closeModal.running || mergeModal.loading || mergeModal.running || selectedDiffState?._tag === "Loading"
1075
- const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
1152
+ const loadingIndicator = SPINNER_FRAMES[loadingFrame % SPINNER_FRAMES.length]!
1076
1153
 
1077
1154
  useEffect(() => {
1078
1155
  if (!hasActiveLoadingIndicator) return
1079
1156
  const interval = globalThis.setInterval(() => {
1080
- setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1157
+ setLoadingFrame((current) => current + 1)
1081
1158
  }, 120)
1082
1159
  return () => globalThis.clearInterval(interval)
1083
1160
  }, [hasActiveLoadingIndicator])
1084
1161
 
1162
+ useEffect(() => {
1163
+ if (isInitialLoading) setLoadingFrame(0)
1164
+ }, [isInitialLoading])
1165
+
1166
+ useEffect(() => {
1167
+ if (startupLoadComplete || pullRequestStatus === "loading") return
1168
+ setStartupLoadComplete(true)
1169
+ }, [startupLoadComplete, pullRequestStatus])
1170
+
1085
1171
  useEffect(() => {
1086
1172
  if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1087
1173
  hydratePullRequestDetails(selectedPullRequest, true)
1088
1174
  }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.state, selectedPullRequest?.detailLoaded, selectedPullRequest?.repository, selectedPullRequest?.number])
1089
1175
 
1176
+ useEffect(() => {
1177
+ if (pullRequestStatus !== "ready" || !selectedPullRequest) return
1178
+ loadPullRequestConversation(selectedPullRequest)
1179
+ }, [pullRequestStatus, selectedPullRequest?.url, selectedPullRequest?.headRefOid, selectedPullRequest?.repository, selectedPullRequest?.number])
1180
+
1090
1181
  useEffect(() => {
1091
1182
  if (detailPrefetchTimeoutRef.current !== null) clearTimeout(detailPrefetchTimeoutRef.current)
1092
1183
  if (pullRequestStatus !== "ready" || visiblePullRequests.length === 0) return
@@ -1120,7 +1211,7 @@ export const App = () => {
1120
1211
  title: `${loadingIndicator} Loading pull request details`,
1121
1212
  hint: `${selectedPullRequest.repository} #${selectedPullRequest.number}`,
1122
1213
  } : detailPlaceholderContent
1123
- const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1214
+ const detailJunctions = isSelectedPullRequestDetailLoading ? [] : getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true, rightContentWidth, selectedConversationItems, selectedConversationStatus)
1124
1215
 
1125
1216
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1126
1217
 
@@ -1217,6 +1308,7 @@ export const App = () => {
1217
1308
  setDiffFileIndex(0)
1218
1309
  setDiffScrollTop(0)
1219
1310
  setDiffCommentAnchorIndex(0)
1311
+ setDiffPreferredSide(null)
1220
1312
  setDiffCommentRangeStartIndex(null)
1221
1313
  setDiffRenderView(contentWidth >= 100 ? "split" : "unified")
1222
1314
  diffScrollRef.current?.scrollTo({ x: 0, y: 0 })
@@ -1238,7 +1330,7 @@ export const App = () => {
1238
1330
  const scrollTop = diffScrollRef.current?.scrollTop
1239
1331
  if (scrollTop === undefined || stackedDiffFiles.length === 0) return
1240
1332
  setDiffScrollTop((current) => current === scrollTop ? current : scrollTop)
1241
- const nextIndex = stackedDiffFileAtLine(stackedDiffFiles, scrollTop)?.index ?? 0
1333
+ const nextIndex = Math.max(0, stackedDiffFileIndexAtLine(stackedDiffFiles, scrollTop))
1242
1334
  setDiffFileIndex((current) => current === nextIndex ? current : nextIndex)
1243
1335
  }
1244
1336
 
@@ -1267,7 +1359,8 @@ export const App = () => {
1267
1359
  const nextIndex = safeDiffFileIndex(readyDiffFiles, diffFileIndex + delta)
1268
1360
  setDiffFileIndex(nextIndex)
1269
1361
  setDiffCommentRangeStartIndex(null)
1270
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === selectedDiffCommentAnchor?.side)
1362
+ const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
1363
+ const nextAnchor = diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex && anchor.side === targetSide)
1271
1364
  ?? diffCommentAnchors.find((anchor) => anchor.fileIndex === nextIndex)
1272
1365
  if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
1273
1366
  scrollToDiffFile(nextIndex)
@@ -1280,13 +1373,8 @@ export const App = () => {
1280
1373
  const moveDiffCommentAnchor = (delta: number, options: { readonly preserveViewportRow?: boolean } = {}) => {
1281
1374
  const anchors = navigableDiffCommentAnchors()
1282
1375
  if (anchors.length === 0) return
1283
- const rows = [...new Set(anchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right)
1284
1376
  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))]
1287
- if (nextRow === undefined) return
1288
- const nextAnchor = anchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === currentAnchor?.side)
1289
- ?? anchors.find((anchor) => anchor.renderLine === nextRow)
1377
+ const nextAnchor = verticalDiffAnchor(anchors, currentAnchor ?? null, delta, diffPreferredSide)
1290
1378
  if (!nextAnchor) return
1291
1379
  if (options.preserveViewportRow) {
1292
1380
  const scroll = diffScrollRef.current
@@ -1328,8 +1416,9 @@ export const App = () => {
1328
1416
  }
1329
1417
 
1330
1418
  const selectDiffCommentSide = (side: DiffCommentSide) => {
1419
+ setDiffPreferredSide(side)
1331
1420
  if (!selectedDiffCommentAnchor) return
1332
- const nextAnchor = diffCommentAnchors.find((anchor) => anchor.renderLine === selectedDiffCommentAnchor.renderLine && anchor.side === side)
1421
+ const nextAnchor = diffAnchorOnSide(diffCommentAnchors, selectedDiffCommentAnchor, side)
1333
1422
  if (!nextAnchor) return
1334
1423
  setDiffCommentRangeStartIndex(null)
1335
1424
  setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
@@ -1340,6 +1429,7 @@ export const App = () => {
1340
1429
  const nextAnchor = (side ? lineAnchors.find((anchor) => anchor.side === side) : undefined) ?? lineAnchors[0]
1341
1430
  if (!nextAnchor) return
1342
1431
  suppressNextDiffCommentScrollRef.current = true
1432
+ setDiffPreferredSide(side ?? nextAnchor.side)
1343
1433
  if (diffCommentRangeStartAnchor && !sameDiffCommentTarget(diffCommentRangeStartAnchor, nextAnchor)) {
1344
1434
  setDiffCommentRangeStartIndex(null)
1345
1435
  }
@@ -1516,16 +1606,7 @@ export const App = () => {
1516
1606
  setCloseModal((current) => ({ ...current, running: true, error: null }))
1517
1607
  void closePullRequest({ repository, number })
1518
1608
  .then(() => {
1519
- if (previousPullRequest) {
1520
- setRecentlyCompletedPullRequests((current) => ({
1521
- ...current,
1522
- [previousPullRequest.url]: {
1523
- ...previousPullRequest,
1524
- state: "closed",
1525
- autoMergeEnabled: false,
1526
- },
1527
- }))
1528
- }
1609
+ if (previousPullRequest) markPullRequestCompleted(previousPullRequest, "closed")
1529
1610
  closeActiveModal()
1530
1611
  refreshPullRequests(`Closed #${number}`)
1531
1612
  })
@@ -1560,6 +1641,12 @@ export const App = () => {
1560
1641
  setThemeId(id)
1561
1642
  }
1562
1643
 
1644
+ const toggleDiffWhitespaceMode = () => {
1645
+ const next = diffWhitespaceMode === "ignore" ? "show" : "ignore"
1646
+ setDiffWhitespaceMode(next)
1647
+ void Effect.runPromise(saveStoredDiffWhitespaceMode(next)).catch((error) => flashNotice(errorMessage(error)))
1648
+ }
1649
+
1563
1650
  const moveThemeSelection = (delta: number) => {
1564
1651
  const filteredThemes = filterThemeDefinitions(themeModalRef.current.query)
1565
1652
  if (filteredThemes.length === 0) return
@@ -1655,30 +1742,15 @@ export const App = () => {
1655
1742
  const { repository, number } = mergeModal.info
1656
1743
  const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
1657
1744
  const previousPullRequest = targetPullRequest ?? null
1658
- const previousMergeInfo = mergeModal.info
1659
1745
 
1660
1746
  if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
1661
1747
  updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
1662
- setMergeModal((current) => ({
1663
- ...current,
1664
- info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
1665
- }))
1666
1748
  }
1749
+ if (targetPullRequest && option.optimisticState === "merged") markPullRequestCompleted(targetPullRequest, "merged")
1667
1750
 
1668
- setMergeModal((current) => ({ ...current, running: true, error: null }))
1751
+ closeActiveModal()
1669
1752
  void mergePullRequest({ repository, number, action: option.action })
1670
1753
  .then(() => {
1671
- if (option.refreshOnSuccess && previousPullRequest) {
1672
- setRecentlyCompletedPullRequests((current) => ({
1673
- ...current,
1674
- [previousPullRequest.url]: {
1675
- ...previousPullRequest,
1676
- state: "merged",
1677
- autoMergeEnabled: false,
1678
- },
1679
- }))
1680
- }
1681
- closeActiveModal()
1682
1754
  if (option.refreshOnSuccess) {
1683
1755
  refreshPullRequests(`${option.pastTense} #${number}`)
1684
1756
  } else {
@@ -1686,8 +1758,7 @@ export const App = () => {
1686
1758
  }
1687
1759
  })
1688
1760
  .catch((error) => {
1689
- if (previousPullRequest) updatePullRequest(previousPullRequest.url, () => previousPullRequest)
1690
- setMergeModal((current) => ({ ...current, running: false, info: previousMergeInfo, error: errorMessage(error) }))
1761
+ if (previousPullRequest) restoreOptimisticPullRequest(previousPullRequest)
1691
1762
  flashNotice(errorMessage(error))
1692
1763
  })
1693
1764
  }
@@ -1801,6 +1872,7 @@ export const App = () => {
1801
1872
  diffReady: selectedDiffState?._tag === "Ready",
1802
1873
  effectiveDiffRenderView,
1803
1874
  diffWrapMode,
1875
+ diffWhitespaceMode,
1804
1876
  readyDiffFileCount: readyDiffFiles.length,
1805
1877
  diffFileIndex,
1806
1878
  diffRangeActive: diffCommentRangeActive,
@@ -1843,6 +1915,7 @@ export const App = () => {
1843
1915
  },
1844
1916
  toggleDiffRenderView: () => setDiffRenderView((current) => current === "unified" ? "split" : "unified"),
1845
1917
  toggleDiffWrapMode: () => setDiffWrapMode((current) => current === "none" ? "word" : "none"),
1918
+ toggleDiffWhitespaceMode,
1846
1919
  jumpDiffFile,
1847
1920
  openSelectedDiffComment,
1848
1921
  toggleDiffCommentRange,
@@ -1874,7 +1947,6 @@ export const App = () => {
1874
1947
  }
1875
1948
  const runCommandByIdRef = useRef(runCommandById)
1876
1949
  runCommandByIdRef.current = runCommandById
1877
- useAppCommandRegistry(appCommands, runCommandByIdRef)
1878
1950
  const dynamicPaletteCommands: readonly AppCommand[] = (() => {
1879
1951
  if (!commandPaletteActive) return []
1880
1952
  const repository = parseRepositoryInput(commandPalette.query)
@@ -1901,217 +1973,30 @@ export const App = () => {
1901
1973
  const selectedCommandIndex = clampCommandIndex(commandPalette.selectedIndex, commandPaletteCommands)
1902
1974
  const selectedCommand = commandPaletteCommands[selectedCommandIndex] ?? null
1903
1975
 
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
-
1976
+ // === Helpers used by the keymap layers ===
1925
1977
  const moveMergeSelection = (delta: -1 | 1) => setMergeModal((current) => {
1926
1978
  const max = Math.max(0, availableMergeActions(mergeModal.info).length - 1)
1927
1979
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1928
1980
  })
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
1981
  const scrollCommentThread = (delta: number) => setCommentThreadModal((current) => ({
1944
1982
  ...current,
1945
1983
  scrollOffset: Math.max(0, current.scrollOffset + delta),
1946
1984
  }))
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
1985
  const moveLabelSelection = (delta: -1 | 1) => setLabelModal((current) => {
1967
1986
  const max = Math.max(0, filterLabels(labelModal.availableLabels, labelModal.query).length - 1)
1968
1987
  return { ...current, selectedIndex: Math.max(0, Math.min(max, current.selectedIndex + delta)) }
1969
1988
  })
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
1989
  const moveCommandPaletteSelection = (delta: -1 | 1) => setCommandPalette((current) => {
2042
1990
  const selectedIndex = clampCommandIndex(current.selectedIndex + delta, commandPaletteCommands)
2043
1991
  return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2044
1992
  })
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
- },
1993
+ const selectCommandPaletteIndex = (index: number) => setCommandPalette((current) => {
1994
+ const selectedIndex = clampCommandIndex(index, commandPaletteCommands)
1995
+ return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex }
2113
1996
  })
2114
-
1997
+ const runCommandPaletteCommand = (command: AppCommand) => {
1998
+ runCommand(command, { notifyDisabled: true, closePalette: true })
1999
+ }
2115
2000
  const scrollDetailFullViewBy = (delta: number) => {
2116
2001
  detailScrollRef.current?.scrollBy({ x: 0, y: delta })
2117
2002
  setDetailScrollOffset((current) => Math.max(0, current + delta))
@@ -2120,26 +2005,6 @@ export const App = () => {
2120
2005
  detailScrollRef.current?.scrollTo({ x: 0, y })
2121
2006
  setDetailScrollOffset(y)
2122
2007
  }
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
2008
  const moveSelectedToPreviousGroup = () => setSelectedIndex((current) => {
2144
2009
  if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
2145
2010
  const currentGroup = getCurrentGroupIndex(current)
@@ -2178,48 +2043,168 @@ export const App = () => {
2178
2043
  if (visiblePullRequests.length === 0) return 0
2179
2044
  return current <= 0 ? visiblePullRequests.length - 1 : current - 1
2180
2045
  })
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),
2046
+ const handleQuitOrClose = () => {
2047
+ if (themeModalActive) {
2048
+ closeThemeModal(false)
2049
+ return
2050
+ }
2051
+ if (activeModal._tag !== "None") {
2052
+ closeActiveModal()
2053
+ return
2054
+ }
2055
+ runCommandById("app.quit")
2056
+ }
2057
+
2058
+ // === Build the keymap context ===
2059
+ const appCtx: AppCtx = {
2060
+ closeModalActive,
2061
+ mergeModalActive,
2062
+ commentThreadModalActive,
2063
+ labelModalActive,
2064
+ themeModalActive,
2065
+ openRepositoryModalActive,
2066
+ commentModalActive,
2067
+ commandPaletteActive,
2068
+ filterMode,
2069
+ diffFullView,
2070
+ detailFullView,
2071
+ textInputActive: commentModalActive
2072
+ || commandPaletteActive
2073
+ || openRepositoryModalActive
2074
+ || labelModalActive
2075
+ || filterMode
2076
+ || (themeModalActive && themeModal.filterMode),
2077
+ closeModal: {
2078
+ closeModal: closeActiveModal,
2079
+ confirmClose: confirmClosePullRequest,
2221
2080
  },
2222
- })
2081
+ mergeModal: {
2082
+ availableActionCount: availableMergeActions(mergeModal.info).length,
2083
+ closeModal: closeActiveModal,
2084
+ confirmMerge: confirmMergeAction,
2085
+ moveSelection: moveMergeSelection,
2086
+ },
2087
+ commentThreadModal: {
2088
+ halfPage,
2089
+ closeModal: closeActiveModal,
2090
+ openInlineComment: openDiffCommentModal,
2091
+ scrollBy: scrollCommentThread,
2092
+ },
2093
+ labelModal: {
2094
+ closeModal: closeActiveModal,
2095
+ toggleSelected: toggleLabelAtIndex,
2096
+ moveSelection: moveLabelSelection,
2097
+ },
2098
+ themeModal: {
2099
+ filterMode: themeModal.filterMode,
2100
+ hasFilteredResults: filterThemeDefinitions(themeModal.query).length > 0,
2101
+ closeWithoutSaving: () => closeThemeModal(false),
2102
+ clearFilter: () => updateThemeQuery("", { filterMode: false }),
2103
+ enterFilterMode: () => updateThemeQuery("", { filterMode: true }),
2104
+ confirmSelection: () => closeThemeModal(true),
2105
+ moveSelection: moveThemeSelection,
2106
+ },
2107
+ openRepositoryModal: {
2108
+ closeModal: closeActiveModal,
2109
+ openFromInput: openRepositoryFromInput,
2110
+ },
2111
+ commentModal: {
2112
+ closeModal: closeActiveModal,
2113
+ submit: submitDiffComment,
2114
+ insertNewline: () => editComment((state) => insertText(state, "\n")),
2115
+ moveLeft: () => editComment(editorMoveLeft),
2116
+ moveRight: () => editComment(editorMoveRight),
2117
+ moveUp: () => editComment((state) => moveVertically(state, -1)),
2118
+ moveDown: () => editComment((state) => moveVertically(state, 1)),
2119
+ moveLineStart: () => editComment(moveLineStart),
2120
+ moveLineEnd: () => editComment(moveLineEnd),
2121
+ moveWordBackward: () => editComment(moveWordBackward),
2122
+ moveWordForward: () => editComment(moveWordForward),
2123
+ backspace: () => editComment(editorBackspace),
2124
+ deleteForward: () => editComment(editorDeleteForward),
2125
+ deleteWordBackward: () => editComment(deleteWordBackward),
2126
+ deleteWordForward: () => editComment(deleteWordForward),
2127
+ deleteToLineStart: () => editComment(deleteToLineStart),
2128
+ deleteToLineEnd: () => editComment(deleteToLineEnd),
2129
+ },
2130
+ commandPalette: {
2131
+ closeModal: closeActiveModal,
2132
+ runSelected: () => {
2133
+ if (selectedCommand) runCommandPaletteCommand(selectedCommand)
2134
+ },
2135
+ moveSelection: moveCommandPaletteSelection,
2136
+ },
2137
+ filterModeCtx: {
2138
+ cancel: () => {
2139
+ setFilterDraft(filterQuery)
2140
+ setFilterMode(false)
2141
+ },
2142
+ commit: () => {
2143
+ setFilterQuery(filterDraft)
2144
+ setFilterMode(false)
2145
+ },
2146
+ },
2147
+ diff: {
2148
+ halfPage,
2149
+ handleEscape: () => {
2150
+ if (diffCommentRangeActive) setDiffCommentRangeStartIndex(null)
2151
+ else runCommandById("diff.close")
2152
+ },
2153
+ openSelectedComment: openSelectedDiffComment,
2154
+ addComment: () => runCommandById("diff.add-comment"),
2155
+ toggleRange: () => runCommandById("diff.toggle-range"),
2156
+ toggleView: () => runCommandById("diff.toggle-view"),
2157
+ toggleWrap: () => runCommandById("diff.toggle-wrap"),
2158
+ reload: () => runCommandById("diff.reload"),
2159
+ nextThread: () => runCommandById("diff.next-thread"),
2160
+ previousThread: () => runCommandById("diff.previous-thread"),
2161
+ moveAnchor: moveDiffCommentAnchor,
2162
+ moveAnchorToBoundary: moveDiffCommentToBoundary,
2163
+ alignAnchor: alignSelectedDiffCommentAnchor,
2164
+ selectSide: selectDiffCommentSide,
2165
+ nextFile: () => runCommandById("diff.next-file"),
2166
+ previousFile: () => runCommandById("diff.previous-file"),
2167
+ openInBrowser: () => runCommandById("pull.open-browser"),
2168
+ },
2169
+ detail: {
2170
+ halfPage,
2171
+ scrollBy: scrollDetailFullViewBy,
2172
+ scrollTo: scrollDetailFullViewTo,
2173
+ closeDetail: () => runCommandById("detail.close"),
2174
+ openTheme: () => runCommandById("theme.open"),
2175
+ openDiff: () => runCommandById("diff.open"),
2176
+ closePullRequest: () => runCommandById("pull.close"),
2177
+ openLabels: () => runCommandById("pull.labels"),
2178
+ openMerge: () => runCommandById("pull.merge"),
2179
+ toggleDraft: () => runCommandById("pull.toggle-draft"),
2180
+ refresh: () => runCommandById("pull.refresh"),
2181
+ openInBrowser: () => runCommandById("pull.open-browser"),
2182
+ copyMetadata: () => runCommandById("pull.copy-metadata"),
2183
+ },
2184
+ listNav: {
2185
+ halfPage,
2186
+ visibleCount: visiblePullRequests.length,
2187
+ hasFilter: filterQuery.length > 0,
2188
+ canScrollDetailPreview: isWideLayout && selectedPullRequest !== null,
2189
+ runCommandById: (id) => { runCommandById(id) },
2190
+ switchQueueMode,
2191
+ scrollDetailPreviewBy,
2192
+ scrollDetailPreviewTo,
2193
+ clearFilter: () => { runCommandById("filter.clear") },
2194
+ stepSelected,
2195
+ stepSelectedUp,
2196
+ stepSelectedDown,
2197
+ stepSelectedUpWrap,
2198
+ stepSelectedDownWithLoadMore,
2199
+ moveSelectedToPreviousGroup,
2200
+ moveSelectedToNextGroup,
2201
+ setSelected: (index) => setSelectedIndex(index),
2202
+ },
2203
+ openCommandPalette: () => { runCommandById("command.open") },
2204
+ handleQuitOrClose,
2205
+ }
2206
+
2207
+ useKeymap(appKeymap, appCtx, useOpenTuiSubscribe())
2223
2208
 
2224
2209
  useKeyboard((key) => {
2225
2210
  if (commandPaletteActive) {
@@ -2243,18 +2228,9 @@ export const App = () => {
2243
2228
  return
2244
2229
  }
2245
2230
 
2246
- if ((key.name === "q" && !commentModalActive && !(themeModalActive && themeModal.filterMode)) || (key.ctrl && key.name === "c")) {
2247
- if (themeModalActive) {
2248
- closeThemeModal(false)
2249
- return
2250
- }
2251
- if (activeModal._tag !== "None") {
2252
- closeActiveModal()
2253
- return
2254
- }
2255
- runCommandById("app.quit")
2256
- return
2257
- }
2231
+ // q / ctrl+c quit/close-modal logic now lives in the keymap layer
2232
+ // (handleQuitOrClose). This useKeyboard callback only handles raw text
2233
+ // input for modals that need character-by-character accumulation.
2258
2234
 
2259
2235
  if (themeModalActive) {
2260
2236
  if (themeModal.filterMode && isSingleLineInputKey(key)) {
@@ -2290,15 +2266,23 @@ export const App = () => {
2290
2266
  }
2291
2267
  })
2292
2268
 
2269
+ if (isInitialLoading) {
2270
+ return (
2271
+ <box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
2272
+ <LoadingLogoPane content={detailPlaceholderContent} width={contentWidth} height={terminalHeight} frame={loadingFrame} />
2273
+ </box>
2274
+ )
2275
+ }
2276
+
2293
2277
  const fullscreenContentWidth = Math.max(24, contentWidth - 2)
2294
2278
  const fullscreenBodyLines = Math.max(8, terminalHeight - 8)
2295
2279
  const fullscreenDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, contentWidth, isWideLayout)
2296
2280
  const fullscreenDetailBodyViewportHeight = Math.max(1, wideBodyHeight - fullscreenDetailHeaderHeight)
2297
- const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth)
2281
+ const fullscreenDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, fullscreenContentWidth, selectedConversationItems, selectedConversationStatus)
2298
2282
  const fullscreenDetailBodyScrollable = fullscreenDetailBodyHeight > fullscreenDetailBodyViewportHeight
2299
2283
  const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
2300
2284
  const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
2301
- const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth)
2285
+ const wideDetailBodyHeight = getScrollableDetailBodyHeight(selectedPullRequest, rightContentWidth, selectedConversationItems, selectedConversationStatus)
2302
2286
  const wideDetailBodyScrollable = wideDetailBodyHeight > wideDetailBodyViewportHeight
2303
2287
 
2304
2288
  const prListProps = {
@@ -2370,20 +2354,19 @@ export const App = () => {
2370
2354
  <box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.background}>
2371
2355
  <PlainLine text={headerLine} fg={colors.muted} bold />
2372
2356
  </box>
2373
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2357
+ {isWideLayout && !detailFullView && !diffFullView ? (
2374
2358
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
2375
2359
  ) : (
2376
2360
  <Divider width={contentWidth} />
2377
2361
  )}
2378
- {isInitialLoading ? (
2379
- <LoadingPane content={detailPlaceholderContent} width={contentWidth} height={wideBodyHeight} />
2380
- ) : diffFullView ? (
2362
+ {diffFullView ? (
2381
2363
  <PullRequestDiffPane
2382
2364
  pullRequest={selectedPullRequest}
2383
- diffState={selectedDiffState}
2365
+ diffState={displayedDiffState}
2384
2366
  stackedFiles={stackedDiffFiles}
2385
2367
  scrollTop={diffScrollTop}
2386
2368
  view={effectiveDiffRenderView}
2369
+ whitespaceMode={diffWhitespaceMode}
2387
2370
  wrapMode={diffWrapMode}
2388
2371
  paneWidth={contentWidth}
2389
2372
  height={wideBodyHeight}
@@ -2406,8 +2389,8 @@ export const App = () => {
2406
2389
  {selectedPullRequest ? (
2407
2390
  <>
2408
2391
  <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} />
2392
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2393
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2411
2394
  </scrollbox>
2412
2395
  </>
2413
2396
  ) : (
@@ -2434,7 +2417,7 @@ export const App = () => {
2434
2417
  <>
2435
2418
  <DetailHeader pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2436
2419
  <scrollbox ref={detailPreviewScrollRef} flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
2437
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} loadingIndicator={loadingIndicator} themeId={themeId} />
2420
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2438
2421
  </scrollbox>
2439
2422
  </>
2440
2423
  ) : (
@@ -2447,8 +2430,8 @@ export const App = () => {
2447
2430
  {selectedPullRequest ? (
2448
2431
  <>
2449
2432
  <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} />
2433
+ <scrollbox ref={detailScrollRef} focusable={false} flexGrow={1} verticalScrollbarOptions={{ visible: fullscreenDetailBodyScrollable }}>
2434
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={fullscreenContentWidth} bodyLines={fullscreenBodyLines} bodyLineLimit={DETAIL_BODY_SCROLL_LIMIT} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
2452
2435
  </scrollbox>
2453
2436
  </>
2454
2437
  ) : (
@@ -2457,7 +2440,7 @@ export const App = () => {
2457
2440
  </box>
2458
2441
  ) : (
2459
2442
  <box key="narrow-main" height={wideBodyHeight} flexDirection="column">
2460
- <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2443
+ <DetailsPane pullRequest={selectedPullRequest} viewerUsername={username} contentWidth={fullscreenContentWidth} paneWidth={contentWidth} conversationItems={selectedConversationItems} conversationStatus={selectedConversationStatus} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} themeId={themeId} />
2461
2444
  <Divider width={contentWidth} />
2462
2445
  <box flexGrow={1} flexDirection="column">
2463
2446
  <scrollbox ref={prListScrollRef} focusable={false} flexGrow={1}>
@@ -2469,7 +2452,7 @@ export const App = () => {
2469
2452
  </box>
2470
2453
  )}
2471
2454
 
2472
- {isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
2455
+ {isWideLayout && !detailFullView && !diffFullView ? (
2473
2456
  <Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
2474
2457
  ) : (
2475
2458
  <Divider width={contentWidth} />
@@ -2573,6 +2556,8 @@ export const App = () => {
2573
2556
  modalHeight={commandPaletteHeight}
2574
2557
  offsetLeft={commandPaletteLeft}
2575
2558
  offsetTop={commandPaletteTop}
2559
+ onSelectCommandIndex={selectCommandPaletteIndex}
2560
+ onRunCommand={runCommandPaletteCommand}
2576
2561
  />
2577
2562
  ) : null}
2578
2563
  </box>