@kitlangton/ghui 0.1.0
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 +2 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/bin/ghui +3 -0
- package/package.json +54 -0
- package/src/App.tsx +1502 -0
- package/src/config.ts +9 -0
- package/src/date.ts +20 -0
- package/src/domain.ts +30 -0
- package/src/index.tsx +14 -0
- package/src/services/CommandRunner.ts +43 -0
- package/src/services/GitHubService.ts +193 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { useAtom } from "@effect/atom-react"
|
|
3
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
4
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
5
|
+
import { Fragment, useEffect, useMemo, useRef } from "react"
|
|
6
|
+
import { config } from "./config.js"
|
|
7
|
+
import type { CheckItem, PullRequestItem, PullRequestLabel } from "./domain.js"
|
|
8
|
+
import { daysOpen, formatRelativeDate, formatShortDate, formatTimestamp } from "./date.js"
|
|
9
|
+
import { addPullRequestLabel, getAuthenticatedUser, listOpenPullRequests as loadOpenPullRequests, listRepoLabels, removePullRequestLabel, toggleDraftStatus } from "./services/GitHubService.js"
|
|
10
|
+
|
|
11
|
+
const toggleDraft = (repository: string, number: number, isDraft: boolean) => toggleDraftStatus(repository, number, isDraft)
|
|
12
|
+
|
|
13
|
+
const colors = {
|
|
14
|
+
text: "#ede7da",
|
|
15
|
+
muted: "#9f9788",
|
|
16
|
+
separator: "#6f685d",
|
|
17
|
+
accent: "#f4a51c",
|
|
18
|
+
inlineCode: "#d7c5a1",
|
|
19
|
+
error: "#f97316",
|
|
20
|
+
selectedBg: "#1d2430",
|
|
21
|
+
selectedText: "#f8fafc",
|
|
22
|
+
count: "#d7c5a1",
|
|
23
|
+
status: {
|
|
24
|
+
draft: "#f59e0b",
|
|
25
|
+
approved: "#7dd3a3",
|
|
26
|
+
changes: "#f87171",
|
|
27
|
+
review: "#93c5fd",
|
|
28
|
+
none: "#9f9788",
|
|
29
|
+
passing: "#7dd3a3",
|
|
30
|
+
pending: "#f4a51c",
|
|
31
|
+
failing: "#f87171",
|
|
32
|
+
},
|
|
33
|
+
repos: {
|
|
34
|
+
opencode: "#60a5fa",
|
|
35
|
+
"effect-smol": "#34d399",
|
|
36
|
+
"opencode-console": "#f472b6",
|
|
37
|
+
opencontrol: "#f59e0b",
|
|
38
|
+
default: "#93c5fd",
|
|
39
|
+
},
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
type LoadStatus = "loading" | "ready" | "error"
|
|
43
|
+
|
|
44
|
+
interface PullRequestState {
|
|
45
|
+
readonly status: LoadStatus
|
|
46
|
+
readonly data: readonly PullRequestItem[]
|
|
47
|
+
readonly error: string | null
|
|
48
|
+
readonly fetchedAt: Date | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PreviewLine {
|
|
52
|
+
readonly segments: ReadonlyArray<{
|
|
53
|
+
readonly text: string
|
|
54
|
+
readonly fg: string
|
|
55
|
+
readonly bold?: boolean
|
|
56
|
+
}>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
60
|
+
|
|
61
|
+
const initialPullRequestState: PullRequestState = {
|
|
62
|
+
status: "loading",
|
|
63
|
+
data: [],
|
|
64
|
+
error: null,
|
|
65
|
+
fetchedAt: null,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pullRequestStateAtom = Atom.make(initialPullRequestState).pipe(Atom.keepAlive)
|
|
69
|
+
const selectedIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
70
|
+
const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
71
|
+
const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
72
|
+
const filterQueryAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
73
|
+
const filterDraftAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
74
|
+
const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
75
|
+
const pendingGAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
76
|
+
const detailFullViewAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
77
|
+
const detailScrollOffsetAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
78
|
+
|
|
79
|
+
const GROUP_ICONS = ["▸", "◆", "●", "▪", "›", "◈", "▹", "◉", "⬥", "⏵", "⊡", "⬩"] as const
|
|
80
|
+
const groupIconIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
81
|
+
|
|
82
|
+
interface LabelModalState {
|
|
83
|
+
readonly open: boolean
|
|
84
|
+
readonly repository: string | null
|
|
85
|
+
readonly query: string
|
|
86
|
+
readonly selectedIndex: number
|
|
87
|
+
readonly availableLabels: readonly PullRequestLabel[]
|
|
88
|
+
readonly loading: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const initialLabelModalState: LabelModalState = {
|
|
92
|
+
open: false,
|
|
93
|
+
repository: null,
|
|
94
|
+
query: "",
|
|
95
|
+
selectedIndex: 0,
|
|
96
|
+
availableLabels: [],
|
|
97
|
+
loading: false,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
101
|
+
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
102
|
+
const usernameAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
103
|
+
|
|
104
|
+
const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
105
|
+
|
|
106
|
+
const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
|
|
107
|
+
|
|
108
|
+
const BlankRow = () => <box height={1} />
|
|
109
|
+
|
|
110
|
+
const reviewLabel = (pullRequest: PullRequestItem) => {
|
|
111
|
+
if (pullRequest.reviewStatus === "draft") return "draft"
|
|
112
|
+
if (pullRequest.reviewStatus === "approved") return "approved"
|
|
113
|
+
if (pullRequest.reviewStatus === "changes") return "changes"
|
|
114
|
+
if (pullRequest.reviewStatus === "review") return "review"
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
|
|
119
|
+
|
|
120
|
+
const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
|
|
121
|
+
const DETAIL_BODY_LINES = 6
|
|
122
|
+
|
|
123
|
+
const wrapText = (text: string, width: number): string[] => {
|
|
124
|
+
if (text.length === 0 || width <= 0) return [""]
|
|
125
|
+
const words = text.split(/\s+/)
|
|
126
|
+
const lines: string[] = []
|
|
127
|
+
let current = ""
|
|
128
|
+
for (const word of words) {
|
|
129
|
+
const next = current.length > 0 ? `${current} ${word}` : word
|
|
130
|
+
if (next.length > width && current.length > 0) {
|
|
131
|
+
lines.push(current)
|
|
132
|
+
current = word
|
|
133
|
+
} else {
|
|
134
|
+
current = next
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (current.length > 0) lines.push(current)
|
|
138
|
+
return lines.length > 0 ? lines : [""]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const reviewIcon = (pullRequest: PullRequestItem) => {
|
|
142
|
+
if (pullRequest.reviewStatus === "draft") return "◌"
|
|
143
|
+
if (pullRequest.reviewStatus === "approved") return "✓"
|
|
144
|
+
if (pullRequest.reviewStatus === "changes") return "!"
|
|
145
|
+
if (pullRequest.reviewStatus === "review") return "◐"
|
|
146
|
+
return "·"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const getRowLayout = (contentWidth: number, numberWidth = 6) => {
|
|
150
|
+
const reviewWidth = 1
|
|
151
|
+
const checkWidth = 6
|
|
152
|
+
const ageWidth = 4
|
|
153
|
+
const leftWidth = Math.max(24, contentWidth - reviewWidth - checkWidth - ageWidth - 2) // -2 for spaces between columns
|
|
154
|
+
const titleWidth = Math.max(8, leftWidth - numberWidth - 2)
|
|
155
|
+
return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
159
|
+
if (pullRequests.length === 0) return 4
|
|
160
|
+
const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
|
|
161
|
+
return maxLen + 1 // +1 for the # prefix
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
|
|
165
|
+
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
166
|
+
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
170
|
+
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
171
|
+
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
|
|
178
|
+
const junctions = new Set(junctionRows)
|
|
179
|
+
return (
|
|
180
|
+
<box width={1} height={height} flexDirection="column">
|
|
181
|
+
{Array.from({ length: height }, (_, index) => (
|
|
182
|
+
<PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
|
|
183
|
+
))}
|
|
184
|
+
</box>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
189
|
+
|
|
190
|
+
const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLine["segments"] => {
|
|
191
|
+
const parts = text.split(/(`[^`]+`)/g).filter((part) => part.length > 0)
|
|
192
|
+
return parts.flatMap((part) => {
|
|
193
|
+
if (part.startsWith("`") && part.endsWith("`")) {
|
|
194
|
+
return [{ text: part.slice(1, -1), fg: colors.inlineCode, bold }]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return part
|
|
198
|
+
.split(pullRequestReferencePattern)
|
|
199
|
+
.filter((segment) => segment.length > 0)
|
|
200
|
+
.map((segment) => ({
|
|
201
|
+
text: segment,
|
|
202
|
+
fg: segment.match(/^#[0-9]+$/) ? colors.count : fg,
|
|
203
|
+
bold,
|
|
204
|
+
}))
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
|
|
209
|
+
const tokens = segments.flatMap((segment) =>
|
|
210
|
+
segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const lines: Array<PreviewLine> = []
|
|
214
|
+
let current: Array<PreviewLine["segments"][number]> = []
|
|
215
|
+
let currentLength = 0
|
|
216
|
+
|
|
217
|
+
const pushLine = () => {
|
|
218
|
+
lines.push({ segments: current.length > 0 ? current : [{ text: "", fg: colors.muted }] })
|
|
219
|
+
current = indent.length > 0 ? [{ text: indent, fg: colors.muted }] : []
|
|
220
|
+
currentLength = indent.length
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const token of tokens) {
|
|
224
|
+
const tokenLength = token.text.length
|
|
225
|
+
if (currentLength > 0 && currentLength + tokenLength > width) {
|
|
226
|
+
pushLine()
|
|
227
|
+
}
|
|
228
|
+
current.push(token)
|
|
229
|
+
currentLength += tokenLength
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (current.length > 0) {
|
|
233
|
+
lines.push({ segments: current })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const fallbackLabelColor = (name: string) => {
|
|
240
|
+
let hash = 0
|
|
241
|
+
for (const char of name) {
|
|
242
|
+
hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
|
243
|
+
}
|
|
244
|
+
const hue = hash % 360
|
|
245
|
+
return `hsl(${hue} 55% 35%)`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
|
|
249
|
+
|
|
250
|
+
const labelTextColor = (color: string) => {
|
|
251
|
+
if (color.startsWith("#") && color.length === 7) {
|
|
252
|
+
const red = Number.parseInt(color.slice(1, 3), 16)
|
|
253
|
+
const green = Number.parseInt(color.slice(3, 5), 16)
|
|
254
|
+
const blue = Number.parseInt(color.slice(5, 7), 16)
|
|
255
|
+
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
|
|
256
|
+
return luminance > 0.6 ? "#111111" : "#f8fafc"
|
|
257
|
+
}
|
|
258
|
+
return "#f8fafc"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Array<PreviewLine> => {
|
|
262
|
+
const sourceLines = body.replace(/\r/g, "").split("\n")
|
|
263
|
+
const preview: Array<PreviewLine> = []
|
|
264
|
+
let inCodeBlock = false
|
|
265
|
+
|
|
266
|
+
for (const rawLine of sourceLines) {
|
|
267
|
+
if (preview.length >= limit) break
|
|
268
|
+
|
|
269
|
+
const line = rawLine.trim()
|
|
270
|
+
if (line.startsWith("```")) {
|
|
271
|
+
inCodeBlock = !inCodeBlock
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
if (line.length === 0) continue
|
|
275
|
+
|
|
276
|
+
let text = line
|
|
277
|
+
let fg: string = colors.text
|
|
278
|
+
let bold = false
|
|
279
|
+
let indent = ""
|
|
280
|
+
|
|
281
|
+
if (!inCodeBlock && /^#{1,6}\s+/.test(line)) {
|
|
282
|
+
if (preview.length > 0) {
|
|
283
|
+
preview.push({ segments: [{ text: "", fg: colors.muted }] })
|
|
284
|
+
if (preview.length >= limit) break
|
|
285
|
+
}
|
|
286
|
+
text = line.replace(/^#{1,6}\s+/, "")
|
|
287
|
+
fg = colors.count
|
|
288
|
+
bold = true
|
|
289
|
+
} else if (!inCodeBlock && /^[-*+]\s+\[(x|X| )\]\s+/.test(line)) {
|
|
290
|
+
const checked = /^[-*+]\s+\[(x|X)\]\s+/.test(line)
|
|
291
|
+
text = `${checked ? "☑" : "☐"} ${line.replace(/^[-*+]\s+\[(x|X| )\]\s+/, "")}`
|
|
292
|
+
fg = checked ? colors.status.passing : colors.text
|
|
293
|
+
indent = " "
|
|
294
|
+
} else if (!inCodeBlock && /^\[(x|X| )\]\s+/.test(line)) {
|
|
295
|
+
const checked = /^\[(x|X)\]\s+/.test(line)
|
|
296
|
+
text = `${checked ? "☑" : "☐"} ${line.replace(/^\[(x|X| )\]\s+/, "")}`
|
|
297
|
+
fg = checked ? colors.status.passing : colors.text
|
|
298
|
+
indent = " "
|
|
299
|
+
} else if (!inCodeBlock && /^[-*+]\s+/.test(line)) {
|
|
300
|
+
text = `• ${line.replace(/^[-*+]\s+/, "")}`
|
|
301
|
+
indent = " "
|
|
302
|
+
} else if (!inCodeBlock && /^\d+\.\s+/.test(line)) {
|
|
303
|
+
text = line
|
|
304
|
+
indent = " "
|
|
305
|
+
} else if (!inCodeBlock && /^>\s+/.test(line)) {
|
|
306
|
+
text = `> ${line.replace(/^>\s+/, "")}`
|
|
307
|
+
fg = colors.muted
|
|
308
|
+
indent = " "
|
|
309
|
+
} else if (inCodeBlock) {
|
|
310
|
+
fg = colors.muted
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
314
|
+
for (const wrappedLine of wrapped) {
|
|
315
|
+
preview.push(wrappedLine)
|
|
316
|
+
if (preview.length >= limit) break
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (preview.length === 0) {
|
|
321
|
+
return [{ segments: [{ text: "No description.", fg: colors.muted }] }]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return preview.slice(0, limit)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
328
|
+
const lines = [
|
|
329
|
+
pullRequest.title,
|
|
330
|
+
`${pullRequest.repository} #${pullRequest.number}`,
|
|
331
|
+
pullRequest.url,
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
const review = reviewLabel(pullRequest)
|
|
335
|
+
if (review) {
|
|
336
|
+
lines.push(`review: ${review}`)
|
|
337
|
+
}
|
|
338
|
+
if (pullRequest.checkSummary) {
|
|
339
|
+
lines.push(pullRequest.checkSummary)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const proc = Bun.spawn({
|
|
343
|
+
cmd: ["pbcopy"],
|
|
344
|
+
stdin: "pipe",
|
|
345
|
+
stdout: "ignore",
|
|
346
|
+
stderr: "pipe",
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
if (!proc.stdin) {
|
|
350
|
+
throw new Error("Clipboard is not available")
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
proc.stdin.write(lines.join("\n"))
|
|
354
|
+
proc.stdin.end()
|
|
355
|
+
|
|
356
|
+
const exitCode = await proc.exited
|
|
357
|
+
if (exitCode !== 0) {
|
|
358
|
+
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
359
|
+
throw new Error(stderr.trim() || "Could not copy PR metadata")
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
|
|
364
|
+
<box height={1}>
|
|
365
|
+
{bold ? (
|
|
366
|
+
<text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
|
|
367
|
+
{text}
|
|
368
|
+
</text>
|
|
369
|
+
) : (
|
|
370
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
371
|
+
{text}
|
|
372
|
+
</text>
|
|
373
|
+
)}
|
|
374
|
+
</box>
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
|
|
378
|
+
<box height={1}>
|
|
379
|
+
{bg ? (
|
|
380
|
+
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
381
|
+
{children}
|
|
382
|
+
</text>
|
|
383
|
+
) : (
|
|
384
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
385
|
+
{children}
|
|
386
|
+
</text>
|
|
387
|
+
)}
|
|
388
|
+
</box>
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const SectionTitle = ({ title }: { title: string }) => (
|
|
392
|
+
<TextLine>
|
|
393
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
394
|
+
{title}
|
|
395
|
+
</span>
|
|
396
|
+
</TextLine>
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
const FooterHints = ({ showFilterClear, detailFullView }: { showFilterClear: boolean; detailFullView: boolean }) => (
|
|
400
|
+
<TextLine>
|
|
401
|
+
<span fg={colors.count}>↑↓</span>
|
|
402
|
+
<span fg={colors.muted}> move </span>
|
|
403
|
+
<span fg={colors.count}>/</span>
|
|
404
|
+
<span fg={colors.muted}> filter </span>
|
|
405
|
+
{showFilterClear ? (
|
|
406
|
+
<>
|
|
407
|
+
<span fg={colors.count}>esc</span>
|
|
408
|
+
<span fg={colors.muted}> clear </span>
|
|
409
|
+
</>
|
|
410
|
+
) : null}
|
|
411
|
+
{detailFullView ? (
|
|
412
|
+
<>
|
|
413
|
+
<span fg={colors.count}>esc</span>
|
|
414
|
+
<span fg={colors.muted}> back </span>
|
|
415
|
+
</>
|
|
416
|
+
) : (
|
|
417
|
+
<>
|
|
418
|
+
<span fg={colors.count}>enter</span>
|
|
419
|
+
<span fg={colors.muted}> expand </span>
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
422
|
+
<span fg={colors.count}>r</span>
|
|
423
|
+
<span fg={colors.muted}> ref </span>
|
|
424
|
+
<span fg={colors.count}>d</span>
|
|
425
|
+
<span fg={colors.muted}> draft </span>
|
|
426
|
+
<span fg={colors.count}>l</span>
|
|
427
|
+
<span fg={colors.muted}> labels </span>
|
|
428
|
+
<span fg={colors.count}>o</span>
|
|
429
|
+
<span fg={colors.muted}> open </span>
|
|
430
|
+
<span fg={colors.count}>y</span>
|
|
431
|
+
<span fg={colors.muted}> copy </span>
|
|
432
|
+
<span fg={colors.count}>q</span>
|
|
433
|
+
<span fg={colors.muted}> quit</span>
|
|
434
|
+
</TextLine>
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
const GroupTitle = ({ label, color, icon }: { label: string; color: string; icon: string }) => (
|
|
438
|
+
<TextLine>
|
|
439
|
+
<span fg={color}>{icon} </span>
|
|
440
|
+
<span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
|
|
441
|
+
</TextLine>
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
const PullRequestRow = ({
|
|
445
|
+
pullRequest,
|
|
446
|
+
selected,
|
|
447
|
+
contentWidth,
|
|
448
|
+
numWidth,
|
|
449
|
+
onSelect,
|
|
450
|
+
}: {
|
|
451
|
+
pullRequest: PullRequestItem
|
|
452
|
+
selected: boolean
|
|
453
|
+
contentWidth: number
|
|
454
|
+
numWidth: number
|
|
455
|
+
onSelect: () => void
|
|
456
|
+
}) => {
|
|
457
|
+
const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
458
|
+
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
459
|
+
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<box height={1} onMouseDown={onSelect}>
|
|
463
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
464
|
+
<span fg={statusColor(pullRequest.reviewStatus)}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
465
|
+
<span> </span>
|
|
466
|
+
<span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
|
|
467
|
+
<span> </span>
|
|
468
|
+
<span>{fitCell(pullRequest.title, titleWidth)}</span>
|
|
469
|
+
<span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
470
|
+
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
471
|
+
</TextLine>
|
|
472
|
+
</box>
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
|
|
477
|
+
const groups = new Map<string, T[]>()
|
|
478
|
+
for (const item of items) {
|
|
479
|
+
const key = getKey(item)
|
|
480
|
+
const existing = groups.get(key)
|
|
481
|
+
if (existing) {
|
|
482
|
+
existing.push(item)
|
|
483
|
+
} else {
|
|
484
|
+
groups.set(key, [item])
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const order = new Map(orderedKeys.map((key, index) => [key, index]))
|
|
489
|
+
return [...groups.entries()].sort((left, right) => {
|
|
490
|
+
const leftIndex = order.get(left[0])
|
|
491
|
+
const rightIndex = order.get(right[0])
|
|
492
|
+
if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
|
|
493
|
+
if (leftIndex !== undefined) return -1
|
|
494
|
+
if (rightIndex !== undefined) return 1
|
|
495
|
+
return left[0].localeCompare(right[0])
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
type PullRequestGroups = Array<[string, PullRequestItem[]]>
|
|
500
|
+
|
|
501
|
+
const PullRequestList = ({
|
|
502
|
+
groups,
|
|
503
|
+
selectedUrl,
|
|
504
|
+
status,
|
|
505
|
+
error,
|
|
506
|
+
contentWidth,
|
|
507
|
+
filterText,
|
|
508
|
+
showFilterBar,
|
|
509
|
+
isFilterEditing,
|
|
510
|
+
groupIcon,
|
|
511
|
+
onSelectPullRequest,
|
|
512
|
+
}: {
|
|
513
|
+
groups: PullRequestGroups
|
|
514
|
+
selectedUrl: string | null
|
|
515
|
+
status: LoadStatus
|
|
516
|
+
error: string | null
|
|
517
|
+
contentWidth: number
|
|
518
|
+
filterText: string
|
|
519
|
+
showFilterBar: boolean
|
|
520
|
+
isFilterEditing: boolean
|
|
521
|
+
groupIcon: string
|
|
522
|
+
onSelectPullRequest: (url: string) => void
|
|
523
|
+
}) => {
|
|
524
|
+
const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
|
|
525
|
+
const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<box flexDirection="column">
|
|
529
|
+
<SectionTitle title="PULL REQUESTS" />
|
|
530
|
+
{showFilterBar ? (
|
|
531
|
+
<TextLine>
|
|
532
|
+
<span fg={colors.count}>/</span>
|
|
533
|
+
<span fg={colors.muted}> </span>
|
|
534
|
+
<span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
|
|
535
|
+
</TextLine>
|
|
536
|
+
) : null}
|
|
537
|
+
{status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
|
|
538
|
+
{status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
|
|
539
|
+
{status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
|
|
540
|
+
{groups.map(([repo, pullRequests]) => {
|
|
541
|
+
const numWidth = groupNumberWidth(pullRequests)
|
|
542
|
+
return (
|
|
543
|
+
<Fragment key={repo}>
|
|
544
|
+
<box flexDirection="column">
|
|
545
|
+
<GroupTitle label={repo} color={repoColor(repo)} icon={groupIcon} />
|
|
546
|
+
{pullRequests.map((pullRequest) => (
|
|
547
|
+
<PullRequestRow
|
|
548
|
+
key={pullRequest.url}
|
|
549
|
+
pullRequest={pullRequest}
|
|
550
|
+
selected={pullRequest.url === selectedUrl}
|
|
551
|
+
contentWidth={contentWidth}
|
|
552
|
+
numWidth={numWidth}
|
|
553
|
+
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
554
|
+
/>
|
|
555
|
+
))}
|
|
556
|
+
</box>
|
|
557
|
+
</Fragment>
|
|
558
|
+
)
|
|
559
|
+
})}
|
|
560
|
+
</box>
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
565
|
+
const seen = new Map<string, CheckItem>()
|
|
566
|
+
for (const check of checks) {
|
|
567
|
+
const existing = seen.get(check.name)
|
|
568
|
+
if (!existing || (check.status === "completed" && existing.status !== "completed")) {
|
|
569
|
+
seen.set(check.name, check)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return [...seen.values()]
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const checkIcon = (check: CheckItem) => {
|
|
576
|
+
if (check.status === "completed") {
|
|
577
|
+
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "✓"
|
|
578
|
+
if (check.conclusion === "failure") return "✗"
|
|
579
|
+
return "·"
|
|
580
|
+
}
|
|
581
|
+
if (check.status === "in_progress") return "●"
|
|
582
|
+
return "○"
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const checkColor = (check: CheckItem) => {
|
|
586
|
+
if (check.status === "completed") {
|
|
587
|
+
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return colors.status.passing
|
|
588
|
+
if (check.conclusion === "failure") return colors.status.failing
|
|
589
|
+
return colors.muted
|
|
590
|
+
}
|
|
591
|
+
if (check.status === "in_progress") return colors.status.pending
|
|
592
|
+
return colors.muted
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const checksRowCount = (checks: readonly CheckItem[]) => {
|
|
596
|
+
const unique = deduplicateChecks(checks)
|
|
597
|
+
return Math.ceil(unique.length / 2)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[]; contentWidth: number }) => {
|
|
601
|
+
const unique = deduplicateChecks(checks)
|
|
602
|
+
if (unique.length === 0) return null
|
|
603
|
+
|
|
604
|
+
const colWidth = Math.floor((contentWidth - 1) / 2) // -1 for gap between columns
|
|
605
|
+
const nameCol = Math.max(4, colWidth - 2) // -2 for icon + space
|
|
606
|
+
const rows = Math.ceil(unique.length / 2)
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<box flexDirection="column">
|
|
610
|
+
<TextLine>
|
|
611
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
|
|
612
|
+
</TextLine>
|
|
613
|
+
{Array.from({ length: rows }, (_, rowIndex) => {
|
|
614
|
+
const left = unique[rowIndex * 2]
|
|
615
|
+
const right = unique[rowIndex * 2 + 1]
|
|
616
|
+
return (
|
|
617
|
+
<TextLine key={rowIndex}>
|
|
618
|
+
{left ? (
|
|
619
|
+
<>
|
|
620
|
+
<span fg={checkColor(left)}>{checkIcon(left)} </span>
|
|
621
|
+
<span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
|
|
622
|
+
</>
|
|
623
|
+
) : null}
|
|
624
|
+
{right ? (
|
|
625
|
+
<>
|
|
626
|
+
<span fg={colors.muted}> </span>
|
|
627
|
+
<span fg={checkColor(right)}>{checkIcon(right)} </span>
|
|
628
|
+
<span fg={colors.text}>{right.name}</span>
|
|
629
|
+
</>
|
|
630
|
+
) : null}
|
|
631
|
+
</TextLine>
|
|
632
|
+
)
|
|
633
|
+
})}
|
|
634
|
+
</box>
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const DetailHeader = ({
|
|
639
|
+
pullRequest,
|
|
640
|
+
contentWidth,
|
|
641
|
+
paneWidth,
|
|
642
|
+
showChecks = false,
|
|
643
|
+
}: {
|
|
644
|
+
pullRequest: PullRequestItem
|
|
645
|
+
contentWidth: number
|
|
646
|
+
paneWidth: number
|
|
647
|
+
showChecks?: boolean
|
|
648
|
+
}) => {
|
|
649
|
+
const labels = pullRequest.labels
|
|
650
|
+
const wrappedTitle = wrapText(pullRequest.title, Math.max(1, paneWidth - 2))
|
|
651
|
+
const unique = deduplicateChecks(pullRequest.checks)
|
|
652
|
+
const checkRows = checksRowCount(unique)
|
|
653
|
+
|
|
654
|
+
return (
|
|
655
|
+
<>
|
|
656
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
657
|
+
{(() => {
|
|
658
|
+
const opened = formatRelativeDate(pullRequest.createdAt)
|
|
659
|
+
const repo = shortRepoName(pullRequest.repository)
|
|
660
|
+
const number = String(pullRequest.number)
|
|
661
|
+
const review = reviewLabel(pullRequest)
|
|
662
|
+
const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
|
|
663
|
+
const statusParts = [review, checks].filter((part): part is string => Boolean(part))
|
|
664
|
+
const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
|
|
665
|
+
const leftWidth = 1 + number.length + 1 + repo.length
|
|
666
|
+
const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
|
|
667
|
+
|
|
668
|
+
return (
|
|
669
|
+
<TextLine>
|
|
670
|
+
<span fg={colors.count}>#{number}</span>
|
|
671
|
+
<span fg={colors.muted}> {repo}</span>
|
|
672
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
673
|
+
{review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
|
|
674
|
+
{review && checks ? <span fg={colors.muted}> </span> : null}
|
|
675
|
+
{checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
|
|
676
|
+
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
677
|
+
<span fg={colors.muted}>{opened}</span>
|
|
678
|
+
</TextLine>
|
|
679
|
+
)
|
|
680
|
+
})()}
|
|
681
|
+
</box>
|
|
682
|
+
<box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
683
|
+
{wrappedTitle.map((line, index) => (
|
|
684
|
+
<PlainLine key={index} text={line} bold />
|
|
685
|
+
))}
|
|
686
|
+
</box>
|
|
687
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
688
|
+
<TextLine>
|
|
689
|
+
{labels.length > 0 ? labels.map((label, index) => (
|
|
690
|
+
<Fragment key={label.name}>
|
|
691
|
+
{index > 0 ? <span fg={colors.muted}> </span> : null}
|
|
692
|
+
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
|
|
693
|
+
</Fragment>
|
|
694
|
+
)) : <span fg={colors.muted}>no labels</span>}
|
|
695
|
+
</TextLine>
|
|
696
|
+
</box>
|
|
697
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
698
|
+
{showChecks && unique.length > 0 ? (
|
|
699
|
+
<>
|
|
700
|
+
<box height={checkRows + 1} paddingLeft={1} paddingRight={1}>
|
|
701
|
+
<ChecksSection checks={pullRequest.checks} contentWidth={contentWidth} />
|
|
702
|
+
</box>
|
|
703
|
+
<box height={1}><Divider width={paneWidth} /></box>
|
|
704
|
+
</>
|
|
705
|
+
) : null}
|
|
706
|
+
</>
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const DetailBody = ({
|
|
711
|
+
pullRequest,
|
|
712
|
+
contentWidth,
|
|
713
|
+
bodyLines = DETAIL_BODY_LINES,
|
|
714
|
+
}: {
|
|
715
|
+
pullRequest: PullRequestItem
|
|
716
|
+
contentWidth: number
|
|
717
|
+
bodyLines?: number
|
|
718
|
+
}) => {
|
|
719
|
+
const previewLines = useMemo(
|
|
720
|
+
() => bodyPreview(pullRequest.body, contentWidth, bodyLines),
|
|
721
|
+
[pullRequest.body, contentWidth, bodyLines],
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
return (
|
|
725
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
726
|
+
{previewLines.map((line, index) => (
|
|
727
|
+
<TextLine key={`${pullRequest.url}-${index}`}>
|
|
728
|
+
{line.segments.map((segment, segmentIndex) => (
|
|
729
|
+
("bold" in segment && segment.bold === true) ? (
|
|
730
|
+
<span key={segmentIndex} fg={segment.fg} attributes={TextAttributes.BOLD}>
|
|
731
|
+
{segment.text}
|
|
732
|
+
</span>
|
|
733
|
+
) : (
|
|
734
|
+
<span key={segmentIndex} fg={segment.fg}>
|
|
735
|
+
{segment.text}
|
|
736
|
+
</span>
|
|
737
|
+
)
|
|
738
|
+
))}
|
|
739
|
+
</TextLine>
|
|
740
|
+
))}
|
|
741
|
+
</box>
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const DetailsPane = ({
|
|
746
|
+
pullRequest,
|
|
747
|
+
contentWidth,
|
|
748
|
+
bodyLines = DETAIL_BODY_LINES,
|
|
749
|
+
paneWidth = contentWidth + 2,
|
|
750
|
+
showChecks = false,
|
|
751
|
+
}: {
|
|
752
|
+
pullRequest: PullRequestItem | null
|
|
753
|
+
contentWidth: number
|
|
754
|
+
bodyLines?: number
|
|
755
|
+
paneWidth?: number
|
|
756
|
+
showChecks?: boolean
|
|
757
|
+
}) => {
|
|
758
|
+
const titleLines = pullRequest ? wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length : 1
|
|
759
|
+
const uniqueChecks = pullRequest ? deduplicateChecks(pullRequest.checks) : []
|
|
760
|
+
const checkRows = checksRowCount(uniqueChecks)
|
|
761
|
+
// checks heading (1) + grid rows + divider (1)
|
|
762
|
+
const checksHeight = showChecks && uniqueChecks.length > 0 ? 1 + checkRows + 1 : 0
|
|
763
|
+
const previewLines = useMemo(
|
|
764
|
+
() => (pullRequest ? bodyPreview(pullRequest.body, contentWidth, bodyLines) : []),
|
|
765
|
+
[pullRequest?.body, contentWidth, bodyLines],
|
|
766
|
+
)
|
|
767
|
+
const contentHeight = titleLines + 2 + 1 + checksHeight + previewLines.length
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<box flexDirection="column" height={contentHeight}>
|
|
771
|
+
{pullRequest ? (
|
|
772
|
+
<>
|
|
773
|
+
<DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
774
|
+
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} />
|
|
775
|
+
</>
|
|
776
|
+
) : (
|
|
777
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
778
|
+
<PlainLine text="Select a pull request with up/down." fg={colors.muted} />
|
|
779
|
+
{Array.from({ length: DETAIL_BODY_LINES + 2 }, (_, index) => (
|
|
780
|
+
<BlankRow key={index} />
|
|
781
|
+
))}
|
|
782
|
+
</box>
|
|
783
|
+
)}
|
|
784
|
+
</box>
|
|
785
|
+
)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const LabelModal = ({
|
|
789
|
+
state,
|
|
790
|
+
currentLabels,
|
|
791
|
+
modalWidth,
|
|
792
|
+
modalHeight,
|
|
793
|
+
offsetLeft,
|
|
794
|
+
offsetTop,
|
|
795
|
+
}: {
|
|
796
|
+
state: LabelModalState
|
|
797
|
+
currentLabels: readonly PullRequestLabel[]
|
|
798
|
+
modalWidth: number
|
|
799
|
+
modalHeight: number
|
|
800
|
+
offsetLeft: number
|
|
801
|
+
offsetTop: number
|
|
802
|
+
}) => {
|
|
803
|
+
const contentWidth = modalWidth - 4
|
|
804
|
+
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
805
|
+
const filtered = state.availableLabels.filter((label) =>
|
|
806
|
+
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
807
|
+
)
|
|
808
|
+
const maxVisible = Math.max(1, modalHeight - 5)
|
|
809
|
+
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
810
|
+
const scrollStart = Math.min(
|
|
811
|
+
Math.max(0, filtered.length - maxVisible),
|
|
812
|
+
Math.max(0, selectedIndex - maxVisible + 1),
|
|
813
|
+
)
|
|
814
|
+
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
815
|
+
|
|
816
|
+
return (
|
|
817
|
+
<box
|
|
818
|
+
position="absolute"
|
|
819
|
+
left={offsetLeft}
|
|
820
|
+
top={offsetTop}
|
|
821
|
+
width={modalWidth}
|
|
822
|
+
height={modalHeight}
|
|
823
|
+
flexDirection="column"
|
|
824
|
+
backgroundColor="#1a1a2e"
|
|
825
|
+
>
|
|
826
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
827
|
+
<TextLine>
|
|
828
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>Labels</span>
|
|
829
|
+
{state.repository ? <span fg={colors.muted}> {state.repository}</span> : null}
|
|
830
|
+
</TextLine>
|
|
831
|
+
</box>
|
|
832
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
833
|
+
<TextLine>
|
|
834
|
+
<span fg={colors.count}>> </span>
|
|
835
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>
|
|
836
|
+
{state.query.length > 0 ? state.query : "type to filter..."}
|
|
837
|
+
</span>
|
|
838
|
+
</TextLine>
|
|
839
|
+
</box>
|
|
840
|
+
<Divider width={modalWidth} />
|
|
841
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
842
|
+
{state.loading ? (
|
|
843
|
+
<PlainLine text="Loading labels..." fg={colors.muted} />
|
|
844
|
+
) : visibleLabels.length === 0 ? (
|
|
845
|
+
<PlainLine text={state.query.length > 0 ? "No matching labels." : "No labels found."} fg={colors.muted} />
|
|
846
|
+
) : (
|
|
847
|
+
visibleLabels.map((label, index) => {
|
|
848
|
+
const actualIndex = scrollStart + index
|
|
849
|
+
const isActive = currentNames.has(label.name.toLowerCase())
|
|
850
|
+
const isSelected = actualIndex === selectedIndex
|
|
851
|
+
return (
|
|
852
|
+
<box key={label.name} height={1}>
|
|
853
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
854
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{isActive ? "✓ " : " "}</span>
|
|
855
|
+
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {fitCell(label.name, Math.min(label.name.length, contentWidth - 6))} </span>
|
|
856
|
+
</TextLine>
|
|
857
|
+
</box>
|
|
858
|
+
)
|
|
859
|
+
})
|
|
860
|
+
)}
|
|
861
|
+
</box>
|
|
862
|
+
<box flexGrow={1} />
|
|
863
|
+
<Divider width={modalWidth} />
|
|
864
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
865
|
+
<TextLine>
|
|
866
|
+
<span fg={colors.count}>enter</span>
|
|
867
|
+
<span fg={colors.muted}> toggle </span>
|
|
868
|
+
<span fg={colors.count}>esc</span>
|
|
869
|
+
<span fg={colors.muted}> close</span>
|
|
870
|
+
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
871
|
+
</TextLine>
|
|
872
|
+
</box>
|
|
873
|
+
</box>
|
|
874
|
+
)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export const App = () => {
|
|
878
|
+
const { width, height } = useTerminalDimensions()
|
|
879
|
+
const [pullRequestState, setPullRequestState] = useAtom(pullRequestStateAtom)
|
|
880
|
+
const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom)
|
|
881
|
+
const [notice, setNotice] = useAtom(noticeAtom)
|
|
882
|
+
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
883
|
+
const [filterQuery, setFilterQuery] = useAtom(filterQueryAtom)
|
|
884
|
+
const [filterDraft, setFilterDraft] = useAtom(filterDraftAtom)
|
|
885
|
+
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
886
|
+
const [pendingG, setPendingG] = useAtom(pendingGAtom)
|
|
887
|
+
const [detailFullView, setDetailFullView] = useAtom(detailFullViewAtom)
|
|
888
|
+
const [_detailScrollOffset, setDetailScrollOffset] = useAtom(detailScrollOffsetAtom)
|
|
889
|
+
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
890
|
+
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
891
|
+
const [username, setUsername] = useAtom(usernameAtom)
|
|
892
|
+
const [groupIconIndex, setGroupIconIndex] = useAtom(groupIconIndexAtom)
|
|
893
|
+
const groupIcon = GROUP_ICONS[groupIconIndex % GROUP_ICONS.length]!
|
|
894
|
+
const contentWidth = Math.max(60, width ?? 100)
|
|
895
|
+
const isWideLayout = (width ?? 100) >= 100
|
|
896
|
+
const splitGap = 1
|
|
897
|
+
const sectionPadding = 1
|
|
898
|
+
const leftPaneWidth = isWideLayout ? Math.max(44, Math.floor((contentWidth - splitGap) * 0.56)) : contentWidth
|
|
899
|
+
const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
|
|
900
|
+
const dividerJunctionAt = Math.max(1, leftPaneWidth)
|
|
901
|
+
const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
|
|
902
|
+
const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
|
|
903
|
+
const wideDetailLines = Math.max(8, (height ?? 24) - 8) // fill available vertical space
|
|
904
|
+
const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
|
|
905
|
+
const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
906
|
+
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
907
|
+
const headerFooterWidth = Math.max(24, contentWidth - 2)
|
|
908
|
+
|
|
909
|
+
const flashNotice = (message: string) => {
|
|
910
|
+
if (noticeTimeoutRef.current !== null) {
|
|
911
|
+
clearTimeout(noticeTimeoutRef.current)
|
|
912
|
+
}
|
|
913
|
+
setNotice(message)
|
|
914
|
+
noticeTimeoutRef.current = globalThis.setTimeout(() => {
|
|
915
|
+
setNotice((current) => (current === message ? null : current))
|
|
916
|
+
}, 2500)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
useEffect(() => () => {
|
|
920
|
+
if (noticeTimeoutRef.current !== null) {
|
|
921
|
+
clearTimeout(noticeTimeoutRef.current)
|
|
922
|
+
}
|
|
923
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
924
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
925
|
+
}
|
|
926
|
+
}, [])
|
|
927
|
+
|
|
928
|
+
useEffect(() => {
|
|
929
|
+
let cancelled = false
|
|
930
|
+
if (config.author !== "@me") {
|
|
931
|
+
setUsername(config.author.replace(/^@/, ""))
|
|
932
|
+
return () => {
|
|
933
|
+
cancelled = true
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
void getAuthenticatedUser()
|
|
938
|
+
.then((login) => {
|
|
939
|
+
if (!cancelled) setUsername(login)
|
|
940
|
+
})
|
|
941
|
+
.catch(() => {
|
|
942
|
+
if (!cancelled) setUsername(null)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
return () => {
|
|
946
|
+
cancelled = true
|
|
947
|
+
}
|
|
948
|
+
}, [setUsername])
|
|
949
|
+
|
|
950
|
+
useEffect(() => {
|
|
951
|
+
let cancelled = false
|
|
952
|
+
|
|
953
|
+
setPullRequestState((current) => ({
|
|
954
|
+
...current,
|
|
955
|
+
status: current.fetchedAt === null ? "loading" : "ready",
|
|
956
|
+
error: null,
|
|
957
|
+
}))
|
|
958
|
+
|
|
959
|
+
loadOpenPullRequests()
|
|
960
|
+
.then((pullRequests) => {
|
|
961
|
+
if (cancelled) return
|
|
962
|
+
setPullRequestState({
|
|
963
|
+
status: "ready",
|
|
964
|
+
data: pullRequests,
|
|
965
|
+
error: null,
|
|
966
|
+
fetchedAt: new Date(),
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
.catch((error) => {
|
|
970
|
+
if (cancelled) return
|
|
971
|
+
setPullRequestState((current) => ({
|
|
972
|
+
...current,
|
|
973
|
+
status: "error",
|
|
974
|
+
error: error instanceof Error ? error.message : String(error),
|
|
975
|
+
}))
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
return () => {
|
|
979
|
+
cancelled = true
|
|
980
|
+
}
|
|
981
|
+
}, [refreshNonce])
|
|
982
|
+
|
|
983
|
+
const effectiveFilterQuery = (filterMode ? filterDraft : filterQuery).trim().toLowerCase()
|
|
984
|
+
const visibleFilterText = filterMode ? filterDraft : filterQuery
|
|
985
|
+
|
|
986
|
+
const filteredPullRequests = pullRequestState.data.filter((pullRequest) => {
|
|
987
|
+
const query = effectiveFilterQuery
|
|
988
|
+
if (query.length === 0) return true
|
|
989
|
+
return [pullRequest.title, pullRequest.repository, String(pullRequest.number)]
|
|
990
|
+
.some((value) => value.toLowerCase().includes(query))
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
const visibleGroups = groupBy(
|
|
994
|
+
filteredPullRequests,
|
|
995
|
+
(pullRequest) => pullRequest.repository,
|
|
996
|
+
)
|
|
997
|
+
const visiblePullRequests = visibleGroups.flatMap(([, pullRequests]) => pullRequests)
|
|
998
|
+
const groupStarts = visibleGroups.reduce<Array<number>>((starts, [, pullRequests], index) => {
|
|
999
|
+
if (index === 0) {
|
|
1000
|
+
starts.push(0)
|
|
1001
|
+
return starts
|
|
1002
|
+
}
|
|
1003
|
+
starts.push(starts[index - 1]! + visibleGroups[index - 1]![1].length)
|
|
1004
|
+
return starts
|
|
1005
|
+
}, [])
|
|
1006
|
+
const getCurrentGroupIndex = (current: number) => {
|
|
1007
|
+
for (let index = groupStarts.length - 1; index >= 0; index--) {
|
|
1008
|
+
if (groupStarts[index]! <= current) return index
|
|
1009
|
+
}
|
|
1010
|
+
return 0
|
|
1011
|
+
}
|
|
1012
|
+
const summaryRight = pullRequestState.fetchedAt
|
|
1013
|
+
? `updated ${formatShortDate(pullRequestState.fetchedAt)} ${formatTimestamp(pullRequestState.fetchedAt)}`
|
|
1014
|
+
: pullRequestState.status === "loading"
|
|
1015
|
+
? "loading pull requests..."
|
|
1016
|
+
: ""
|
|
1017
|
+
const headerLeft = username ? `GHUI ${username}` : "GHUI"
|
|
1018
|
+
const headerLine = `${fitCell(headerLeft, Math.max(0, headerFooterWidth - summaryRight.length))}${summaryRight}`
|
|
1019
|
+
const footerNotice = notice ? fitCell(notice, headerFooterWidth) : null
|
|
1020
|
+
const selectPullRequestByUrl = (url: string) => {
|
|
1021
|
+
const index = visiblePullRequests.findIndex((pullRequest) => pullRequest.url === url)
|
|
1022
|
+
if (index >= 0) setSelectedIndex(index)
|
|
1023
|
+
}
|
|
1024
|
+
const updatePullRequest = (url: string, transform: (pullRequest: PullRequestItem) => PullRequestItem) => {
|
|
1025
|
+
setPullRequestState((current) => ({
|
|
1026
|
+
...current,
|
|
1027
|
+
data: current.data.map((pullRequest) => (pullRequest.url === url ? transform(pullRequest) : pullRequest)),
|
|
1028
|
+
}))
|
|
1029
|
+
}
|
|
1030
|
+
const refreshPullRequests = (message?: string) => {
|
|
1031
|
+
setRefreshNonce((current) => current + 1)
|
|
1032
|
+
if (message) flashNotice(message)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
useEffect(() => {
|
|
1036
|
+
setSelectedIndex((current) => {
|
|
1037
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1038
|
+
return Math.max(0, Math.min(current, visiblePullRequests.length - 1))
|
|
1039
|
+
})
|
|
1040
|
+
}, [visiblePullRequests.length])
|
|
1041
|
+
|
|
1042
|
+
const selectedPullRequest = visiblePullRequests[selectedIndex] ?? null
|
|
1043
|
+
const titleWrapWidth = Math.max(1, rightPaneWidth - 2) // account for paddingLeft/paddingRight in detail pane
|
|
1044
|
+
const titleLines = selectedPullRequest ? wrapText(selectedPullRequest.title, titleWrapWidth).length : 1
|
|
1045
|
+
const detailDividerRow = 1 + titleLines + 1 // info row + title lines + labels row
|
|
1046
|
+
const detailChecks = selectedPullRequest ? deduplicateChecks(selectedPullRequest.checks) : []
|
|
1047
|
+
const checksRows = checksRowCount(detailChecks)
|
|
1048
|
+
// checks heading (1) + grid rows + divider
|
|
1049
|
+
const checksDividerRow = detailChecks.length > 0 ? detailDividerRow + 1 + checksRows + 1 : -1
|
|
1050
|
+
const detailJunctions = detailChecks.length > 0 ? [detailDividerRow, checksDividerRow] : [detailDividerRow]
|
|
1051
|
+
|
|
1052
|
+
const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2))
|
|
1053
|
+
|
|
1054
|
+
const openLabelModal = () => {
|
|
1055
|
+
if (!selectedPullRequest) return
|
|
1056
|
+
const repository = selectedPullRequest.repository
|
|
1057
|
+
const cachedLabels = labelCache[repository]
|
|
1058
|
+
if (cachedLabels) {
|
|
1059
|
+
setLabelModal({
|
|
1060
|
+
open: true,
|
|
1061
|
+
repository,
|
|
1062
|
+
query: "",
|
|
1063
|
+
selectedIndex: 0,
|
|
1064
|
+
availableLabels: cachedLabels,
|
|
1065
|
+
loading: false,
|
|
1066
|
+
})
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
setLabelModal((current) => ({ ...current, open: true, repository, query: "", selectedIndex: 0, availableLabels: [], loading: true }))
|
|
1071
|
+
void listRepoLabels(repository)
|
|
1072
|
+
.then((labels) => {
|
|
1073
|
+
setLabelCache((current) => ({ ...current, [repository]: labels }))
|
|
1074
|
+
setLabelModal((current) => current.repository === repository ? { ...current, availableLabels: labels, loading: false } : current)
|
|
1075
|
+
})
|
|
1076
|
+
.catch((error) => {
|
|
1077
|
+
setLabelModal((current) => current.repository === repository ? { ...current, loading: false } : current)
|
|
1078
|
+
flashNotice(error instanceof Error ? error.message : String(error))
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const toggleLabelAtIndex = () => {
|
|
1083
|
+
if (!selectedPullRequest) return
|
|
1084
|
+
const filtered = labelModal.availableLabels.filter((label) =>
|
|
1085
|
+
labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
|
|
1086
|
+
)
|
|
1087
|
+
const label = filtered[labelModal.selectedIndex]
|
|
1088
|
+
if (!label) return
|
|
1089
|
+
|
|
1090
|
+
const isActive = selectedPullRequest.labels.some((l) => l.name.toLowerCase() === label.name.toLowerCase())
|
|
1091
|
+
const previousPullRequest = selectedPullRequest
|
|
1092
|
+
|
|
1093
|
+
if (isActive) {
|
|
1094
|
+
updatePullRequest(selectedPullRequest.url, (pr) => ({
|
|
1095
|
+
...pr,
|
|
1096
|
+
labels: pr.labels.filter((l) => l.name.toLowerCase() !== label.name.toLowerCase()),
|
|
1097
|
+
}))
|
|
1098
|
+
void removePullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1099
|
+
.then(() => flashNotice(`Removed ${label.name} from #${selectedPullRequest.number}`))
|
|
1100
|
+
.catch((error) => {
|
|
1101
|
+
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
1102
|
+
flashNotice(error instanceof Error ? error.message : String(error))
|
|
1103
|
+
})
|
|
1104
|
+
} else {
|
|
1105
|
+
updatePullRequest(selectedPullRequest.url, (pr) => ({
|
|
1106
|
+
...pr,
|
|
1107
|
+
labels: [...pr.labels, { name: label.name, color: label.color }],
|
|
1108
|
+
}))
|
|
1109
|
+
void addPullRequestLabel(selectedPullRequest.repository, selectedPullRequest.number, label.name)
|
|
1110
|
+
.then(() => flashNotice(`Added ${label.name} to #${selectedPullRequest.number}`))
|
|
1111
|
+
.catch((error) => {
|
|
1112
|
+
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
1113
|
+
flashNotice(error instanceof Error ? error.message : String(error))
|
|
1114
|
+
})
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
useKeyboard((key) => {
|
|
1119
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
1120
|
+
if (labelModal.open) {
|
|
1121
|
+
setLabelModal(initialLabelModalState)
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
if (key.name === "q") {
|
|
1125
|
+
process.exit(0)
|
|
1126
|
+
}
|
|
1127
|
+
process.exit(0)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Label modal takes priority over everything else
|
|
1131
|
+
if (labelModal.open) {
|
|
1132
|
+
if (key.name === "escape") {
|
|
1133
|
+
setLabelModal(initialLabelModalState)
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
if (key.name === "return" || key.name === "enter") {
|
|
1137
|
+
toggleLabelAtIndex()
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1140
|
+
if (key.name === "up" || key.name === "k") {
|
|
1141
|
+
setLabelModal((current) => ({
|
|
1142
|
+
...current,
|
|
1143
|
+
selectedIndex: Math.max(0, current.selectedIndex - 1),
|
|
1144
|
+
}))
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
if (key.name === "down" || key.name === "j") {
|
|
1148
|
+
const filtered = labelModal.availableLabels.filter((label) =>
|
|
1149
|
+
labelModal.query.length === 0 || label.name.toLowerCase().includes(labelModal.query.toLowerCase()),
|
|
1150
|
+
)
|
|
1151
|
+
setLabelModal((current) => ({
|
|
1152
|
+
...current,
|
|
1153
|
+
selectedIndex: Math.min(Math.max(0, filtered.length - 1), current.selectedIndex + 1),
|
|
1154
|
+
}))
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
if (key.name === "backspace") {
|
|
1158
|
+
setLabelModal((current) => ({
|
|
1159
|
+
...current,
|
|
1160
|
+
query: current.query.slice(0, -1),
|
|
1161
|
+
selectedIndex: 0,
|
|
1162
|
+
}))
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
if (key.ctrl && key.name === "u") {
|
|
1166
|
+
setLabelModal((current) => ({ ...current, query: "", selectedIndex: 0 }))
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
if (!key.ctrl && !key.meta && key.sequence.length === 1) {
|
|
1170
|
+
setLabelModal((current) => ({
|
|
1171
|
+
...current,
|
|
1172
|
+
query: current.query + key.sequence,
|
|
1173
|
+
selectedIndex: 0,
|
|
1174
|
+
}))
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
|
|
1181
|
+
if (detailFullView) {
|
|
1182
|
+
if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
|
|
1183
|
+
setDetailFullView(false)
|
|
1184
|
+
setDetailScrollOffset(0)
|
|
1185
|
+
return
|
|
1186
|
+
}
|
|
1187
|
+
if (key.name === "up" || key.name === "k") {
|
|
1188
|
+
setDetailScrollOffset((current) => Math.max(0, current - 1))
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
if (key.name === "down" || key.name === "j") {
|
|
1192
|
+
setDetailScrollOffset((current) => current + 1)
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
if (key.ctrl && key.name === "u") {
|
|
1196
|
+
setDetailScrollOffset((current) => Math.max(0, current - halfPage))
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
if (key.ctrl && (key.name === "d" || key.name === "v")) {
|
|
1200
|
+
setDetailScrollOffset((current) => current + halfPage)
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
if (key.name === "o" && selectedPullRequest) {
|
|
1204
|
+
void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
|
|
1205
|
+
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
if (key.name === "y" && selectedPullRequest) {
|
|
1209
|
+
void copyPullRequestMetadata(selectedPullRequest)
|
|
1210
|
+
.then(() => flashNotice(`Copied #${selectedPullRequest.number} metadata`))
|
|
1211
|
+
.catch((error) => flashNotice(error instanceof Error ? error.message : String(error)))
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (filterMode) {
|
|
1218
|
+
if (key.name === "escape") {
|
|
1219
|
+
setFilterDraft(filterQuery)
|
|
1220
|
+
setFilterMode(false)
|
|
1221
|
+
return
|
|
1222
|
+
}
|
|
1223
|
+
if (key.name === "enter") {
|
|
1224
|
+
setFilterQuery(filterDraft)
|
|
1225
|
+
setFilterMode(false)
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
if (key.ctrl && key.name === "u") {
|
|
1229
|
+
setFilterDraft("")
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
if (key.ctrl && key.name === "w") {
|
|
1233
|
+
setFilterDraft((current) => deleteLastWord(current))
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
1236
|
+
if (key.name === "backspace") {
|
|
1237
|
+
setFilterDraft((current) => current.slice(0, -1))
|
|
1238
|
+
return
|
|
1239
|
+
}
|
|
1240
|
+
if (!key.ctrl && !key.meta && key.sequence.length === 1 && key.name !== "return") {
|
|
1241
|
+
setFilterDraft((current) => current + key.sequence)
|
|
1242
|
+
return
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (key.name === "/") {
|
|
1247
|
+
setFilterDraft(filterQuery)
|
|
1248
|
+
setFilterMode(true)
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
if (key.name === "escape" && filterQuery.length > 0) {
|
|
1252
|
+
setFilterQuery("")
|
|
1253
|
+
setFilterDraft("")
|
|
1254
|
+
setFilterMode(false)
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
if (key.name === "r") {
|
|
1258
|
+
refreshPullRequests("Refreshing pull requests...")
|
|
1259
|
+
return
|
|
1260
|
+
}
|
|
1261
|
+
if (
|
|
1262
|
+
key.name === "[" ||
|
|
1263
|
+
((key.option || key.meta) && (key.name === "up" || key.name === "k")) ||
|
|
1264
|
+
(key.shift && key.name === "k") ||
|
|
1265
|
+
key.name === "K"
|
|
1266
|
+
) {
|
|
1267
|
+
setSelectedIndex((current) => {
|
|
1268
|
+
if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
|
|
1269
|
+
const currentGroup = getCurrentGroupIndex(current)
|
|
1270
|
+
if (currentGroup <= 0) return groupStarts[groupStarts.length - 1]!
|
|
1271
|
+
return groupStarts[currentGroup - 1]!
|
|
1272
|
+
})
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
if (
|
|
1276
|
+
key.name === "]" ||
|
|
1277
|
+
((key.option || key.meta) && (key.name === "down" || key.name === "j")) ||
|
|
1278
|
+
(key.shift && key.name === "j") ||
|
|
1279
|
+
key.name === "J"
|
|
1280
|
+
) {
|
|
1281
|
+
setSelectedIndex((current) => {
|
|
1282
|
+
if (visiblePullRequests.length === 0 || groupStarts.length === 0) return 0
|
|
1283
|
+
const currentGroup = getCurrentGroupIndex(current)
|
|
1284
|
+
if (currentGroup >= groupStarts.length - 1) return groupStarts[0]!
|
|
1285
|
+
return groupStarts[currentGroup + 1]!
|
|
1286
|
+
})
|
|
1287
|
+
return
|
|
1288
|
+
}
|
|
1289
|
+
if (key.ctrl && key.name === "u") {
|
|
1290
|
+
setSelectedIndex((current) => {
|
|
1291
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1292
|
+
return Math.max(0, current - halfPage)
|
|
1293
|
+
})
|
|
1294
|
+
return
|
|
1295
|
+
}
|
|
1296
|
+
if (key.ctrl && key.name === "d") {
|
|
1297
|
+
setSelectedIndex((current) => {
|
|
1298
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1299
|
+
return Math.min(visiblePullRequests.length - 1, current + halfPage)
|
|
1300
|
+
})
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
if (key.name === "up" || key.name === "k") {
|
|
1304
|
+
setSelectedIndex((current) => {
|
|
1305
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1306
|
+
return current <= 0 ? visiblePullRequests.length - 1 : current - 1
|
|
1307
|
+
})
|
|
1308
|
+
return
|
|
1309
|
+
}
|
|
1310
|
+
if (key.name === "down" || key.name === "j") {
|
|
1311
|
+
setSelectedIndex((current) => {
|
|
1312
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1313
|
+
return current >= visiblePullRequests.length - 1 ? 0 : current + 1
|
|
1314
|
+
})
|
|
1315
|
+
return
|
|
1316
|
+
}
|
|
1317
|
+
// Vim-style navigation: gg to go to top, G to go to bottom
|
|
1318
|
+
if (key.name === "G" || key.name === "g" && key.shift) {
|
|
1319
|
+
setSelectedIndex((_current) => {
|
|
1320
|
+
if (visiblePullRequests.length === 0) return 0
|
|
1321
|
+
return visiblePullRequests.length - 1
|
|
1322
|
+
})
|
|
1323
|
+
return
|
|
1324
|
+
}
|
|
1325
|
+
if (key.name === "g") {
|
|
1326
|
+
if (pendingG) {
|
|
1327
|
+
setSelectedIndex(0)
|
|
1328
|
+
setPendingG(false)
|
|
1329
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
1330
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
1331
|
+
pendingGTimeoutRef.current = null
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
setPendingG(true)
|
|
1335
|
+
pendingGTimeoutRef.current = setTimeout(() => {
|
|
1336
|
+
setPendingG(false)
|
|
1337
|
+
pendingGTimeoutRef.current = null
|
|
1338
|
+
}, 500)
|
|
1339
|
+
}
|
|
1340
|
+
return
|
|
1341
|
+
}
|
|
1342
|
+
if ((key.name === "return" || key.name === "enter") && !detailFullView) {
|
|
1343
|
+
setDetailFullView(true)
|
|
1344
|
+
setDetailScrollOffset(0)
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
if (key.name === "l" && selectedPullRequest) {
|
|
1348
|
+
openLabelModal()
|
|
1349
|
+
return
|
|
1350
|
+
}
|
|
1351
|
+
if (key.name === "p" || key.name === "P") {
|
|
1352
|
+
setGroupIconIndex((current) => {
|
|
1353
|
+
const next = (current + 1) % GROUP_ICONS.length
|
|
1354
|
+
flashNotice(`icon: ${GROUP_ICONS[next]}`)
|
|
1355
|
+
return next
|
|
1356
|
+
})
|
|
1357
|
+
return
|
|
1358
|
+
}
|
|
1359
|
+
if (key.name === "o" && selectedPullRequest) {
|
|
1360
|
+
void Bun.spawn({ cmd: ["open", selectedPullRequest.url], stdout: "ignore", stderr: "ignore" })
|
|
1361
|
+
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
if ((key.name === "d" || key.name === "D") && selectedPullRequest) {
|
|
1365
|
+
const previousPullRequest = selectedPullRequest
|
|
1366
|
+
const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
|
|
1367
|
+
updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
|
|
1368
|
+
...pullRequest,
|
|
1369
|
+
reviewStatus: nextReviewStatus,
|
|
1370
|
+
}))
|
|
1371
|
+
void toggleDraft(selectedPullRequest.repository, selectedPullRequest.number, selectedPullRequest.reviewStatus === "draft")
|
|
1372
|
+
.then(() => {
|
|
1373
|
+
flashNotice(selectedPullRequest.reviewStatus === "draft" ? `Marked #${selectedPullRequest.number} ready` : `Marked #${selectedPullRequest.number} draft`)
|
|
1374
|
+
})
|
|
1375
|
+
.catch((error) => {
|
|
1376
|
+
updatePullRequest(selectedPullRequest.url, () => previousPullRequest)
|
|
1377
|
+
flashNotice(error instanceof Error ? error.message : String(error))
|
|
1378
|
+
})
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
if (key.name === "y" && selectedPullRequest) {
|
|
1382
|
+
void copyPullRequestMetadata(selectedPullRequest)
|
|
1383
|
+
.then(() => {
|
|
1384
|
+
flashNotice(`Copied #${selectedPullRequest.number} metadata`)
|
|
1385
|
+
})
|
|
1386
|
+
.catch((error) => {
|
|
1387
|
+
flashNotice(error instanceof Error ? error.message : String(error))
|
|
1388
|
+
})
|
|
1389
|
+
}
|
|
1390
|
+
})
|
|
1391
|
+
|
|
1392
|
+
const fullscreenContentWidth = Math.max(24, contentWidth - 2)
|
|
1393
|
+
const fullscreenBodyLines = Math.max(8, (height ?? 24) - 8)
|
|
1394
|
+
|
|
1395
|
+
const prListProps = {
|
|
1396
|
+
groups: visibleGroups,
|
|
1397
|
+
selectedUrl: selectedPullRequest?.url ?? null,
|
|
1398
|
+
status: pullRequestState.status,
|
|
1399
|
+
error: pullRequestState.error,
|
|
1400
|
+
filterText: visibleFilterText,
|
|
1401
|
+
showFilterBar: filterMode || filterQuery.length > 0,
|
|
1402
|
+
isFilterEditing: filterMode,
|
|
1403
|
+
groupIcon,
|
|
1404
|
+
onSelectPullRequest: selectPullRequestByUrl,
|
|
1405
|
+
} as const
|
|
1406
|
+
|
|
1407
|
+
const labelModalWidth = Math.min(40, contentWidth - 4)
|
|
1408
|
+
const labelModalHeight = Math.min(20, (height ?? 24) - 4)
|
|
1409
|
+
const labelModalLeft = Math.floor((contentWidth - labelModalWidth) / 2)
|
|
1410
|
+
const labelModalTop = Math.floor(((height ?? 24) - labelModalHeight) / 2)
|
|
1411
|
+
|
|
1412
|
+
return (
|
|
1413
|
+
<box flexGrow={1} flexDirection="column">
|
|
1414
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="column">
|
|
1415
|
+
<PlainLine text={headerLine} fg={colors.muted} bold />
|
|
1416
|
+
</box>
|
|
1417
|
+
{isWideLayout && !detailFullView ? (
|
|
1418
|
+
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┬" />
|
|
1419
|
+
) : (
|
|
1420
|
+
<Divider width={contentWidth} />
|
|
1421
|
+
)}
|
|
1422
|
+
{isWideLayout && detailFullView ? (
|
|
1423
|
+
<box flexGrow={1} flexDirection="column">
|
|
1424
|
+
<scrollbox flexGrow={1}>
|
|
1425
|
+
<DetailsPane
|
|
1426
|
+
pullRequest={selectedPullRequest}
|
|
1427
|
+
contentWidth={fullscreenContentWidth}
|
|
1428
|
+
bodyLines={fullscreenBodyLines}
|
|
1429
|
+
paneWidth={contentWidth}
|
|
1430
|
+
showChecks
|
|
1431
|
+
/>
|
|
1432
|
+
</scrollbox>
|
|
1433
|
+
</box>
|
|
1434
|
+
) : isWideLayout ? (
|
|
1435
|
+
<box flexGrow={1} flexDirection="row">
|
|
1436
|
+
<box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column" paddingLeft={sectionPadding} paddingRight={sectionPadding}>
|
|
1437
|
+
<scrollbox height={wideBodyHeight} flexGrow={0}>
|
|
1438
|
+
<PullRequestList {...prListProps} contentWidth={leftContentWidth} />
|
|
1439
|
+
</scrollbox>
|
|
1440
|
+
</box>
|
|
1441
|
+
<SeparatorColumn height={wideBodyHeight} junctionRows={detailJunctions} />
|
|
1442
|
+
<box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
1443
|
+
{selectedPullRequest ? (
|
|
1444
|
+
<>
|
|
1445
|
+
<DetailHeader pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={rightPaneWidth} showChecks />
|
|
1446
|
+
<scrollbox flexGrow={1}>
|
|
1447
|
+
<DetailBody pullRequest={selectedPullRequest} contentWidth={rightContentWidth} bodyLines={wideDetailLines} />
|
|
1448
|
+
</scrollbox>
|
|
1449
|
+
</>
|
|
1450
|
+
) : (
|
|
1451
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
1452
|
+
<PlainLine text="Select a pull request with up/down." fg={colors.muted} />
|
|
1453
|
+
</box>
|
|
1454
|
+
)}
|
|
1455
|
+
</box>
|
|
1456
|
+
</box>
|
|
1457
|
+
) : detailFullView ? (
|
|
1458
|
+
<box flexGrow={1} flexDirection="column">
|
|
1459
|
+
<scrollbox flexGrow={1}>
|
|
1460
|
+
<DetailsPane
|
|
1461
|
+
pullRequest={selectedPullRequest}
|
|
1462
|
+
contentWidth={fullscreenContentWidth}
|
|
1463
|
+
bodyLines={fullscreenBodyLines}
|
|
1464
|
+
paneWidth={contentWidth}
|
|
1465
|
+
/>
|
|
1466
|
+
</scrollbox>
|
|
1467
|
+
</box>
|
|
1468
|
+
) : (
|
|
1469
|
+
<>
|
|
1470
|
+
<DetailsPane pullRequest={selectedPullRequest} contentWidth={rightContentWidth} paneWidth={contentWidth} />
|
|
1471
|
+
<Divider width={contentWidth} />
|
|
1472
|
+
<box flexGrow={1} flexDirection="column">
|
|
1473
|
+
<scrollbox flexGrow={1}>
|
|
1474
|
+
<box paddingLeft={sectionPadding} paddingRight={sectionPadding}>
|
|
1475
|
+
<PullRequestList {...prListProps} contentWidth={leftContentWidth} />
|
|
1476
|
+
</box>
|
|
1477
|
+
</scrollbox>
|
|
1478
|
+
</box>
|
|
1479
|
+
</>
|
|
1480
|
+
)}
|
|
1481
|
+
|
|
1482
|
+
{isWideLayout && !detailFullView ? (
|
|
1483
|
+
<Divider width={contentWidth} junctionAt={dividerJunctionAt} junctionChar="┴" />
|
|
1484
|
+
) : (
|
|
1485
|
+
<Divider width={contentWidth} />
|
|
1486
|
+
)}
|
|
1487
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
1488
|
+
{footerNotice ? <PlainLine text={footerNotice} fg={colors.count} /> : <FooterHints showFilterClear={filterMode || filterQuery.length > 0} detailFullView={detailFullView} />}
|
|
1489
|
+
</box>
|
|
1490
|
+
{labelModal.open ? (
|
|
1491
|
+
<LabelModal
|
|
1492
|
+
state={labelModal}
|
|
1493
|
+
currentLabels={selectedPullRequest?.labels ?? []}
|
|
1494
|
+
modalWidth={labelModalWidth}
|
|
1495
|
+
modalHeight={labelModalHeight}
|
|
1496
|
+
offsetLeft={labelModalLeft}
|
|
1497
|
+
offsetTop={labelModalTop}
|
|
1498
|
+
/>
|
|
1499
|
+
) : null}
|
|
1500
|
+
</box>
|
|
1501
|
+
)
|
|
1502
|
+
}
|