@kitlangton/ghui 0.1.19 → 0.1.21

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,6 +1,6 @@
1
1
  import { Data } from "effect"
2
2
  import { colors } from "./colors.js"
3
- import { TextLine } from "./primitives.js"
3
+ import { HintRow, type HintItem } from "./primitives.js"
4
4
 
5
5
  export type RetryProgress = Data.TaggedEnum<{
6
6
  Idle: {}
@@ -10,185 +10,90 @@ export type RetryProgress = Data.TaggedEnum<{
10
10
  export const RetryProgress = Data.taggedEnum<RetryProgress>()
11
11
  export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
12
12
 
13
- export const FooterHints = ({
14
- filterEditing,
15
- showFilterClear,
16
- detailFullView,
17
- diffFullView,
18
- diffCommentMode,
19
- hasSelection,
20
- canCloseSelection,
21
- hasError,
22
- isLoading,
23
- loadingIndicator,
24
- retryProgress,
25
- }: {
26
- filterEditing: boolean
27
- showFilterClear: boolean
28
- detailFullView: boolean
29
- diffFullView: boolean
30
- diffCommentMode: boolean
31
- hasSelection: boolean
32
- canCloseSelection: boolean
33
- hasError: boolean
34
- isLoading: boolean
35
- loadingIndicator: string
36
- retryProgress: RetryProgress
37
- }) => {
38
- if (filterEditing) {
39
- return (
40
- <TextLine>
41
- <span fg={colors.count}>search</span>
42
- <span fg={colors.muted}> typing </span>
43
- <span fg={colors.count}>↑↓</span>
44
- <span fg={colors.muted}> move </span>
45
- <span fg={colors.count}>enter</span>
46
- <span fg={colors.muted}> apply </span>
47
- <span fg={colors.count}>esc</span>
48
- <span fg={colors.muted}> cancel </span>
49
- <span fg={colors.count}>ctrl-u</span>
50
- <span fg={colors.muted}> clear </span>
51
- <span fg={colors.count}>ctrl-w</span>
52
- <span fg={colors.muted}> word</span>
53
- </TextLine>
54
- )
55
- }
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
25
+ }
26
+
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
+ ]
56
46
 
57
- if (diffFullView) {
58
- if (diffCommentMode) {
59
- return (
60
- <TextLine>
61
- <span fg={colors.count}>↑↓</span>
62
- <span fg={colors.muted}> line </span>
63
- <span fg={colors.count}>pgup/pgdn</span>
64
- <span fg={colors.muted}> jump </span>
65
- <span fg={colors.count}>←→</span>
66
- <span fg={colors.muted}> side </span>
67
- <span fg={colors.count}>enter</span>
68
- <span fg={colors.muted}> open </span>
69
- <span fg={colors.count}>a</span>
70
- <span fg={colors.muted}> comment </span>
71
- <span fg={colors.count}>c</span>
72
- <span fg={colors.muted}> done </span>
73
- <span fg={colors.count}>[]</span>
74
- <span fg={colors.muted}> files </span>
75
- <span fg={colors.count}>esc</span>
76
- <span fg={colors.muted}> back</span>
77
- </TextLine>
78
- )
79
- }
80
- return (
81
- <TextLine>
82
- <span fg={colors.count}>esc</span>
83
- <span fg={colors.muted}> back </span>
84
- <span fg={colors.count}>v</span>
85
- <span fg={colors.muted}> view </span>
86
- <span fg={colors.count}>w</span>
87
- <span fg={colors.muted}> wrap </span>
88
- <span fg={colors.count}>c</span>
89
- <span fg={colors.muted}> comment </span>
90
- <span fg={colors.count}>[]</span>
91
- <span fg={colors.muted}> files </span>
92
- <span fg={colors.count}>r</span>
93
- <span fg={colors.muted}> reload </span>
94
- <span fg={colors.count}>o</span>
95
- <span fg={colors.muted}> open </span>
96
- <span fg={colors.count}>q</span>
97
- <span fg={colors.muted}> quit</span>
98
- </TextLine>
99
- )
100
- }
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
+ ]
101
57
 
