@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/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, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, 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,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 DETAIL_PLACEHOLDER_ROWS = 4
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 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
119
+ const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
175
120
 
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
- }
121
+ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
122
+ const lines = [
123
+ pullRequest.title,
124
+ `${pullRequest.repository} #${pullRequest.number}`,
125
+ pullRequest.url,
126
+ ]
181
127
 
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
128
+ const review = reviewLabel(pullRequest)
129
+ if (review) {
130
+ lines.push(`review: ${review}`)
189
131
  }
190
-
191
- if (current.length > 0) {
192
- lines.push({ segments: current })
132
+ if (pullRequest.checkSummary) {
133
+ lines.push(pullRequest.checkSummary)
193
134
  }
194
135
 
195
- return lines
196
- }
136
+ const proc = Bun.spawn({
137
+ cmd: ["pbcopy"],
138
+ stdin: "pipe",
139
+ stdout: "ignore",
140
+ stderr: "pipe",
141
+ })
197
142
 
198
- const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
143
+ if (!proc.stdin) {
144
+ throw new Error("Clipboard is not available")
145
+ }
199
146
 
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)
147
+ proc.stdin.write(lines.join("\n"))
148
+ proc.stdin.end()
210
149
 
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
- )
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 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]
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: null,
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 = mergeModalOptions(mergeModal.info)
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.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 }))
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: false } : current.info,
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.action === "squash" || option.action === "admin") {
1133
- refreshPullRequests(`${mergeActionPastTense(option.action)} #${number}`)
579
+ if (option.refreshOnSuccess) {
580
+ refreshPullRequests(`${option.pastTense} #${number}`)
1134
581
  } else {
1135
- flashNotice(`${mergeActionPastTense(option.action)} #${number}`)
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 = mergeModalOptions(mergeModal.info)
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}