@kitlangton/ghui 0.1.6 → 0.1.8

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,24 @@
1
- import { parseColor, SyntaxStyle, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
1
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
3
- import { useKeyboard, useTerminalDimensions } from "@opentui/react"
4
- import { Cause, Effect, Schedule } from "effect"
3
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
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
7
  import { Fragment, 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 { CheckItem, PullRequestItem, PullRequestLabel, PullRequestMergeAction } from "./domain.js"
10
+ import { formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
11
+ import { Observability } from "./observability.js"
11
12
  import { GitHubService } from "./services/GitHubService.js"
13
+ import { colors } from "./ui/colors.js"
14
+ import { diffStatText, diffSyntaxStyle, patchRenderableLineCount, pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
15
+ 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"
19
+ import { PullRequestList } from "./ui/PullRequestList.js"
12
20
 
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
21
+ const githubRuntime = Atom.runtime(GitHubService.layer.pipe(Layer.provideMerge(Observability.layer)))
43
22
 
44
23
  type LoadStatus = "loading" | "ready" | "error"
45
24
 
@@ -61,22 +40,6 @@ interface DetailPlaceholderContent {
61
40
  readonly hint: string
62
41
  }
63
42
 
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
43
  interface DetailPlaceholderInput {
81
44
  readonly status: LoadStatus
82
45
  readonly retryProgress: RetryProgress | null
@@ -125,27 +88,8 @@ const diffRenderViewAtom = Atom.make<"unified" | "split">("split").pipe(Atom.kee
125
88
  const diffWrapModeAtom = Atom.make<"none" | "word">("none").pipe(Atom.keepAlive)
126
89
  const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>({}).pipe(Atom.keepAlive)
127
90
 
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
91
  const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
92
+ const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
149
93
  const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
150
94
  const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
151
95
  const usernameAtom = githubRuntime.atom(
@@ -157,6 +101,9 @@ const usernameAtom = githubRuntime.atom(
157
101
  const listRepoLabelsAtom = githubRuntime.fn<string>()((repository) =>
158
102
  GitHubService.use((github) => github.listRepoLabels(repository))
159
103
  )
104
+ const listOpenPullRequestDetailsAtom = githubRuntime.fn<void>()(() =>
105
+ GitHubService.use((github) => github.listOpenPullRequestDetails())
106
+ )
160
107
  const addPullRequestLabelAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly label: string }>()((input) =>
161
108
  GitHubService.use((github) => github.addPullRequestLabel(input.repository, input.number, input.label))
162
109
  )
@@ -169,24 +116,14 @@ const toggleDraftAtom = githubRuntime.fn<{ readonly repository: string; readonly
169
116
  const getPullRequestDiffAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
170
117
  GitHubService.use((github) => github.getPullRequestDiff(input.repository, input.number))
171
118
  )
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
119
+ const getPullRequestMergeInfoAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number }>()((input) =>
120
+ GitHubService.use((github) => github.getPullRequestMergeInfo(input.repository, input.number))
121
+ )
122
+ const mergePullRequestAtom = githubRuntime.fn<{ readonly repository: string; readonly number: number; readonly action: PullRequestMergeAction }>()((input) =>
123
+ GitHubService.use((github) => github.mergePullRequest(input.repository, input.number, input.action))
124
+ )
176
125
 
177
126
  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
127
  const DETAIL_BODY_LINES = 6
191
128
 
192
129
  const wrapText = (text: string, width: number): string[] => {
@@ -207,61 +144,6 @@ const wrapText = (text: string, width: number): string[] => {
207
144
  return lines.length > 0 ? lines : [""]
208
145
  }
209
146
 
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
- }
264
-
265
147
  const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
266
148
 
267
149
  const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
@@ -313,136 +195,10 @@ const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, i
313
195
  return lines
314
196
  }
315
197
 
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
198
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
326
199
 
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\//, "")
400
-
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
- }
410
- }
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 }]
423
- }
424
-
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 }
431
- })
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
200
  const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
201
+ if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
446
202
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
447
203
  type Part = { key: string; text: string; color: string }
448
204
  const rawParts: Array<Part | null> = [
@@ -464,106 +220,6 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
464
220
  )
465
221
  }
