@kitlangton/ghui 0.1.8 → 0.1.9
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/package.json +1 -1
- package/src/App.tsx +50 -661
- package/src/mergeActions.ts +90 -0
- package/src/services/GitHubService.ts +2 -9
- package/src/ui/DetailsPane.tsx +498 -0
- package/src/ui/PullRequestDiffPane.tsx +148 -0
- package/src/ui/modals.tsx +34 -79
|
@@ -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
|
-
|
|
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,498 @@
|
|
|
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 DetailHeader = ({
|
|
270
|
+
pullRequest,
|
|
271
|
+
contentWidth,
|
|
272
|
+
paneWidth,
|
|
273
|
+
showChecks = false,
|
|
274
|
+
}: {
|
|
275
|
+
pullRequest: PullRequestItem
|
|
276
|
+
contentWidth: number
|
|
277
|
+
paneWidth: number
|
|
278
|
+
showChecks?: boolean
|
|
279
|
+
}) => {
|
|
280
|
+
const labels = pullRequest.labels
|
|
281
|
+
const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
|
|
282
|
+
const unique = deduplicateChecks(pullRequest.checks)
|
|
283
|
+
const checkRows = checksRowCount(unique)
|
|
284
|
+
const statsText = diffStatText(pullRequest)
|
|
285
|
+
const labelsWidth = !pullRequest.detailLoaded
|
|
286
|
+
? "loading details...".length
|
|
287
|
+
: labels.length > 0
|
|
288
|
+
? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
|
|
289
|
+
: "no labels".length
|
|
290
|
+
const showStats = contentWidth - labelsWidth - statsText.length >= 2
|
|
291
|
+
const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<>
|
|
295
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
296
|
+
{(() => {
|
|
297
|
+
const opened = formatRelativeDate(pullRequest.createdAt)
|
|
298
|
+
const repo = shortRepoName(pullRequest.repository)
|
|
299
|
+
const number = String(pullRequest.number)
|
|
300
|
+
const review = reviewLabel(pullRequest)
|
|
301
|
+
const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
|
|
302
|
+
const statusParts = [review, checks].filter((part): part is string => Boolean(part))
|
|
303
|
+
const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
|
|
304
|
+
const leftWidth = 1 + number.length + 1 + repo.length
|
|
305
|
+
const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<TextLine>
|
|
309
|
+
<span fg={colors.count}>#{number}</span>
|
|
310
|
+
<span fg={colors.muted}> {repo}</span>
|
|
311
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
312
|
+
{review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
|
|
313
|
+
{review && checks ? <span fg={colors.muted}> </span> : null}
|
|
314
|
+
{checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
|
|
315
|
+
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
316
|
+
<span fg={colors.muted}>{opened}</span>
|
|
317
|
+
</TextLine>
|
|
318
|
+
)
|
|
319
|
+
})()}
|
|
320
|
+
</box>
|
|
321
|
+
<box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
322
|
+
{wrappedTitle.map((line, index) => (
|
|
323
|
+
<PlainLine key={index} text={line} bold />
|
|
324
|
+
))}
|
|
325
|
+
</box>
|
|
326
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
327
|
+
<TextLine>
|
|
328
|
+
{!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
|
|
329
|
+
<Fragment key={label.name}>
|
|
330
|
+
{index > 0 ? <span fg={colors.muted}> </span> : null}
|
|
331
|
+
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
|
|
332
|
+
</Fragment>
|
|
333
|
+
)) : <span fg={colors.muted}>no labels</span>}
|
|
334
|
+
{showStats ? (
|
|
335
|
+
<>
|
|
336
|
+
<span fg={colors.muted}>{" ".repeat(statsGap)}</span>
|
|
337
|
+
<DiffStats pullRequest={pullRequest} />
|
|
338
|
+
</>
|
|
339
|
+
) : null}
|
|
340
|
+
</TextLine>
|
|
341
|
+
</box>
|
|
342
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
343
|
+
{showChecks && unique.length > 0 ? (
|
|
344
|
+
<>
|
|
345
|
+
<box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
|
|
346
|
+
<ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
|
|
347
|
+
</box>
|
|
348
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
349
|
+
</>
|
|
350
|
+
) : null}
|
|
351
|
+
</>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const DetailBody = ({
|
|
356
|
+
pullRequest,
|
|
357
|
+
contentWidth,
|
|
358
|
+
bodyLines = DETAIL_BODY_LINES,
|
|
359
|
+
loadingIndicator,
|
|
360
|
+
}: {
|
|
361
|
+
pullRequest: PullRequestItem
|
|
362
|
+
contentWidth: number
|
|
363
|
+
bodyLines?: number
|
|
364
|
+
loadingIndicator: string
|
|
365
|
+
}) => {
|
|
366
|
+
const previewLines = useMemo(
|
|
367
|
+
() => bodyPreview(pullRequest.body, contentWidth, bodyLines),
|
|
368
|
+
[pullRequest.body, contentWidth, bodyLines],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if (!pullRequest.detailLoaded) {
|
|
372
|
+
const topRows = Math.max(0, Math.floor((bodyLines - 1) / 2))
|
|
373
|
+
const bottomRows = Math.max(0, bodyLines - topRows - 1)
|
|
374
|
+
return (
|
|
375
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
|
|
376
|
+
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
377
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
|
|
378
|
+
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
379
|
+
</box>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
385
|
+
{previewLines.map((line, index) => (
|
|
386
|
+
<TextLine key={`${pullRequest.url}-${index}`}>
|
|
387
|
+
{line.segments.map((segment, segmentIndex) => (
|
|
388
|
+
("bold" in segment && segment.bold === true) ? (
|
|
389
|
+
<span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
|
|
390
|
+
{segment.text}
|
|
391
|
+
</span>
|
|
392
|
+
) : (
|
|
393
|
+
<span key={segmentIndex} fg={segment.fg}>
|
|
394
|
+
{segment.text}
|
|
395
|
+
</span>
|
|
396
|
+
)
|
|
397
|
+
))}
|
|
398
|
+
</TextLine>
|
|
399
|
+
))}
|
|
400
|
+
</box>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
|
|
405
|
+
const innerWidth = Math.max(1, width - 2)
|
|
406
|
+
const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
|
|
407
|
+
const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
|
|
408
|
+
const cardInnerWidth = Math.max(1, cardWidth - 2)
|
|
409
|
+
const contentLine = (text: string, fg: string, bold = false) => (
|
|
410
|
+
<TextLine>
|
|
411
|
+
<span fg={colors.separator}>{offset}│</span>
|
|
412
|
+
{bold ? (
|
|
413
|
+
<span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
|
|
414
|
+
) : (
|
|
415
|
+
<span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
|
|
416
|
+
)}
|
|
417
|
+
<span fg={colors.separator}>│</span>
|
|
418
|
+
</TextLine>
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
423
|
+
<PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
|
|
424
|
+
{contentLine(content.title, colors.count, true)}
|
|
425
|
+
{contentLine(content.hint, colors.muted)}
|
|
426
|
+
<PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
|
|
427
|
+
</box>
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
|
|
432
|
+
<box flexDirection="column">
|
|
433
|
+
<StatusCard content={content} width={paneWidth} />
|
|
434
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
435
|
+
</box>
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
export const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
|
|
439
|
+
const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
|
|
440
|
+
const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<box height={height} flexDirection="column">
|
|
444
|
+
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
445
|
+
<StatusCard content={content} width={width} />
|
|
446
|
+
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
447
|
+
</box>
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export const DetailsPane = ({
|
|
452
|
+
pullRequest,
|
|
453
|
+
contentWidth,
|
|
454
|
+
bodyLines = DETAIL_BODY_LINES,
|
|
455
|
+
paneWidth = contentWidth + 2,
|
|
456
|
+
showChecks = false,
|
|
457
|
+
placeholderContent,
|
|
458
|
+
loadingIndicator,
|
|
459
|
+
}: {
|
|
460
|
+
pullRequest: PullRequestItem | null
|
|
461
|
+
contentWidth: number
|
|
462
|
+
bodyLines?: number
|
|
463
|
+
paneWidth?: number
|
|
464
|
+
showChecks?: boolean
|
|
465
|
+
placeholderContent: DetailPlaceholderContent
|
|
466
|
+
loadingIndicator: string
|
|
467
|
+
}) => {
|
|
468
|
+
const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
|
|
469
|
+
const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
|
|
470
|
+
const checkRows = checksRowCount(uniqueChecks)
|
|
471
|
+
const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
|
|
472
|
+
const previewLines = useMemo(
|
|
473
|
+
() => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
|
|
474
|
+
[pullRequest?.body, contentWidth, bodyLines],
|
|
475
|
+
)
|
|
476
|
+
const bodyHeight = pullRequest && !pullRequest.detailLoaded ? bodyLines : previewLines.length
|
|
477
|
+
const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + bodyHeight : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
<box flexDirection="column" height={contentHeight}>
|
|
481
|
+
{pullRequest ? (
|
|
482
|
+
<>
|
|
483
|
+
<DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
484
|
+
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
|
|
485
|
+
</>
|
|
486
|
+
) : (
|
|
487
|
+
<>
|
|
488
|
+
<DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
|
|
489
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
490
|
+
{Array.from({ length: bodyLines }, (_, index) => (
|
|
491
|
+
<BlankRow key={index} />
|
|
492
|
+
))}
|
|
493
|
+
</box>
|
|
494
|
+
</>
|
|
495
|
+
)}
|
|
496
|
+
</box>
|
|
497
|
+
)
|
|
498
|
+
}
|