@kitlangton/ghui 0.1.7 → 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,45 +1,27 @@
1
- import { parseColor, SyntaxStyle, 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
- import { Cause, Effect, Schedule } from "effect"
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 } from "./domain.js"
10
- import { daysOpen, 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"
12
+ import { Observability } from "./observability.js"
11
13
  import { GitHubService } from "./services/GitHubService.js"
12
-
13
- const githubRuntime = Atom.runtime(GitHubService.layer)
14
-
15
- const colors = {
16
- text: "#ede7da",
17
- muted: "#9f9788",
18
- separator: "#6f685d",
19
- accent: "#f4a51c",
20
- inlineCode: "#d7c5a1",
21
- error: "#f97316",
22
- selectedBg: "#1d2430",
23
- selectedText: "#f8fafc",
24
- count: "#d7c5a1",
25
- status: {
26
- draft: "#f59e0b",
27
- approved: "#7dd3a3",
28
- changes: "#f87171",
29
- review: "#93c5fd",
30
- none: "#9f9788",
31
- passing: "#7dd3a3",
32
- pending: "#f4a51c",
33
- failing: "#f87171",
34
- },
35
- repos: {
36
- opencode: "#60a5fa",
37
- "effect-smol": "#34d399",
38
- "opencode-console": "#f472b6",
39
- opencontrol: "#f59e0b",
40
- default: "#93c5fd",
41
- },
42
- } as const
14
+ import { colors } from "./ui/colors.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"
17
+ import { FooterHints, type RetryProgress } from "./ui/FooterHints.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"
22
+ import { PullRequestList } from "./ui/PullRequestList.js"
23
+
24
+ const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
43
25
 
44
26
  type LoadStatus = "loading" | "ready" | "error"
45
27
 
@@ -48,35 +30,6 @@ interface PullRequestLoad {
48
30
  readonly fetchedAt: Date | null
49
31
  }
50
32
 
51
- interface PreviewLine {
52
- readonly segments: ReadonlyArray<{
53
- readonly text: string
54
- readonly fg: string
55
- readonly bold?: boolean
56
- }>
57
- }
58
-
59
- interface DetailPlaceholderContent {
60
- readonly title: string
61
- readonly hint: string
62
- }
63
-
64
- interface RetryProgress {
65
- readonly attempt: number
66
- readonly max: number
67
- }
68
-
69
- interface DiffFilePatch {
70
- readonly name: string
71
- readonly filetype: string | undefined
72
- readonly patch: string
73
- }
74
-
75
- type PullRequestDiffState =
76
- | { readonly status: "loading" }
77
- | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
78
- | { readonly status: "error"; readonly error: string }
79
-
80
33
  interface DetailPlaceholderInput {
81
34
  readonly status: LoadStatus
82
35
  readonly retryProgress: RetryProgress | null
@@ -85,9 +38,7 @@ interface DetailPlaceholderInput {
85
38
  readonly filterText: string
86
39
  }
87
40
 
88
- const pullRequestReferencePattern = /(#[0-9]+)/g
89
41
  const PR_FETCH_RETRIES = 6
90
- const DETAIL_PLACEHOLDER_ROWS = 4
91
42
  const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
92
43
 
93
44
  const retryProgressAtom = Atom.make<RetryProgress | null>(null).pipe(Atom.keepAlive)
@@ -125,27 +76,8 @@ const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.kee
125
76
  const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
126
77
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
127
78
 
128
- const GROUP_ICON = "◆"
129
-
130
- interface LabelModalState {
131
- readonly open: boolean
132
- readonly repository: string | null
133
- readonly query: string
134
- readonly selectedIndex: number
135
- readonly availableLabels: readonly PullRequestLabel[]
136
- readonly loading: boolean
137
- }
138
-
139
- const initialLabelModalState: LabelModalState = {
140
- open: false,
141
- repository: null,
142
- query: "",
143
- selectedIndex: 0,
144
- availableLabels: [],
145
- loading: false,
146
- }
147
-
148
79
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
80
+ const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
149
81
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
150
82
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
151
83
  const usernameAtom = githubRuntime.atom(
@@ -157,6 +89,9 @@ const usernameAtom = githubRuntime.atom(
157
89
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
158
90
  GitHubService.use((github) => github.listRepoLabels(repository))
159
91
  )
92
+ const listOpenPullRequestDetailsAtom = githubRuntime.fn<void>()(() =>
93
+ GitHubService.use((github) => github.listOpenPullRequestDetails())
94
+ )
160
95
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
161
96
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
162
97
  )
@@ -169,399 +104,51 @@ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly
169
104
  const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
170
105
  GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
171
106
  )
172
-
173
- const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
174
-
175
- const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
176
-
177
- const BlankRow = () => <box height={1} />
178
-
179
- const reviewLabel = (pullRequest: PullRequestItem) => {
180
- if (pullRequest.reviewStatus === "draft") return "draft"
181
- if (pullRequest.reviewStatus === "approved") return "approved"
182
- if (pullRequest.reviewStatus === "changes") return "changes"
183
- if (pullRequest.reviewStatus === "review") return "review"
184
- return null
185
- }
186
-
187
- const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
188
-
189
- const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
190
- const DETAIL_BODY_LINES = 6
191
-
192
- const wrapText = (text: string, width: number): string[] => {
193
- if (text.length === 0 || width <= 0) return [""]
194
- const words = text.split(/\s+/)
195
- const lines: string[] = []
196
- let current = ""
197
- for (const word of words) {
198
- const next = current.length > 0 ? `${current} ${word}` : word
199
- if (next.length > width && current.length > 0) {
200
- lines.push(current)
201
- current = word
202
- } else {
203
- current = next
204
- }
205
- }
206
- if (current.length > 0) lines.push(current)
207
- return lines.length > 0 ? lines : [""]
208
- }
209
-
210
- const reviewIcon = (pullRequest: PullRequestItem) => {
211
- if (pullRequest.reviewStatus === "draft") return "◌"
212
- if (pullRequest.reviewStatus === "approved") return "✓"
213
- if (pullRequest.reviewStatus === "changes") return "!"
214
- if (pullRequest.reviewStatus === "review") return "◐"
215
- return "·"
216
- }
217
-
218
- const getRowLayout = (contentWidth: number, numberWidth = 6) => {
219
- const reviewWidth = 1
220
- const checkWidth = 6
221
- const ageWidth = 4
222
- const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
223
- const titleWidth = Math.max(8, contentWidth - fixedWidth)
224
- return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
225
- }
226
-
227
- const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
228
- if (pullRequests.length === 0) return 4
229
- const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
230
- return maxLen + 1 // +1 for the # prefix
231
- }
232
-
233
- const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
234
- const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
235
- return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
236
- }
237
-
238
- const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
239
-
240
- const centerCell = (text: string, width: number) => {
241
- const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
242
- const left = Math.floor((width - trimmed.length) / 2)
243
- return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
244
- }
245
-
246
- const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
247
- if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
248
- return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
249
- }
250
-
251
- return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
252
- }
253
-
254
- const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
255
- const junctions = new Set(junctionRows)
256
- return (
257
- <box width={1} height={height} flexDirection="column">
258
- {Array.from({ length: height }, (_, index) => (
259
- <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
260
- ))}
261
- </box>
262
- )
263
- }
107
+ const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
108
+ GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
109
+ )
110
+ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
111
+ GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
112
+ )
264
113
 
265
114
  const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
266
115
 
