@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/.env.example +4 -0
- package/package.json +1 -1
- package/src/App.tsx +219 -1417
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/mergeActions.ts +90 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +154 -14
- package/src/ui/DetailsPane.tsx +498 -0
- package/src/ui/FooterHints.tsx +145 -0
- package/src/ui/PullRequestDiffPane.tsx +148 -0
- package/src/ui/PullRequestList.tsx +128 -0
- package/src/ui/colors.ts +28 -0
- package/src/ui/diff.ts +220 -0
- package/src/ui/modals.tsx +249 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
package/src/App.tsx
CHANGED
|
@@ -1,45 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useAtom, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
3
3
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
4
|
-
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 {
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
8
8
|
import { config } from "./config.js"
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
9
|
+
import type { PullRequestItem, PullRequestLabel, PullRequestMergeAction } from "./domain.js"
|
|
10
|
+
import { formatShortDate, formatTimestamp } from "./date.js"
|
|
11
|
+
import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
|
|
12
|
+
import { Observability } from "./observability.js"
|
|
11
13
|
import { GitHubService } from "./services/GitHubService.js"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
1630
|
-
filteredPullRequests,
|
|
1631
|
-
|
|
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
|
|
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
|
}
|