@kitlangton/ghui 0.1.8 → 0.1.10

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/index.tsx CHANGED
@@ -7,13 +7,21 @@ import { App } from "./App.js"
7
7
 
8
8
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
9
9
 
10
+ const FOCUS_REPORTING_ENABLE = "\x1b[?1004h"
11
+ const FOCUS_REPORTING_DISABLE = "\x1b[?1004l"
12
+
10
13
  const renderer = await createCliRenderer({
11
14
  exitOnCtrlC: false,
12
15
  screenMode: "alternate-screen",
13
16
  externalOutputMode: "passthrough",
14
- onDestroy: () => process.exit(0),
17
+ onDestroy: () => {
18
+ process.stdout.write(FOCUS_REPORTING_DISABLE)
19
+ process.exit(0)
20
+ },
15
21
  })
16
22
 
23
+ process.stdout.write(FOCUS_REPORTING_ENABLE)
24
+
17
25
  createRoot(renderer).render(
18
26
  <RegistryProvider>
19
27
  <App />
@@ -0,0 +1,90 @@
1
+ import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "./domain.js"
2
+
3
+ export interface MergeActionDefinition {
4
+ readonly action: PullRequestMergeAction
5
+ readonly title: string
6
+ readonly description: string
7
+ readonly cliArgs: readonly string[]
8
+ readonly pastTense: string
9
+ readonly danger?: boolean
10
+ readonly refreshOnSuccess?: boolean
11
+ readonly optimisticAutoMergeEnabled?: boolean
12
+ readonly isAvailable: (info: PullRequestMergeInfo) => boolean
13
+ }
14
+
15
+ const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
16
+ info.state === "open" &&
17
+ !info.isDraft &&
18
+ info.mergeable === "mergeable" &&
19
+ info.reviewStatus !== "changes" &&
20
+ info.reviewStatus !== "review" &&
21
+ info.checkStatus !== "pending" &&
22
+ info.checkStatus !== "failing"
23
+
24
+ const mergeActionDefinitions = {
25
+ squash: {
26
+ action: "squash",
27
+ title: "Squash merge now",
28
+ description: "Merge this pull request and delete the branch.",
29
+ cliArgs: ["--squash", "--delete-branch"],
30
+ pastTense: "Merged",
31
+ refreshOnSuccess: true,
32
+ isAvailable: isCleanlyMergeable,
33
+ },
34
+ auto: {
35
+ action: "auto",
36
+ title: "Enable auto-merge",
37
+ description: "Squash merge automatically after GitHub requirements pass.",
38
+ cliArgs: ["--squash", "--auto", "--delete-branch"],
39
+ pastTense: "Enabled auto-merge",
40
+ optimisticAutoMergeEnabled: true,
41
+ isAvailable: (info) => info.state === "open" && !info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting",
42
+ },
43
+ "disable-auto": {
44
+ action: "disable-auto",
45
+ title: "Disable auto-merge",
46
+ description: "Cancel the pending GitHub auto-merge request.",
47
+ cliArgs: ["--disable-auto"],
48
+ pastTense: "Disabled auto-merge",
49
+ optimisticAutoMergeEnabled: false,
50
+ isAvailable: (info) => info.state === "open" && info.autoMergeEnabled,
51
+ },
52
+ admin: {
53
+ action: "admin",
54
+ title: "Admin override merge",
55
+ description: "Bypass unmet merge requirements with --admin.",
56
+ cliArgs: ["--squash", "--admin", "--delete-branch"],
57
+ pastTense: "Admin merged",
58
+ danger: true,
59
+ refreshOnSuccess: true,
60
+ isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
61
+ },
62
+ } as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
63
+
64
+ export const mergeActions = [
65
+ mergeActionDefinitions.squash,
66
+ mergeActionDefinitions.auto,
67
+ mergeActionDefinitions["disable-auto"],
68
+ mergeActionDefinitions.admin,
69
+ ] as const satisfies readonly MergeActionDefinition[]
70
+
71
+ export const availableMergeActions = (info: PullRequestMergeInfo | null): readonly MergeActionDefinition[] => {
72
+ if (!info) return []
73
+ return mergeActions.filter((action) => action.isAvailable(info))
74
+ }
75
+
76
+ export const getMergeActionDefinition = (action: PullRequestMergeAction): MergeActionDefinition =>
77
+ mergeActionDefinitions[action]
78
+
79
+ export const mergeInfoFromPullRequest = (pullRequest: PullRequestItem): PullRequestMergeInfo => ({
80
+ repository: pullRequest.repository,
81
+ number: pullRequest.number,
82
+ title: pullRequest.title,
83
+ state: pullRequest.state,
84
+ isDraft: pullRequest.reviewStatus === "draft",
85
+ mergeable: "unknown",
86
+ reviewStatus: pullRequest.reviewStatus,
87
+ checkStatus: pullRequest.checkStatus,
88
+ checkSummary: pullRequest.checkSummary,
89
+ autoMergeEnabled: pullRequest.autoMergeEnabled,
90
+ })
@@ -1,6 +1,7 @@
1
1
  import { Context, Effect, Layer } from "effect"
2
2
  import { config } from "../config.js"
3
3
  import type { CheckItem, PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
4
+ import { getMergeActionDefinition } from "../mergeActions.js"
4
5
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
6
 
6
7
  interface GitHubPullRequestSummaryNode {
@@ -395,15 +396,7 @@ export class GitHubService extends Context.Service<GitHubService, {
395
396
 
396
397
  const mergePullRequest = Effect.fn("GitHubService.mergePullRequest")(function*(repository: string, number: number, action: PullRequestMergeAction) {
397
398
  const base = ["pr", "merge", String(number), "--repo", repository] as const
398
- if (action === "disable-auto") {
399
- yield* command.run("gh", [...base, "--disable-auto"])
400
- } else if (action === "auto") {
401
- yield* command.run("gh", [...base, "--squash", "--auto", "--delete-branch"])
402
- } else if (action === "admin") {
403
- yield* command.run("gh", [...base, "--squash", "--admin", "--delete-branch"])
404
- } else {
405
- yield* command.run("gh", [...base, "--squash", "--delete-branch"])
406
- }
399
+ yield* command.run("gh", [...base, ...getMergeActionDefinition(action).cliArgs])
407
400
  })
408
401
 
409
402
  const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
@@ -0,0 +1,519 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { Fragment, useMemo } from "react"
3
+ import { formatRelativeDate } from "../date.js"
4
+ import type { CheckItem, PullRequestItem } from "../domain.js"
5
+ import { colors } from "./colors.js"
6
+ import { diffStatText } from "./diff.js"
7
+ import { centerCell, Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
+ import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
9
+
10
+ interface PreviewLine {
11
+ readonly segments: ReadonlyArray<{
12
+ readonly text: string
13
+ readonly fg: string
14
+ readonly bold?: boolean
15
+ }>
16
+ }
17
+
18
+ export interface DetailPlaceholderContent {
19
+ readonly title: string
20
+ readonly hint: string
21
+ }
22
+
23
+ export const DETAIL_BODY_LINES = 6
24
+ export const DETAIL_PLACEHOLDER_ROWS = 4
25
+
26
+ const pullRequestReferencePattern = /(#[0-9]+)/g
27
+
28
+ export const wrapText = (text: string, width: number): string[] => {
29
+ if (text.length === 0 || width <= 0) return [""]
30
+ const words = text.split(/\s+/)
31
+ const lines: string[] = []
32
+ let current = ""
33
+ for (const word of words) {
34
+ const next = current.length > 0 ? `${current} ${word}` : word
35
+ if (next.length > width && current.length > 0) {
36
+ lines.push(current)
37
+ current = word
38
+ } else {
39
+ current = next
40
+ }
41
+ }
42
+ if (current.length > 0) lines.push(current)
43
+ return lines.length > 0 ? lines : [""]
44
+ }
45
+
46
+ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
47
+ const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
48
+ return parts.flatMap((part) => {
49
+ if (part.startsWith("`") && part.endsWith("`")) {
50
+ return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
51
+ }
52
+
53
+ return part
54
+ .split(pullRequestReferencePattern)
55
+ .filter((segment) => segment.length > 0)
56
+ .map((segment) => ({
57
+ text: segment,
58
+ fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
59
+ bold,
60
+ }))
61
+ })
62
+ }
63
+
64
+ const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
65
+ const tokens = segments.flatMap((segment) =>
66
+ segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
67
+ )
68
+
69
+ const lines: Array<PreviewLine> = []
70
+ let current: Array<PreviewLine["segments"][number]> = []
71
+ let currentLength = 0
72
+
73
+ const pushLine = () => {
74
+ lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
75
+ current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
76
+ currentLength = indent.length
77
+ }
78
+
79
+ for (const token of tokens) {
80
+ const tokenLength = token.text.length
81
+ if (currentLength > 0 && currentLength + tokenLength > width) {
82
+ pushLine()
83
+ }
84
+ current.push(token)
85
+ currentLength += tokenLength
86
+ }
87
+
88
+ if (current.length > 0) {
89
+ lines.push({ segments: current })
90
+ }
91
+
92
+ return lines
93
+ }
94
+
95
+ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
96
+ const sourceLines = body.replace(/\r/g, "").split("\n")
97
+ const preview: Array<PreviewLine> = []
98
+ let inCodeBlock = false
99
+
100
+ for (const rawLine of sourceLines) {
101
+ if (preview.length >= limit) break
102
+
103
+ const line = rawLine.trim()
104
+ if (line.startsWith("```")) {
105
+ inCodeBlock = !inCodeBlock
106
+ continue
107
+ }
108
+ if (line.length === 0) continue
109
+
110
+ let text = line
111
+ let fg: string = colors.text
112
+ let bold = false
113
+ let indent = ""
114
+
115
+ if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
116
+ if (preview.length > 0) {
117
+ preview.push({ segments: [{ text: "", fg: colors.muted }] })
118
+ if (preview.length >= limit) break
119
+ }
120
+ text = line.replace(/^#{1,6}\s+/, "")
121
+ fg = colors.count
122
+ bold = true
123
+ } else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
124
+ const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
125
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
126
+ fg = checked ? colors.status.passing : colors.text
127
+ indent = " "
128
+ } else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
129
+ const checked = /^\[(x|X)\]\s+/.test(line)
130
+ text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
131
+ fg = checked ? colors.status.passing : colors.text
132
+ indent = " "
133
+ } else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
134
+ text = `• ${line.replace(/^[-*+]\s+/, "")}`
135
+ indent = " "
136
+ } else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
137
+ text = line
138
+ indent = " "
139
+ } else if (!inCodeBlock && /^>\s+/.test(line)) {
140
+ text = `> ${line.replace(/^>\s+/, "")}`
141
+ fg = colors.muted
142
+ indent = " "
143
+ } else if (inCodeBlock) {
144
+ fg = colors.muted
145
+ }
146
+
147
+ const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
148
+ for (const wrappedLine of wrapped) {
149
+ preview.push(wrappedLine)
150
+ if (preview.length >= limit) break
151
+ }
152
+ }
153
+
154
+ if (preview.length === 0) {
155
+ return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
156
+ }
157
+
158
+ return preview.slice(0, limit)
159
+ }
160
+
161
+ const BlankRow = () => <box height={1} />
162
+
163
+ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
164
+ if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
165
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
166
+ type Part = { key: string; text: string; color: string }
167
+ const rawParts: Array<Part | null> = [
168
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
169
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
170
+ { key: "files", text: files, color: colors.muted },
171
+ ]
172
+ const parts = rawParts.filter((part): part is Part => part !== null)
173
+
174
+ return (
175
+ <>
176
+ {parts.map((part, index) => (
177
+ <Fragment key={part.key}>
178
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
179
+ <span fg={part.color}>{part.text}</span>
180
+ </Fragment>
181
+ ))}
182
+ </>
183
+ )
184
+ }
185
+
186
+ const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
187
+ const seen = new Map<string, CheckItem>()
188
+ for (const check of checks) {
189
+ const existing = seen.get(check.name)
190
+ if (!existing || (check.status === "completed" && existing.status !== "completed")) {
191
+ seen.set(check.name, check)
192
+ }
193
+ }
194
+ return [...seen.values()]
195
+ }
196
+
197
+ const checkIcon = (check: CheckItem) => {
198
+ if (check.status === "completed") {
199
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
200
+ if (check.conclusion === "failure") return "✗"
201
+ return "·"
202
+ }
203
+ if (check.status === "in_progress") return "●"
204
+ return "○"
205
+ }
206
+
207
+ const checkColor = (check: CheckItem) => {
208
+ if (check.status === "completed") {
209
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
210
+ if (check.conclusion === "failure") return colors.status.failing
211
+ return colors.muted
212
+ }
213
+ if (check.status === "in_progress") return colors.status.pending
214
+ return colors.muted
215
+ }
216
+
217
+ const checksRowCount = (checks: readonly CheckItem[]) => {
218
+ const unique = deduplicateChecks(checks)
219
+ return Math.ceil(unique.length / 2)
220
+ }
221
+
222
+ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
223
+ const unique = deduplicateChecks(checks)
224
+ if (unique.length === 0) return null
225
+
226
+ const colWidth = Math.floor((contentWidth - 1) / 2)
227
+ const nameCol = Math.max(4, colWidth - 2)
228
+ const rows = Math.ceil(unique.length / 2)
229
+
230
+ return (
231
+ <box flexDirection="column">
232
+ <TextLine>
233
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
234
+ </TextLine>
235
+ {Array.from({ length: rows }, (_, rowIndex) => {
236
+ const left = unique[rowIndex * 2]
237
+ const right = unique[rowIndex * 2 + 1]
238
+ return (
239
+ <TextLine key={rowIndex}>
240
+ {left ? (
241
+ <>
242
+ <span fg={checkColor(left)}>{checkIcon(left)} </span>
243
+ <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
244
+ </>
245
+ ) : null}
246
+ {right ? (
247
+ <>
248
+ <span fg={colors.muted}> </span>
249
+ <span fg={checkColor(right)}>{checkIcon(right)} </span>
250
+ <span fg={colors.text}>{right.name}</span>
251
+ </>
252
+ ) : null}
253
+ </TextLine>
254
+ )
255
+ })}
256
+ </box>
257
+ )
258
+ }
259
+
260
+ export const getDetailJunctionRows = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false): readonly number[] => {
261
+ if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
262
+ const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
263
+ const detailDividerRow = 1 + titleLines + 1
264
+ const checks = deduplicateChecks(pullRequest.checks)
265
+ const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
266
+ return showChecks && checks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
267
+ }
268
+
269
+ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
270
+ if (!pullRequest) return DETAIL_PLACEHOLDER_ROWS + 1
271
+ const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
272
+ const checks = deduplicateChecks(pullRequest.checks)
273
+ const checksHeight = showChecks && checks.length > 0 ? checksRowCount(checks) + 2 : 0
274
+ return titleLines + 3 + checksHeight
275
+ }
276
+
277
+ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
278
+ if (!pullRequest) return bodyLines
279
+ if (!pullRequest.detailLoaded) return bodyLines
280
+ return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
281
+ }
282
+
283
+ export const getDetailsPaneHeight = ({
284
+ pullRequest,
285
+ contentWidth,
286
+ bodyLines = DETAIL_BODY_LINES,
287
+ paneWidth = contentWidth + 2,
288
+ showChecks = false,
289
+ }: {
290
+ pullRequest: PullRequestItem | null
291
+ contentWidth: number
292
+ bodyLines?: number
293
+ paneWidth?: number
294
+ showChecks?: boolean
295
+ }) => pullRequest
296
+ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
297
+ : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
298
+
299
+ export const DetailHeader = ({
300
+ pullRequest,
301
+ contentWidth,
302
+ paneWidth,
303
+ showChecks = false,
304
+ }: {
305
+ pullRequest: PullRequestItem
306
+ contentWidth: number
307
+ paneWidth: number
308
+ showChecks?: boolean
309
+ }) => {
310
+ const labels = pullRequest.labels
311
+ const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
312
+ const unique = deduplicateChecks(pullRequest.checks)
313
+ const checkRows = checksRowCount(unique)
314
+ const statsText = diffStatText(pullRequest)
315
+ const labelsWidth = !pullRequest.detailLoaded
316
+ ? "loading details...".length
317
+ : labels.length > 0
318
+ ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
319
+ : "no labels".length
320
+ const showStats = contentWidth - labelsWidth - statsText.length >= 2
321
+ const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
322
+
323
+ return (
324
+ <>
325
+ <box height={1} paddingLeft={1} paddingRight={1}>
326
+ {(() => {
327
+ const opened = formatRelativeDate(pullRequest.createdAt)
328
+ const repo = shortRepoName(pullRequest.repository)
329
+ const number = String(pullRequest.number)
330
+ const review = reviewLabel(pullRequest)
331
+ const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
332
+ const statusParts = [review, checks].filter((part): part is string => Boolean(part))
333
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
334
+ const leftWidth = 1 + number.length + 1 + repo.length
335
+ const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
336
+
337
+ return (
338
+ <TextLine>
339
+ <span fg={colors.count}>#{number}</span>
340
+ <span fg={colors.muted}> {repo}</span>
341
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
342
+ {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
343
+ {review && checks ? <span fg={colors.muted}> </span> : null}
344
+ {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
345
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
346
+ <span fg={colors.muted}>{opened}</span>
347
+ </TextLine>
348
+ )
349
+ })()}
350
+ </box>
351
+ <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
352
+ {wrappedTitle.map((line, index) => (
353
+ <PlainLine key={index} text={line} bold />
354
+ ))}
355
+ </box>
356
+ <box height={1} paddingLeft={1} paddingRight={1}>
357
+ <TextLine>
358
+ {!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
359
+ <Fragment key={label.name}>
360
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
361
+ <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
362
+ </Fragment>
363
+ )) : <span fg={colors.muted}>no labels</span>}
364
+ {showStats ? (
365
+ <>
366
+ <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
367
+ <DiffStats pullRequest={pullRequest} />
368
+ </>
369
+ ) : null}
370
+ </TextLine>
371
+ </box>
372
+ <box height={1}><Divider width={paneWidth} /></box>
373
+ {showChecks && unique.length > 0 ? (
374
+ <>
375
+ <box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
376
+ <ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
377
+ </box>
378
+ <box height={1}><Divider width={paneWidth} /></box>
379
+ </>
380
+ ) : null}
381
+ </>
382
+ )
383
+ }
384
+
385
+ export const DetailBody = ({
386
+ pullRequest,
387
+ contentWidth,
388
+ bodyLines = DETAIL_BODY_LINES,
389
+ loadingIndicator,
390
+ }: {
391
+ pullRequest: PullRequestItem
392
+ contentWidth: number
393
+ bodyLines?: number
394
+ loadingIndicator: string
395
+ }) => {
396
+ const previewLines = useMemo(
397
+ () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
398
+ [pullRequest.body, contentWidth, bodyLines],
399
+ )
400
+
401
+ if (!pullRequest.detailLoaded) {
402
+ const topRows = Math.max(0, Math.floor((bodyLines - 1) / 2))
403
+ const bottomRows = Math.max(0, bodyLines - topRows - 1)
404
+ return (
405
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
406
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
407
+ <PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
408
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
409
+ </box>
410
+ )
411
+ }
412
+
413
+ return (
414
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
415
+ {previewLines.map((line, index) => (
416
+ <TextLine key={`${pullRequest.url}-${index}`}>
417
+ {line.segments.map((segment, segmentIndex) => (
418
+ ("bold" in segment && segment.bold === true) ? (
419
+ <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
420
+ {segment.text}
421
+ </span>
422
+ ) : (
423
+ <span key={segmentIndex} fg={segment.fg}>
424
+ {segment.text}
425
+ </span>
426
+ )
427
+ ))}
428
+ </TextLine>
429
+ ))}
430
+ </box>
431
+ )
432
+ }
433
+
434
+ export const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
435
+ const innerWidth = Math.max(1, width - 2)
436
+ const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
437
+ const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
438
+ const cardInnerWidth = Math.max(1, cardWidth - 2)
439
+ const contentLine = (text: string, fg: string, bold = false) => (
440
+ <TextLine>
441
+ <span fg={colors.separator}>{offset}│</span>
442
+ {bold ? (
443
+ <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
444
+ ) : (
445
+ <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
446
+ )}
447
+ <span fg={colors.separator}>│</span>
448
+ </TextLine>
449
+ )
450
+
451
+ return (
452
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
453
+ <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
454
+ {contentLine(content.title, colors.count, true)}
455
+ {contentLine(content.hint, colors.muted)}
456
+ <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
457
+ </box>
458
+ )
459
+ }
460
+
461
+ export const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
462
+ <box flexDirection="column">
463
+ <StatusCard content={content} width={paneWidth} />
464
+ <box height={1}><Divider width={paneWidth} /></box>
465
+ </box>
466
+ )
467
+
468
+ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
469
+ const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
470
+ const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
471
+
472
+ return (
473
+ <box height={height} flexDirection="column">
474
+ {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
475
+ <StatusCard content={content} width={width} />
476
+ {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
477
+ </box>
478
+ )
479
+ }
480
+
481
+ export const DetailsPane = ({
482
+ pullRequest,
483
+ contentWidth,
484
+ bodyLines = DETAIL_BODY_LINES,
485
+ paneWidth = contentWidth + 2,
486
+ showChecks = false,
487
+ placeholderContent,
488
+ loadingIndicator,
489
+ }: {
490
+ pullRequest: PullRequestItem | null
491
+ contentWidth: number
492
+ bodyLines?: number
493
+ paneWidth?: number
494
+ showChecks?: boolean
495
+ placeholderContent: DetailPlaceholderContent
496
+ loadingIndicator: string
497
+ }) => {
498
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines, paneWidth, showChecks })
499
+
500
+ return (
501
+ <box flexDirection="column" height={contentHeight}>
502
+ {pullRequest ? (
503
+ <>
504
+ <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
505
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
506
+ </>
507
+ ) : (
508
+ <>
509
+ <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
510
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
511
+ {Array.from({ length: bodyLines }, (_, index) => (
512
+ <BlankRow key={index} />
513
+ ))}
514
+ </box>
515
+ </>
516
+ )}
517
+ </box>
518
+ )
519
+ }