267
- const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
268
- const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
269
- return parts.flatMap((part) => {
270
- if (part.startsWith("`") && part.endsWith("`")) {
271
- return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
272
- }
273
-
274
- return part
275
- .split(pullRequestReferencePattern)
276
- .filter((segment) => segment.length > 0)
277
- .map((segment) => ({
278
- text: segment,
279
- fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
280
- bold,
281
- }))
282
- })
283
- }
284
-
285
- const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
286
- const tokens = segments.flatMap((segment) =>
287
- segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
288
- )
289
-
290
- const lines: Array<PreviewLine> = []
291
- let current: Array<PreviewLine["segments"][number]> = []
292
- let currentLength = 0
293
-
294
- const pushLine = () => {
295
- lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
296
- current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
297
- currentLength = indent.length
298
- }
299
-
300
- for (const token of tokens) {
301
- const tokenLength = token.text.length
302
- if (currentLength > 0 && currentLength + tokenLength > width) {
303
- pushLine()
304
- }
305
- current.push(token)
306
- currentLength += tokenLength
307
- }
308
-
309
- if (current.length > 0) {
310
- lines.push({ segments: current })
311
- }
312
-
313
- return lines
314
- }
315
-
316
- const fallbackLabelColor = (name: string) => {
317
- let hash = 0
318
- for (const char of name) {
319
- hash = (hash * 31 + char.charCodeAt(0)) >>> 0
320
- }
321
- const hue = hash % 360
322
- return `hsl(${hue} 55% 35%)`
323
- }
324
-
325
116
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
326
117
 
327
- const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
328
-
329
- const labelTextColor = (color: string) => {
330
- if (color.startsWith("#") && color.length === 7) {
331
- const red = Number.parseInt(color.slice(1, 3), 16)
332
- const green = Number.parseInt(color.slice(3, 5), 16)
333
- const blue = Number.parseInt(color.slice(5, 7), 16)
334
- const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
335
- return luminance > 0.6 ? "#111111" : "#f8fafc"
336
- }
337
- return "#f8fafc"
338
- }
339
-
340
- const diffSyntaxStyle = SyntaxStyle.fromStyles({
341
- keyword: { fg: parseColor("#f4a51c"), bold: true },
342
- "keyword.import": { fg: parseColor("#f4a51c"), bold: true },
343
- string: { fg: parseColor("#d7c5a1") },
344
- comment: { fg: parseColor(colors.muted), italic: true },
345
- number: { fg: parseColor("#93c5fd") },
346
- boolean: { fg: parseColor("#93c5fd") },
347
- constant: { fg: parseColor("#93c5fd") },
348
- function: { fg: parseColor("#7dd3a3") },
349
- "function.call": { fg: parseColor("#7dd3a3") },
350
- constructor: { fg: parseColor("#f59e0b") },
351
- type: { fg: parseColor("#f59e0b") },
352
- operator: { fg: parseColor("#f87171") },
353
- variable: { fg: parseColor(colors.text) },
354
- property: { fg: parseColor("#93c5fd") },
355
- bracket: { fg: parseColor(colors.text) },
356
- punctuation: { fg: parseColor(colors.text) },
357
- default: { fg: parseColor(colors.text) },
358
- })
359
-
360
- const extensionFiletypes: Record<string, string> = {
361
- c: "c",
362
- cc: "cpp",
363
- cpp: "cpp",
364
- cs: "csharp",
365
- css: "css",
366
- go: "go",
367
- h: "c",
368
- hpp: "cpp",
369
- html: "html",
370
- java: "java",
371
- js: "javascript",
372
- jsx: "javascript",
373
- json: "json",
374
- kt: "kotlin",
375
- md: "markdown",
376
- mjs: "javascript",
377
- py: "python",
378
- rs: "rust",
379
- rb: "ruby",
380
- sh: "bash",
381
- svelte: "svelte",
382
- toml: "toml",
383
- ts: "typescript",
384
- tsx: "typescript",
385
- txt: "text",
386
- vue: "vue",
387
- yaml: "yaml",
388
- yml: "yaml",
389
- zig: "zig",
390
- }
391
-
392
- const filetypeForPath = (path: string) => {
393
- const basename = path.split("/").at(-1) ?? path
394
- if (basename === "Dockerfile") return "dockerfile"
395
- const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
396
- return extension ? extensionFiletypes[extension] : undefined
397
- }
398
-
399
- const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
118
+ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
119
+ const lines = [
120
+ pullRequest.title,
121
+ `${pullRequest.repository} #${pullRequest.number}`,
122
+ pullRequest.url,
123
+ ]
400
124
 
