@kitlangton/ghui 0.1.18 → 0.1.20

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.
@@ -1,190 +1,99 @@
1
+ import { Data } from "effect"
1
2
  import { colors } from "./colors.js"
2
- import { TextLine } from "./primitives.js"
3
+ import { HintRow, type HintItem } from "./primitives.js"
3
4
 
4
- export interface RetryProgress {
5
- readonly attempt: number
6
- readonly max: number
5
+ export type RetryProgress = Data.TaggedEnum<{
6
+ Idle: {}
7
+ Retrying: { readonly attempt: number; readonly max: number }
8
+ }>
9
+
10
+ export const RetryProgress = Data.taggedEnum<RetryProgress>()
11
+ export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
12
+
13
+ interface HintsContext {
14
+ readonly filterEditing: boolean
15
+ readonly showFilterClear: boolean
16
+ readonly detailFullView: boolean
17
+ readonly diffFullView: boolean
18
+ readonly diffCommentMode: boolean
19
+ readonly hasSelection: boolean
20
+ readonly canCloseSelection: boolean
21
+ readonly hasError: boolean
22
+ readonly isLoading: boolean
23
+ readonly loadingIndicator: string
24
+ readonly retryProgress: RetryProgress
7
25
  }
8
26
 
9
- export const FooterHints = ({
10
- filterEditing,
11
- showFilterClear,
12
- detailFullView,
13
- diffFullView,
14
- diffCommentMode,
15
- hasSelection,
16
- canCloseSelection,
17
- hasError,
18
- isLoading,
19
- loadingIndicator,
20
- retryProgress,
21
- }: {
22
- filterEditing: boolean
23
- showFilterClear: boolean
24
- detailFullView: boolean
25
- diffFullView: boolean
26
- diffCommentMode: boolean
27
- hasSelection: boolean
28
- canCloseSelection: boolean
29
- hasError: boolean
30
- isLoading: boolean
31
- loadingIndicator: string
32
- retryProgress: RetryProgress | null
33
- }) => {
34
- if (filterEditing) {
35
- return (
36
- <TextLine>
37
- <span fg={colors.count}>search</span>
38
- <span fg={colors.muted}> typing </span>
39
- <span fg={colors.count}>↑↓</span>
40
- <span fg={colors.muted}> move </span>
41
- <span fg={colors.count}>enter</span>
42
- <span fg={colors.muted}> apply </span>
43
- <span fg={colors.count}>esc</span>
44
- <span fg={colors.muted}> cancel </span>
45
- <span fg={colors.count}>ctrl-u</span>
46
- <span fg={colors.muted}> clear </span>
47
- <span fg={colors.count}>ctrl-w</span>
48
- <span fg={colors.muted}> word</span>
49
- </TextLine>
50
- )
51
- }
27
+ const filterEditingHints: readonly HintItem[] = [
28
+ { key: "search", label: "typing" },
29
+ { key: "↑↓", label: "move" },
30
+ { key: "enter", label: "apply" },
31
+ { key: "esc", label: "cancel" },
32
+ { key: "ctrl-u", label: "clear" },
33
+ { key: "ctrl-w", label: "word" },
34
+ ]
35
+
36
+ const diffCommentModeHints: readonly HintItem[] = [
37
+ { key: "↑↓", label: "line" },
38
+ { key: "pgup/pgdn", label: "jump" },
39
+ { key: "←→", label: "side" },
40
+ { key: "enter", label: "open" },
41
+ { key: "a", label: "comment" },
42
+ { key: "c", label: "done" },
43
+ { key: "[]", label: "files" },
44
+ { key: "esc", label: "back" },
45
+ ]
52
46
 
53
- if (diffFullView) {
54
- if (diffCommentMode) {
55
- return (
56
- <TextLine>
57
- <span fg={colors.count}>↑↓</span>
58
- <span fg={colors.muted}> line </span>
59
- <span fg={colors.count}>pgup/pgdn</span>
60
- <span fg={colors.muted}> jump </span>
61
- <span fg={colors.count}>←→</span>
62
- <span fg={colors.muted}> side </span>
63
- <span fg={colors.count}>enter</span>
64
- <span fg={colors.muted}> open </span>
65
- <span fg={colors.count}>a</span>
66
- <span fg={colors.muted}> comment </span>
67
- <span fg={colors.count}>c</span>
68
- <span fg={colors.muted}> done </span>
69
- <span fg={colors.count}>[]</span>
70
- <span fg={colors.muted}> files </span>
71
- <span fg={colors.count}>esc</span>
72
- <span fg={colors.muted}> back</span>
73
- </TextLine>
74
- )
75
- }
76
- return (
77
- <TextLine>
78
- <span fg={colors.count}>esc</span>
79
- <span fg={colors.muted}> back </span>
80
- <span fg={colors.count}>v</span>
81
- <span fg={colors.muted}> view </span>
82
- <span fg={colors.count}>w</span>
83
- <span fg={colors.muted}> wrap </span>
84
- <span fg={colors.count}>c</span>
85
- <span fg={colors.muted}> comment </span>
86
- <span fg={colors.count}>[]</span>
87
- <span fg={colors.muted}> files </span>
88
- <span fg={colors.count}>r</span>
89
- <span fg={colors.muted}> reload </span>
90
- <span fg={colors.count}>o</span>
91
- <span fg={colors.muted}> open </span>
92
- <span fg={colors.count}>q</span>
93
- <span fg={colors.muted}> quit</span>
94
- </TextLine>
95
- )
96
- }
47
+ const diffViewHints: readonly HintItem[] = [
48
+ { key: "esc", label: "back" },
49
+ { key: "v", label: "view" },
50
+ { key: "w", label: "wrap" },
51
+ { key: "c", label: "comment" },
52
+ { key: "[]", label: "files" },
53
+ { key: "r", label: "reload" },
54
+ { key: "o", label: "open" },
55
+ { key: "q", label: "quit" },
56
+ ]
97
57
 
98
- if (detailFullView) {
99
- return (
100
- <TextLine>
101
- <span fg={colors.count}>esc</span>
102
- <span fg={colors.muted}> back </span>
103
- <span fg={colors.count}>↑↓</span>
104
- <span fg={colors.muted}> scroll </span>
105
- <span fg={colors.count}>r</span>
106
- <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
107
- <span fg={colors.count}>t</span>
108
- <span fg={colors.muted}> theme </span>
109
- {hasSelection ? (
110
- <>
111
- <span fg={colors.count}>s</span>
112
- <span fg={colors.muted}> state </span>
113
- <span fg={colors.count}>d</span>
114
- <span fg={colors.muted}> diff </span>
115
- <span fg={colors.count}>l</span>
116
- <span fg={colors.muted}> labels </span>
117
- <span fg={colors.count}>m</span>
118
- <span fg={colors.muted}> merge </span>
119
- {canCloseSelection ? (
120
- <>
121
- <span fg={colors.count}>x</span>
122
- <span fg={colors.muted}> close </span>
123
- </>
124
- ) : null}
125
- </>
126
- ) : null}
127
- <span fg={colors.count}>o</span>
128
- <span fg={colors.muted}> open </span>
129
- <span fg={colors.count}>y</span>
130
- <span fg={colors.muted}> copy </span>
131
- <span fg={colors.count}>q</span>
132
- <span fg={colors.muted}> quit</span>
133
- </TextLine>
134
- )
135
- }
58
+ const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [
59
+ { key: "esc", label: "back" },
60
+ { key: "↑↓", label: "scroll" },
61
+ { key: "r", label: ctx.hasError ? "retry" : "refresh" },
62
+ { key: "t", label: "theme" },
63
+ { key: "s", label: "state", when: ctx.hasSelection },
64
+ { key: "d", label: "diff", when: ctx.hasSelection },
65
+ { key: "l", label: "labels", when: ctx.hasSelection },
66
+ { key: "m", label: "merge", when: ctx.hasSelection },
67
+ { key: "x", label: "close", when: ctx.hasSelection && ctx.canCloseSelection },
68
+ { key: "o", label: "open" },
69
+ { key: "y", label: "copy" },
70
+ { key: "q", label: "quit" },
71
+ ]
136
72
 
137
- return (
138
- <TextLine>
139
- <span fg={colors.count}>tab</span>
140
- <span fg={colors.muted}> queue </span>
141
- <span fg={colors.count}>/</span>
142
- <span fg={colors.muted}> filter </span>
143
- <span fg={colors.count}>t</span>
144
- <span fg={colors.muted}> theme </span>
145
- {showFilterClear ? (
146
- <>
147
- <span fg={colors.count}>esc</span>
148
- <span fg={colors.muted}> clear </span>
149
- </>
150
- ) : null}
151
- {retryProgress ? (
152
- <>
153
- <span fg={colors.status.pending}>retry</span>
154
- <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
155
- </>
156
- ) : isLoading ? (
157
- <>
158
- <span fg={colors.status.pending}>{loadingIndicator}</span>
159
- <span fg={colors.muted}> loading </span>
160
- </>
161
- ) : null}
162
- <span fg={colors.count}>r</span>
163
- <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
164
- {hasSelection ? (
165
- <>
166
- <span fg={colors.count}>s</span>
167
- <span fg={colors.muted}> state </span>
168
- <span fg={colors.count}>d</span>
169
- <span fg={colors.muted}> diff </span>
170
- <span fg={colors.count}>l</span>
171
- <span fg={colors.muted}> labels </span>
172
- <span fg={colors.count}>m</span>
173
- <span fg={colors.muted}> merge </span>
174
- {canCloseSelection ? (
175
- <>
176
- <span fg={colors.count}>x</span>
177
- <span fg={colors.muted}> close </span>
178
- </>
179
- ) : null}
180
- <span fg={colors.count}>o</span>
181
- <span fg={colors.muted}> open </span>
182
- <span fg={colors.count}>y</span>
183
- <span fg={colors.muted}> copy </span>
184
- </>
185
- ) : null}
186
- <span fg={colors.count}>q</span>
187
- <span fg={colors.muted}> quit</span>
188
- </TextLine>
189
- )
73
+ const defaultHints = (ctx: HintsContext): readonly HintItem[] => {
74
+ const retrying = ctx.retryProgress._tag === "Retrying"
75
+ return [
76
+ { key: "/", label: "filter" },
77
+ { key: "t", label: "theme" },
78
+ { key: "esc", label: "clear", when: ctx.showFilterClear },
79
+ { key: "retry", label: retrying ? `${(ctx.retryProgress as { attempt: number; max: number }).attempt}/${(ctx.retryProgress as { attempt: number; max: number }).max}` : "", when: retrying, keyFg: colors.status.pending },
80
+ { key: ctx.loadingIndicator, label: "loading", when: !retrying && ctx.isLoading, keyFg: colors.status.pending },
81
+ { key: "r", label: "retry", when: ctx.hasError },
82
+ { key: "d", label: "diff", when: ctx.hasSelection },
83
+ { key: "l", label: "labels", when: ctx.hasSelection },
84
+ { key: "m", label: "merge", when: ctx.hasSelection },
85
+ { key: "x", label: "close", when: ctx.hasSelection && ctx.canCloseSelection },
86
+ { key: "o", label: "open", when: ctx.hasSelection },
87
+ { key: "y", label: "copy", when: ctx.hasSelection },
88
+ { key: "ctrl-p", label: "commands" },
89
+ ]
190
90
  }
91
+
92
+ const footerHints = (ctx: HintsContext): readonly HintItem[] => {
93
+ if (ctx.filterEditing) return filterEditingHints
94
+ if (ctx.diffFullView) return ctx.diffCommentMode ? diffCommentModeHints : diffViewHints
95
+ if (ctx.detailFullView) return detailFullViewHints(ctx)
96
+ return defaultHints(ctx)
97
+ }
98
+
99
+ export const FooterHints = (props: HintsContext) => <HintRow items={footerHints(props)} />
@@ -1,34 +1,31 @@
1
- import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
1
+ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
- import type { PullRequestItem, PullRequestReviewComment } from "../domain.js"
3
+ import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
4
  import { colors, type ThemeId } from "./colors.js"
5
- import { createDiffSyntaxStyle, diffFileStats, diffFileStatText, diffStatText, safeDiffFileIndex, type DiffCommentAnchor, type PullRequestDiffState, type StackedDiffFilePatch } from "./diff.js"
5
+ import { createDiffSyntaxStyle, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
6
6
  import { LoadingPane, StatusCard } from "./DetailsPane.js"
7
- import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
7
+ import { DiffStats } from "./diffStats.js"
8
+ import { Divider, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
8
9
  import { shortRepoName } from "./pullRequests.js"
9
10
 
10
- const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
11
- if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
12
- const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
13
- type Part = { key: string; text: string; color: string }
14
- const rawParts: Array<Part | null> = [
15
- pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
16
- pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
17
- { key: "files", text: files, color: colors.muted },
18
- ]
19
- const parts = rawParts.filter((part): part is Part => part !== null)
20
-
11
+ const DiffPaneHeader = ({ pullRequest, paneWidth }: { pullRequest: PullRequestItem; paneWidth: number }) => {
12
+ const stats = diffStatText(pullRequest)
13
+ const headerWidth = Math.max(24, paneWidth - 2)
14
+ const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
15
+ const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
21
16
  return (
22
- <>
23
- {parts.map((part, index) => (
24
- <span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
25
- ))}
26
- </>
17
+ <PaddedRow>
18
+ <TextLine>
19
+ <span fg={colors.count}>#{pullRequest.number}</span>
20
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
21
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
22
+ <DiffStats pullRequest={pullRequest} />
23
+ </TextLine>
24
+ </PaddedRow>
27
25
  )
28
26
  }
29
27
 
30
- const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
31
- const stats = diffFileStats(file)
28
+ const FileStats = ({ stats }: { stats: DiffFileStats }) => {
32
29
  return (
33
30
  <>
34
31
  {stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
@@ -38,16 +35,32 @@ const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
38
35
  )
39
36
  }
40
37
 
41
- const FileHeader = ({ file, index, count, width }: { file: StackedDiffFilePatch["file"]; index: number; count: number; width: number }) => {
38
+ const FileHeader = ({
39
+ file,
40
+ index,
41
+ count,
42
+ width,
43
+ suffix = "",
44
+ suffixColor = colors.muted,
45
+ }: {
46
+ file: StackedDiffFilePatch["file"]
47
+ index: number
48
+ count: number
49
+ width: number
50
+ suffix?: string
51
+ suffixColor?: string
52
+ }) => {
42
53
  const counter = `${index + 1}/${count}`
43
- const statsText = diffFileStatText(file)
44
- const nameWidth = Math.max(1, width - counter.length - statsText.length - 5)
54
+ const stats = diffFileStats(file)
55
+ const statsText = diffFileStatsText(stats)
56
+ const nameWidth = Math.max(1, width - counter.length - statsText.length - suffix.length - 5)
45
57
  return (
46
58
  <TextLine>
47
59
  <span fg={colors.muted}>{counter} </span>
48
60
  <span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
49
61
  {statsText ? <span fg={colors.muted}> </span> : null}
50
- <FileStats file={file} />
62
+ <FileStats stats={stats} />
63
+ {suffix ? <span fg={suffixColor}>{suffix}</span> : null}
51
64
  </TextLine>
52
65
  )
53
66
  }
@@ -56,7 +69,7 @@ export const PullRequestDiffPane = ({
56
69
  pullRequest,
57
70
  diffState,
58
71
  stackedFiles,
59
- fileIndex,
72
+ scrollTop,
60
73
  view,
61
74
  wrapMode,
62
75
  paneWidth,
@@ -67,83 +80,60 @@ export const PullRequestDiffPane = ({
67
80
  commentMode,
68
81
  selectedCommentAnchor,
69
82
  selectedCommentThread,
70
- commentCount,
83
+ onSelectCommentLine,
71
84
  themeId,
72
85
  }: {
73
86
  pullRequest: PullRequestItem | null
74
87
  diffState: PullRequestDiffState | undefined
75
88
  stackedFiles: readonly StackedDiffFilePatch[]
76
- fileIndex: number
77
- view: "unified" | "split"
78
- wrapMode: "none" | "word"
89
+ scrollTop: number
90
+ view: DiffView
91
+ wrapMode: DiffWrapMode
79
92
  paneWidth: number
80
93
  height: number
81
94
  loadingIndicator: string
82
95
  scrollRef: Ref<ScrollBoxRenderable>
83
96
  setDiffRef: (index: number, diff: DiffRenderable | null) => void
84
97
  commentMode: boolean
85
- selectedCommentAnchor: DiffCommentAnchor | null
98
+ selectedCommentAnchor: StackedDiffCommentAnchor | null
86
99
  selectedCommentThread: readonly PullRequestReviewComment[]
87
- commentCount: number
100
+ onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
88
101
  themeId: ThemeId
89
102
  }) => {
90
- const readyFiles = diffState?.status === "ready" ? diffState.files : []
91
- const safeIndex = safeDiffFileIndex(readyFiles, fileIndex)
92
- const file = readyFiles[safeIndex] ?? null
103
+ const readyFiles = diffState?._tag === "Ready" ? diffState.files : []
93
104
  const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
94
105
 
95
106
  if (!pullRequest) {
96
107
  return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
97
108
  }
98
109
 
99
- const stats = diffStatText(pullRequest)
100
- const headerWidth = Math.max(24, paneWidth - 2)
101
- const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
102
- const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
103
-
104
- if (!diffState || diffState.status === "loading") {
110
+ if (!diffState || diffState._tag === "Loading") {
105
111
  return (
106
112
  <box height={height} flexDirection="column">
107
- <box height={1} paddingLeft={1} paddingRight={1}>
108
- <TextLine>
109
- <span fg={colors.count}>#{pullRequest.number}</span>
110
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
111
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
112
- <DiffStats pullRequest={pullRequest} />
113
- </TextLine>
114
- </box>
113
+ <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
115
114
  <Divider width={paneWidth} />
116
115
  <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
117
116
  </box>
118
117
  )
119
118
  }
120
119
 
121
- if (diffState.status === "error") {
120
+ if (diffState._tag === "Error") {
122
121
  return (
123
122
  <box height={height} flexDirection="column">
124
- <box height={1} paddingLeft={1} paddingRight={1}>
123
+ <PaddedRow>
125
124
  <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
126
- </box>
125
+ </PaddedRow>
127
126
  <Divider width={paneWidth} />
128
127
  <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
129
128
  </box>
130
129
  )
131
130
  }
132
131
 
133
- if (readyFiles.length === 0 || !file) {
132
+ if (readyFiles.length === 0 || stackedFiles.length === 0) {
134
133
  return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
135
134
  }
136
135
 
137
- const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
138
- const currentFileStatsText = file ? diffFileStatText(file) : ""
139
136
  const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
140
- const commentLabel = commentMode && selectedCommentAnchor
141
- ? ` ${selectedSideLabel} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
142
- : commentMode ? " c no lines" : commentCount > 0 ? ` ● ${commentCount}` : ""
143
- const commentLabelColor = commentMode && selectedCommentAnchor?.side === "LEFT"
144
- ? colors.status.failing
145
- : commentMode ? colors.status.passing : colors.status.passing
146
- const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - currentFileStatsText.length - commentLabel.length - 4)
147
137
  const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
148
138
  ? selectedCommentThread[selectedCommentThread.length - 1]!
149
139
  : null
@@ -152,34 +142,43 @@ export const PullRequestDiffPane = ({
152
142
  const commentPeekMeta = commentPeek && selectedCommentAnchor
153
143
  ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
154
144
  : ""
145
+ const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
146
+ const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
147
+ const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
148
+ const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
149
+ const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
150
+ const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
151
+ const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
152
+ if (!commentMode) return ""
153
+ if (!selectedCommentAnchor) return " c no lines"
154
+ if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
155
+ return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
156
+ }
157
+ const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
158
+ const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
159
+ if (event.button !== 0) return
160
+ const localY = event.y - this.viewport.y
161
+ if (localY < 0 || localY >= this.viewport.height) return
162
+ const localX = event.x - this.viewport.x
163
+ const side = view === "split"
164
+ ? localX < Math.floor(paneWidth / 2) ? "LEFT" : "RIGHT"
165
+ : null
166
+ onSelectCommentLine(Math.max(0, Math.floor(this.scrollTop + localY)), side)
167
+ event.preventDefault()
168
+ event.stopPropagation()
169
+ }
155
170
 
156
171
  return (
157
172
  <box height={height} flexDirection="column">
158
- <box height={1} paddingLeft={1} paddingRight={1}>
159
- <TextLine>
160
- <span fg={colors.count}>#{pullRequest.number}</span>
161
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
162
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
163
- <DiffStats pullRequest={pullRequest} />
164
- </TextLine>
165
- </box>
166
- <box height={1} paddingLeft={1} paddingRight={1}>
167
- <TextLine>
168
- <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
169
- <span fg={colors.muted}> {fileCounter}</span>
170
- {currentFileStatsText ? <span fg={colors.muted}> </span> : null}
171
- <FileStats file={file} />
172
- {commentLabel ? <span fg={commentLabelColor}>{commentLabel}</span> : null}
173
- </TextLine>
174
- </box>
173
+ <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
175
174
  <Divider width={paneWidth} />
176
- <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false}>
175
+ <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
177
176
  {stackedFiles.map((stackedFile) => (
178
177
  <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
179
178
  {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
180
- <box height={1} paddingLeft={1} paddingRight={1}>
179
+ <PaddedRow>
181
180
  <FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
182
- </box>
181
+ </PaddedRow>
183
182
  <Divider width={paneWidth} />
184
183
  <diff
185
184
  ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
@@ -207,15 +206,34 @@ export const PullRequestDiffPane = ({
207
206
  </box>
208
207
  ))}
209
208
  </scrollbox>
209
+ {stickyFile ? (
210
+ <box position="absolute" top={2} left={0} width={paneWidth} height={2} zIndex={10} flexDirection="column" backgroundColor={colors.background}>
211
+ {incomingFile ? (
212
+ <>
213
+ <Divider width={paneWidth} />
214
+ <PaddedRow backgroundColor={colors.background}>
215
+ <FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
216
+ </PaddedRow>
217
+ </>
218
+ ) : (
219
+ <>
220
+ <PaddedRow backgroundColor={colors.background}>
221
+ <FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
222
+ </PaddedRow>
223
+ <Divider width={paneWidth} />
224
+ </>
225
+ )}
226
+ </box>
227
+ ) : null}
210
228
  {commentPeek ? (
211
229
  <>
212
230
  <Divider width={paneWidth} />
213
- <box height={1} paddingLeft={1} paddingRight={1}>
231
+ <PaddedRow>
214
232
  <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
215
- </box>
216
- <box height={1} paddingLeft={1} paddingRight={1}>
233
+ </PaddedRow>
234
+ <PaddedRow>
217
235
  <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
218
- </box>
236
+ </PaddedRow>
219
237
  </>
220
238
  ) : null}
221
239
  </box>