@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
package/src/App.tsx
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
3
3
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
4
4
|
import { Cause, Effect, Layer, Schedule } from "effect"
|
|
5
5
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
|
|
6
6
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
7
|
-
import {
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
8
8
|
import { config } from "./config.js"
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
9
|
+
import type { PullRequestItem, PullRequestLabel, PullRequestMergeAction } from "./domain.js"
|
|
10
|
+
import { formatShortDate, formatTimestamp } from "./date.js"
|
|
11
|
+
import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
|
|
11
12
|
import { Observability } from "./observability.js"
|
|
12
13
|
import { GitHubService } from "./services/GitHubService.js"
|
|
13
14
|
import { colors } from "./ui/colors.js"
|
|
14
|
-
import {
|
|
15
|
+
import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
|
|
16
|
+
import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailJunctionRows, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
|
|
15
17
|
import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
|
|
16
|
-
import {
|
|
17
|
-
import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal
|
|
18
|
-
import { groupBy,
|
|
18
|
+
import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
|
|
19
|
+
import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal } from "./ui/modals.js"
|
|
20
|
+
import { groupBy, reviewLabel } from "./ui/pullRequests.js"
|
|
21
|
+
import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
|
|
19
22
|
import { PullRequestList } from "./ui/PullRequestList.js"
|
|
20
23
|
|
|
21
24
|
const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
|
|
@@ -27,19 +30,6 @@ interface PullRequestLoad {
|
|
|
27
30
|
readonly fetchedAt: Date | null
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
interface PreviewLine {
|
|
31
|
-
readonly segments: ReadonlyArray<{
|
|
32
|
-
readonly text: string
|
|
33
|
-
readonly fg: string
|
|
34
|
-
readonly bold?: boolean
|
|
35
|
-
}>
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface DetailPlaceholderContent {
|
|
39
|
-
readonly title: string
|
|
40
|
-
readonly hint: string
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
interface DetailPlaceholderInput {
|
|
44
34
|
readonly status: LoadStatus
|
|
45
35
|
readonly retryProgress: RetryProgress | null
|
|
@@ -48,9 +38,7 @@ interface DetailPlaceholderInput {
|
|
|
48
38
|
readonly filterText: string
|
|
49
39
|
}
|
|
50
40
|
|
|
51
|
-
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
52
41
|
const PR_FETCH_RETRIES = 6
|
|
53
|
-
const DETAIL_PLACEHOLDER_ROWS = 4
|
|
54
42
|
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
55
43
|
|
|
56
44
|
const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
|
|
@@ -123,101 +111,44 @@ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
|
|
|
123
111
|
GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
|
|
124
112
|
)
|
|
125
113
|
|
|
126
|
-
const BlankRow = () => <box height={1} />
|
|
127
|
-
const DETAIL_BODY_LINES = 6
|
|
128
|
-
|
|
129
|
-
const wrapText = (text: string, width: number): string[] => {
|
|
130
|
-
if (text.length === 0 || width <= 0) return [""]
|
|
131
|
-
const words = text.split(/\s+/)
|
|
132
|
-
const lines: string[] = []
|
|
133
|
-
let current = ""
|
|
134
|
-
for (const word of words) {
|
|
135
|
-
const next = current.length > 0 ? `${current} ${word}` : word
|
|
136
|
-
if (next.length > width && current.length > 0) {
|
|
137
|
-
lines.push(current)
|
|
138
|
-
current = word
|
|
139
|
-
} else {
|
|
140
|
-
current = next
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (current.length > 0) lines.push(current)
|
|
144
|
-
return lines.length > 0 ? lines : [""]
|
|
145
|
-
}
|
|
146
|
-
|
|
147
114
|
const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
148
115
|
|
|
149
|
-
const
|
|
150
|
-
const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
|
|
151
|
-
return parts.flatMap((part) => {
|
|
152
|
-
if (part.startsWith("`") && part.endsWith("`")) {
|
|
153
|
-
return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return part
|
|
157
|
-
.split(pullRequestReferencePattern)
|
|
158
|
-
.filter((segment) => segment.length > 0)
|
|
159
|
-
.map((segment) => ({
|
|
160
|
-
text: segment,
|
|
161
|
-
fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
|
|
162
|
-
bold,
|
|
163
|
-
}))
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
|
|
168
|
-
const tokens = segments.flatMap((segment) =>
|
|
169
|
-
segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
const lines: Array<PreviewLine> = []
|
|
173
|
-
let current: Array<PreviewLine["segments"][number]> = []
|
|
174
|
-
let currentLength = 0
|
|
116
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
175
117
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
118
|
+
const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
119
|
+
const lines = [
|
|
120
|
+
pullRequest.title,
|
|
121
|
+
`${pullRequest.repository} #${pullRequest.number}`,
|
|
122
|
+
pullRequest.url,
|
|
123
|
+
]
|
|
181
124
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
pushLine()
|
|
186
|
-
}
|
|
187
|
-
current.push(token)
|
|
188
|
-
currentLength += tokenLength
|
|
125
|
+
const review = reviewLabel(pullRequest)
|
|
126
|
+
if (review) {
|
|
127
|
+
lines.push(`review: ${review}`)
|
|
189
128
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
lines.push({ segments: current })
|
|
129
|
+
if (pullRequest.checkSummary) {
|
|
130
|
+
lines.push(pullRequest.checkSummary)
|
|
193
131
|
}
|
|
194
132
|
|
|
195
|
-
|
|
196
|
-
|
|
133
|
+
const proc = Bun.spawn({
|
|
134
|
+
cmd: ["pbcopy"],
|
|
135
|
+
stdin: "pipe",
|
|
136
|
+
stdout: "ignore",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
})
|
|
197
139
|
|
|
198
|
-
|
|
140
|
+
if (!proc.stdin) {
|
|
141
|
+
throw new Error("Clipboard is not available")
|
|
142
|
+
}
|
|
199
143
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
203
|
-
type Part = { key: string; text: string; color: string }
|
|
204
|
-
const rawParts: Array<Part | null> = [
|
|
205
|
-
pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
|
|
206
|
-
pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
|
|
207
|
-
{ key: "files", text: files, color: colors.muted },
|
|
208
|
-
]
|
|
209
|
-
const parts = rawParts.filter((part): part is Part => part !== null)
|
|
144
|
+
proc.stdin.write(lines.join("\n"))
|
|
145
|
+
proc.stdin.end()
|
|
210
146
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
<span fg={part.color}>{part.text}</span>
|
|
217
|
-
</Fragment>
|
|
218
|
-
))}
|
|
219
|
-
</>
|
|
220
|
-
)
|
|
147
|
+
const exitCode = await proc.exited
|
|
148
|
+
if (exitCode !== 0) {
|
|
149
|
+
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
150
|
+
throw new Error(stderr.trim() || "Could not copy PR metadata")
|
|
151
|
+
}
|
|
221
152
|
}
|
|
222
153
|
|
|
223
154
|
const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
|
|
@@ -263,534 +194,6 @@ const getDetailPlaceholderContent = ({
|
|
|
263
194
|
}
|
|
264
195
|
}
|
|
265
196
|
|
|
266
|
-
const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
|
|
267
|
-
const sourceLines = body.replace(/\r/g, "").split("\n")
|
|
268
|
-
const preview: Array<PreviewLine> = []
|
|
269
|
-
let inCodeBlock = false
|
|
270
|
-
|
|
271
|
-
for (const rawLine of sourceLines) {
|
|
272
|
-
if (preview.length >= limit) break
|
|
273
|
-
|
|
274
|
-
const line = rawLine.trim()
|
|
275
|
-
if (line.startsWith("```")) {
|
|
276
|
-
inCodeBlock = !inCodeBlock
|
|
277
|
-
continue
|
|
278
|
-
}
|
|
279
|
-
if (line.length === 0) continue
|
|
280
|
-
|
|
281
|
-
let text = line
|
|
282
|
-
let fg: string = colors.text
|
|
283
|
-
let bold = false
|
|
284
|
-
let indent = ""
|
|
285
|
-
|
|
286
|
-
if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
|
|
287
|
-
if (preview.length > 0) {
|
|
288
|
-
preview.push({ segments: [{ text: "", fg: colors.muted }] })
|
|
289
|
-
if (preview.length >= limit) break
|
|
290
|
-
}
|
|
291
|
-
text = line.replace(/^#{1,6}\s+/, "")
|
|
292
|
-
fg = colors.count
|
|
293
|
-
bold = true
|
|
294
|
-
} else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
|
|
295
|
-
const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
|
|
296
|
-
text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
|
|
297
|
-
fg = checked ? colors.status.passing : colors.text
|
|
298
|
-
indent = " "
|
|
299
|
-
} else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
|
|
300
|
-
const checked = /^\[(x|X)\]\s+/.test(line)
|
|
301
|
-
text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
|
|
302
|
-
fg = checked ? colors.status.passing : colors.text
|
|
303
|
-
indent = " "
|
|
304
|
-
} else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
|
|
305
|
-
text = `• ${line.replace(/^[-*+]\s+/, "")}`
|
|
306
|
-
indent = " "
|
|
307
|
-
} else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
|
|
308
|
-
text = line
|
|
309
|
-
indent = " "
|
|
310
|
-
} else if (!inCodeBlock && /^>\s+/.test(line)) {
|
|
311
|
-
text = `> ${line.replace(/^>\s+/, "")}`
|
|
312
|
-
fg = colors.muted
|
|
313
|
-
indent = " "
|
|
314
|
-
} else if (inCodeBlock) {
|
|
315
|
-
fg = colors.muted
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
319
|
-
for (const wrappedLine of wrapped) {
|
|
320
|
-
preview.push(wrappedLine)
|
|
321
|
-
if (preview.length >= limit) break
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (preview.length === 0) {
|
|
326
|
-
return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return preview.slice(0, limit)
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
333
|
-
const lines = [
|
|
334
|
-
pullRequest.title,
|
|
335
|
-
`${pullRequest.repository} #${pullRequest.number}`,
|
|
336
|
-
pullRequest.url,
|
|
337
|
-
]
|
|
338
|
-
|
|
339
|
-
const review = reviewLabel(pullRequest)
|
|
340
|
-
if (review) {
|
|
341
|
-
lines.push(`review: ${review}`)
|
|
342
|
-
}
|
|
343
|
-
if (pullRequest.checkSummary) {
|
|
344
|
-
lines.push(pullRequest.checkSummary)
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const proc = Bun.spawn({
|
|
348
|
-
cmd: ["pbcopy"],
|
|
349
|
-
stdin: "pipe",
|
|
350
|
-
stdout: "ignore",
|
|
351
|
-
stderr: "pipe",
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
if (!proc.stdin) {
|
|
355
|
-
throw new Error("Clipboard is not available")
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
proc.stdin.write(lines.join("\n"))
|
|
359
|
-
proc.stdin.end()
|
|
360
|
-
|
|
361
|
-
const exitCode = await proc.exited
|
|
362
|
-
if (exitCode !== 0) {
|
|
363
|
-
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
364
|
-
throw new Error(stderr.trim() || "Could not copy PR metadata")
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
369
|
-
const seen = new Map<string, CheckItem>()
|
|
370
|
-
for (const check of checks) {
|
|
371
|
-
const existing = seen.get(check.name)
|
|
372
|
-
if (!existing || (check.status === "completed" && existing.status !== "completed")) {
|
|
373
|
-
seen.set(check.name, check)
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
return [...seen.values()]
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const checkIcon = (check: CheckItem) => {
|
|
380
|
-
if (check.status === "completed") {
|
|
381
|
-
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
|
|
382
|
-
if (check.conclusion === "failure") return "✗"
|
|
383
|
-
return "·"
|
|
384
|
-
}
|
|
385
|
-
if (check.status === "in_progress") return "●"
|
|
386
|
-
return "○"
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const checkColor = (check: CheckItem) => {
|
|
390
|
-
if (check.status === "completed") {
|
|
391
|
-
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
|
|
392
|
-
if (check.conclusion === "failure") return colors.status.failing
|
|
393
|
-
return colors.muted
|
|
394
|
-
}
|
|
395
|
-
if (check.status === "in_progress") return colors.status.pending
|
|
396
|
-
return colors.muted
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const checksRowCount = (checks: readonly CheckItem[]) => {
|
|
400
|
-
const unique = deduplicateChecks(checks)
|
|
401
|
-
return Math.ceil(unique.length / 2)
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
|
|
405
|
-
const unique = deduplicateChecks(checks)
|
|
406
|
-
if (unique.length === 0) return null
|
|
407
|
-
|
|
408
|
-
const colWidth = Math.floor((contentWidth - 1) / 2) // -1 for gap between columns
|
|
409
|
-
const nameCol = Math.max(4, colWidth - 2) // -2 for icon + space
|
|
410
|
-
const rows = Math.ceil(unique.length / 2)
|
|
411
|
-
|
|
412
|
-
return (
|
|
413
|
-
<box flexDirection="column">
|
|
414
|
-
<TextLine>
|
|
415
|
-
<span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
|
|
416
|
-
</TextLine>
|
|
417
|
-
{Array.from({ length: rows }, (_, rowIndex) => {
|
|
418
|
-
const left = unique[rowIndex * 2]
|
|
419
|
-
const right = unique[rowIndex * 2 + 1]
|
|
420
|
-
return (
|
|
421
|
-
<TextLine key={rowIndex}>
|
|
422
|
-
{left ? (
|
|
423
|
-
<>
|
|
424
|
-
<span fg={checkColor(left)}>{checkIcon(left)} </span>
|
|
425
|
-
<span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
|
|
426
|
-
</>
|
|
427
|
-
) : null}
|
|
428
|
-
{right ? (
|
|
429
|
-
<>
|
|
430
|
-
<span fg={colors.muted}> </span>
|
|
431
|
-
<span fg={checkColor(right)}>{checkIcon(right)} </span>
|
|
432
|
-
<span fg={colors.text}>{right.name}</span>
|
|
433
|
-
</>
|
|
434
|
-
) : null}
|
|
435
|
-
</TextLine>
|
|
436
|
-
)
|
|
437
|
-
})}
|
|
438
|
-
</box>
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const DetailHeader = ({
|
|
443
|
-
pullRequest,
|
|
444
|
-
contentWidth,
|
|
445
|
-
paneWidth,
|
|
446
|
-
showChecks = false,
|
|
447
|
-
}: {
|
|
448
|
-
pullRequest: PullRequestItem
|
|
449
|
-
contentWidth: number
|
|
450
|
-
paneWidth: number
|
|
451
|
-
showChecks?: boolean
|
|
452
|
-
}) => {
|
|
453
|
-
const labels = pullRequest.labels
|
|
454
|
-
const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
|
|
455
|
-
const unique = deduplicateChecks(pullRequest.checks)
|
|
456
|
-
const checkRows = checksRowCount(unique)
|
|
457
|
-
const statsText = diffStatText(pullRequest)
|
|
458
|
-
const labelsWidth = !pullRequest.detailLoaded
|
|
459
|
-
? "loading details...".length
|
|
460
|
-
: labels.length > 0
|
|
461
|
-
? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
|
|
462
|
-
: "no labels".length
|
|
463
|
-
const showStats = contentWidth - labelsWidth - statsText.length >= 2
|
|
464
|
-
const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
|
|
465
|
-
|
|
466
|
-
return (
|
|
467
|
-
<>
|
|
468
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
469
|
-
{(() => {
|
|
470
|
-
const opened = formatRelativeDate(pullRequest.createdAt)
|
|
471
|
-
const repo = shortRepoName(pullRequest.repository)
|
|
472
|
-
const number = String(pullRequest.number)
|
|
473
|
-
const review = reviewLabel(pullRequest)
|
|
474
|
-
const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
|
|
475
|
-
const statusParts = [review, checks].filter((part): part is string => Boolean(part))
|
|
476
|
-
const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
|
|
477
|
-
const leftWidth = 1 + number.length + 1 + repo.length
|
|
478
|
-
const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
|
|
479
|
-
|
|
480
|
-
return (
|
|
481
|
-
<TextLine>
|
|
482
|
-
<span fg={colors.count}>#{number}</span>
|
|
483
|
-
<span fg={colors.muted}> {repo}</span>
|
|
484
|
-
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
485
|
-
{review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
|
|
486
|
-
{review && checks ? <span fg={colors.muted}> </span> : null}
|
|
487
|
-
{checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
|
|
488
|
-
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
489
|
-
<span fg={colors.muted}>{opened}</span>
|
|
490
|
-
</TextLine>
|
|
491
|
-
)
|
|
492
|
-
})()}
|
|
493
|
-
</box>
|
|
494
|
-
<box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
495
|
-
{wrappedTitle.map((line, index) => (
|
|
496
|
-
<PlainLine key={index} text={line} bold />
|
|
497
|
-
))}
|
|
498
|
-
</box>
|
|
499
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
500
|
-
<TextLine>
|
|
501
|
-
{!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
|
|
502
|
-
<Fragment key={label.name}>
|
|
503
|
-
{index > 0 ? <span fg={colors.muted}> </span> : null}
|
|
504
|
-
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
|
|
505
|
-
</Fragment>
|
|
506
|
-
)) : <span fg={colors.muted}>no labels</span>}
|
|
507
|
-
{showStats ? (
|
|
508
|
-
<>
|
|
509
|
-
<span fg={colors.muted}>{" ".repeat(statsGap)}</span>
|
|
510
|
-
<DiffStats pullRequest={pullRequest} />
|
|
511
|
-
</>
|
|
512
|
-
) : null}
|
|
513
|
-
</TextLine>
|
|
514
|
-
</box>
|
|
515
|
-
<box height={1}><Divider width={paneWidth} /></box>
|
|
516
|
-
{showChecks && unique.length > 0 ? (
|
|
517
|
-
<>
|
|
518
|
-
<box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
|
|
519
|
-
<ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
|
|
520
|
-
</box>
|
|
521
|
-
<box height={1}><Divider width={paneWidth} /></box>
|
|
522
|
-
</>
|
|
523
|
-
) : null}
|
|
524
|
-
</>
|
|
525
|
-
)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const DetailBody = ({
|
|
529
|
-
pullRequest,
|
|
530
|
-
contentWidth,
|
|
531
|
-
bodyLines = DETAIL_BODY_LINES,
|
|
532
|
-
loadingIndicator,
|
|
533
|
-
}: {
|
|
534
|
-
pullRequest: PullRequestItem
|
|
535
|
-
contentWidth: number
|
|
536
|
-
bodyLines?: number
|
|
537
|
-
loadingIndicator: string
|
|
538
|
-
}) => {
|
|
539
|
-
const previewLines = useMemo(
|
|
540
|
-
() => bodyPreview(pullRequest.body, contentWidth, bodyLines),
|
|
541
|
-
[pullRequest.body, contentWidth, bodyLines],
|
|
542
|
-
)
|
|
543
|
-
|
|
544
|
-
if (!pullRequest.detailLoaded) {
|
|
545
|
-
const topRows = Math.max(0, Math.floor((bodyLines - 1) / 2))
|
|
546
|
-
const bottomRows = Math.max(0, bodyLines - topRows - 1)
|
|
547
|
-
return (
|
|
548
|
-
<box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
|
|
549
|
-
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
550
|
-
<PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
|
|
551
|
-
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
552
|
-
</box>
|
|
553
|
-
)
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return (
|
|
557
|
-
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
558
|
-
{previewLines.map((line, index) => (
|
|
559
|
-
<TextLine key={`${pullRequest.url}-${index}`}>
|
|
560
|
-
{line.segments.map((segment, segmentIndex) => (
|
|
561
|
-
("bold" in segment && segment.bold === true) ? (
|
|
562
|
-
<span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
|
|
563
|
-
{segment.text}
|
|
564
|
-
</span>
|
|
565
|
-
) : (
|
|
566
|
-
<span key={segmentIndex} fg={segment.fg}>
|
|
567
|
-
{segment.text}
|
|
568
|
-
</span>
|
|
569
|
-
)
|
|
570
|
-
))}
|
|
571
|
-
</TextLine>
|
|
572
|
-
))}
|
|
573
|
-
</box>
|
|
574
|
-
)
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
|
|
578
|
-
const innerWidth = Math.max(1, width - 2)
|
|
579
|
-
const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
|
|
580
|
-
const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
|
|
581
|
-
const cardInnerWidth = Math.max(1, cardWidth - 2)
|
|
582
|
-
const contentLine = (text: string, fg: string, bold = false) => (
|
|
583
|
-
<TextLine>
|
|
584
|
-
<span fg={colors.separator}>{offset}│</span>
|
|
585
|
-
{bold ? (
|
|
586
|
-
<span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
|
|
587
|
-
) : (
|
|
588
|
-
<span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
|
|
589
|
-
)}
|
|
590
|
-
<span fg={colors.separator}>│</span>
|
|
591
|
-
</TextLine>
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
return (
|
|
595
|
-
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
596
|
-
<PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
|
|
597
|
-
{contentLine(content.title, colors.count, true)}
|
|
598
|
-
{contentLine(content.hint, colors.muted)}
|
|
599
|
-
<PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
|
|
600
|
-
</box>
|
|
601
|
-
)
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
|
|
605
|
-
<box flexDirection="column">
|
|
606
|
-
<StatusCard content={content} width={paneWidth} />
|
|
607
|
-
<box height={1}><Divider width={paneWidth} /></box>
|
|
608
|
-
</box>
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
|
|
612
|
-
const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
|
|
613
|
-
const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
|
|
614
|
-
|
|
615
|
-
return (
|
|
616
|
-
<box height={height} flexDirection="column">
|
|
617
|
-
{Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
|
|
618
|
-
<StatusCard content={content} width={width} />
|
|
619
|
-
{Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
|
|
620
|
-
</box>
|
|
621
|
-
)
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
const DetailsPane = ({
|
|
625
|
-
pullRequest,
|
|
626
|
-
contentWidth,
|
|
627
|
-
bodyLines = DETAIL_BODY_LINES,
|
|
628
|
-
paneWidth = contentWidth + 2,
|
|
629
|
-
showChecks = false,
|
|
630
|
-
placeholderContent,
|
|
631
|
-
loadingIndicator,
|
|
632
|
-
}: {
|
|
633
|
-
pullRequest: PullRequestItem | null
|
|
634
|
-
contentWidth: number
|
|
635
|
-
bodyLines?: number
|
|
636
|
-
paneWidth?: number
|
|
637
|
-
showChecks?: boolean
|
|
638
|
-
placeholderContent: DetailPlaceholderContent
|
|
639
|
-
loadingIndicator: string
|
|
640
|
-
}) => {
|
|
641
|
-
const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
|
|
642
|
-
const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
|
|
643
|
-
const checkRows = checksRowCount(uniqueChecks)
|
|
644
|
-
// checks heading (1) + grid rows + divider (1)
|
|
645
|
-
const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
|
|
646
|
-
const previewLines = useMemo(
|
|
647
|
-
() => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
|
|
648
|
-
[pullRequest?.body, contentWidth, bodyLines],
|
|
649
|
-
)
|
|
650
|
-
const bodyHeight = pullRequest && !pullRequest.detailLoaded ? bodyLines : previewLines.length
|
|
651
|
-
const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + bodyHeight : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
652
|
-
|
|
653
|
-
return (
|
|
654
|
-
<box flexDirection="column" height={contentHeight}>
|
|
655
|
-
{pullRequest ? (
|
|
656
|
-
<>
|
|
657
|
-
<DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
658
|
-
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
|
|
659
|
-
</>
|
|
660
|
-
) : (
|
|
661
|
-
<>
|
|
662
|
-
<DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
|
|
663
|
-
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
664
|
-
{Array.from({ length: bodyLines }, (_, index) => (
|
|
665
|
-
<BlankRow key={index} />
|
|
666
|
-
))}
|
|
667
|
-
</box>
|
|
668
|
-
</>
|
|
669
|
-
)}
|
|
670
|
-
</box>
|
|
671
|
-
)
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const PullRequestDiffPane = ({
|
|
675
|
-
pullRequest,
|
|
676
|
-
diffState,
|
|
677
|
-
fileIndex,
|
|
678
|
-
view,
|
|
679
|
-
wrapMode,
|
|
680
|
-
paneWidth,
|
|
681
|
-
height,
|
|
682
|
-
loadingIndicator,
|
|
683
|
-
scrollRef,
|
|
684
|
-
}: {
|
|
685
|
-
pullRequest: PullRequestItem | null
|
|
686
|
-
diffState: PullRequestDiffState | undefined
|
|
687
|
-
fileIndex: number
|
|
688
|
-
view: "unified" | "split"
|
|
689
|
-
wrapMode: "none" | "word"
|
|
690
|
-
paneWidth: number
|
|
691
|
-
height: number
|
|
692
|
-
loadingIndicator: string
|
|
693
|
-
scrollRef: React.Ref<ScrollBoxRenderable>
|
|
694
|
-
}) => {
|
|
695
|
-
const readyFiles = diffState?.status === "ready" ? diffState.files : []
|
|
696
|
-
const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
|
|
697
|
-
const file = readyFiles[safeIndex] ?? null
|
|
698
|
-
const diffHeight = useMemo(
|
|
699
|
-
() => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
|
|
700
|
-
[file?.patch, view, wrapMode, paneWidth],
|
|
701
|
-
)
|
|
702
|
-
|
|
703
|
-
if (!pullRequest) {
|
|
704
|
-
return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const stats = diffStatText(pullRequest)
|
|
708
|
-
const headerWidth = Math.max(24, paneWidth - 2)
|
|
709
|
-
const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
|
|
710
|
-
const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
|
|
711
|
-
|
|
712
|
-
if (!diffState || diffState.status === "loading") {
|
|
713
|
-
return (
|
|
714
|
-
<box height={height} flexDirection="column">
|
|
715
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
716
|
-
<TextLine>
|
|
717
|
-
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
718
|
-
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
719
|
-
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
720
|
-
<DiffStats pullRequest={pullRequest} />
|
|
721
|
-
</TextLine>
|
|
722
|
-
</box>
|
|
723
|
-
<Divider width={paneWidth} />
|
|
724
|
-
<LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
|
|
725
|
-
</box>
|
|
726
|
-
)
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
if (diffState.status === "error") {
|
|
730
|
-
return (
|
|
731
|
-
<box height={height} flexDirection="column">
|
|
732
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
733
|
-
<PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
|
|
734
|
-
</box>
|
|
735
|
-
<Divider width={paneWidth} />
|
|
736
|
-
<StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
|
|
737
|
-
</box>
|
|
738
|
-
)
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (readyFiles.length === 0 || !file) {
|
|
742
|
-
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
|
|
746
|
-
const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
|
|
747
|
-
|
|
748
|
-
return (
|
|
749
|
-
<box height={height} flexDirection="column">
|
|
750
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
751
|
-
<TextLine>
|
|
752
|
-
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
753
|
-
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
754
|
-
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
755
|
-
<DiffStats pullRequest={pullRequest} />
|
|
756
|
-
</TextLine>
|
|
757
|
-
</box>
|
|
758
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
759
|
-
<TextLine>
|
|
760
|
-
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
761
|
-
<span fg={colors.muted}> {fileCounter}</span>
|
|
762
|
-
</TextLine>
|
|
763
|
-
</box>
|
|
764
|
-
<Divider width={paneWidth} />
|
|
765
|
-
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
|
|
766
|
-
<diff
|
|
767
|
-
key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
|
|
768
|
-
diff={file.patch}
|
|
769
|
-
view={view}
|
|
770
|
-
syncScroll
|
|
771
|
-
filetype={file.filetype ?? "text"}
|
|
772
|
-
syntaxStyle={diffSyntaxStyle}
|
|
773
|
-
showLineNumbers
|
|
774
|
-
wrapMode={wrapMode}
|
|
775
|
-
addedBg="#17351f"
|
|
776
|
-
removedBg="#3a1e22"
|
|
777
|
-
contextBg="transparent"
|
|
778
|
-
addedSignColor={colors.status.passing}
|
|
779
|
-
removedSignColor={colors.status.failing}
|
|
780
|
-
lineNumberFg={colors.muted}
|
|
781
|
-
lineNumberBg="#151515"
|
|
782
|
-
addedLineNumberBg="#12301a"
|
|
783
|
-
removedLineNumberBg="#35171b"
|
|
784
|
-
selectionBg={colors.selectedBg}
|
|
785
|
-
selectionFg={colors.selectedText}
|
|
786
|
-
height={diffHeight}
|
|
787
|
-
style={{ flexShrink: 0 }}
|
|
788
|
-
/>
|
|
789
|
-
</scrollbox>
|
|
790
|
-
</box>
|
|
791
|
-
)
|
|
792
|
-
}
|
|
793
|
-
|
|
794
197
|
export const App = () => {
|
|
795
198
|
const renderer = useRenderer()
|
|
796
199
|
const { width, height } = useTerminalDimensions()
|
|
@@ -982,16 +385,7 @@ export const App = () => {
|
|
|
982
385
|
visibleCount: visiblePullRequests.length,
|
|
983
386
|
filterText: visibleFilterText,
|
|
984
387
|
})
|
|
985
|
-
const
|
|
986
|
-
const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
|
|
987
|
-
const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
|
|
988
|
-
const detailChecks = selectedPullRequest ? deduplicateChecks(selectedPullRequest.checks) : []
|
|
989
|
-
const checksRows = checksRowCount(detailChecks)
|
|
990
|
-
// checks heading (1) + grid rows + divider
|
|
991
|
-
const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
|
|
992
|
-
const detailJunctions = selectedPullRequest
|
|
993
|
-
? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
|
|
994
|
-
: [DETAIL_PLACEHOLDER_ROWS]
|
|
388
|
+
const detailJunctions = getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
|
|
995
389
|
|
|
996
390
|
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
997
391
|
|
|
@@ -1076,6 +470,7 @@ export const App = () => {
|
|
|
1076
470
|
if (!selectedPullRequest) return
|
|
1077
471
|
const repository = selectedPullRequest.repository
|
|
1078
472
|
const number = selectedPullRequest.number
|
|
473
|
+
const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
|
|
1079
474
|
setLabelModal(initialLabelModalState)
|
|
1080
475
|
setMergeModal({
|
|
1081
476
|
open: true,
|
|
@@ -1084,7 +479,7 @@ export const App = () => {
|
|
|
1084
479
|
selectedIndex: 0,
|
|
1085
480
|
loading: true,
|
|
1086
481
|
running: false,
|
|
1087
|
-
info:
|
|
482
|
+
info: seededInfo,
|
|
1088
483
|
error: null,
|
|
1089
484
|
})
|
|
1090
485
|
void getPullRequestMergeInfo({ repository, number })
|
|
@@ -1102,7 +497,7 @@ export const App = () => {
|
|
|
1102
497
|
|
|
1103
498
|
const confirmMergeAction = () => {
|
|
1104
499
|
if (!mergeModal.info || mergeModal.loading || mergeModal.running) return
|
|
1105
|
-
const options =
|
|
500
|
+
const options = availableMergeActions(mergeModal.info)
|
|
1106
501
|
const option = options[mergeModal.selectedIndex]
|
|
1107
502
|
if (!option) return
|
|
1108
503
|
|
|
@@ -1111,17 +506,11 @@ export const App = () => {
|
|
|
1111
506
|
const previousPullRequest = targetPullRequest ?? null
|
|
1112
507
|
const previousMergeInfo = mergeModal.info
|
|
1113
508
|
|
|
1114
|
-
if (targetPullRequest && option.
|
|
1115
|
-
updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled:
|
|
1116
|
-
setMergeModal((current) => ({
|
|
1117
|
-
...current,
|
|
1118
|
-
info: current.info ? { ...current.info, autoMergeEnabled: true } : current.info,
|
|
1119
|
-
}))
|
|
1120
|
-
} else if (targetPullRequest && option.action === "disable-auto") {
|
|
1121
|
-
updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: false }))
|
|
509
|
+
if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
|
|
510
|
+
updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
|
|
1122
511
|
setMergeModal((current) => ({
|
|
1123
512
|
...current,
|
|
1124
|
-
info: current.info ? { ...current.info, autoMergeEnabled:
|
|
513
|
+
info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
|
|
1125
514
|
}))
|
|
1126
515
|
}
|
|
1127
516
|
|
|
@@ -1129,10 +518,10 @@ export const App = () => {
|
|
|
1129
518
|
void mergePullRequest({ repository, number, action: option.action })
|
|
1130
519
|
.then(() => {
|
|
1131
520
|
setMergeModal(initialMergeModalState)
|
|
1132
|
-
if (option.
|
|
1133
|
-
refreshPullRequests(`${
|
|
521
|
+
if (option.refreshOnSuccess) {
|
|
522
|
+
refreshPullRequests(`${option.pastTense} #${number}`)
|
|
1134
523
|
} else {
|
|
1135
|
-
flashNotice(`${
|
|
524
|
+
flashNotice(`${option.pastTense} #${number}`)
|
|
1136
525
|
}
|
|
1137
526
|
})
|
|
1138
527
|
.catch((error) => {
|
|
@@ -1193,7 +582,7 @@ export const App = () => {
|
|
|
1193
582
|
}
|
|
1194
583
|
|
|
1195
584
|
if (mergeModal.open) {
|
|
1196
|
-
const options =
|
|
585
|
+
const options = availableMergeActions(mergeModal.info)
|
|
1197
586
|
if (key.name === "escape") {
|
|
1198
587
|
setMergeModal(initialMergeModalState)
|
|
1199
588
|
return
|