401
- const patchFileName = (patch: string) => {
402
- const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
403
- if (diffLine) {
404
- const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
405
- if (match) {
406
- const next = unquoteDiffPath(match[2]!)
407
- if (next !== "/dev/null") return next
408
- return unquoteDiffPath(match[1]!)
409
- }
125
+ const review = reviewLabel(pullRequest)
126
+ if (review) {
127
+ lines.push(`review: ${review}`)
410
128
  }
411
-
412
- const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
413
- return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
414
- }
415
-
416
- const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
417
- const trimmed = patch.trimEnd()
418
- if (trimmed.length === 0) return []
419
-
420
- const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
421
- if (matches.length === 0) {
422
- return [{ name: "diff", filetype: undefined, patch: trimmed }]
129
+ if (pullRequest.checkSummary) {
130
+ lines.push(pullRequest.checkSummary)
423
131
  }
424
132
 
425
- return matches.map((match, index) => {
426
- const start = match.index ?? 0
427
- const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
428
- const filePatch = trimmed.slice(start, end).trimEnd()
429
- const name = patchFileName(filePatch)
430
- return { name, filetype: filetypeForPath(name), patch: filePatch }
133
+ const proc = Bun.spawn({
134
+ cmd: ["pbcopy"],
135
+ stdin: "pipe",
136
+ stdout: "ignore",
137
+ stderr: "pipe",
431
138
  })
432
- }
433
-
434
- const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
435
-
436
- const diffStatText = (pullRequest: PullRequestItem) => {
437
- const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
438
- return [
439
- pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
440
- pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
441
- files,
442
- ].filter((part): part is string => part !== null).join(" ")
443
- }
444
-
445
- const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
446
- const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
447
- type Part = { key: string; text: string; color: string }
448
- const rawParts: Array<Part | null> = [
449
- pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
450
- pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
451
- { key: "files", text: files, color: colors.muted },
452
- ]
453
- const parts = rawParts.filter((part): part is Part => part !== null)
454
-
455
- return (
456
- <>
457
- {parts.map((part, index) => (
458
- <Fragment key={part.key}>
459
- {index > 0 ? <span fg={colors.muted}> </span> : null}
460
- <span fg={part.color}>{part.text}</span>
461
- </Fragment>
462
- ))}
463
- </>
464
- )
465
- }
466
139
 
467
- const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
468
- if (wrapMode === "none") return 1
469
- return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
470
- }
471
-
472
- const patchLineNumberGutterWidth = (lines: readonly string[]) => {
473
- let maxLineNumber = 1
474
- let hasSigns = false
475
- let oldLine = 0
476
- let newLine = 0
477
-
478
- for (const line of lines) {
479
- const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
480
- if (hunk) {
481
- oldLine = Number(hunk[1])
482
- newLine = Number(hunk[2])
483
- maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
484
- continue
485
- }
486
-
487
- const firstChar = line[0]
488
- if (firstChar === "-") {
489
- hasSigns = true
490
- maxLineNumber = Math.max(maxLineNumber, oldLine)
491
- oldLine++
492
- } else if (firstChar === "+") {
493
- hasSigns = true
494
- maxLineNumber = Math.max(maxLineNumber, newLine)
495
- newLine++
496
- } else if (firstChar === " ") {
497
- maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
498
- oldLine++
499
- newLine++
500
- }
501
- }
502
-
503
- const digits = Math.floor(Math.log10(maxLineNumber)) + 1
504
- return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
505
- }
506
-
507
- const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
508
- const lines = patch.split("\n")
509
- const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
510
- const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
511
- const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
512
- const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
513
- let count = 0
514
- let inHunk = false
515
- let deletions: number[] = []
516
- let additions: number[] = []
517
-
518
- const flushChangeBlock = () => {
519
- if (deletions.length === 0 && additions.length === 0) return
520
- if (view === "split") {
521
- const rows = Math.max(deletions.length, additions.length)
522
- for (let index = 0; index < rows; index++) {
523
- const deletionCount = index < deletions.length ? deletions[index]! : 1
524
- const additionCount = index < additions.length ? additions[index]! : 1
525
- count += Math.max(deletionCount, additionCount)
526
- }
527
- } else {
528
- for (const deletion of deletions) count += deletion
529
- for (const addition of additions) count += addition
530
- }
531
- deletions = []
532
- additions = []
140
+ if (!proc.stdin) {
141
+ throw new Error("Clipboard is not available")
533
142
  }
534
143
 
535
- for (const line of lines) {
536
- if (line.startsWith("@@")) {
537
- flushChangeBlock()
538
- inHunk = true
539
- continue
540
- }
541
-
542
- if (!inHunk) continue
543
-
544
- const firstChar = line[0]
545
- if (firstChar === "\\") continue
546
-
547
- if (firstChar === "-") {
548
- deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
549
- continue
550
- }
551
-
552
- if (firstChar === "+") {
553
- additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
554
- continue
555
- }
144
+ proc.stdin.write(lines.join("\n"))
145
+ proc.stdin.end()
556
146
 
557
- if (firstChar === " ") {
558
- flushChangeBlock()
559
- count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
560
- }
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")
561
151
  }
562
-
563
- flushChangeBlock()
564
- return Math.max(1, count)
565
152
  }
566
153
 
567
154
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
@@ -607,926 +194,6 @@ const getDetailPlaceholderContent = ({
607
194
  }
608
195
  }
609
196
 
610
- const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
611
- const sourceLines = body.replace(/\r/g, "").split("\n")
612
- const preview: Array<PreviewLine> = []
613
- let inCodeBlock = false
614
-
615
- for (const rawLine of sourceLines) {
616
- if (preview.length >= limit) break
617
-
618
- const line = rawLine.trim()
619
- if (line.startsWith("```")) {
620
- inCodeBlock = !inCodeBlock
621
- continue
622
- }
623
- if (line.length === 0) continue
624
-
625
- let text = line
626
- let fg: string = colors.text
627
- let bold = false
628
- let indent = ""
629
-
630
- if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
631
- if (preview.length > 0) {
632
- preview.push({ segments: [{ text: "", fg: colors.muted }] })
633
- if (preview.length >= limit) break
634
- }
635
- text = line.replace(/^#{1,6}\s+/, "")
636
- fg = colors.count
637
- bold = true
638
- } else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
639
- const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
640
- text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
641
- fg = checked ? colors.status.passing : colors.text
642
- indent = " "
643
- } else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
644
- const checked = /^\[(x|X)\]\s+/.test(line)
645
- text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
646
- fg = checked ? colors.status.passing : colors.text
647
- indent = " "
648
- } else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
649
- text = `• ${line.replace(/^[-*+]\s+/, "")}`
650
- indent = " "
651
- } else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
652
- text = line
653
- indent = " "
654
- } else if (!inCodeBlock && /^>\s+/.test(line)) {
655
- text = `> ${line.replace(/^>\s+/, "")}`
656
- fg = colors.muted
657
- indent = " "
658
- } else if (inCodeBlock) {
659
- fg = colors.muted
660
- }
661
-
662
- const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
663
- for (const wrappedLine of wrapped) {
664
- preview.push(wrappedLine)
665
- if (preview.length >= limit) break
666
- }
667
- }
668
-
669
- if (preview.length === 0) {
670
- return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
671
- }
672
-
673
- return preview.slice(0, limit)
674
- }
675
-
676
- const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
677
- const lines = [
678
- pullRequest.title,
679
- `${pullRequest.repository} #${pullRequest.number}`,
680
- pullRequest.url,
681
- ]
682
-
683
- const review = reviewLabel(pullRequest)
684
- if (review) {
685
- lines.push(`review: ${review}`)
686
- }
687
- if (pullRequest.checkSummary) {
688
- lines.push(pullRequest.checkSummary)
689
- }
690
-
691
- const proc = Bun.spawn({
692
- cmd: ["pbcopy"],
693
- stdin: "pipe",
694
- stdout: "ignore",
695
- stderr: "pipe",
696
- })
697
-
698
- if (!proc.stdin) {
699
- throw new Error("Clipboard is not available")
700
- }
701
-
702
- proc.stdin.write(lines.join("\n"))
703
- proc.stdin.end()
704
-
705
- const exitCode = await proc.exited
706
- if (exitCode !== 0) {
707
- const stderr = await Bun.readableStreamToText(proc.stderr)
708
- throw new Error(stderr.trim() || "Could not copy PR metadata")
709
- }
710
- }
711
-
712
- const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
713
- <box height={1}>
714
- {bold ? (
715
- <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
716
- {text}
717
- </text>
718
- ) : (
719
- <text wrapMode="none" truncate fg={fg}>
720
- {text}
721
- </text>
722
- )}
723
- </box>
724
- )
725
-
726
- const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
727
- <box height={1}>
728
- {bg ? (
729
- <text wrapMode="none" truncate fg={fg} bg={bg}>
730
- {children}
731
- </text>
732
- ) : (
733
- <text wrapMode="none" truncate fg={fg}>
734
- {children}
735
- </text>
736
- )}
737
- </box>
738
- )
739
-
740
- const SectionTitle = ({ title }: { title: string }) => (
741
- <TextLine>
742
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>
743
- {title}
744
- </span>
745
- </TextLine>
746
- )
747
-
748
- const FooterHints = ({
749
- filterEditing,
750
- showFilterClear,
751
- detailFullView,
752
- diffFullView,
753
- hasSelection,
754
- hasError,
755
- isLoading,
756
- loadingIndicator,
757
- retryProgress,
758
- }: {
759
- filterEditing: boolean
760
- showFilterClear: boolean
761
- detailFullView: boolean
762
- diffFullView: boolean
763
- hasSelection: boolean
764
- hasError: boolean
765
- isLoading: boolean
766
- loadingIndicator: string
767
- retryProgress: RetryProgress | null
768
- }) => {
769
- if (filterEditing) {
770
- return (
771
- <TextLine>
772
- <span fg={colors.count}>search</span>
773
- <span fg={colors.muted}> typing </span>
774
- <span fg={colors.count}>↑↓</span>
775
- <span fg={colors.muted}> move </span>
776
- <span fg={colors.count}>enter</span>
777
- <span fg={colors.muted}> apply </span>
778
- <span fg={colors.count}>esc</span>
779
- <span fg={colors.muted}> cancel </span>
780
- <span fg={colors.count}>ctrl-u</span>
781
- <span fg={colors.muted}> clear </span>
782
- <span fg={colors.count}>ctrl-w</span>
783
- <span fg={colors.muted}> word</span>
784
- </TextLine>
785
- )
786
- }
787
-
788
- if (diffFullView) {
789
- return (
790
- <TextLine>
791
- <span fg={colors.count}>esc</span>
792
- <span fg={colors.muted}> back </span>
793
- <span fg={colors.count}>v</span>
794
- <span fg={colors.muted}> view </span>
795
- <span fg={colors.count}>w</span>
796
- <span fg={colors.muted}> wrap </span>
797
- <span fg={colors.count}>[]</span>
798
- <span fg={colors.muted}> files </span>
799
- <span fg={colors.count}>r</span>
800
- <span fg={colors.muted}> reload </span>
801
- <span fg={colors.count}>o</span>
802
- <span fg={colors.muted}> open </span>
803
- <span fg={colors.count}>q</span>
804
- <span fg={colors.muted}> quit</span>
805
- </TextLine>
806
- )
807
- }
808
-
809
- if (detailFullView) {
810
- return (
811
- <TextLine>
812
- <span fg={colors.count}>esc</span>
813
- <span fg={colors.muted}> back </span>
814
- <span fg={colors.count}>o</span>
815
- <span fg={colors.muted}> open </span>
816
- <span fg={colors.count}>y</span>
817
- <span fg={colors.muted}> copy </span>
818
- <span fg={colors.count}>q</span>
819
- <span fg={colors.muted}> quit</span>
820
- </TextLine>
821
- )
822
- }
823
-
824
- return (
825
- <TextLine>
826
- <span fg={colors.count}>/</span>
827
- <span fg={colors.muted}> filter </span>
828
- {showFilterClear ? (
829
- <>
830
- <span fg={colors.count}>esc</span>
831
- <span fg={colors.muted}> clear </span>
832
- </>
833
- ) : null}
834
- {retryProgress ? (
835
- <>
836
- <span fg={colors.status.pending}>retry</span>
837
- <span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
838
- </>
839
- ) : isLoading ? (
840
- <>
841
- <span fg={colors.status.pending}>{loadingIndicator}</span>
842
- <span fg={colors.muted}> loading </span>
843
- </>
844
- ) : null}
845
- <span fg={colors.count}>r</span>
846
- <span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
847
- {hasSelection ? (
848
- <>
849
- <span fg={colors.count}>↑↓</span>
850
- <span fg={colors.muted}> move </span>
851
- </>
852
- ) : null}
853
- {hasSelection && detailFullView ? (
854
- <>
855
- <span fg={colors.count}>esc</span>
856
- <span fg={colors.muted}> back </span>
857
- </>
858
- ) : hasSelection ? (
859
- <>
860
- <span fg={colors.count}>enter</span>
861
- <span fg={colors.muted}> expand </span>
862
- </>
863
- ) : null}
864
- {hasSelection ? (
865
- <>
866
- <span fg={colors.count}>d</span>
867
- <span fg={colors.muted}> draft </span>
868
- <span fg={colors.count}>p</span>
869
- <span fg={colors.muted}> diff </span>
870
- <span fg={colors.count}>l</span>
871
- <span fg={colors.muted}> labels </span>
872
- <span fg={colors.count}>o</span>
873
- <span fg={colors.muted}> open </span>
874
- <span fg={colors.count}>y</span>
875
- <span fg={colors.muted}> copy </span>
876
- </>
877
- ) : null}
878
- <span fg={colors.count}>q</span>
879
- <span fg={colors.muted}> quit</span>
880
- </TextLine>
881
- )
882
- }
883
-
884
- const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
885
- <TextLine>
886
- <span fg={color}>{icon} </span>
887
- <span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
888
- </TextLine>
889
- )
890
-
891
- const PullRequestRow = ({
892
- pullRequest,
893
- selected,
894
- contentWidth,
895
- numWidth,
896
- onSelect,
897
- }: {
898
- pullRequest: PullRequestItem
899
- selected: boolean
900
- contentWidth: number
901
- numWidth: number
902
- onSelect: () => void
903
- }) => {
904
- const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
905
- const ageText = `${daysOpen(pullRequest.createdAt)}d`
906
- const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
907
- const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
908
- const fillerWidth = Math.max(0, contentWidth - rowWidth)
909
-
910
- return (
911
- <box height={1} onMouseDown={onSelect}>
912
- <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
913
- <span fg={statusColor(pullRequest.reviewStatus)}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
914
- <span> </span>
915
- <span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
916
- <span> </span>
917
- <span>{fitCell(pullRequest.title, titleWidth)}</span>
918
- <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
919
- <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
920
- {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
921
- </TextLine>
922
- </box>
923
- )
924
- }
925
-
926
- const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
927
- const groups = new Map<string, T[]>()
928
- for (const item of items) {
929
- const key = getKey(item)
930
- const existing = groups.get(key)
931
- if (existing) {
932
- existing.push(item)
933
- } else {
934
- groups.set(key, [item])
935
- }
936
- }
937
-
938
- const order = new Map(orderedKeys.map((key, index) => [key, index]))
939
- return [...groups.entries()].sort((left, right) => {
940
- const leftIndex = order.get(left[0])
941
- const rightIndex = order.get(right[0])
942
- if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
943
- if (leftIndex !== undefined) return -1
944
- if (rightIndex !== undefined) return 1
945
- return left[0].localeCompare(right[0])
946
- })
947
- }
948
-
949
- type PullRequestGroups = Array<[string, PullRequestItem[]]>
950
-
951
- const PullRequestList = ({
952
- groups,
953
- selectedUrl,
954
- status,
955
- error,
956
- contentWidth,
957
- filterText,
958
- showFilterBar,
959
- isFilterEditing,
960
- groupIcon,
961
- onSelectPullRequest,
962
- }: {
963
- groups: PullRequestGroups
964
- selectedUrl: string | null
965
- status: LoadStatus
966
- error: string | null
967
- contentWidth: number
968
- filterText: string
969
- showFilterBar: boolean
970
- isFilterEditing: boolean
971
- groupIcon: string
972
- onSelectPullRequest: (url: string) => void
973
- }) => {
974
- const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
975
- const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
976
-
977
- return (
978
- <box flexDirection="column">
979
- <SectionTitle title="PULL REQUESTS" />
980
- {showFilterBar ? (
981
- <TextLine>
982
- <span fg={colors.count}>/</span>
983
- <span fg={colors.muted}> </span>
984
- <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
985
- </TextLine>
986
- ) : null}
987
- {status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
988
- {status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
989
- {status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
990
- {groups.map(([repo, pullRequests]) => {
991
- const numWidth = groupNumberWidth(pullRequests)
992
- return (
993
- <Fragment key={repo}>
994
- <box flexDirection="column">
995
- <GroupTitle label={repo} color={repoColor(repo)} icon={groupIcon} />
996
- {pullRequests.map((pullRequest) => (
997
- <PullRequestRow
998
- key={pullRequest.url}
999
- pullRequest={pullRequest}
1000
- selected={pullRequest.url === selectedUrl}
1001
- contentWidth={contentWidth}
1002
- numWidth={numWidth}
1003
- onSelect={() => onSelectPullRequest(pullRequest.url)}
1004
- />
1005
- ))}
1006
- </box>
1007
- </Fragment>
1008
- )
1009
- })}
1010
- </box>
1011
- )
1012
- }
1013
-
1014
- const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
1015
- const seen = new Map<string, CheckItem>()
1016
- for (const check of checks) {
1017
- const existing = seen.get(check.name)
1018
- if (!existing || (check.status === "completed" && existing.status !== "completed")) {
1019
- seen.set(check.name, check)
1020
- }
1021
- }
1022
- return [...seen.values()]
1023
- }
1024
-
1025
- const checkIcon = (check: CheckItem) => {
1026
- if (check.status === "completed") {
1027
- if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
1028
- if (check.conclusion === "failure") return "✗"
1029
- return "·"
1030
- }
1031
- if (check.status === "in_progress") return "●"
1032
- return "○"
1033
- }
1034
-
1035
- const checkColor = (check: CheckItem) => {
1036
- if (check.status === "completed") {
1037
- if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
1038
- if (check.conclusion === "failure") return colors.status.failing
1039
- return colors.muted
1040
- }
1041
- if (check.status === "in_progress") return colors.status.pending
1042
- return colors.muted
1043
- }
1044
-
1045
- const checksRowCount = (checks: readonly CheckItem[]) => {
1046
- const unique = deduplicateChecks(checks)
1047
- return Math.ceil(unique.length / 2)
1048
- }
1049
-
1050
- const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
1051
- const unique = deduplicateChecks(checks)
1052
- if (unique.length === 0) return null
1053
-
1054
- const colWidth = Math.floor((contentWidth - 1) / 2) // -1 for gap between columns
1055
- const nameCol = Math.max(4, colWidth - 2) // -2 for icon + space
1056
- const rows = Math.ceil(unique.length / 2)
1057
-
1058
- return (
1059
- <box flexDirection="column">
1060
- <TextLine>
1061
- <span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
1062
- </TextLine>
1063
- {Array.from({ length: rows }, (_, rowIndex) => {
1064
- const left = unique[rowIndex * 2]
1065
- const right = unique[rowIndex * 2 + 1]
1066
- return (
1067
- <TextLine key={rowIndex}>
1068
- {left ? (
1069
- <>
1070
- <span fg={checkColor(left)}>{checkIcon(left)} </span>
1071
- <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
1072
- </>
1073
- ) : null}
1074
- {right ? (
1075
- <>
1076
- <span fg={colors.muted}> </span>
1077
- <span fg={checkColor(right)}>{checkIcon(right)} </span>
1078
- <span fg={colors.text}>{right.name}</span>
1079
- </>
1080
- ) : null}
1081
- </TextLine>
1082
- )
1083
- })}
1084
- </box>
1085
- )
1086
- }
1087
-
1088
- const DetailHeader = ({
1089
- pullRequest,
1090
- contentWidth,
1091
- paneWidth,
1092
- showChecks = false,
1093
- }: {
1094
- pullRequest: PullRequestItem
1095
- contentWidth: number
1096
- paneWidth: number
1097
- showChecks?: boolean
1098
- }) => {
1099
- const labels = pullRequest.labels
1100
- const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
1101
- const unique = deduplicateChecks(pullRequest.checks)
1102
- const checkRows = checksRowCount(unique)
1103
- const statsText = diffStatText(pullRequest)
1104
- const labelsWidth = labels.length > 0
1105
- ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
1106
- : "no labels".length
1107
- const showStats = contentWidth - labelsWidth - statsText.length >= 2
1108
- const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
1109
-
1110
- return (
1111
- <>
1112
- <box height={1} paddingLeft={1} paddingRight={1}>
1113
- {(() => {
1114
- const opened = formatRelativeDate(pullRequest.createdAt)
1115
- const repo = shortRepoName(pullRequest.repository)
1116
- const number = String(pullRequest.number)
1117
- const review = reviewLabel(pullRequest)
1118
- const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
1119
- const statusParts = [review, checks].filter((part): part is string => Boolean(part))
1120
- const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
1121
- const leftWidth = 1 + number.length + 1 + repo.length
1122
- const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
1123
-
1124
- return (
1125
- <TextLine>
1126
- <span fg={colors.count}>#{number}</span>
1127
- <span fg={colors.muted}> {repo}</span>
1128
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
1129
- {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
1130
- {review && checks ? <span fg={colors.muted}> </span> : null}
1131
- {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
1132
- {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
1133
- <span fg={colors.muted}>{opened}</span>
1134
- </TextLine>
1135
- )
1136
- })()}
1137
- </box>
1138
- <box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
1139
- {wrappedTitle.map((line, index) => (
1140
- <PlainLine key={index} text={line} bold />
1141
- ))}
1142
- </box>
1143
- <box height={1} paddingLeft={1} paddingRight={1}>
1144
- <TextLine>
1145
- {labels.length > 0 ? labels.map((label, index) => (
1146
- <Fragment key={label.name}>
1147
- {index > 0 ? <span fg={colors.muted}> </span> : null}
1148
- <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
1149
- </Fragment>
1150
- )) : <span fg={colors.muted}>no labels</span>}
1151
- {showStats ? (
1152
- <>
1153
- <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
1154
- <DiffStats pullRequest={pullRequest} />
1155
- </>
1156
- ) : null}
1157
- </TextLine>
1158
- </box>
1159
- <box height={1}><Divider width={paneWidth} /></box>
1160
- {showChecks && unique.length > 0 ? (
1161
- <>
1162
- <box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
1163
- <ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
1164
- </box>
1165
- <box height={1}><Divider width={paneWidth} /></box>
1166
- </>
1167
- ) : null}
1168
- </>
1169
- )
1170
- }
1171
-
1172
- const DetailBody = ({
1173
- pullRequest,
1174
- contentWidth,
1175
- bodyLines = DETAIL_BODY_LINES,
1176
- }: {
1177
- pullRequest: PullRequestItem
1178
- contentWidth: number
1179
- bodyLines?: number
1180
- }) => {
1181
- const previewLines = useMemo(
1182
- () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
1183
- [pullRequest.body, contentWidth, bodyLines],
1184
- )
1185
-
1186
- return (
1187
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1188
- {previewLines.map((line, index) => (
1189
- <TextLine key={`${pullRequest.url}-${index}`}>
1190
- {line.segments.map((segment, segmentIndex) => (
1191
- ("bold" in segment && segment.bold === true) ? (
1192
- <span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
1193
- {segment.text}
1194
- </span>
1195
- ) : (
1196
- <span key={segmentIndex} fg={segment.fg}>
1197
- {segment.text}
1198
- </span>
1199
- )
1200
- ))}
1201
- </TextLine>
1202
- ))}
1203
- </box>
1204
- )
1205
- }
1206
-
1207
- const StatusCard = ({ content, width }: { content: DetailPlaceholderContent; width: number }) => {
1208
- const innerWidth = Math.max(1, width - 2)
1209
- const cardWidth = Math.min(innerWidth, Math.max(28, content.title.length + 4, content.hint.length + 4))
1210
- const offset = " ".repeat(Math.max(0, Math.floor((innerWidth - cardWidth) / 2)))
1211
- const cardInnerWidth = Math.max(1, cardWidth - 2)
1212
- const contentLine = (text: string, fg: string, bold = false) => (
1213
- <TextLine>
1214
- <span fg={colors.separator}>{offset}│</span>
1215
- {bold ? (
1216
- <span fg={fg} attributes={TextAttributes.BOLD}>{centerCell(text, cardInnerWidth)}</span>
1217
- ) : (
1218
- <span fg={fg}>{centerCell(text, cardInnerWidth)}</span>
1219
- )}
1220
- <span fg={colors.separator}>│</span>
1221
- </TextLine>
1222
- )
1223
-
1224
- return (
1225
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1226
- <PlainLine text={`${offset}┌${"─".repeat(cardInnerWidth)}┐`} fg={colors.separator} />
1227
- {contentLine(content.title, colors.count, true)}
1228
- {contentLine(content.hint, colors.muted)}
1229
- <PlainLine text={`${offset}└${"─".repeat(cardInnerWidth)}┘`} fg={colors.separator} />
1230
- </box>
1231
- )
1232
- }
1233
-
1234
- const DetailPlaceholder = ({ content, paneWidth }: { content: DetailPlaceholderContent; paneWidth: number }) => (
1235
- <box flexDirection="column">
1236
- <StatusCard content={content} width={paneWidth} />
1237
- <box height={1}><Divider width={paneWidth} /></box>
1238
- </box>
1239
- )
1240
-
1241
- const LoadingPane = ({ content, width, height }: { content: DetailPlaceholderContent; width: number; height: number }) => {
1242
- const topRows = Math.max(0, Math.floor((height - DETAIL_PLACEHOLDER_ROWS) / 2))
1243
- const bottomRows = Math.max(0, height - topRows - DETAIL_PLACEHOLDER_ROWS)
1244
-
1245
- return (
1246
- <box height={height} flexDirection="column">
1247
- {Array.from({ length: topRows }, (_, index) => <BlankRow key={`top-${index}`} />)}
1248
- <StatusCard content={content} width={width} />
1249
- {Array.from({ length: bottomRows }, (_, index) => <BlankRow key={`bottom-${index}`} />)}
1250
- </box>
1251
- )
1252
- }
1253
-
1254
- const DetailsPane = ({
1255
- pullRequest,
1256
- contentWidth,
1257
- bodyLines = DETAIL_BODY_LINES,
1258
- paneWidth = contentWidth + 2,
1259
- showChecks = false,
1260
- placeholderContent,
1261
- }: {
1262
- pullRequest: PullRequestItem | null
1263
- contentWidth: number
1264
- bodyLines?: number
1265
- paneWidth?: number
1266
- showChecks?: boolean
1267
- placeholderContent: DetailPlaceholderContent
1268
- }) => {
1269
- const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
1270
- const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
1271
- const checkRows = checksRowCount(uniqueChecks)
1272
- // checks heading (1) + grid rows + divider (1)
1273
- const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
1274
- const previewLines = useMemo(
1275
- () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
1276
- [pullRequest?.body, contentWidth, bodyLines],
1277
- )
1278
- const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
1279
-
1280
- return (
1281
- <box flexDirection="column" height={contentHeight}>
1282
- {pullRequest ? (
1283
- <>
1284
- <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
1285
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
1286
- </>
1287
- ) : (
1288
- <>
1289
- <DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
1290
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1291
- {Array.from({ length: bodyLines }, (_, index) => (
1292
- <BlankRow key={index} />
1293
- ))}
1294
- </box>
1295
- </>
1296
- )}
1297
- </box>
1298
- )
1299
- }
1300
-
1301
- const PullRequestDiffPane = ({
1302
- pullRequest,
1303
- diffState,
1304
- fileIndex,
1305
- view,
1306
- wrapMode,
1307
- paneWidth,
1308
- height,
1309
- loadingIndicator,
1310
- scrollRef,
1311
- }: {
1312
- pullRequest: PullRequestItem | null
1313
- diffState: PullRequestDiffState | undefined
1314
- fileIndex: number
1315
- view: "unified" | "split"
1316
- wrapMode: "none" | "word"
1317
- paneWidth: number
1318
- height: number
1319
- loadingIndicator: string
1320
- scrollRef: React.Ref<ScrollBoxRenderable>
1321
- }) => {
1322
- const readyFiles = diffState?.status === "ready" ? diffState.files : []
1323
- const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
1324
- const file = readyFiles[safeIndex] ?? null
1325
- const diffHeight = useMemo(
1326
- () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
1327
- [file?.patch, view, wrapMode, paneWidth],
1328
- )
1329
-
1330
- if (!pullRequest) {
1331
- return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
1332
- }
1333
-
1334
- const stats = diffStatText(pullRequest)
1335
- const headerWidth = Math.max(24, paneWidth - 2)
1336
- const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
1337
- const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
1338
-
1339
- if (!diffState || diffState.status === "loading") {
1340
- return (
1341
- <box height={height} flexDirection="column">
1342
- <box height={1} paddingLeft={1} paddingRight={1}>
1343
- <TextLine>
1344
- <span fg={colors.count}>#{pullRequest.number}</span>
1345
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1346
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1347
- <DiffStats pullRequest={pullRequest} />
1348
- </TextLine>
1349
- </box>
1350
- <Divider width={paneWidth} />
1351
- <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
1352
- </box>
1353
- )
1354
- }
1355
-
1356
- if (diffState.status === "error") {
1357
- return (
1358
- <box height={height} flexDirection="column">
1359
- <box height={1} paddingLeft={1} paddingRight={1}>
1360
- <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
1361
- </box>
1362
- <Divider width={paneWidth} />
1363
- <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
1364
- </box>
1365
- )
1366
- }
1367
-
1368
- if (readyFiles.length === 0 || !file) {
1369
- return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
1370
- }
1371
-
1372
- const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
1373
- const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
1374
-
1375
- return (
1376
- <box height={height} flexDirection="column">
1377
- <box height={1} paddingLeft={1} paddingRight={1}>
1378
- <TextLine>
1379
- <span fg={colors.count}>#{pullRequest.number}</span>
1380
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1381
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1382
- <DiffStats pullRequest={pullRequest} />
1383
- </TextLine>
1384
- </box>
1385
- <box height={1} paddingLeft={1} paddingRight={1}>
1386
- <TextLine>
1387
- <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
1388
- <span fg={colors.muted}> {fileCounter}</span>
1389
- </TextLine>
1390
- </box>
1391
- <Divider width={paneWidth} />
1392
- <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
1393
- <diff
1394
- key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
1395
- diff={file.patch}
1396
- view={view}
1397
- syncScroll
1398
- filetype={file.filetype ?? "text"}
1399
- syntaxStyle={diffSyntaxStyle}
1400
- showLineNumbers
1401
- wrapMode={wrapMode}
1402
- addedBg="#17351f"
1403
- removedBg="#3a1e22"
1404
- contextBg="transparent"
1405
- addedSignColor={colors.status.passing}
1406
- removedSignColor={colors.status.failing}
1407
- lineNumberFg={colors.muted}
1408
- lineNumberBg="#151515"
1409
- addedLineNumberBg="#12301a"
1410
- removedLineNumberBg="#35171b"
1411
- selectionBg={colors.selectedBg}
1412
- selectionFg={colors.selectedText}
1413
- height={diffHeight}
1414
- style={{ flexShrink: 0 }}
1415
- />
1416
- </scrollbox>
1417
- </box>
1418
- )
1419
- }
1420
-
1421
- const LabelModal = ({
1422
- state,
1423
- currentLabels,
1424
- modalWidth,
1425
- modalHeight,
1426
- offsetLeft,
1427
- offsetTop,
1428
- loadingIndicator,
1429
- }: {
1430
- state: LabelModalState
1431
- currentLabels: readonly PullRequestLabel[]
1432
- modalWidth: number
1433
- modalHeight: number
1434
- offsetLeft: number
1435
- offsetTop: number
1436
- loadingIndicator: string
1437
- }) => {
1438
- const contentWidth = Math.max(16, modalWidth - 2)
1439
- const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
1440
- const filtered = state.availableLabels.filter((label) =>
1441
- state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
1442
- )
1443
- const maxVisible = Math.max(1, modalHeight - 6)
1444
- const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
1445
- const scrollStart = Math.min(
1446
- Math.max(0, filtered.length - maxVisible),
1447
- Math.max(0, selectedIndex - maxVisible + 1),
1448
- )
1449
- const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
1450
- const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
1451
- const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
1452
- const headerGap = Math.max(1, contentWidth - title.length - countText.length)
1453
- const queryText = state.query.length > 0 ? state.query : "type to filter labels"
1454
- const queryPrefix = state.query.length > 0 ? "/ " : "/ "
1455
- const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
1456
-
1457
- return (
1458
- <box
1459
- position="absolute"
1460
- left={offsetLeft}
1461
- top={offsetTop}
1462
- width={modalWidth}
1463
- height={modalHeight}
1464
- flexDirection="column"
1465
- backgroundColor="#1a1a2e"
1466
- >
1467
- <box height={1} paddingLeft={1} paddingRight={1}>
1468
- <TextLine>
1469
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
1470
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1471
- <span fg={colors.muted}>{countText}</span>
1472
- </TextLine>
1473
- </box>
1474
- <box height={1} paddingLeft={1} paddingRight={1}>
1475
- <TextLine>
1476
- <span fg={colors.count}>{queryPrefix}</span>
1477
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>
1478
- {fitCell(queryText, queryWidth)}
1479
- </span>
1480
- </TextLine>
1481
- </box>
1482
- <Divider width={modalWidth} />
1483
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1484
- {state.loading ? (
1485
- <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
1486
- ) : visibleLabels.length === 0 ? (
1487
- <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
1488
- ) : (
1489
- visibleLabels.map((label, index) => {
1490
- const actualIndex = scrollStart + index
1491
- const isActive = currentNames.has(label.name.toLowerCase())
1492
- const isSelected = actualIndex === selectedIndex
1493
- const status = isActive ? "added" : ""
1494
- const statusText = status.length > 0 ? ` ${status}` : ""
1495
- const nameWidth = Math.max(1, contentWidth - 5 - statusText.length)
1496
- return (
1497
- <box key={label.name} height={1}>
1498
- <TextLine bg={isSelected ? colors.selectedBg : undefined}>
1499
- <span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "✓" : " "}</span>
1500
- <span> </span>
1501
- <span bg={labelColor(label)}> </span>
1502
- <span> </span>
1503
- <span fg={isSelected ? colors.selectedText : colors.text}>{trimCell(label.name, nameWidth)}</span>
1504
- {statusText ? <span fg={colors.status.passing}>{statusText}</span> : null}
1505
- </TextLine>
1506
- </box>
1507
- )
1508
- })
1509
- )}
1510
- </box>
1511
- <box flexGrow={1} />
1512
- <Divider width={modalWidth} />
1513
- <box height={1} paddingLeft={1} paddingRight={1}>
1514
- <TextLine>
1515
- <span fg={colors.count}>↑↓</span>
1516
- <span fg={colors.muted}> move </span>
1517
- <span fg={colors.count}>enter</span>
1518
- <span fg={colors.muted}> toggle </span>
1519
- <span fg={colors.count}>/</span>
1520
- <span fg={colors.muted}> filter </span>
1521
- <span fg={colors.count}>esc</span>
1522
- <span fg={colors.muted}> close</span>
1523
- {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
1524
- </TextLine>
1525
- </box>
1526
- </box>
1527
- )
1528
- }
1529
-
1530
197
  export const App = () => {
1531
198
  const renderer = useRenderer()
1532
199
  const { width, height } = useTerminalDimensions()
@@ -1546,17 +213,20 @@ export const App = () => {
1546
213
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
1547
214
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
1548
215
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
216
+ const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
1549
217
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
1550
218
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
1551
219
  const retryProgress = useAtomValue(retryProgressAtom)
1552
220
  const [loadingFrame, setLoadingFrame] = useState(0)
1553
221
  const usernameResult = useAtomValue(usernameAtom)
1554
222
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
223
+ const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
1555
224
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1556
225
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1557
226
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1558
227
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
1559
- const groupIcon = GROUP_ICON
228
+ const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
229
+ const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
1560
230
  const contentWidth = Math.max(60, width ?? 100)
1561
231
  const isWideLayout = (width ?? 100) >= 100
1562
232
  const splitGap = 1
@@ -1571,6 +241,7 @@ export const App = () => {
1571
241
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1572
242
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1573
243
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
244
+ const detailHydrationRef = useRef<number | null>(null)
1574
245
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1575
246
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1576
247
  const headerFooterWidth = Math.max(24, contentWidth - 2)
@@ -1598,7 +269,10 @@ export const App = () => {
1598
269
  }, [])
1599
270
 
1600
271
  const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
1601
- const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
272
+ const pullRequests = useMemo(
273
+ () => pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? [],
274
+ [pullRequestLoad?.data, pullRequestOverrides],
275
+ )
1602
276
  const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
1603
277
  ? "loading"
1604
278
  : AsyncResult.isFailure(pullRequestResult)
@@ -1608,37 +282,29 @@ export const App = () => {
1608
282
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
1609
283
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
1610
284
 
1611
- useEffect(() => {
1612
- if (pullRequestStatus !== "loading") return
1613
- const interval = globalThis.setInterval(() => {
1614
- setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1615
- }, 120)
1616
- return () => globalThis.clearInterval(interval)
1617
- }, [pullRequestStatus])
1618
-
1619
285
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
1620
286
  const visibleFilterText = filterMode ? filterDraft : filterQuery
1621
287
 
1622
- const filteredPullRequests = pullRequests.filter((pullRequest) => {
288
+ const filteredPullRequests = useMemo(() => pullRequests.filter((pullRequest) => {
1623
289
  const query = effectiveFilterQuery
1624
290
  if (query.length === 0) return true
1625
291
  return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
1626
292
  .some((value) => value.toLowerCase().includes(query))
1627
- })
293
+ }), [pullRequests, effectiveFilterQuery])
1628
294
 
1629
- const visibleGroups = groupBy(
1630
- filteredPullRequests,
1631
- (pullRequest) => pullRequest.repository,
295
+ const visibleGroups = useMemo(
296
+ () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository),
297
+ [filteredPullRequests],
1632
298
  )
1633
- const visiblePullRequests = visibleGroups.flatMap(([, pullRequests]) => pullRequests)
1634
- const groupStarts = visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
299
+ const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
300
+ const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
1635
301
  if (index === 0) {
1636
302
  starts.push(0)
1637
303
  return starts
1638
304
  }
1639
305
  starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
1640
306
  return starts
1641
- }, [])
307
+ }, []), [visibleGroups])
1642
308
  const getCurrentGroupIndex = (current: number) => {
1643
309
  for (let index = groupStarts.length - 1; index >= 0; index--) {
1644
310
  if (groupStarts[index]! <= current) return index
@@ -1663,7 +329,6 @@ export const App = () => {
1663
329
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
1664
330
  }
1665
331
  const refreshPullRequests = (message?: string) => {
1666
- setPullRequestOverrides({})
1667
332
  refreshPullRequestsAtom()
1668
333
  if (message) flashNotice(message)
1669
334
  }
@@ -1682,7 +347,37 @@ export const App = () => {
1682
347
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1683
348
  const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
1684
349
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
350
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => !pullRequest.detailLoaded)
351
+ const hasActiveLoadingIndicator = pullRequestStatus === "loading" || isHydratingPullRequestDetails || labelModal.loading || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
1685
352
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
353
+
354
+ useEffect(() => {
355
+ if (!hasActiveLoadingIndicator) return
356
+ const interval = globalThis.setInterval(() => {
357
+ setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
358
+ }, 120)
359
+ return () => globalThis.clearInterval(interval)
360
+ }, [hasActiveLoadingIndicator])
361
+
362
+ useEffect(() => {
363
+ const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
364
+ if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
365
+ if (detailHydrationRef.current === fetchedAt) return
366
+ if (!pullRequests.some((pullRequest) => !pullRequest.detailLoaded)) return
367
+ detailHydrationRef.current = fetchedAt
368
+ void loadPullRequestDetails().then((details) => {
369
+ setPullRequestOverrides((current) => {
370
+ const next = { ...current }
371
+ for (const detail of details) {
372
+ next[detail.url] = current[detail.url]?.detailLoaded ? current[detail.url]! : detail
373
+ }
374
+ return next
375
+ })
376
+ }).catch((error) => {
377
+ flashNotice(error instanceof Error ? error.message : String(error))
378
+ })
379
+ }, [pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
380
+
1686
381
  const detailPlaceholderContent = getDetailPlaceholderContent({
1687
382
  status: pullRequestStatus,
1688
383
  retryProgress,
@@ -1690,16 +385,7 @@ export const App = () => {
1690
385
  visibleCount: visiblePullRequests.length,
1691
386
  filterText: visibleFilterText,
1692
387
  })
1693
- const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
1694
- const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
1695
- const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
1696
- const detailChecks = selectedPullRequest ? deduplicateChecks(selectedPullRequest.checks) : []
1697
- const checksRows = checksRowCount(detailChecks)
1698
- // checks heading (1) + grid rows + divider
1699
- const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
1700
- const detailJunctions = selectedPullRequest
1701
- ? detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
1702
- : [DETAIL_PLACEHOLDER_ROWS]
388
+ const detailJunctions = getDetailJunctionRows(selectedPullRequest, rightPaneWidth, true)
1703
389
 
1704
390
  const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
1705
391
 
@@ -1753,6 +439,7 @@ export const App = () => {
1753
439
 
1754
440
  const openLabelModal = () => {
1755
441
  if (!selectedPullRequest) return
442
+ setMergeModal(initialMergeModalState)
1756
443
  const repository = selectedPullRequest.repository
1757
444
  const cachedLabels = labelCache[repository]
1758
445
  if (cachedLabels) {
@@ -1779,6 +466,71 @@ export const App = () => {
1779
466
  })
1780
467
  }
1781
468
 
469
+ const openMergeModal = () => {
470
+ if (!selectedPullRequest) return
471
+ const repository = selectedPullRequest.repository
472
+ const number = selectedPullRequest.number
473
+ const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
474
+ setLabelModal(initialLabelModalState)
475
+ setMergeModal({
476
+ open: true,
477
+ repository,
478
+ number,
479
+ selectedIndex: 0,
480
+ loading: true,
481
+ running: false,
482
+ info: seededInfo,
483
+ error: null,
484
+ })
485
+ void getPullRequestMergeInfo({ repository, number })
486
+ .then((info) => {
487
+ setMergeModal((current) => current.repository === repository && current.number === number
488
+ ? { ...current, loading: false, info, selectedIndex: 0 }
489
+ : current)
490
+ })
491
+ .catch((error) => {
492
+ setMergeModal((current) => current.repository === repository && current.number === number
493
+ ? { ...current, loading: false, error: errorMessage(error) }
494
+ : current)
495
+ })
496
+ }
497
+
498
+ const confirmMergeAction = () => {
499
+ if (!mergeModal.info || mergeModal.loading || mergeModal.running) return
500
+ const options = availableMergeActions(mergeModal.info)
501
+ const option = options[mergeModal.selectedIndex]
502
+ if (!option) return
503
+
504
+ const { repository, number } = mergeModal.info
505
+ const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
506
+ const previousPullRequest = targetPullRequest ?? null
507
+ const previousMergeInfo = mergeModal.info
508
+
509
+ if (targetPullRequest && option.optimisticAutoMergeEnabled !== undefined) {
510
+ updatePullRequest(targetPullRequest.url, (pullRequest) => ({ ...pullRequest, autoMergeEnabled: option.optimisticAutoMergeEnabled! }))
511
+ setMergeModal((current) => ({
512
+ ...current,
513
+ info: current.info ? { ...current.info, autoMergeEnabled: option.optimisticAutoMergeEnabled! } : current.info,
514
+ }))
515
+ }
516
+
517
+ setMergeModal((current) => ({ ...current, running: true, error: null }))
518
+ void mergePullRequest({ repository, number, action: option.action })
519
+ .then(() => {
520
+ setMergeModal(initialMergeModalState)
521
+ if (option.refreshOnSuccess) {
522
+ refreshPullRequests(`${option.pastTense} #${number}`)
523
+ } else {
524
+ flashNotice(`${option.pastTense} #${number}`)
525
+ }
526
+ })
527
+ .catch((error) => {
528
+ if (previousPullRequest) updatePullRequest(previousPullRequest.url, () => previousPullRequest)
529
+ setMergeModal((current) => ({ ...current, running: false, info: previousMergeInfo, error: errorMessage(error) }))
530
+ flashNotice(errorMessage(error))
531
+ })
532
+ }
533
+
1782
534
  const toggleLabelAtIndex = () => {
1783
535
  if (!selectedPullRequest) return
1784
536
  const filtered = labelModal.availableLabels.filter((label) =>
@@ -1817,6 +569,10 @@ export const App = () => {
1817
569
 
1818
570
  useKeyboard((key) => {
1819
571
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
572
+ if (mergeModal.open) {
573
+ setMergeModal(initialMergeModalState)
574
+ return
575
+ }
1820
576
  if (labelModal.open) {
1821
577
  setLabelModal(initialLabelModalState)
1822
578
  return
@@ -1825,6 +581,33 @@ export const App = () => {
1825
581
  return
1826
582
  }
1827
583
 
584
+ if (mergeModal.open) {
585
+ const options = availableMergeActions(mergeModal.info)
586
+ if (key.name === "escape") {
587
+ setMergeModal(initialMergeModalState)
588
+ return
589
+ }
590
+ if ((key.name === "return" || key.name === "enter") && options.length > 0) {
591
+ confirmMergeAction()
592
+ return
593
+ }
594
+ if (key.name === "up" || key.name === "k") {
595
+ setMergeModal((current) => ({
596
+ ...current,
597
+ selectedIndex: Math.max(0, current.selectedIndex - 1),
598
+ }))
599
+ return
600
+ }
601
+ if (key.name === "down" || key.name === "j") {
602
+ setMergeModal((current) => ({
603
+ ...current,
604
+ selectedIndex: Math.min(Math.max(0, options.length - 1), current.selectedIndex + 1),
605
+ }))
606
+ return
607
+ }
608
+ return
609
+ }
610
+
1828
611
  // Label modal takes priority over everything else
1829
612
  if (labelModal.open) {
1830
613
  if (key.name === "escape") {
@@ -2191,6 +974,10 @@ export const App = () => {
2191
974
  openLabelModal()
2192
975
  return
2193
976
  }
977
+ if (key.name === "m" || key.name === "M") {
978
+ if (selectedPullRequest) openMergeModal()
979
+ return
980
+ }
2194
981
  if (key.name === "o" && selectedPullRequest) {
2195
982
  void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
2196
983
  flashNotice(`Opened #${selectedPullRequest.number} in browser`)
@@ -2235,7 +1022,6 @@ export const App = () => {
2235
1022
  filterText: visibleFilterText,
2236
1023
  showFilterBar: filterMode || filterQuery.length > 0,
2237
1024
  isFilterEditing: filterMode,
2238
- groupIcon,
2239
1025
  onSelectPullRequest: selectPullRequestByUrl,
2240
1026
  } as const
2241
1027
 
@@ -2244,6 +1030,10 @@ export const App = () => {
2244
1030
  const labelModalHeight = Math.min(20, (height ?? 24) - 4)
2245
1031
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
2246
1032
  const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
1033
+ const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1034
+ const mergeModalHeight = Math.min(16, (height ?? 24) - 4)
1035
+ const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1036
+ const mergeModalTop = Math.floor(((height ?? 24) - mergeModalHeight) / 2)
2247
1037
 
2248
1038
  return (
2249
1039
  <box flexGrow={1} flexDirection="column">
@@ -2279,6 +1069,7 @@ export const App = () => {
2279
1069
  paneWidth={contentWidth}
2280
1070
  showChecks
2281
1071
  placeholderContent={detailPlaceholderContent}
1072
+ loadingIndicator={loadingIndicator}
2282
1073
  />
2283
1074
  </scrollbox>
2284
1075
  </box>
@@ -2295,7 +1086,7 @@ export const App = () => {
2295
1086
  <>
2296
1087
  <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2297
1088
  <scrollbox flexGrow={1}>
2298
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} />
1089
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} />
2299
1090
  </scrollbox>
2300
1091
  </>
2301
1092
  ) : (
@@ -2312,12 +1103,13 @@ export const App = () => {
2312
1103
  bodyLines={fullscreenBodyLines}
2313
1104
  paneWidth={contentWidth}
2314
1105
  placeholderContent={detailPlaceholderContent}
1106
+ loadingIndicator={loadingIndicator}
2315
1107
  />
2316
1108
  </scrollbox>
2317
1109
  </box>
2318
1110
  ) : (
2319
1111
  <>
2320
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
1112
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} />
2321
1113
  <Divider width={contentWidth} />
2322
1114
  <box flexGrow={1} flexDirection="column">
2323
1115
  <scrollbox flexGrow={1}>
@@ -2362,6 +1154,16 @@ export const App = () => {
2362
1154
  loadingIndicator={loadingIndicator}
2363
1155
  />
2364
1156
  ) : null}
1157
+ {mergeModal.open ? (
1158
+ <MergeModal
1159
+ state={mergeModal}
1160
+ modalWidth={mergeModalWidth}
1161
+ modalHeight={mergeModalHeight}
1162
+ offsetLeft={mergeModalLeft}
1163
+ offsetTop={mergeModalTop}
1164
+ loadingIndicator={loadingIndicator}
1165
+ />
1166
+ ) : null}
2365
1167
  </box>
2366
1168
  )
2367
1169
  }