@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.
@@ -0,0 +1,146 @@
1
+ import { Effect, Layer } from "effect"
2
+ import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
3
+ import { GitHubService } from "./GitHubService.js"
4
+
5
+ export interface MockOptions {
6
+ readonly prCount: number
7
+ readonly repoCount?: number
8
+ readonly username?: string
9
+ readonly seed?: number
10
+ }
11
+
12
+ const REVIEW_CYCLE: readonly ReviewStatus[] = ["approved", "changes", "review", "none", "draft"]
13
+ const MERGEABLE_CYCLE: readonly Mergeable[] = ["mergeable", "conflicting", "unknown"]
14
+
15
+ const synthCheckSummary = (passed: number, total: number): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
16
+ const checks: readonly CheckItem[] = Array.from({ length: total }, (_, index) => ({
17
+ name: `check-${index}`,
18
+ status: "completed",
19
+ conclusion: index < passed ? "success" : "failure",
20
+ }))
21
+ if (total === 0) return { checkStatus: "none", checkSummary: null, checks: [] }
22
+ if (passed === total) return { checkStatus: "passing", checkSummary: `${passed}/${total}`, checks }
23
+ return { checkStatus: "failing", checkSummary: `${passed}/${total}`, checks }
24
+ }
25
+
26
+ const synthLabels = (index: number): readonly PullRequestLabel[] => {
27
+ if (index % 5 === 0) return [{ name: "bug", color: "#d73a4a" }]
28
+ if (index % 7 === 0) return [{ name: "enhancement", color: "#a2eeef" }, { name: "tests", color: "#0e8a16" }]
29
+ return []
30
+ }
31
+
32
+ const buildPullRequest = (index: number, options: Required<MockOptions>): PullRequestItem => {
33
+ const repoIndex = index % options.repoCount
34
+ const repository = `mock-org/repo-${repoIndex}`
35
+ const number = 1000 + index
36
+ const total = 8 + (index % 5)
37
+ const passed = total - (index % 3 === 0 ? 1 : 0)
38
+ const review = REVIEW_CYCLE[index % REVIEW_CYCLE.length]!
39
+ const createdAt = new Date(Date.now() - index * 86_400_000)
40
+
41
+ return {
42
+ repository,
43
+ author: options.username,
44
+ headRefOid: `deadbeef${index.toString(16).padStart(8, "0")}`,
45
+ number,
46
+ title: `Mock PR ${number}: example change ${index}`,
47
+ body: `This is mock pull request #${number}.\n\nLine A.\nLine B.`,
48
+ labels: synthLabels(index),
49
+ additions: 10 + index,
50
+ deletions: 5 + (index % 11),
51
+ changedFiles: 1 + (index % 7),
52
+ state: "open",
53
+ reviewStatus: review,
54
+ ...synthCheckSummary(passed, total),
55
+ autoMergeEnabled: index % 11 === 0,
56
+ detailLoaded: true,
57
+ createdAt,
58
+ closedAt: null,
59
+ url: `https://github.com/${repository}/pull/${number}`,
60
+ }
61
+ }
62
+
63
+ export const buildMockPullRequests = (options: MockOptions): readonly PullRequestItem[] => {
64
+ const resolved: Required<MockOptions> = {
65
+ prCount: options.prCount,
66
+ repoCount: options.repoCount ?? 4,
67
+ username: options.username ?? "mock-user",
68
+ seed: options.seed ?? 0,
69
+ }
70
+ return Array.from({ length: resolved.prCount }, (_, index) => buildPullRequest(index, resolved))
71
+ }
72
+
73
+ const filterByView = (mode: PullRequestQueueMode, repository: string | null, source: readonly PullRequestItem[]) => {
74
+ if (mode === "repository") return repository ? source.filter((item) => item.repository === repository) : []
75
+ return source
76
+ }
77
+
78
+ const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pageSize: number): PullRequestPage => {
79
+ const start = cursor ? Number.parseInt(cursor, 10) : 0
80
+ const safeStart = Number.isFinite(start) && start >= 0 ? start : 0
81
+ const safePageSize = Math.max(1, Math.min(100, pageSize))
82
+ const end = Math.min(source.length, safeStart + safePageSize)
83
+ return {
84
+ items: source.slice(safeStart, end),
85
+ endCursor: end > safeStart ? String(end) : null,
86
+ hasNextPage: end < source.length,
87
+ }
88
+ }
89
+
90
+ export const MockGitHubService = {
91
+ layer: (options: MockOptions) => {
92
+ const items = buildMockPullRequests(options)
93
+ const username = options.username ?? "mock-user"
94
+ const summaryItems = items.map((item) => ({
95
+ ...item,
96
+ body: "",
97
+ labels: [],
98
+ additions: 0,
99
+ deletions: 0,
100
+ changedFiles: 0,
101
+ detailLoaded: false,
102
+ } satisfies PullRequestItem))
103
+ const findPullRequest = (repository: string, number: number) => items.find((item) => item.repository === repository && item.number === number) ?? items[0]!
104
+
105
+ return Layer.succeed(
106
+ GitHubService,
107
+ GitHubService.of({
108
+ listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, summaryItems)),
109
+ listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, summaryItems), input.cursor, input.pageSize)),
110
+ listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
111
+ getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
112
+ getAuthenticatedUser: () => Effect.succeed(username),
113
+ getPullRequestDiff: (_repo, _number) => Effect.succeed(""),
114
+ listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
115
+ getPullRequestMergeInfo: (repository, number) => Effect.succeed({
116
+ repository,
117
+ number,
118
+ title: `Mock PR ${number}`,
119
+ state: "open",
120
+ isDraft: false,
121
+ mergeable: MERGEABLE_CYCLE[number % MERGEABLE_CYCLE.length]!,
122
+ reviewStatus: "approved",
123
+ checkStatus: "passing",
124
+ checkSummary: "10/10",
125
+ autoMergeEnabled: false,
126
+ } satisfies PullRequestMergeInfo),
127
+ mergePullRequest: () => Effect.void,
128
+ closePullRequest: () => Effect.void,
129
+ createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.succeed({
130
+ id: `mock:${Date.now()}`,
131
+ path: input.path,
132
+ line: input.line,
133
+ side: input.side,
134
+ author: username,
135
+ body: input.body,
136
+ createdAt: new Date(),
137
+ url: null,
138
+ } satisfies PullRequestReviewComment),
139
+ toggleDraftStatus: () => Effect.void,
140
+ listRepoLabels: () => Effect.succeed([]),
141
+ addPullRequestLabel: () => Effect.void,
142
+ removePullRequestLabel: () => Effect.void,
143
+ }),
144
+ )
145
+ },
146
+ }
@@ -0,0 +1,156 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { useEffect, useMemo, useState } from "react"
3
+ import type { AppCommand } from "../commands.js"
4
+ import { clampCommandIndex } from "../commands.js"
5
+ import { colors } from "./colors.js"
6
+ import { scrollTopForVisibleLine } from "./diff.js"
7
+ import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine, trimCell } from "./primitives.js"
8
+
9
+ const scopeLabels = {
10
+ Global: "App",
11
+ View: "View",
12
+ "Pull request": "Pull Request",
13
+ Diff: "Diff",
14
+ Navigation: "Navigation",
15
+ System: "System",
16
+ } as const satisfies Record<AppCommand["scope"], string>
17
+
18
+ export type CommandPaletteRow =
19
+ | { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
20
+ | { readonly _tag: "spacer" }
21
+ | { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
22
+
23
+ export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
24
+ const rows: CommandPaletteRow[] = []
25
+ let previousScope: AppCommand["scope"] | null = null
26
+ for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
27
+ const command = commands[commandIndex]!
28
+ if (command.scope !== previousScope) {
29
+ if (previousScope !== null) rows.push({ _tag: "spacer" })
30
+ rows.push({ _tag: "section", scope: command.scope })
31
+ previousScope = command.scope
32
+ }
33
+ rows.push({ _tag: "command", command, commandIndex })
34
+ }
35
+ return rows
36
+ }
37
+
38
+ export const commandPaletteSelectedRowIndex = (rows: readonly CommandPaletteRow[], selectedCommandIndex: number) =>
39
+ Math.max(0, rows.findIndex((row) => row._tag === "command" && row.commandIndex === selectedCommandIndex))
40
+
41
+ export const commandPaletteScrollTop = ({
42
+ current,
43
+ rowsLength,
44
+ listHeight,
45
+ selectedRowIndex,
46
+ }: {
47
+ readonly current: number
48
+ readonly rowsLength: number
49
+ readonly listHeight: number
50
+ readonly selectedRowIndex: number
51
+ }) => {
52
+ if (rowsLength <= listHeight) return 0
53
+ const maxScrollTop = Math.max(0, rowsLength - listHeight)
54
+ const clampScrollTop = (value: number) => Math.max(0, Math.min(value, maxScrollTop))
55
+ return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
56
+ }
57
+
58
+ export const CommandPalette = ({
59
+ commands,
60
+ query,
61
+ selectedIndex,
62
+ modalWidth,
63
+ modalHeight,
64
+ offsetLeft,
65
+ offsetTop,
66
+ }: {
67
+ commands: readonly AppCommand[]
68
+ query: string
69
+ selectedIndex: number
70
+ modalWidth: number
71
+ modalHeight: number
72
+ offsetLeft: number
73
+ offsetTop: number
74
+ }) => {
75
+ const { contentWidth, bodyHeight: listHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
76
+ const clampedIndex = clampCommandIndex(selectedIndex, commands)
77
+ const [scrollTop, setScrollTop] = useState(0)
78
+ const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
79
+ const selectedRowIndex = commandPaletteSelectedRowIndex(rows, clampedIndex)
80
+ const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
81
+ const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
82
+ const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
83
+ const queryText = query.length > 0 ? query : "type a command, state, or shortcut"
84
+ const queryWidth = Math.max(1, contentWidth - 2)
85
+ const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
86
+ const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
87
+ useEffect(() => {
88
+ setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
89
+ }, [listHeight, rows.length, selectedRowIndex])
90
+
91
+ return (
92
+ <StandardModal
93
+ left={offsetLeft}
94
+ top={offsetTop}
95
+ width={modalWidth}
96
+ height={modalHeight}
97
+ title="Command Palette"
98
+ headerRight={{ text: countText }}
99
+ subtitle={
100
+ <TextLine>
101
+ <span fg={colors.count}>› </span>
102
+ <span fg={query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
103
+ </TextLine>
104
+ }
105
+ footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />}
106
+ >
107
+ {rows.length === 0 ? (
108
+ <>
109
+ <Filler rows={emptyTopRows} prefix="top" />
110
+ <PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
111
+ <Filler rows={emptyBottomRows} prefix="bottom" />
112
+ </>
113
+ ) : (
114
+ <>
115
+ {visibleRows.map((row, index) => {
116
+ const rowIndex = scrollTop + index
117
+ if (row._tag === "spacer") {
118
+ return <PlainLine key={`spacer-${rowIndex}`} text="" />
119
+ }
120
+ if (row._tag === "section") {
121
+ return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
122
+ }
123
+
124
+ const { command, commandIndex } = row
125
+ const isSelected = commandIndex === clampedIndex
126
+ const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
127
+ const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
128
+ const trailingPadding = shortcut.length === 0 ? 0 : 1
129
+ // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
130
+ const SELECTOR_WIDTH = 2
131
+ const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
132
+ const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
133
+ const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
134
+ const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
135
+ const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
136
+
137
+ return (
138
+ <box key={command.id} height={1}>
139
+ <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
140
+ <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
141
+ <span> </span>
142
+ {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
143
+ {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
144
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
145
+ {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
146
+ {trailingPadding > 0 ? <span> </span> : null}
147
+ </TextLine>
148
+ </box>
149
+ )
150
+ })}
151
+ <Filler rows={bottomPaddingRows} prefix="pad" />
152
+ </>
153
+ )}
154
+ </StandardModal>
155
+ )
156
+ }
@@ -4,7 +4,8 @@ import { formatRelativeDate } from "../date.js"
4
4
  import type { CheckItem, PullRequestItem } from "../domain.js"
5
5
  import { colors, type ThemeId } from "./colors.js"
6
6
  import { diffStatText } from "./diff.js"
7
- import { centerCell, Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
7
+ import { DiffStats } from "./diffStats.js"
8
+ import { centerCell, Divider, Filler, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
8
9
  import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
9
10
 
10
11
  interface PreviewLine {
@@ -22,6 +23,7 @@ export interface DetailPlaceholderContent {
22
23
 
23
24
  export const DETAIL_BODY_LINES = 6
24
25
  export const DETAIL_PLACEHOLDER_ROWS = 4
26
+ export const DETAIL_BODY_SCROLL_LIMIT = 1_000
25
27
 
26
28
  const pullRequestReferencePattern = /(#[0-9]+)/g
27
29
 
@@ -158,31 +160,6 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
158
160
  return preview.slice(0, limit)
159
161
  }
160
162
 
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
163
  const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
187
164
  const seen = new Map<string, CheckItem>()
188
165
  for (const check of checks) {
@@ -194,26 +171,30 @@ const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
194
171
  return [...seen.values()]
195
172
  }
196
173
 
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 ""
174
+ type CheckKind = "passing" | "failing" | "in-progress" | "queued" | "missing"
175
+
176
+ const CHECK_DISPLAY: Record<CheckKind, { icon: string; color: string }> = {
177
+ passing: { icon: "", color: colors.status.passing },
178
+ failing: { icon: "", color: colors.status.failing },
179
+ "in-progress": { icon: "●", color: colors.status.pending },
180
+ queued: { icon: "", color: colors.muted },
181
+ missing: { icon: "·", color: colors.muted },
205
182
  }
206
183
 
207
- const checkColor = (check: CheckItem) => {
184
+ const checkKind = (check: CheckItem): CheckKind => {
208
185
  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
186
+ if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "passing"
187
+ if (check.conclusion === "failure") return "failing"
188
+ return "missing"
212
189
  }
213
- if (check.status === "in_progress") return colors.status.pending
214
- return colors.muted
190
+ if (check.status === "in_progress") return "in-progress"
191
+ return "queued"
215
192
  }
216
193
 
194
+ const checkIcon = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].icon
195
+
196
+ const checkColor = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].color
197
+
217
198
  const checksRowCount = (checks: readonly CheckItem[]) => {
218
199
  const unique = deduplicateChecks(checks)
219
200
  return Math.ceil(unique.length / 2)
@@ -280,6 +261,10 @@ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, content
280
261
  return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
281
262
  }
282
263
 
264
+ export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
265
+ return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
266
+ }
267
+
283
268
  export const getDetailsPaneHeight = ({
284
269
  pullRequest,
285
270
  contentWidth,
@@ -316,11 +301,10 @@ export const DetailHeader = ({
316
301
  const statsText = diffStatText(pullRequest)
317
302
  const labelsWidth = !pullRequest.detailLoaded
318
303
  ? "loading details...".length
319
- : labels.length > 0
320
- ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
321
- : "no labels".length
322
- const showStats = contentWidth - labelsWidth - statsText.length >= 2
323
- const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
304
+ : labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
305
+ const hasLabelContent = labelsWidth > 0
306
+ const showStats = contentWidth - labelsWidth - statsText.length >= (hasLabelContent ? 2 : 0)
307
+ const statsGap = Math.max(hasLabelContent ? 2 : 0, contentWidth - labelsWidth - statsText.length)
324
308
  const opened = formatRelativeDate(pullRequest.createdAt)
325
309
  const repo = shortRepoName(pullRequest.repository)
326
310
  const author = viewerUsername && pullRequest.author !== viewerUsername ? ` by ${pullRequest.author}` : ""
@@ -334,7 +318,7 @@ export const DetailHeader = ({
334
318
 
335
319
  return (
336
320
  <>
337
- <box height={1} paddingLeft={1} paddingRight={1}>
321
+ <PaddedRow>
338
322
  <TextLine>
339
323
  <span fg={colors.count}>#{number}</span>
340
324
  <span fg={colors.muted}> {repo}</span>
@@ -346,28 +330,28 @@ export const DetailHeader = ({
346
330
  {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
347
331
  <span fg={colors.muted}>{opened}</span>
348
332
  </TextLine>
349
- </box>
333
+ </PaddedRow>
350
334
  <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
351
335
  {wrappedTitle.map((line, index) => (
352
336
  <PlainLine key={index} text={line} bold />
353
337
  ))}
354
338
  </box>
355
- <box height={1} paddingLeft={1} paddingRight={1}>
339
+ <PaddedRow>
356
340
  <TextLine>
357
341
  {!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
358
342
  <Fragment key={label.name}>
359
343
  {index > 0 ? <span fg={colors.muted}> </span> : null}
360
344
  <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
361
345
  </Fragment>
362
- )) : <span fg={colors.muted}>no labels</span>}
346
+ )) : null}
363
347
  {showStats ? (
364
348
  <>
365
- <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
349
+ {statsGap > 0 ? <span fg={colors.muted}>{" ".repeat(statsGap)}</span> : null}
366
350
  <DiffStats pullRequest={pullRequest} />
367
351
  </>
368
352
  ) : null}
369
353
  </TextLine>
370
- </box>
354
+ </PaddedRow>
371
355
  <box height={1}><Divider width={paneWidth} /></box>
372
356
  {showChecks && unique.length > 0 ? (
373
357
  <>
@@ -385,18 +369,20 @@ export const DetailBody = ({
385
369
  pullRequest,
386
370
  contentWidth,
387
371
  bodyLines = DETAIL_BODY_LINES,
372
+ bodyLineLimit = bodyLines,
388
373
  loadingIndicator,
389
374
  themeId,
390
375
  }: {
391
376
  pullRequest: PullRequestItem
392
377
  contentWidth: number
393
378
  bodyLines?: number
379
+ bodyLineLimit?: number
394
380
  loadingIndicator: string
395
381
  themeId: ThemeId
396
382
  }) => {
397
383
  const previewLines = useMemo(
398
- () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
399
- [pullRequest.body, contentWidth, bodyLines, themeId],
384
+ () => bodyPreview(pullRequest.body, contentWidth, bodyLineLimit),
385
+ [pullRequest.body, contentWidth, bodyLineLimit, themeId],
400
386
  )
401
387
 
402
388
  if (!pullRequest.detailLoaded) {
@@ -404,15 +390,15 @@ export const DetailBody = ({
404
390
  const bottomRows = Math.max(0, bodyLines - topRows - 1)
405
391
  return (
406
392
  <box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
407
- {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
393
+ <Filler rows={topRows} prefix="top" />
408
394
  <PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
409
- {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
395
+ <Filler rows={bottomRows} prefix="bottom" />
410
396
  </box>
411
397
  )
412
398
  }
413
399
 
414
400
  return (
415
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
401
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} height={previewLines.length}>
416
402
  {previewLines.map((line, index) => (
417
403
  <TextLine key={`${pullRequest.url}-${index}`}>
418
404
  {line.segments.map((segment, segmentIndex) => (
@@ -472,9 +458,9 @@ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceho
472
458
 
473
459
  return (
474
460
  <box height={height} flexDirection="column">
475
- {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
461
+ <Filler rows={topRows} prefix="top" />
476
462
  <StatusCard content={content} width={width} />
477
- {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
463
+ <Filler rows={bottomRows} prefix="bottom" />
478
464
  </box>
479
465
  )
480
466
  }
@@ -484,6 +470,7 @@ export const DetailsPane = ({
484
470
  viewerUsername,
485
471
  contentWidth,
486
472
  bodyLines = DETAIL_BODY_LINES,
473
+ bodyLineLimit = bodyLines,
487
474
  paneWidth = contentWidth + 2,
488
475
  showChecks = false,
489
476
  placeholderContent,
@@ -494,28 +481,27 @@ export const DetailsPane = ({
494
481
  viewerUsername: string | null
495
482
  contentWidth: number
496
483
  bodyLines?: number
484
+ bodyLineLimit?: number
497
485
  paneWidth?: number
498
486
  showChecks?: boolean
499
487
  placeholderContent: DetailPlaceholderContent
500
488
  loadingIndicator: string
501
489
  themeId: ThemeId
502
490
  }) => {
503
- const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines, paneWidth, showChecks })
491
+ const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
504
492
 
505
493
  return (
506
494
  <box flexDirection="column" height={contentHeight}>
507
495
  {pullRequest ? (
508
496
  <>
509
497
  <DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
510
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} themeId={themeId} />
498
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} loadingIndicator={loadingIndicator} themeId={themeId} />
511
499
  </>
512
500
  ) : (
513
501
  <>
514
502
  <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
515
503
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
516
- {Array.from({ length: bodyLines }, (_, index) => (
517
- <BlankRow key={index} />
518
- ))}
504
+ <Filler rows={bodyLines} prefix="empty" />
519
505
  </box>
520
506
  </>
521
507
  )}