@kitlangton/ghui 0.2.1 → 0.3.2

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.
Files changed (53) hide show
  1. package/README.md +1 -4
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9419 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2604
  6. package/src/appCommands.ts +0 -384
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -28
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -139
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -103
  15. package/src/keymap/closeModal.ts +0 -13
  16. package/src/keymap/commandPalette.ts +0 -16
  17. package/src/keymap/commentModal.ts +0 -73
  18. package/src/keymap/commentThreadModal.ts +0 -24
  19. package/src/keymap/detailView.ts +0 -30
  20. package/src/keymap/diffView.ts +0 -91
  21. package/src/keymap/filterMode.ts +0 -13
  22. package/src/keymap/helpers.ts +0 -24
  23. package/src/keymap/labelModal.ts +0 -16
  24. package/src/keymap/listNav.ts +0 -119
  25. package/src/keymap/mergeModal.ts +0 -23
  26. package/src/keymap/openRepositoryModal.ts +0 -13
  27. package/src/keymap/themeModal.ts +0 -49
  28. package/src/mergeActions.ts +0 -88
  29. package/src/observability.ts +0 -46
  30. package/src/pullRequestCache.ts +0 -19
  31. package/src/pullRequestViews.ts +0 -45
  32. package/src/services/BrowserOpener.ts +0 -22
  33. package/src/services/Clipboard.ts +0 -46
  34. package/src/services/CommandRunner.ts +0 -91
  35. package/src/services/GitHubService.ts +0 -741
  36. package/src/services/MockGitHubService.ts +0 -181
  37. package/src/themeStore.ts +0 -60
  38. package/src/ui/CommandPalette.tsx +0 -216
  39. package/src/ui/DetailsPane.tsx +0 -656
  40. package/src/ui/FooterHints.tsx +0 -90
  41. package/src/ui/LoadingLogo.tsx +0 -75
  42. package/src/ui/PullRequestDiffPane.tsx +0 -248
  43. package/src/ui/PullRequestList.tsx +0 -210
  44. package/src/ui/colors.ts +0 -814
  45. package/src/ui/commentEditor.ts +0 -126
  46. package/src/ui/comments.tsx +0 -143
  47. package/src/ui/diff.ts +0 -650
  48. package/src/ui/diffStats.tsx +0 -25
  49. package/src/ui/modals.tsx +0 -614
  50. package/src/ui/primitives.tsx +0 -205
  51. package/src/ui/pullRequests.ts +0 -106
  52. package/src/ui/singleLineInput.ts +0 -26
  53. package/src/ui/spinner.ts +0 -1