102
- if (detailFullView) {
103
- return (
104
- <TextLine>
105
- <span fg={colors.count}>esc</span>
106
- <span fg={colors.muted}> back </span>
107
- <span fg={colors.count}>↑↓</span>
108
- <span fg={colors.muted}> scroll </span>
109
- <span fg={colors.count}>r</span>
110
- <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
111
- <span fg={colors.count}>t</span>
112
- <span fg={colors.muted}> theme </span>
113
- {hasSelection ? (
114
- <>
115
- <span fg={colors.count}>s</span>
116
- <span fg={colors.muted}> state </span>
117
- <span fg={colors.count}>d</span>
118
- <span fg={colors.muted}> diff </span>
119
- <span fg={colors.count}>l</span>
120
- <span fg={colors.muted}> labels </span>
121
- <span fg={colors.count}>m</span>
122
- <span fg={colors.muted}> merge </span>
123
- {canCloseSelection ? (
124
- <>
125
- <span fg={colors.count}>x</span>
126
- <span fg={colors.muted}> close </span>
127
- </>
128
- ) : null}
129
- </>
130
- ) : null}
131
- <span fg={colors.count}>o</span>
132
- <span fg={colors.muted}> open </span>
133
- <span fg={colors.count}>y</span>
134
- <span fg={colors.muted}> copy </span>
135
- <span fg={colors.count}>q</span>
136
- <span fg={colors.muted}> quit</span>
137
- </TextLine>
138
- )
139
- }
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
+ ]
140
72
 
141
- return (
142
- <TextLine>
143
- <span fg={colors.count}>tab</span>
144
- <span fg={colors.muted}> queue </span>
145
- <span fg={colors.count}>/</span>
146
- <span fg={colors.muted}> filter </span>
147
- <span fg={colors.count}>t</span>
148
- <span fg={colors.muted}> theme </span>
149
- {showFilterClear ? (
150
- <>
151
- <span fg={colors.count}>esc</span>
152
- <span fg={colors.muted}> clear </span>
153
- </>
154
- ) : null}
155
- {retryProgress._tag === "Retrying" ? (
156
- <>
157
- <span fg={colors.status.pending}>retry</span>
158
- <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
159
- </>
160
- ) : isLoading ? (
161
- <>
162
- <span fg={colors.status.pending}>{loadingIndicator}</span>
163
- <span fg={colors.muted}> loading </span>
164
- </>
165
- ) : null}
166
- <span fg={colors.count}>r</span>
167
- <span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
168
- {hasSelection ? (
169
- <>
170
- <span fg={colors.count}>s</span>
171
- <span fg={colors.muted}> state </span>
172
- <span fg={colors.count}>d</span>
173
- <span fg={colors.muted}> diff </span>
174
- <span fg={colors.count}>l</span>
175
- <span fg={colors.muted}> labels </span>
176
- <span fg={colors.count}>m</span>
177
- <span fg={colors.muted}> merge </span>
178
- {canCloseSelection ? (
179
- <>
180
- <span fg={colors.count}>x</span>
181
- <span fg={colors.muted}> close </span>
182
- </>
183
- ) : null}
184
- <span fg={colors.count}>o</span>
185
- <span fg={colors.muted}> open </span>
186
- <span fg={colors.count}>y</span>
187
- <span fg={colors.muted}> copy </span>
188
- </>
189
- ) : null}
190
- <span fg={colors.count}>q</span>
191
- <span fg={colors.muted}> quit</span>
192
- </TextLine>
193
- )
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
+ ]
194
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)} />
@@ -4,26 +4,24 @@ import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from
4
4
  import { colors, type ThemeId } from "./colors.js"
5
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
 
