@kitlangton/ghui 0.3.0 → 0.3.3
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/README.md +13 -3
- package/bin/ghui.js +6 -1
- package/dist/index.js +9456 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2779
- package/src/appCommands.ts +0 -409
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -20
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -149
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -116
- package/src/keymap/changedFilesModal.ts +0 -23
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -93
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/submitReviewModal.ts +0 -48
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -43
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -756
- package/src/services/MockGitHubService.ts +0 -182
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -176
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -249
- package/src/ui/PullRequestList.tsx +0 -194
- package/src/ui/colors.ts +0 -821
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -964
- package/src/ui/primitives.tsx +0 -350
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- package/src/ui/spinner.ts +0 -1
package/src/ui/DetailsPane.tsx
DELETED
|
@@ -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
|
-
}
|