@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/src/App.tsx CHANGED
@@ -1,21 +1,24 @@
1
- import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
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 { Fragment, useEffect, useMemo, useRef, useState } from "react"
7
+ import { useEffect, useMemo, useRef, useState } from "react"
8
8
  import { config } from "./config.js"
9
- import type { CheckItem, PullRequestItem, PullRequestLabel, PullRequestMergeAction } from "./domain.js"
10
- import { formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
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 { diffStatText, diffSyntaxStyle, patchRenderableLineCount, pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
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 { centerCell, Divider, fitCell, PlainLine, SeparatorColumn, TextLine } from "./ui/primitives.js"
17
- import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal, mergeActionPastTense, mergeModalOptions } from "./ui/modals.js"
18
- import { groupBy, labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./ui/pullRequests.js"
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 parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
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
- const pushLine = () => {
177
- lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
178
- current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
179
- currentLength = indent.length
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
- for (const token of tokens) {
183
- const tokenLength = token.text.length
184
- if (currentLength > 0 && currentLength + tokenLength > width) {
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
- if (current.length > 0) {
192
- lines.push({ segments: current })
129
+ if (pullRequest.checkSummary) {
130
+ lines.push(pullRequest.checkSummary)
193
131
  }
194
132
 
195
- return lines
196
- }
133
+ const proc = Bun.spawn({
134
+ cmd: ["pbcopy"],
135
+ stdin: "pipe",
136
+ stdout: "ignore",
137
+ stderr: "pipe",
138
+ })
197
139
 
198
- const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
140
+ if (!proc.stdin) {
141
+ throw new Error("Clipboard is not available")
142
+ }
199
143
 
200
- const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
201
- if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
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
- return (
212
- <>
213
- {parts.map((part, index) => (
214
- <Fragment key={part.key}>
215
- {index > 0 ? <span fg={colors.muted}> </span> : null}
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 titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
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: null,
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 = mergeModalOptions(mergeModal.info)
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.action === "auto") {
1115
- updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: true }))
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: false } : current.info,
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.action === "squash" || option.action === "admin") {
1133
- refreshPullRequests(`${mergeActionPastTense(option.action)} #${number}`)
521
+ if (option.refreshOnSuccess) {
522
+ refreshPullRequests(`${option.pastTense} #${number}`)
1134
523
  } else {
1135
- flashNotice(`${mergeActionPastTense(option.action)} #${number}`)
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 = mergeModalOptions(mergeModal.info)
585
+ const options = availableMergeActions(mergeModal.info)
1197
586
  if (key.name === "escape") {
1198
587
  setMergeModal(initialMergeModalState)
1199
588
  return