@@ -1,656 +0,0 @@
1
- import { TextAttributes } from "@opentui/core"
2
- import { Fragment, useMemo } from "react"
3
- import { formatRelativeDate } from "../date.js"
4
- import type { CheckItem, PullRequestConversationItem, PullRequestItem } from "../domain.js"
5
- import { colors, type ThemeId } from "./colors.js"
6
- import { commentCountText, commentDisplayRows, commentSideColor, CommentSegmentsLine, type CommentSegment } from "./comments.js"
7
- import { diffCommentLineLabel, diffStatText } from "./diff.js"
8
- import { DiffStats } from "./diffStats.js"
9
- import { centerCell, Divider, Filler, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
10
- import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
11
-
12
- interface PreviewLine {
13
- readonly divider?: boolean
14
- readonly segments: readonly CommentSegment[]
15
- }
16
-
17
- export interface DetailPlaceholderContent {
18
- readonly title: string
19
- readonly hint: string
20
- }
21
-
22
- export const DETAIL_BODY_LINES = 6
23
- export const DETAIL_PLACEHOLDER_ROWS = 4
24
- export const DETAIL_BODY_SCROLL_LIMIT = 1_000
25
-
26
- export type DetailConversationStatus = "idle" | "loading" | "ready"
27
-
28
- const pullRequestReferencePattern = /(#[0-9]+)/g
29
- const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
30
- const codeTokenPattern = /(\/\/.*|`(?:\\.|[^`])*`|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|finally|for|from|function|if|import|interface|let|new|return|switch|throw|try|type|var|while|yield)\b|\b(?:true|false|null|undefined)\b|\b\d+(?:\.\d+)?\b)/g
31
- const codeFenceLine = (line: string) => line.trim().replace(/\\`/g, "`").match(codeFencePattern)
32
-
33
- export const wrapText = (text: string, width: number): string[] => {
34
- if (text.length === 0 || width <= 0) return [""]
35
- const words = text.split(/\s+/)
36
- const lines: string[] = []
37
- let current = ""
38
- for (const word of words) {
39
- const next = current.length > 0 ? `${current} ${word}` : word
40
- if (next.length > width && current.length > 0) {
41
- lines.push(current)
42
- current = word
43
- } else {
44
- current = next
45
- }
46
- }
47
- if (current.length > 0) lines.push(current)
48
- return lines.length > 0 ? lines : [""]
49
- }
50
-
51
- const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
52
- const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
53
- return parts.flatMap((part) => {
54
- if (part.startsWith("`") && part.endsWith("`")) {
55
- return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
56
- }
57
-
58
- return part
59
- .split(pullRequestReferencePattern)
60
- .filter((segment) => segment.length > 0)
61
- .map((segment) => ({
62
- text: segment,
63
- fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
64
- bold,
65
- }))
66
- })
67
- }
68
-
69
- const parseCodeSegments = (text: string): PreviewLine["segments"] => {
70
- const segments: Array<PreviewLine["segments"][number]> = []
71
- let index = 0
72
- for (const match of text.matchAll(codeTokenPattern)) {
73
- const start = match.index ?? 0
74
- if (start > index) segments.push({ text: text.slice(index, start), fg: colors.text })
75
- const token = match[0]
76
- const fg = token.startsWith("//")
77
- ? colors.muted
78
- : token.startsWith("`") || token.startsWith("\"") || token.startsWith("'")
79
- ? colors.inlineCode
80
- : /^\d/.test(token)
81
- ? colors.status.review
82
- : token === "true" || token === "false" || token === "null" || token === "undefined"
83
- ? colors.status.review
84
- : colors.accent
85
- segments.push({ text: token, fg, bold: fg === colors.accent })
86
- index = start + token.length
87
- }
88
- if (index < text.length) segments.push({ text: text.slice(index), fg: colors.text })
89
- return segments.length > 0 ? segments : [{ text: "", fg: colors.muted }]
90
- }
91
-
92
- const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
93
- const tokens = segments.flatMap((segment) =>
94
- segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
95
- )
96
-
97
- const lines: Array<PreviewLine> = []
98
- let current: Array<PreviewLine["segments"][number]> = []
99
- let currentLength = 0
100
-
101
- const pushLine = () => {
102
- lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
103
- current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
104
- currentLength = indent.length
105
- }
106
-
107
- for (const token of tokens) {
108
- const tokenLength = token.text.length
109
- if (currentLength > 0 && currentLength + tokenLength > width) {
110
- pushLine()
111
- }
112
- current.push(token)
113
- currentLength += tokenLength
114
- }
115
-
116
- if (current.length > 0) {
117
- lines.push({ segments: current })
118
- }
119
-
120
- return lines
121
- }
122
-
123
- const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
124
- const sourceLines = body.replace(/\r/g, "").split("\n")
125
- const preview: Array<PreviewLine> = []
126
- let inCodeBlock = false
127
-
128
- for (const rawLine of sourceLines) {
129
- if (preview.length >= limit) break
130
-
131
- const fence = codeFenceLine(rawLine)
132
- if (fence) {
133
- inCodeBlock = !inCodeBlock
134
- continue
135
- }
136
-
137
- const line = inCodeBlock ? rawLine.replace(/\t/g, " ") : rawLine.trim()
138
- if (line.length === 0) continue
139
-
140
- let text = line
141
- let fg: string = colors.text
142
- let bold = false
143
- let indent = ""
144
-
145
- if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
146
- if (preview.length > 0) {
147
- preview.push({ segments: [{ text: "", fg: colors.muted }] })
148
- if (preview.length >= limit) break
149
- }
150
- text = line.replace(/^#{1,6}\s+/, "")
151
- fg = colors.count
152
- bold = true
153
- } else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
154
- const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
155
- text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
156
- fg = checked ? colors.status.passing : colors.text
157
- indent = " "
158
- } else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
159
- const checked = /^\[(x|X)\]\s+/.test(line)
160
- text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
161
- fg = checked ? colors.status.passing : colors.text
162
- indent = " "
163
- } else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
164
- text = `• ${line.replace(/^[-*+]\s+/, "")}`
165
- indent = " "
166
- } else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
167
- text = line
168
- indent = " "
169
- } else if (!inCodeBlock && /^>\s+/.test(line)) {
170
- text = `> ${line.replace(/^>\s+/, "")}`
171
- fg = colors.muted
172
- indent = " "
173
- }
174
-
175
- const wrapped = wrapPreviewSegments(inCodeBlock ? parseCodeSegments(text) : parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
176
- for (const wrappedLine of wrapped) {
177
- preview.push(wrappedLine)
178
- if (preview.length >= limit) break
179
- }
180
- }
181
-
182
- if (preview.length === 0) {
183
- return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
184
- }
185
-
186
- return preview.slice(0, limit)
187
- }
188
-
189
- const previewDivider = (): PreviewLine => ({
190
- divider: true,
191
- segments: [],
192
- })
193
-
194
- const conversationItemGroups = (item: PullRequestConversationItem, width: number): readonly (readonly CommentSegment[])[] => {
195
- if (item._tag !== "review-comment") return []
196
- const locationWidth = Math.max(8, width - item.author.length - 20)
197
- return [[
198
- { text: fitCell(item.path, locationWidth), fg: colors.inlineCode },
199
- { text: diffCommentLineLabel(item), fg: commentSideColor(item.side), bold: true },
200
- ]]
201
- }
202
-
203
- const conversationPreview = ({
204
- items,
205
- status,
206
- width,
207
- limit,
208
- }: {
209
- readonly items: readonly PullRequestConversationItem[]
210
- readonly status: DetailConversationStatus
211
- readonly width: number
212
- readonly limit: number
213
- }): Array<PreviewLine> => {
214
- if (status === "idle" || (status === "ready" && items.length === 0) || limit <= 0) return []
215
- const rows: Array<PreviewLine> = []
216
- const title = "Conversation"
217
- const countText = status === "loading" ? "loading" : commentCountText(items.length)
218
- const gap = Math.max(2, width - title.length - countText.length)
219
- rows.push({
220
- segments: [
221
- { text: title, fg: colors.count, bold: true },
222
- { text: " ".repeat(gap), fg: colors.muted },
223
- { text: countText, fg: colors.muted },
224
- ],
225
- })
226
- if (items.length === 0) {
227
- rows.push({ segments: [{ text: "│ ", fg: colors.muted }, { text: "Loading comments...", fg: colors.muted }] })
228
- return rows.slice(0, limit)
229
- }
230
-
231
- for (const item of items) {
232
- if (rows.length >= limit) break
233
- rows.push(...commentDisplayRows({ item, width, groups: conversationItemGroups(item, width) }).slice(0, limit - rows.length))
234
- }
235
-
236
- return rows.slice(0, limit)
237
- }
238
-
239
- const detailBodyPreview = ({
240
- pullRequest,
241
- contentWidth,
242
- limit,
243
- conversationItems,
244
- conversationStatus,
245
- }: {
246
- readonly pullRequest: PullRequestItem
247
- readonly contentWidth: number
248
- readonly limit: number
249
- readonly conversationItems: readonly PullRequestConversationItem[]
250
- readonly conversationStatus: DetailConversationStatus
251
- }) => {
252
- const summaryRows = bodyPreview(pullRequest.body, contentWidth, limit)
253
- const conversationRows = conversationPreview({
254
- items: conversationItems,
255
- status: conversationStatus,
256
- width: contentWidth,
257
- limit: Math.max(0, limit - summaryRows.length - 1),
258
- })
259
-
260
- if (conversationRows.length === 0) return summaryRows
261
- return [
262
- ...summaryRows,
263
- previewDivider(),
264
- ...conversationRows,
265
- ].slice(0, limit)
266
- }
267
-
268
- const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
269
- const seen = new Map<string, CheckItem>()
270
- for (const check of checks) {
271
- const existing = seen.get(check.name)
272
- if (!existing || (check.status === "completed" && existing.status !== "completed")) {
273
- seen.set(check.name, check)
274
- }
275
- }
276
- return [...seen.values()]
277
- }
278
-
279
- type CheckKind = "passing" | "failing" | "in-progress" | "queued" | "missing"
280
-
281
- const CHECK_DISPLAY: Record<CheckKind, { icon: string; color: string }> = {
282
- passing: { icon: "✓", color: colors.status.passing },
283
- failing: { icon: "✗", color: colors.status.failing },
284
- "in-progress": { icon: "●", color: colors.status.pending },
285
- queued: { icon: "○", color: colors.muted },
286
- missing: { icon: "·", color: colors.muted },
287
- }
288
-
289
- const checkKind = (check: CheckItem): CheckKind => {
290
- if (check.status === "completed") {
291
- if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "passing"
292
- if (check.conclusion === "failure") return "failing"
293
- return "missing"
294
- }
295
- if (check.status === "in_progress") return "in-progress"
296
- return "queued"
297
- }
298
-
299
- const checkIcon = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].icon
300
-
301
- const checkColor = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].color
302
-
303
- const checksRowCount = (checks: readonly CheckItem[]) => {
304
- const unique = deduplicateChecks(checks)
305
- return Math.ceil(unique.length / 2)
306
- }
307
-
308
- const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
309
- const unique = deduplicateChecks(checks)
310
- if (unique.length === 0) return null
311
-
312
- const columns = 2
313
- const colWidth = Math.floor((contentWidth - 1) / columns)
314
- const nameCol = Math.max(4, colWidth - 2)
315
- const rows = Math.ceil(unique.length / columns)
316
-
317
- return (
318
- <box flexDirection="column">
319
- <TextLine>
320
- <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
321
- </TextLine>
322
- {Array.from({ length: rows }, (_, rowIndex) => {
323
- return (
324
- <TextLine key={rowIndex}>
325
- {Array.from({ length: columns }, (_, columnIndex) => {
326
- const check = unique[rowIndex * columns + columnIndex]
327
- return (
328
- <Fragment key={columnIndex}>
329
- {columnIndex > 0 ? <span fg={colors.muted}> </span> : null}
330
- {check ? (
331
- <>
332
- <span fg={checkColor(check)}>{checkIcon(check)} </span>
333
- <span fg={colors.text}>{fitCell(check.name, nameCol)}</span>
334
- </>
335
- ) : <span>{" ".repeat(colWidth)}</span>}
336
- </Fragment>
337
- )
338
- })}
339
- </TextLine>
340
- )
341
- })}
342
- </box>
343
- )
344
- }
345
-
346
- const conversationDividerBodyRow = (pullRequest: PullRequestItem, contentWidth: number, conversationItems: readonly PullRequestConversationItem[], conversationStatus: DetailConversationStatus) => {
347
- if (!pullRequest.detailLoaded) return null
348
- const dividerIndex = detailBodyPreview({ pullRequest, contentWidth, limit: DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus })
349
- .findIndex((line) => line.divider === true)
350
- return dividerIndex >= 0 ? dividerIndex : null
351
- }
352
-
353
- export const getDetailJunctionRows = ({
354
- pullRequest,
355
- paneWidth,
356
- showChecks = false,
357
- contentWidth,
358
- conversationItems = [],
359
- conversationStatus = "idle",
360
- bodyScrollTop = 0,
361
- bodyViewportHeight = Number.POSITIVE_INFINITY,
362
- }: {
363
- readonly pullRequest: PullRequestItem | null
364
- readonly paneWidth: number
365
- readonly showChecks?: boolean
366
- readonly contentWidth?: number
367
- readonly conversationItems?: readonly PullRequestConversationItem[]
368
- readonly conversationStatus?: DetailConversationStatus
369
- readonly bodyScrollTop?: number
370
- readonly bodyViewportHeight?: number
371
- }): readonly number[] => {
372
- if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
373
- const resolvedContentWidth = contentWidth ?? Math.max(1, paneWidth - 2)
374
- const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
375
- const detailDividerRow = 1 + titleLines + 1
376
- const checks = deduplicateChecks(pullRequest.checks)
377
- const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
378
- const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
379
- const conversationDivider = conversationDividerBodyRow(pullRequest, resolvedContentWidth, conversationItems, conversationStatus)
380
- const visibleConversationDivider = conversationDivider === null ? null : conversationDivider - Math.max(0, Math.floor(bodyScrollTop))
381
- return [
382
- detailDividerRow,
383
- showChecks && checks.length > 0 ? checksDividerRow : -1,
384
- visibleConversationDivider === null || visibleConversationDivider < 0 || visibleConversationDivider >= bodyViewportHeight ? -1 : headerHeight + visibleConversationDivider,
385
- ].filter((row) => row >= 0)
386
- }
387
-
388
- export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
389
- if (!pullRequest) return DETAIL_PLACEHOLDER_ROWS + 1
390
- const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
391
- const checks = deduplicateChecks(pullRequest.checks)
392
- const checksHeight = showChecks && checks.length > 0 ? checksRowCount(checks) + 2 : 0
393
- return titleLines + 3 + checksHeight
394
- }
395
-
396
- export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
397
- if (!pullRequest) return bodyLines
398
- if (!pullRequest.detailLoaded) return bodyLines
399
- return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
400
- }
401
-
402
- export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
403
- return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus)
404
- }
405
-
406
- export const getDetailsPaneHeight = ({
407
- pullRequest,
408
- contentWidth,
409
- bodyLines = DETAIL_BODY_LINES,
410
- paneWidth = contentWidth + 2,
411
- showChecks = false,
412
- conversationItems = [],
413
- conversationStatus = "idle",
414
- }: {
415
- pullRequest: PullRequestItem | null
416
- contentWidth: number
417
- bodyLines?: number
418
- paneWidth?: number
419
- showChecks?: boolean
420
- conversationItems?: readonly PullRequestConversationItem[]
421
- conversationStatus?: DetailConversationStatus
422
- }) => pullRequest
423
- ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
424
- : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
425
-
426
- export const DetailHeader = ({
427
- pullRequest,
428
- viewerUsername,
429
- contentWidth,
430
- paneWidth,
431
- showChecks = false,
432
- }: {
433
- pullRequest: PullRequestItem
434
- viewerUsername: string | null
435
- contentWidth: number
436
- paneWidth: number
437
- showChecks?: boolean
438
- }) => {
439
- const labels = pullRequest.labels
440
- const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
441
- const unique = deduplicateChecks(pullRequest.checks)
442
- const checkRows = checksRowCount(unique)
443
- const statsText = diffStatText(pullRequest)
444
- const labelsWidth = !pullRequest.detailLoaded
445
- ? "loading details...".length
446
- : labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
447
- const hasLabelContent = labelsWidth > 0
448
- const showStats = contentWidth - labelsWidth - statsText.length >= (hasLabelContent ? 2 : 0)
449
- const statsGap = Math.max(hasLabelContent ? 2 : 0, contentWidth - labelsWidth - statsText.length)
450
- const opened = formatRelativeDate(pullRequest.createdAt)
451
- const repo = shortRepoName(pullRequest.repository)
452
- const author = viewerUsername && pullRequest.author !== viewerUsername ? ` by ${pullRequest.author}` : ""
453
- const number = String(pullRequest.number)
454
- const review = reviewLabel(pullRequest)
455
- const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
456
- const statusParts = [review, checks].filter((part): part is string => Boolean(part))
457
- const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
458
- const leftWidth = 1 + number.length + 1 + repo.length + author.length
459
- const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
460
-
461
- return (
462
- <>
463
- <PaddedRow>
464
- <TextLine>
465
- <span fg={colors.count}>#{number}</span>
466
- <span fg={colors.muted}> {repo}</span>
467
- {author ? <span fg={colors.muted}>{author}</span> : null}
468
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
469
- {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
470
- {review && checks ? <span fg={colors.muted}> </span> : null}
471
- {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
472
- {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
473
- <span fg={colors.muted}>{opened}</span>
474
- </TextLine>
475
- </PaddedRow>
476
- <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
477
- {wrappedTitle.map((line, index) => (
478
- <PlainLine key={index} text={line} bold />
479
- ))}
480
- </box>
481
- <PaddedRow>
482
- <TextLine>
483
- {!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
484
- <Fragment key={label.name}>
485
- {index > 0 ? <span fg={colors.muted}> </span> : null}
486
- <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
487
- </Fragment>
488
- )) : null}
489
- {showStats ? (
490
- <>
491
- {statsGap > 0 ? <span fg={colors.muted}>{" ".repeat(statsGap)}</span> : null}
492
- <DiffStats pullRequest={pullRequest} />
493
- </>
494
- ) : null}
495
- </TextLine>
496
- </PaddedRow>
497
- <box height={1}><Divider width={paneWidth} /></box>
498
- {showChecks && unique.length > 0 ? (
499
- <>
500
- <box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
501
- <ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
502
- </box>
503
- <box height={1}><Divider width={paneWidth} /></box>
504
- </>
505
- ) : null}
506
- </>
507
- )
508
- }
509
-
510
- export const DetailBody = ({
511
- pullRequest,
512
- contentWidth,
513
- paneWidth = contentWidth + 2,
514
- bodyLines = DETAIL_BODY_LINES,
515
- bodyLineLimit = bodyLines,
516
- conversationItems = [],
517
- conversationStatus = "idle",
518
- loadingIndicator,
519
- themeId,
520
- }: {
521
- pullRequest: PullRequestItem
522
- contentWidth: number
523
- paneWidth?: number
524
- bodyLines?: number
525
- bodyLineLimit?: number
526
- conversationItems?: readonly PullRequestConversationItem[]
527
- conversationStatus?: DetailConversationStatus
528
- loadingIndicator: string
529
- themeId: ThemeId
530
- }) => {
531
- const previewLines = useMemo(
532
- () => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
533
- [pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
534
- )
535
-
536
- if (!pullRequest.detailLoaded) {
537
- const topRows = Math.max(0, Math.floor((bodyLines - 1) / 2))
538
- const bottomRows = Math.max(0, bodyLines - topRows - 1)
539
- return (
540
- <box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
541
- <Filler rows={topRows} prefix="top" />
542
- <PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
543
- <Filler rows={bottomRows} prefix="bottom" />
544
- </box>
545
- )
546
- }
547
-
548
- return (
549
- <box flexDirection="column" height={previewLines.length}>
550
- {previewLines.map((line, index) => (
551
- line.divider === true ? (
552
- <Divider key={`${pullRequest.url}-${index}`} width={paneWidth} />
553
- ) : (
554
- <PaddedRow key={`${pullRequest.url}-${index}`}>
555
- <CommentSegmentsLine segments={line.segments} />
556
- </PaddedRow>
557
- )
558
- ))}
559
- </box>
560
- )
561
- }
562
-
563
- export const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
564
- const innerWidth = Math.max(1, width - 2)
565
- const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
566
- const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
567
- const cardInnerWidth = Math.max(1, cardWidth - 2)
568
- const contentLine = (text: string, fg: string, bold = false) => (
569
- <TextLine>
570
- <span fg={colors.separator}>{offset}│</span>
571
- {bold ? (
572
- <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
573
- ) : (
574
- <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
575
- )}
576
- <span fg={colors.separator}>│</span>
577
- </TextLine>
578
- )
579
-
580
- return (
581
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
582
- <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
583
- {contentLine(content.title, colors.count, true)}
584
- {contentLine(content.hint, colors.muted)}
585
- <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
586
- </box>
587
- )
588
- }
589
-
590
- export const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
591
- <box flexDirection="column">
592
- <StatusCard content={content} width={paneWidth} />
593
- <box height={1}><Divider width={paneWidth} /></box>
594
- </box>
595
- )
596
-
597
- export const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
598
- const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
599
- const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
600
-
601
- return (
602
- <box height={height} flexDirection="column">
603
- <Filler rows={topRows} prefix="top" />
604
- <StatusCard content={content} width={width} />
605
- <Filler rows={bottomRows} prefix="bottom" />
606
- </box>
607
- )
608
- }
609
-
610
- export const DetailsPane = ({
611
- pullRequest,
612
- viewerUsername,
613
- contentWidth,
614
- bodyLines = DETAIL_BODY_LINES,
615
- bodyLineLimit = bodyLines,
616
- paneWidth = contentWidth + 2,
617
- showChecks = false,
618
- conversationItems = [],
619
- conversationStatus = "idle",
620
- placeholderContent,
621
- loadingIndicator,
622
- themeId,
623
- }: {
624
- pullRequest: PullRequestItem | null
625
- viewerUsername: string | null
626
- contentWidth: number
627
- bodyLines?: number
628
- bodyLineLimit?: number
629
- paneWidth?: number
630
- showChecks?: boolean
631
- conversationItems?: readonly PullRequestConversationItem[]
632
- conversationStatus?: DetailConversationStatus
633
- placeholderContent: DetailPlaceholderContent
634
- loadingIndicator: string
635
- themeId: ThemeId
636
- }) => {
637
- const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
638
-
639
- return (
640
- <box flexDirection="column" height={contentHeight}>
641
- {pullRequest ? (
642
- <>
643
- <DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
644
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} conversationItems={conversationItems} conversationStatus={conversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
645
- </>
646
- ) : (
647
- <>
648
- <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
649
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
650
- <Filler rows={bodyLines} prefix="empty" />
651
- </box>
652
- </>
653
- )}
654
- </box>
655
- )
656
- }