466
222
 
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 = []
533
- }
534
-
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
- }
556
-
557
- if (firstChar === " ") {
558
- flushChangeBlock()
559
- count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
560
- }
561
- }
562
-
563
- flushChangeBlock()
564
- return Math.max(1, count)
565
- }
566
-
567
223
  const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
568
224
 
569
225
  const getDetailPlaceholderContent = ({
@@ -709,308 +365,6 @@ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
709
365
  }
710
366
  }
711
367
 
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
368
  const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
1015
369
  const seen = new Map<string, CheckItem>()
1016
370
  for (const check of checks) {
@@ -1101,7 +455,9 @@ const DetailHeader = ({
1101
455
  const unique = deduplicateChecks(pullRequest.checks)
1102
456
  const checkRows = checksRowCount(unique)
1103
457
  const statsText = diffStatText(pullRequest)
1104
- const labelsWidth = labels.length > 0
458
+ const labelsWidth = !pullRequest.detailLoaded
459
+ ? "loading details...".length
460
+ : labels.length > 0
1105
461
  ? labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
1106
462
  : "no labels".length
1107
463
  const showStats = contentWidth - labelsWidth - statsText.length >= 2
@@ -1142,7 +498,7 @@ const DetailHeader = ({
1142
498
  </box>
1143
499
  <box height={1} paddingLeft={1} paddingRight={1}>
1144
500
  <TextLine>
1145
- {labels.length > 0 ? labels.map((label, index) => (
501
+ {!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
1146
502
  <Fragment key={label.name}>
1147
503
  {index > 0 ? <span fg={colors.muted}> </span> : null}
1148
504
  <span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
@@ -1173,16 +529,30 @@ const DetailBody = ({
1173
529
  pullRequest,
1174
530
  contentWidth,
1175
531
  bodyLines = DETAIL_BODY_LINES,
532
+ loadingIndicator,
1176
533
  }: {
1177
534
  pullRequest: PullRequestItem
1178
535
  contentWidth: number
1179
536
  bodyLines?: number
537
+ loadingIndicator: string
1180
538
  }) => {
1181
539
  const previewLines = useMemo(
1182
540
  () => bodyPreview(pullRequest.body, contentWidth, bodyLines),
1183
541
  [pullRequest.body, contentWidth, bodyLines],
1184
542
  )
1185
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
+
1186
556
  return (
1187
557
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1188
558
  {previewLines.map((line, index) => (
@@ -1258,6 +628,7 @@ const DetailsPane = ({
1258
628
  paneWidth = contentWidth + 2,
1259
629
  showChecks = false,
1260
630
  placeholderContent,
631
+ loadingIndicator,
1261
632
  }: {
1262
633
  pullRequest: PullRequestItem | null
1263
634
  contentWidth: number
@@ -1265,6 +636,7 @@ const DetailsPane = ({
1265
636
  paneWidth?: number
1266
637
  showChecks?: boolean
1267
638
  placeholderContent: DetailPlaceholderContent
639
+ loadingIndicator: string
1268
640
  }) => {
1269
641
  const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
1270
642
  const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
@@ -1275,14 +647,15 @@ const DetailsPane = ({
1275
647
  () => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
1276
648
  [pullRequest?.body, contentWidth, bodyLines],
1277
649
  )
1278
- const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + previewLines.length : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
650
+ const bodyHeight = pullRequest && !pullRequest.detailLoaded ? bodyLines : previewLines.length
651
+ const contentHeight = pullRequest ? titleLines + 2 + 1 + checksHeight + bodyHeight : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
1279
652
 
1280
653
  return (
1281
654
  <box flexDirection="column" height={contentHeight}>
1282
655
  {pullRequest ? (
1283
656
  <>
1284
657
  <DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
1285
- <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
658
+ <DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} />
1286
659
  </>
1287
660
  ) : (
1288
661
  <>
@@ -1418,116 +791,8 @@ const PullRequestDiffPane = ({
1418
791
  )
1419
792
  }
1420
793
 
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
794
  export const App = () => {
795
+ const renderer = useRenderer()
1531
796
  const { width, height } = useTerminalDimensions()
1532
797
  const pullRequestResult = useAtomValue(pullRequestsAtom)
1533
798
  const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom)
@@ -1545,17 +810,20 @@ export const App = () => {
1545
810
  const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
1546
811
  const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
1547
812
  const [labelModal, setLabelModal] = useAtom(labelModalAtom)
813
+ const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
1548
814
  const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
1549
815
  const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
1550
816
  const retryProgress = useAtomValue(retryProgressAtom)
1551
817
  const [loadingFrame, setLoadingFrame] = useState(0)
1552
818
  const usernameResult = useAtomValue(usernameAtom)
1553
819
  const loadRepoLabels = useAtomSet(listRepoLabelsAtom, { mode: "promise" })
820
+ const loadPullRequestDetails = useAtomSet(listOpenPullRequestDetailsAtom, { mode: "promise" })
1554
821
  const addPullRequestLabel = useAtomSet(addPullRequestLabelAtom, { mode: "promise" })
1555
822
  const removePullRequestLabel = useAtomSet(removePullRequestLabelAtom, { mode: "promise" })
1556
823
  const toggleDraftStatus = useAtomSet(toggleDraftAtom, { mode: "promise" })
1557
824
  const getPullRequestDiff = useAtomSet(getPullRequestDiffAtom, { mode: "promise" })
1558
- const groupIcon = GROUP_ICON
825
+ const getPullRequestMergeInfo = useAtomSet(getPullRequestMergeInfoAtom, { mode: "promise" })
826
+ const mergePullRequest = useAtomSet(mergePullRequestAtom, { mode: "promise" })
1559
827
  const contentWidth = Math.max(60, width ?? 100)
1560
828
  const isWideLayout = (width ?? 100) >= 100
1561
829
  const splitGap = 1
@@ -1570,6 +838,7 @@ export const App = () => {
1570
838
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1571
839
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1572
840
  const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
841
+ const detailHydrationRef = useRef<number | null>(null)
1573
842
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1574
843
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1575
844
  const headerFooterWidth = Math.max(24, contentWidth - 2)
@@ -1597,7 +866,10 @@ export const App = () => {
1597
866
  }, [])
1598
867
 
1599
868
  const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
1600
- const pullRequests = pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? []
869
+ const pullRequests = useMemo(
870
+ () => pullRequestLoad?.data.map((pullRequest) => pullRequestOverrides[pullRequest.url] ?? pullRequest) ?? [],
871
+ [pullRequestLoad?.data, pullRequestOverrides],
872
+ )
1601
873
  const pullRequestStatus: LoadStatus = pullRequestResult.waiting && pullRequestLoad === null
1602
874
  ? "loading"
1603
875
  : AsyncResult.isFailure(pullRequestResult)
@@ -1607,37 +879,29 @@ export const App = () => {
1607
879
  const pullRequestError = AsyncResult.isFailure(pullRequestResult) ? errorMessage(Cause.squash(pullRequestResult.cause)) : null
1608
880
  const username = AsyncResult.isSuccess(usernameResult) ? usernameResult.value : null
1609
881
 
1610
- useEffect(() => {
1611
- if (pullRequestStatus !== "loading") return
1612
- const interval = globalThis.setInterval(() => {
1613
- setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
1614
- }, 120)
1615
- return () => globalThis.clearInterval(interval)
1616
- }, [pullRequestStatus])
1617
-
1618
882
  const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
1619
883
  const visibleFilterText = filterMode ? filterDraft : filterQuery
1620
884
 
1621
- const filteredPullRequests = pullRequests.filter((pullRequest) => {
885
+ const filteredPullRequests = useMemo(() => pullRequests.filter((pullRequest) => {
1622
886
  const query = effectiveFilterQuery
1623
887
  if (query.length === 0) return true
1624
888
  return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
1625
889
  .some((value) => value.toLowerCase().includes(query))
1626
- })
890
+ }), [pullRequests, effectiveFilterQuery])
1627
891
 
1628
- const visibleGroups = groupBy(
1629
- filteredPullRequests,
1630
- (pullRequest) => pullRequest.repository,
892
+ const visibleGroups = useMemo(
893
+ () => groupBy(filteredPullRequests, (pullRequest) => pullRequest.repository),
894
+ [filteredPullRequests],
1631
895
  )
1632
- const visiblePullRequests = visibleGroups.flatMap(([, pullRequests]) => pullRequests)
1633
- const groupStarts = visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
896
+ const visiblePullRequests = useMemo(() => visibleGroups.flatMap(([, pullRequests]) => pullRequests), [visibleGroups])
897
+ const groupStarts = useMemo(() => visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
1634
898
  if (index === 0) {
1635
899
  starts.push(0)
1636
900
  return starts
1637
901
  }
1638
902
  starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
1639
903
  return starts
1640
- }, [])
904
+ }, []), [visibleGroups])
1641
905
  const getCurrentGroupIndex = (current: number) => {
1642
906
  for (let index = groupStarts.length - 1; index >= 0; index--) {
1643
907
  if (groupStarts[index]! <= current) return index
@@ -1662,7 +926,6 @@ export const App = () => {
1662
926
  setPullRequestOverrides((current) => ({ ...current, [url]: transform(pullRequest) }))
1663
927
  }
1664
928
  const refreshPullRequests = (message?: string) => {
1665
- setPullRequestOverrides({})
1666
929
  refreshPullRequestsAtom()
1667
930
  if (message) flashNotice(message)
1668
931
  }
@@ -1681,7 +944,37 @@ export const App = () => {
1681
944
  const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
1682
945
  const selectedDiffState = selectedPullRequest ? pullRequestDiffCache[pullRequestDiffKey(selectedPullRequest)] : undefined
1683
946
  const effectiveDiffRenderView = contentWidth >= 100 ? diffRenderView : "unified"
947
+ const isHydratingPullRequestDetails = pullRequestStatus === "ready" && pullRequests.some((pullRequest) => !pullRequest.detailLoaded)
948
+ const hasActiveLoadingIndicator = pullRequestStatus === "loading" || isHydratingPullRequestDetails || labelModal.loading || mergeModal.loading || mergeModal.running || selectedDiffState?.status === "loading"
1684
949
  const loadingIndicator = LOADING_FRAMES[loadingFrame % LOADING_FRAMES.length]!
950
+
951
+ useEffect(() => {
952
+ if (!hasActiveLoadingIndicator) return
953
+ const interval = globalThis.setInterval(() => {
954
+ setLoadingFrame((current) => (current + 1) % LOADING_FRAMES.length)
955
+ }, 120)
956
+ return () => globalThis.clearInterval(interval)
957
+ }, [hasActiveLoadingIndicator])
958
+
959
+ useEffect(() => {
960
+ const fetchedAt = pullRequestLoad?.fetchedAt?.getTime()
961
+ if (pullRequestStatus !== "ready" || fetchedAt === undefined) return
962
+ if (detailHydrationRef.current === fetchedAt) return
963
+ if (!pullRequests.some((pullRequest) => !pullRequest.detailLoaded)) return
964
+ detailHydrationRef.current = fetchedAt
965
+ void loadPullRequestDetails().then((details) => {
966
+ setPullRequestOverrides((current) => {
967
+ const next = { ...current }
968
+ for (const detail of details) {
969
+ next[detail.url] = current[detail.url]?.detailLoaded ? current[detail.url]! : detail
970
+ }
971
+ return next
972
+ })
973
+ }).catch((error) => {
974
+ flashNotice(error instanceof Error ? error.message : String(error))
975
+ })
976
+ }, [pullRequestStatus, pullRequestLoad?.fetchedAt, pullRequests.length])
977
+
1685
978
  const detailPlaceholderContent = getDetailPlaceholderContent({
1686
979
  status: pullRequestStatus,
1687
980
  retryProgress,
@@ -1752,6 +1045,7 @@ export const App = () => {
1752
1045
 
1753
1046
  const openLabelModal = () => {
1754
1047
  if (!selectedPullRequest) return
1048
+ setMergeModal(initialMergeModalState)
1755
1049
  const repository = selectedPullRequest.repository
1756
1050
  const cachedLabels = labelCache[repository]
1757
1051
  if (cachedLabels) {
@@ -1778,6 +1072,76 @@ export const App = () => {
1778
1072
  })
1779
1073
  }
1780
1074
 
1075
+ const openMergeModal = () => {
1076
+ if (!selectedPullRequest) return
1077
+ const repository = selectedPullRequest.repository
1078
+ const number = selectedPullRequest.number
1079
+ setLabelModal(initialLabelModalState)
1080
+ setMergeModal({
1081
+ open: true,
1082
+ repository,
1083
+ number,
1084
+ selectedIndex: 0,
1085
+ loading: true,
1086
+ running: false,
1087
+ info: null,
1088
+ error: null,
1089
+ })
1090
+ void getPullRequestMergeInfo({ repository, number })
1091
+ .then((info) => {
1092
+ setMergeModal((current) => current.repository === repository && current.number === number
1093
+ ? { ...current, loading: false, info, selectedIndex: 0 }
1094
+ : current)
1095
+ })
1096
+ .catch((error) => {
1097
+ setMergeModal((current) => current.repository === repository && current.number === number
1098
+ ? { ...current, loading: false, error: errorMessage(error) }
1099
+ : current)
1100
+ })
1101
+ }
1102
+
1103
+ const confirmMergeAction = () => {
1104
+ if (!mergeModal.info || mergeModal.loading || mergeModal.running) return
1105
+ const options = mergeModalOptions(mergeModal.info)
1106
+ const option = options[mergeModal.selectedIndex]
1107
+ if (!option) return
1108
+
1109
+ const { repository, number } = mergeModal.info
1110
+ const targetPullRequest = pullRequests.find((pullRequest) => pullRequest.repository === repository && pullRequest.number === number)
1111
+ const previousPullRequest = targetPullRequest ?? null
1112
+ const previousMergeInfo = mergeModal.info
1113
+
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 }))
1122
+ setMergeModal((current) => ({
1123
+ ...current,
1124
+ info: current.info ? { ...current.info, autoMergeEnabled: false } : current.info,
1125
+ }))
1126
+ }
1127
+
1128
+ setMergeModal((current) => ({ ...current, running: true, error: null }))
1129
+ void mergePullRequest({ repository, number, action: option.action })
1130
+ .then(() => {
1131
+ setMergeModal(initialMergeModalState)
1132
+ if (option.action === "squash" || option.action === "admin") {
1133
+ refreshPullRequests(`${mergeActionPastTense(option.action)} #${number}`)
1134
+ } else {
1135
+ flashNotice(`${mergeActionPastTense(option.action)} #${number}`)
1136
+ }
1137
+ })
1138
+ .catch((error) => {
1139
+ if (previousPullRequest) updatePullRequest(previousPullRequest.url, () => previousPullRequest)
1140
+ setMergeModal((current) => ({ ...current, running: false, info: previousMergeInfo, error: errorMessage(error) }))
1141
+ flashNotice(errorMessage(error))
1142
+ })
1143
+ }
1144
+
1781
1145
  const toggleLabelAtIndex = () => {
1782
1146
  if (!selectedPullRequest) return
1783
1147
  const filtered = labelModal.availableLabels.filter((label) =>
@@ -1816,14 +1180,43 @@ export const App = () => {
1816
1180
 
1817
1181
  useKeyboard((key) => {
1818
1182
  if (key.name === "q" || (key.ctrl && key.name === "c")) {
1183
+ if (mergeModal.open) {
1184
+ setMergeModal(initialMergeModalState)
1185
+ return
1186
+ }
1819
1187
  if (labelModal.open) {
1820
1188
  setLabelModal(initialLabelModalState)
1821
1189
  return
1822
1190
  }
1823
- if (key.name === "q") {
1824
- process.exit(0)
1191
+ renderer.destroy()
1192
+ return
1193
+ }
1194
+
1195
+ if (mergeModal.open) {
1196
+ const options = mergeModalOptions(mergeModal.info)
1197
+ if (key.name === "escape") {
1198
+ setMergeModal(initialMergeModalState)
1199
+ return
1825
1200
  }
1826
- process.exit(0)
1201
+ if ((key.name === "return" || key.name === "enter") && options.length > 0) {
1202
+ confirmMergeAction()
1203
+ return
1204
+ }
1205
+ if (key.name === "up" || key.name === "k") {
1206
+ setMergeModal((current) => ({
1207
+ ...current,
1208
+ selectedIndex: Math.max(0, current.selectedIndex - 1),
1209
+ }))
1210
+ return
1211
+ }
1212
+ if (key.name === "down" || key.name === "j") {
1213
+ setMergeModal((current) => ({
1214
+ ...current,
1215
+ selectedIndex: Math.min(Math.max(0, options.length - 1), current.selectedIndex + 1),
1216
+ }))
1217
+ return
1218
+ }
1219
+ return
1827
1220
  }
1828
1221
 
1829
1222
  // Label modal takes priority over everything else
@@ -2192,6 +1585,10 @@ export const App = () => {
2192
1585
  openLabelModal()
2193
1586
  return
2194
1587
  }
1588
+ if (key.name === "m" || key.name === "M") {
1589
+ if (selectedPullRequest) openMergeModal()
1590
+ return
1591
+ }
2195
1592
  if (key.name === "o" && selectedPullRequest) {
2196
1593
  void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
2197
1594
  flashNotice(`Opened #${selectedPullRequest.number} in browser`)
@@ -2236,7 +1633,6 @@ export const App = () => {
2236
1633
  filterText: visibleFilterText,
2237
1634
  showFilterBar: filterMode || filterQuery.length > 0,
2238
1635
  isFilterEditing: filterMode,
2239
- groupIcon,
2240
1636
  onSelectPullRequest: selectPullRequestByUrl,
2241
1637
  } as const
2242
1638
 
@@ -2245,6 +1641,10 @@ export const App = () => {
2245
1641
  const labelModalHeight = Math.min(20, (height ?? 24) - 4)
2246
1642
  const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
2247
1643
  const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
1644
+ const mergeModalWidth = Math.min(68, Math.max(46, contentWidth - 12))
1645
+ const mergeModalHeight = Math.min(16, (height ?? 24) - 4)
1646
+ const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
1647
+ const mergeModalTop = Math.floor(((height ?? 24) - mergeModalHeight) / 2)
2248
1648
 
2249
1649
  return (
2250
1650
  <box flexGrow={1} flexDirection="column">
@@ -2280,6 +1680,7 @@ export const App = () => {
2280
1680
  paneWidth={contentWidth}
2281
1681
  showChecks
2282
1682
  placeholderContent={detailPlaceholderContent}
1683
+ loadingIndicator={loadingIndicator}
2283
1684
  />
2284
1685
  </scrollbox>
2285
1686
  </box>
@@ -2296,7 +1697,7 @@ export const App = () => {
2296
1697
  <>
2297
1698
  <DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
2298
1699
  <scrollbox flexGrow={1}>
2299
- <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} />
1700
+ <DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} loadingIndicator={loadingIndicator} />
2300
1701
  </scrollbox>
2301
1702
  </>
2302
1703
  ) : (
@@ -2313,12 +1714,13 @@ export const App = () => {
2313
1714
  bodyLines={fullscreenBodyLines}
2314
1715
  paneWidth={contentWidth}
2315
1716
  placeholderContent={detailPlaceholderContent}
1717
+ loadingIndicator={loadingIndicator}
2316
1718
  />
2317
1719
  </scrollbox>
2318
1720
  </box>
2319
1721
  ) : (
2320
1722
  <>
2321
- <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} />
1723
+ <DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} placeholderContent={detailPlaceholderContent} loadingIndicator={loadingIndicator} />
2322
1724
  <Divider width={contentWidth} />
2323
1725
  <box flexGrow={1} flexDirection="column">
2324
1726
  <scrollbox flexGrow={1}>
@@ -2363,6 +1765,16 @@ export const App = () => {
2363
1765
  loadingIndicator={loadingIndicator}
2364
1766
  />
2365
1767
  ) : null}
1768
+ {mergeModal.open ? (
1769
+ <MergeModal
1770
+ state={mergeModal}
1771
+ modalWidth={mergeModalWidth}
1772
+ modalHeight={mergeModalHeight}
1773
+ offsetLeft={mergeModalLeft}
1774
+ offsetTop={mergeModalTop}
1775
+ loadingIndicator={loadingIndicator}
1776
+ />
1777
+ ) : null}
2366
1778
  </box>
2367
1779
  )
2368
1780
  }