@kitlangton/ghui 0.1.8 → 0.1.10
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 +127 -664
- package/src/index.tsx +9 -1
- package/src/mergeActions.ts +90 -0
- package/src/services/GitHubService.ts +2 -9
- package/src/ui/DetailsPane.tsx +519 -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, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, 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,10 @@ 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
|
|
42
|
+
const FOCUS_RETURN_REFRESH_MIN_MS = 60_000
|
|
43
|
+
const FOCUSED_IDLE_REFRESH_MS = 5 * 60_000
|
|
44
|
+
const AUTO_REFRESH_JITTER_MS = 10_000
|
|
54
45
|
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
55
46
|
|
|
56
47
|
const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
|
|
@@ -123,101 +114,44 @@ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; rea
|
|
|
123
114
|
GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
|
|
124
115
|
)
|
|
125
116
|
|
|
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
117
|
const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
148
118
|
|
|
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
|
|
119
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
175
120
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
121
|
+
const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
122
|
+
const lines = [
|
|
123
|
+
pullRequest.title,
|
|
124
|
+
`${pullRequest.repository} #${pullRequest.number}`,
|
|
125
|
+
pullRequest.url,
|
|
126
|
+
]
|
|
181
127
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
pushLine()
|
|
186
|
-
}
|
|
187
|
-
current.push(token)
|
|
188
|
-
currentLength += tokenLength
|
|
128
|
+
const review = reviewLabel(pullRequest)
|
|
129
|
+
if (review) {
|
|
130
|
+
lines.push(`review: ${review}`)
|
|
189
131
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
lines.push({ segments: current })
|
|
132
|
+
if (pullRequest.checkSummary) {
|
|
133
|
+
lines.push(pullRequest.checkSummary)
|
|
193
134
|
}
|
|
194
135
|
|
|
195
|
-
|
|
196
|
-
|
|
136
|
+
const proc = Bun.spawn({
|
|
137
|
+
cmd: ["pbcopy"],
|
|
138
|
+
stdin: "pipe",
|
|
139
|
+
stdout: "ignore",
|
|
140
|
+
stderr: "pipe",
|
|
141
|
+
})
|
|
197
142
|
|
|
198
|
-
|
|
143
|
+
if (!proc.stdin) {
|
|
144
|
+
throw new Error("Clipboard is not available")
|
|
145
|
+
}
|
|
199
146
|
|
|
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)
|
|
147
|
+
proc.stdin.write(lines.join("\n"))
|
|
148
|
+
proc.stdin.end()
|
|
210
149
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
<span fg={part.color}>{part.text}</span>
|
|
217
|
-
</Fragment>
|
|
218
|
-
))}
|
|
219
|
-
</>
|
|
220
|
-
)
|
|
150
|
+
const exitCode = await proc.exited
|
|
151
|
+
if (exitCode !== 0) {
|
|
152
|
+
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
153
|
+
throw new Error(stderr.trim() || "Could not copy PR metadata")
|
|
154
|
+
}
|
|
221
155
|
}
|
|
222
156
|
|
|
223
157
|
const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
|
|
@@ -263,534 +197,6 @@ const getDetailPlaceholderContent = ({
|
|
|
263
197
|
}
|
|
264
198
|
}
|
|
265
199
|
|
|
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
200
|
export const App = () => {
|
|
795
201
|
const renderer = useRenderer()
|
|
796
202
|
const { width, height } = useTerminalDimensions()
|
|
@@ -815,6 +221,7 @@ export const App = () => {
|
|
|
815
221
|
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
816
222
|
const retryProgress = useAtomValue(retryProgressAtom)
|
|
817
223
|
const [loadingFrame, setLoadingFrame] = useState(0)
|
|
224
|
+
const [terminalFocused, setTerminalFocused] = useState(true)
|
|
818
225
|
const usernameResult = useAtomValue(usernameAtom)
|
|
819
226
|
const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
|
|
820
227
|
const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
|
|
@@ -839,6 +246,12 @@ export const App = () => {
|
|
|
839
246
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
840
247
|
const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
841
248
|
const detailHydrationRef = useRef<number | null>(null)
|
|
249
|
+
const lastPullRequestRefreshAtRef = useRef(0)
|
|
250
|
+
const terminalFocusedRef = useRef(true)
|
|
251
|
+
const terminalWasBlurredRef = useRef(false)
|
|
252
|
+
const pullRequestStatusRef = useRef<LoadStatus>("loading")
|
|
253
|
+
const refreshPullRequestsRef = useRef<(message?: string) => void>(() => {})
|
|
254
|
+
const maybeRefreshPullRequestsRef = useRef<(minimumAgeMs: number) => void>(() => {})
|
|
842
255
|
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
843
256
|
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
844
257
|
const headerFooterWidth = Math.max(24, contentWidth - 2)
|
|
@@ -878,6 +291,7 @@ export const App = () => {
|
|
|
878
291
|
const isInitialLoading = pullRequestStatus === "loading" && pullRequests.length === 0
|
|
879
292
|
const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
|
|
880
293
|
const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
|
|
294
|
+
pullRequestStatusRef.current = pullRequestStatus
|
|
881
295
|
|
|
882
296
|
const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
|
|
883
297
|
const visibleFilterText = filterMode ? filterDraft : filterQuery
|
|
@@ -929,6 +343,53 @@ export const App = () => {
|
|
|
929
343
|
refreshPullRequestsAtom()
|
|
930
344
|
if (message) flashNotice(message)
|
|
931
345
|
}
|
|
346
|
+
refreshPullRequestsRef.current = refreshPullRequests
|
|
347
|
+
maybeRefreshPullRequestsRef.current = (minimumAgeMs) => {
|
|
348
|
+
if (!terminalFocusedRef.current || pullRequestStatusRef.current === "loading") return
|
|
349
|
+
const lastRefreshAt = lastPullRequestRefreshAtRef.current
|
|
350
|
+
if (lastRefreshAt > 0 && Date.now() - lastRefreshAt < minimumAgeMs) return
|
|
351
|
+
refreshPullRequestsRef.current()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
|
|
356
|
+
if (fetchedAt !== undefined) {
|
|
357
|
+
lastPullRequestRefreshAtRef.current = fetchedAt
|
|
358
|
+
}
|
|
359
|
+
}, [pullRequestLoad?.fetchedAt])
|
|
360
|
+
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
const handleFocus = () => {
|
|
363
|
+
terminalFocusedRef.current = true
|
|
364
|
+
setTerminalFocused(true)
|
|
365
|
+
if (terminalWasBlurredRef.current) {
|
|
366
|
+
maybeRefreshPullRequestsRef.current(FOCUS_RETURN_REFRESH_MIN_MS)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const handleBlur = () => {
|
|
370
|
+
terminalWasBlurredRef.current = true
|
|
371
|
+
terminalFocusedRef.current = false
|
|
372
|
+
setTerminalFocused(false)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
renderer.on("focus", handleFocus)
|
|
376
|
+
renderer.on("blur", handleBlur)
|
|
377
|
+
return () => {
|
|
378
|
+
renderer.off("focus", handleFocus)
|
|
379
|
+
renderer.off("blur", handleBlur)
|
|
380
|
+
}
|
|
381
|
+
}, [renderer])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!terminalFocused) return
|
|
385
|
+
const lastRefreshAt = lastPullRequestRefreshAtRef.current || Date.now()
|
|
386
|
+
const ageMs = Date.now() - lastRefreshAt
|
|
387
|
+
const delayMs = Math.max(0, FOCUSED_IDLE_REFRESH_MS - ageMs) + Math.floor(Math.random() * AUTO_REFRESH_JITTER_MS)
|
|
388
|
+
const timeout = globalThis.setTimeout(() => {
|
|
389
|
+
maybeRefreshPullRequestsRef.current(FOCUSED_IDLE_REFRESH_MS)
|
|
390
|
+
}, delayMs)
|
|
391
|
+
return () => globalThis.clearTimeout(timeout)
|
|
392
|
+
}, [terminalFocused, pullRequestLoad?.fetchedAt])
|
|
932
393
|
|
|
933
394
|
useEffect(() => {
|
|
934
395
|
setSelectedIndex((current) => {
|
|
@@ -982,16 +443,7 @@ export const App = () => {
|
|
|
982
443
|
visibleCount: visiblePullRequests.length,
|
|
983
444
|
filterText: visibleFilterText,
|
|
984
445
|
})
|
|
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]
|
|
446
|
+
const detailJunctions = getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
|
|
995
447
|
|
|
996
448
|
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
997
449
|
|
|
@@ -1076,6 +528,7 @@ export const App = () => {
|
|
|
1076
528
|
if (!selectedPullRequest) return
|
|
1077
529
|
const repository = selectedPullRequest.repository
|
|
1078
530
|
const number = selectedPullRequest.number
|
|
531
|
+
const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
|
|
1079
532
|
setLabelModal(initialLabelModalState)
|
|
1080
533
|
setMergeModal({
|
|
1081
534
|
open: true,
|
|
@@ -1084,7 +537,7 @@ export const App = () => {
|
|
|
1084
537
|
selectedIndex: 0,
|
|
1085
538
|
loading: true,
|
|
1086
539
|
running: false,
|
|
1087
|
-
info:
|
|
540
|
+
info: seededInfo,
|
|
1088
541
|
error: null,
|
|
1089
542
|
})
|
|
1090
543
|
void getPullRequestMergeInfo({ repository, number })
|
|
@@ -1102,7 +555,7 @@ export const App = () => {
|
|
|
1102
555
|
|
|
1103
556
|
const confirmMergeAction = () => {
|
|
1104
557
|
if (!mergeModal.info || mergeModal.loading || mergeModal.running) return
|
|
1105
|
-
const options =
|
|
558
|
+
const options = availableMergeActions(mergeModal.info)
|
|
1106
559
|
const option = options[mergeModal.selectedIndex]
|
|
1107
560
|
if (!option) return
|
|
1108
561
|
|
|
@@ -1111,17 +564,11 @@ export const App = () => {
|
|
|
1111
564
|
const previousPullRequest = targetPullRequest ?? null
|
|
1112
565
|
const previousMergeInfo = mergeModal.info
|
|
1113
566
|
|
|
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 }))
|
|
567
|
+
if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
|
|
568
|
+
updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
|
|
1122
569
|
setMergeModal((current) => ({
|
|
1123
570
|
...current,
|
|
1124
|
-
info: current.info ? { ...current.info, autoMergeEnabled:
|
|
571
|
+
info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
|
|
1125
572
|
}))
|
|
1126
573
|
}
|
|
1127
574
|
|
|
@@ -1129,10 +576,10 @@ export const App = () => {
|
|
|
1129
576
|
void mergePullRequest({ repository, number, action: option.action })
|
|
1130
577
|
.then(() => {
|
|
1131
578
|
setMergeModal(initialMergeModalState)
|
|
1132
|
-
if (option.
|
|
1133
|
-
refreshPullRequests(`${
|
|
579
|
+
if (option.refreshOnSuccess) {
|
|
580
|
+
refreshPullRequests(`${option.pastTense} #${number}`)
|
|
1134
581
|
} else {
|
|
1135
|
-
flashNotice(`${
|
|
582
|
+
flashNotice(`${option.pastTense} #${number}`)
|
|
1136
583
|
}
|
|
1137
584
|
})
|
|
1138
585
|
.catch((error) => {
|
|
@@ -1193,7 +640,7 @@ export const App = () => {
|
|
|
1193
640
|
}
|
|
1194
641
|
|
|
1195
642
|
if (mergeModal.open) {
|
|
1196
|
-
const options =
|
|
643
|
+
const options = availableMergeActions(mergeModal.info)
|
|
1197
644
|
if (key.name === "escape") {
|
|
1198
645
|
setMergeModal(initialMergeModalState)
|
|
1199
646
|
return
|
|
@@ -1624,6 +1071,22 @@ export const App = () => {
|
|
|
1624
1071
|
|
|
1625
1072
|
const fullscreenContentWidth = Math.max(24, contentWidth - 2)
|
|
1626
1073
|
const fullscreenBodyLines = Math.max(8, (height ?? 24) - 8)
|
|
1074
|
+
const wideFullscreenDetailScrollable = getDetailsPaneHeight({
|
|
1075
|
+
pullRequest: selectedPullRequest,
|
|
1076
|
+
contentWidth: fullscreenContentWidth,
|
|
1077
|
+
bodyLines: fullscreenBodyLines,
|
|
1078
|
+
paneWidth: contentWidth,
|
|
1079
|
+
showChecks: true,
|
|
1080
|
+
}) > wideBodyHeight
|
|
1081
|
+
const narrowFullscreenDetailScrollable = getDetailsPaneHeight({
|
|
1082
|
+
pullRequest: selectedPullRequest,
|
|
1083
|
+
contentWidth: fullscreenContentWidth,
|
|
1084
|
+
bodyLines: fullscreenBodyLines,
|
|
1085
|
+
paneWidth: contentWidth,
|
|
1086
|
+
}) > wideBodyHeight
|
|
1087
|
+
const wideDetailHeaderHeight = getDetailHeaderHeight(selectedPullRequest, rightPaneWidth, true)
|
|
1088
|
+
const wideDetailBodyViewportHeight = Math.max(1, wideBodyHeight - wideDetailHeaderHeight)
|
|
1089
|
+
const wideDetailBodyScrollable = getDetailBodyHeight(selectedPullRequest, rightContentWidth, wideDetailLines) > wideDetailBodyViewportHeight
|
|
1627
1090
|
|
|
1628
1091
|
const prListProps = {
|
|
1629
1092
|
groups: visibleGroups,
|
|
@@ -1672,7 +1135,7 @@ export const App = () => {
|
|
|
1672
1135
|
/>
|
|
1673
1136
|
) : isWideLayout && detailFullView ? (
|
|
1674
1137
|
<box flexGrow={1} flexDirection="column">
|
|
1675
|
-
<scrollbox ref={detailScrollRef} focused flexGrow={1}>
|
|
1138
|
+
<scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: wideFullscreenDetailScrollable }}>
|
|
1676
1139
|
<DetailsPane
|
|
1677
1140
|
pullRequest={selectedPullRequest}
|
|
1678
1141
|
contentWidth={fullscreenContentWidth}
|
|
@@ -1696,7 +1159,7 @@ export const App = () => {
|
|
|
1696
1159
|
{selectedPullRequest ? (
|
|
1697
1160
|
<>
|
|
1698
1161
|
<DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
|
|
1699
|
-
<scrollbox flexGrow={1}>
|
|
1162
|
+
<scrollbox flexGrow={1} verticalScrollbarOptions={{ visible: wideDetailBodyScrollable }}>
|
|
1700
1163
|
<DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} />
|
|
1701
1164
|
</scrollbox>
|
|
1702
1165
|
</>
|
|
@@ -1707,7 +1170,7 @@ export const App = () => {
|
|
|
1707
1170
|
</box>
|
|
1708
1171
|
) : detailFullView ? (
|
|
1709
1172
|
<box flexGrow={1} flexDirection="column">
|
|
1710
|
-
<scrollbox ref={detailScrollRef} focused flexGrow={1}>
|
|
1173
|
+
<scrollbox ref={detailScrollRef} focused flexGrow={1} verticalScrollbarOptions={{ visible: narrowFullscreenDetailScrollable }}>
|
|
1711
1174
|
<DetailsPane
|
|
1712
1175
|
pullRequest={selectedPullRequest}
|
|
1713
1176
|
contentWidth={fullscreenContentWidth}
|