@@ -109,22 +107,10 @@ export const PullRequestDiffPane = ({
109
107
  return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
110
108
  }
111
109
 
112
- const stats = diffStatText(pullRequest)
113
- const headerWidth = Math.max(24, paneWidth - 2)
114
- const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
115
- const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
116
-
117
110
  if (!diffState || diffState._tag === "Loading") {
118
111
  return (
119
112
  <box height={height} flexDirection="column">
120
- <box height={1} paddingLeft={1} paddingRight={1}>
121
- <TextLine>
122
- <span fg={colors.count}>#{pullRequest.number}</span>
123
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
124
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
125
- <DiffStats pullRequest={pullRequest} />
126
- </TextLine>
127
- </box>
113
+ <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
128
114
  <Divider width={paneWidth} />
129
115
  <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
130
116
  </box>
@@ -134,9 +120,9 @@ export const PullRequestDiffPane = ({
134
120
  if (diffState._tag === "Error") {
135
121
  return (
136
122
  <box height={height} flexDirection="column">
137
- <box height={1} paddingLeft={1} paddingRight={1}>
123
+ <PaddedRow>
138
124
  <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
139
- </box>
125
+ </PaddedRow>
140
126
  <Divider width={paneWidth} />
141
127
  <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
142
128
  </box>
@@ -184,22 +170,15 @@ export const PullRequestDiffPane = ({
184
170
 
185
171
  return (
186
172
  <box height={height} flexDirection="column">
187
- <box height={1} paddingLeft={1} paddingRight={1}>
188
- <TextLine>
189
- <span fg={colors.count}>#{pullRequest.number}</span>
190
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
191
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
192
- <DiffStats pullRequest={pullRequest} />
193
- </TextLine>
194
- </box>
173
+ <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
195
174
  <Divider width={paneWidth} />
196
175
  <scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
197
176
  {stackedFiles.map((stackedFile) => (
198
177
  <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
199
178
  {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
200
- <box height={1} paddingLeft={1} paddingRight={1}>
179
+ <PaddedRow>
201
180
  <FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
202
- </box>
181
+ </PaddedRow>
203
182
  <Divider width={paneWidth} />
204
183
  <diff
205
184
  ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
@@ -232,15 +211,15 @@ export const PullRequestDiffPane = ({
232
211
  {incomingFile ? (
233
212
  <>
234
213
  <Divider width={paneWidth} />
235
- <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
214
+ <PaddedRow backgroundColor={colors.background}>
236
215
  <FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
237
- </box>
216
+ </PaddedRow>
238
217
  </>
239
218
  ) : (
240
219
  <>
241
- <box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
220
+ <PaddedRow backgroundColor={colors.background}>
242
221
  <FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
243
- </box>
222
+ </PaddedRow>
244
223
  <Divider width={paneWidth} />
245
224
  </>
246
225
  )}
@@ -249,12 +228,12 @@ export const PullRequestDiffPane = ({
249
228
  {commentPeek ? (
250
229
  <>
251
230
  <Divider width={paneWidth} />
252
- <box height={1} paddingLeft={1} paddingRight={1}>
231
+ <PaddedRow>
253
232
  <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
254
- </box>
255
- <box height={1} paddingLeft={1} paddingRight={1}>
233
+ </PaddedRow>
234
+ <PaddedRow>
256
235
  <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
257
- </box>
236
+ </PaddedRow>
258
237
  </>
259
238
  ) : null}
260
239
  </box>
@@ -3,16 +3,23 @@ import type { LoadStatus, PullRequestItem } from "../domain.js"
3
3
  import { daysOpen } from "../date.js"
4
4
  import { colors } from "./colors.js"
5
5
  import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
- import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
6
+ import { pullRequestRowDisplay, repoColor, reviewIcon } from "./pullRequests.js"
7
7
 
8
8
  export type PullRequestGroups = Array<[string, PullRequestItem[]]>
9
9
 
10
+ export type PullRequestListRow =
11
+ | { readonly _tag: "title" }
12
+ | { readonly _tag: "filter" }
13
+ | { readonly _tag: "message"; readonly text: string; readonly color: string }
14
+ | { readonly _tag: "group"; readonly repository: string; readonly pullRequests: readonly PullRequestItem[] }
15
+ | { readonly _tag: "pull-request"; readonly pullRequest: PullRequestItem; readonly groupPullRequests: readonly PullRequestItem[] }
16
+ | { readonly _tag: "load-more"; readonly text: string }
17
+
10
18
  const GROUP_ICON = "◆"
11
19
 
12
- const getRowLayout = (contentWidth: number, numberWidth = 6) => {
20
+ const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number) => {
13
21
  const reviewWidth = 1
14
22
  const checkWidth = 6
15
- const ageWidth = 4
16
23
  const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
17
24
  const titleWidth = Math.max(8, contentWidth - fixedWidth)
18
25
  return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
@@ -24,6 +31,12 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
24
31
  return maxLen + 1
25
32
  }
26
33
 
34
+ const groupAgeWidth = (pullRequests: readonly PullRequestItem[]) => {
35
+ if (pullRequests.length === 0) return 4
36
+ const maxLen = Math.max(...pullRequests.map((pr) => `${daysOpen(pr.createdAt)}d`.length))
37
+ return Math.max(4, maxLen + 1)
38
+ }
39
+
27
40
  const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
28
41
  const fitted = fitCell(text, width, align)
29
42
  const needle = query.trim().toLowerCase()
@@ -47,11 +60,53 @@ const GroupTitle = ({ label, color, filterText }: { label: string; color: string
47
60
  </TextLine>
48
61
  )
49
62
 
63
+ export const buildPullRequestListRows = ({
64
+ groups,
65
+ status,
66
+ error,
67
+ filterText,
68
+ showFilterBar,
69
+ loadedCount,
70
+ hasMore,
71
+ isLoadingMore,
72
+ }: {
73
+ readonly groups: PullRequestGroups
74
+ readonly status: LoadStatus
75
+ readonly error: string | null
76
+ readonly filterText: string
77
+ readonly showFilterBar: boolean
78
+ readonly loadedCount: number
79
+ readonly hasMore: boolean
80
+ readonly isLoadingMore: boolean
81
+ }): readonly PullRequestListRow[] => {
82
+ const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
83
+ const rows: PullRequestListRow[] = [{ _tag: "title" }]
84
+ if (showFilterBar) rows.push({ _tag: "filter" })
85
+ if (status === "loading" && itemCount === 0) rows.push({ _tag: "message", text: "- Loading pull requests...", color: colors.muted })
86
+ if (status === "error") rows.push({ _tag: "message", text: `- ${error ?? "Could not load pull requests."}`, color: colors.error })
87
+ if (status === "ready" && itemCount === 0) rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests.", color: colors.muted })
88
+ for (const [repository, pullRequests] of groups) {
89
+ rows.push({ _tag: "group", repository, pullRequests })
90
+ for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
91
+ }
92
+ if (status === "ready" && itemCount > 0 && (hasMore || isLoadingMore)) {
93
+ rows.push({ _tag: "load-more", text: isLoadingMore ? `- Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
94
+ }
95
+ return rows
96
+ }
97
+
98
+ export const pullRequestListRowIndex = (rows: readonly PullRequestListRow[], url: string | null) => {
99
+ if (!url) return null
100
+ const index = rows.findIndex((row) => row._tag === "pull-request" && row.pullRequest.url === url)
101
+ return index >= 0 ? index : null
102
+ }
103
+
50
104
  const PullRequestRow = ({
51
105
  pullRequest,
52
106
  selected,
53
107
  contentWidth,
54
108
  numWidth,
109
+ ageColWidth,
55
110
  filterText,
56
111
  onSelect,
57
112
  }: {
@@ -59,31 +114,25 @@ const PullRequestRow = ({
59
114
  selected: boolean
60
115
  contentWidth: number
61
116
  numWidth: number
117
+ ageColWidth: number
62
118
  filterText: string
63
119
  onSelect: () => void
64
120
  }) => {
65
- const isClosed = pullRequest.state === "closed"
66
- const isMerged = pullRequest.state === "merged"
67
- const isFinal = isClosed || isMerged
68
- const checkText = isMerged ? "merged" : isClosed ? "closed" : checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
69
121
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
70
- const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
122
+ const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth, ageColWidth)
71
123
  const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
72
124
  const fillerWidth = Math.max(0, contentWidth - rowWidth)
73
- const indicatorColor = isMerged ? colors.status.passing : isClosed ? colors.muted : pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
74
- const rowTextColor = selected ? colors.selectedText : isFinal ? colors.muted : colors.text
75
- const numberColor = selected ? colors.accent : isFinal ? colors.muted : colors.count
76
- const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
125
+ const display = pullRequestRowDisplay(pullRequest, selected)
77
126
 
78
127
  return (
79
128
  <box width={contentWidth} height={1} onMouseDown={onSelect}>
80
- <TextLine width={contentWidth} fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
81
- <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
129
+ <TextLine width={contentWidth} fg={display.rowFg} bg={selected ? colors.selectedBg : undefined}>
130
+ <span fg={display.indicatorFg}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
82
131
  <span> </span>
83
- <span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
132
+ <span fg={display.numberFg}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
84
133
  <span> </span>
85
134
  <span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
86
- <span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
135
+ <span fg={display.checkFg}>{fitCell(display.checkText, checkWidth, "right")}</span>
87
136
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
88
137
  {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
89
138
  </TextLine>
@@ -100,6 +149,9 @@ export const PullRequestList = ({
100
149
  filterText,
101
150
  showFilterBar,
102
151
  isFilterEditing,
152
+ loadedCount,
153
+ hasMore,
154
+ isLoadingMore,
103
155
  onSelectPullRequest,
104
156
  }: {
105
157
  groups: PullRequestGroups
@@ -110,41 +162,43 @@ export const PullRequestList = ({
110
162
  filterText: string
111
163
  showFilterBar: boolean
112
164
  isFilterEditing: boolean
165
+ loadedCount: number
166
+ hasMore: boolean
167
+ isLoadingMore: boolean
113
168
  onSelectPullRequest: (url: string) => void
114
169
  }) => {
115
- const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
116
- const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
170
+ const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore })
117
171
 
118
172
  return (
119
173
  <box width={contentWidth} flexDirection="column">
120
- <SectionTitle title="PULL REQUESTS" />
121
- {showFilterBar ? (
122
- <TextLine>
123
- <span fg={colors.count}>/</span>
124
- <span fg={colors.muted}> </span>
125
- <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
126
- </TextLine>
127
- ) : null}
128
- {status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
129
- {status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
130
- {status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
131
- {groups.map(([repo, pullRequests]) => {
132
- const numWidth = groupNumberWidth(pullRequests)
174
+ {rows.map((row, index) => {
175
+ if (row._tag === "title") return <SectionTitle key="title" title="PULL REQUESTS" />
176
+ if (row._tag === "filter") {
177
+ return (
178
+ <TextLine key="filter">
179
+ <span fg={colors.count}>/</span>
180
+ <span fg={colors.muted}> </span>
181
+ <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
182
+ </TextLine>
183
+ )
184
+ }
185
+ if (row._tag === "message") return <PlainLine key={`message-${index}`} text={row.text} fg={row.color} />
186
+ if (row._tag === "load-more") return <PlainLine key="load-more" text={row.text} fg={colors.muted} />
187
+ if (row._tag === "group") return <GroupTitle key={`group-${row.repository}`} label={row.repository} color={repoColor(row.repository)} filterText={filterText} />
188
+
189
+ const numWidth = groupNumberWidth(row.groupPullRequests)
190
+ const ageColWidth = groupAgeWidth(row.groupPullRequests)
133
191
  return (
134
- <box key={repo} flexDirection="column">
135
- <GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
136
- {pullRequests.map((pullRequest) => (
137
- <PullRequestRow
138
- key={pullRequest.url}
139
- pullRequest={pullRequest}
140
- selected={pullRequest.url === selectedUrl}
141
- contentWidth={contentWidth}
142
- numWidth={numWidth}
143
- filterText={filterText}
144
- onSelect={() => onSelectPullRequest(pullRequest.url)}
145
- />
146
- ))}
147
- </box>
192
+ <PullRequestRow
193
+ key={row.pullRequest.url}
194
+ pullRequest={row.pullRequest}
195
+ selected={row.pullRequest.url === selectedUrl}
196
+ contentWidth={contentWidth}
197
+ numWidth={numWidth}
198
+ ageColWidth={ageColWidth}
199
+ filterText={filterText}
200
+ onSelect={() => onSelectPullRequest(row.pullRequest.url)}
201
+ />
148
202
  )
149
203
  })}
150
204
  </box>