@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/.env.example +4 -0
- package/package.json +1 -1
- package/src/App.tsx +221 -809
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/index.tsx +1 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +161 -14
- package/src/ui/FooterHints.tsx +145 -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 +294 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
package/src/App.tsx
CHANGED
|
@@ -1,45 +1,24 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
1629
|
-
filteredPullRequests,
|
|
1630
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
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
|
}
|