@kitlangton/ghui 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +50 -661
- package/src/mergeActions.ts +90 -0
- package/src/services/GitHubService.ts +2 -9
- package/src/ui/DetailsPane.tsx +498 -0
- package/src/ui/PullRequestDiffPane.tsx +148 -0
- package/src/ui/modals.tsx +34 -79
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import { useMemo, type Ref } from "react"
|
|
3
|
+
import type { PullRequestItem } from "../domain.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { diffStatText, diffSyntaxStyle, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
|
|
6
|
+
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
|
+
import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
|
|
8
|
+
import { shortRepoName } from "./pullRequests.js"
|
|
9
|
+
|
|
10
|
+
const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
11
|
+
if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
|
|
12
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
13
|
+
type Part = { key: string; text: string; color: string }
|
|
14
|
+
const rawParts: Array<Part | null> = [
|
|
15
|
+
pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
|
|
16
|
+
pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
|
|
17
|
+
{ key: "files", text: files, color: colors.muted },
|
|
18
|
+
]
|
|
19
|
+
const parts = rawParts.filter((part): part is Part => part !== null)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{parts.map((part, index) => (
|
|
24
|
+
<span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
|
|
25
|
+
))}
|
|
26
|
+
</>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PullRequestDiffPane = ({
|
|
31
|
+
pullRequest,
|
|
32
|
+
diffState,
|
|
33
|
+
fileIndex,
|
|
34
|
+
view,
|
|
35
|
+
wrapMode,
|
|
36
|
+
paneWidth,
|
|
37
|
+
height,
|
|
38
|
+
loadingIndicator,
|
|
39
|
+
scrollRef,
|
|
40
|
+
}: {
|
|
41
|
+
pullRequest: PullRequestItem | null
|
|
42
|
+
diffState: PullRequestDiffState | undefined
|
|
43
|
+
fileIndex: number
|
|
44
|
+
view: "unified" | "split"
|
|
45
|
+
wrapMode: "none" | "word"
|
|
46
|
+
paneWidth: number
|
|
47
|
+
height: number
|
|
48
|
+
loadingIndicator: string
|
|
49
|
+
scrollRef: Ref<ScrollBoxRenderable>
|
|
50
|
+
}) => {
|
|
51
|
+
const readyFiles = diffState?.status === "ready" ? diffState.files : []
|
|
52
|
+
const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
|
|
53
|
+
const file = readyFiles[safeIndex] ?? null
|
|
54
|
+
const diffHeight = useMemo(
|
|
55
|
+
() => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
|
|
56
|
+
[file?.patch, view, wrapMode, paneWidth],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (!pullRequest) {
|
|
60
|
+
return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const stats = diffStatText(pullRequest)
|
|
64
|
+
const headerWidth = Math.max(24, paneWidth - 2)
|
|
65
|
+
const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
|
|
66
|
+
const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
|
|
67
|
+
|
|
68
|
+
if (!diffState || diffState.status === "loading") {
|
|
69
|
+
return (
|
|
70
|
+
<box height={height} flexDirection="column">
|
|
71
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
72
|
+
<TextLine>
|
|
73
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
74
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
75
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
76
|
+
<DiffStats pullRequest={pullRequest} />
|
|
77
|
+
</TextLine>
|
|
78
|
+
</box>
|
|
79
|
+
<Divider width={paneWidth} />
|
|
80
|
+
<LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
|
|
81
|
+
</box>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (diffState.status === "error") {
|
|
86
|
+
return (
|
|
87
|
+
<box height={height} flexDirection="column">
|
|
88
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
89
|
+
<PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
|
|
90
|
+
</box>
|
|
91
|
+
<Divider width={paneWidth} />
|
|
92
|
+
<StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
|
|
93
|
+
</box>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (readyFiles.length === 0 || !file) {
|
|
98
|
+
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
|
|
102
|
+
const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<box height={height} flexDirection="column">
|
|
106
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
107
|
+
<TextLine>
|
|
108
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
109
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
110
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
111
|
+
<DiffStats pullRequest={pullRequest} />
|
|
112
|
+
</TextLine>
|
|
113
|
+
</box>
|
|
114
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
115
|
+
<TextLine>
|
|
116
|
+
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
117
|
+
<span fg={colors.muted}> {fileCounter}</span>
|
|
118
|
+
</TextLine>
|
|
119
|
+
</box>
|
|
120
|
+
<Divider width={paneWidth} />
|
|
121
|
+
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
|
|
122
|
+
<diff
|
|
123
|
+
key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
|
|
124
|
+
diff={file.patch}
|
|
125
|
+
view={view}
|
|
126
|
+
syncScroll
|
|
127
|
+
filetype={file.filetype ?? "text"}
|
|
128
|
+
syntaxStyle={diffSyntaxStyle}
|
|
129
|
+
showLineNumbers
|
|
130
|
+
wrapMode={wrapMode}
|
|
131
|
+
addedBg="#17351f"
|
|
132
|
+
removedBg="#3a1e22"
|
|
133
|
+
contextBg="transparent"
|
|
134
|
+
addedSignColor={colors.status.passing}
|
|
135
|
+
removedSignColor={colors.status.failing}
|
|
136
|
+
lineNumberFg={colors.muted}
|
|
137
|
+
lineNumberBg="#151515"
|
|
138
|
+
addedLineNumberBg="#12301a"
|
|
139
|
+
removedLineNumberBg="#35171b"
|
|
140
|
+
selectionBg={colors.selectedBg}
|
|
141
|
+
selectionFg={colors.selectedText}
|
|
142
|
+
height={diffHeight}
|
|
143
|
+
style={{ flexShrink: 0 }}
|
|
144
|
+
/>
|
|
145
|
+
</scrollbox>
|
|
146
|
+
</box>
|
|
147
|
+
)
|
|
148
|
+
}
|
package/src/ui/modals.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import type { PullRequestLabel,
|
|
2
|
+
import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
|
|
3
|
+
import { availableMergeActions } from "../mergeActions.js"
|
|
3
4
|
import { colors } from "./colors.js"
|
|
4
5
|
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
5
6
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
@@ -24,13 +25,6 @@ export interface MergeModalState {
|
|
|
24
25
|
readonly error: string | null
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
interface MergeModalOption {
|
|
28
|
-
readonly action: PullRequestMergeAction
|
|
29
|
-
readonly title: string
|
|
30
|
-
readonly description: string
|
|
31
|
-
readonly danger?: boolean
|
|
32
|
-
}
|
|
33
|
-
|
|
34
28
|
export const initialLabelModalState: LabelModalState = {
|
|
35
29
|
open: false,
|
|
36
30
|
repository: null,
|
|
@@ -51,55 +45,6 @@ export const initialMergeModalState: MergeModalState = {
|
|
|
51
45
|
error: null,
|
|
52
46
|
}
|
|
53
47
|
|
|
54
|
-
const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
|
|
55
|
-
info.state === "open" &&
|
|
56
|
-
!info.isDraft &&
|
|
57
|
-
info.mergeable === "mergeable" &&
|
|
58
|
-
info.reviewStatus !== "changes" &&
|
|
59
|
-
info.reviewStatus !== "review" &&
|
|
60
|
-
info.checkStatus !== "pending" &&
|
|
61
|
-
info.checkStatus !== "failing"
|
|
62
|
-
|
|
63
|
-
export const mergeModalOptions = (info: PullRequestMergeInfo | null): readonly MergeModalOption[] => {
|
|
64
|
-
if (!info || info.state !== "open") return []
|
|
65
|
-
const options: MergeModalOption[] = []
|
|
66
|
-
|
|
67
|
-
if (isCleanlyMergeable(info)) {
|
|
68
|
-
options.push({
|
|
69
|
-
action: "squash",
|
|
70
|
-
title: "Squash merge now",
|
|
71
|
-
description: "Merge this pull request and delete the branch.",
|
|
72
|
-
})
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (!info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting") {
|
|
76
|
-
options.push({
|
|
77
|
-
action: "auto",
|
|
78
|
-
title: "Enable auto-merge",
|
|
79
|
-
description: "Squash merge automatically after GitHub requirements pass.",
|
|
80
|
-
})
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (info.autoMergeEnabled) {
|
|
84
|
-
options.push({
|
|
85
|
-
action: "disable-auto",
|
|
86
|
-
title: "Disable auto-merge",
|
|
87
|
-
description: "Cancel the pending GitHub auto-merge request.",
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (!info.isDraft && info.mergeable !== "conflicting") {
|
|
92
|
-
options.push({
|
|
93
|
-
action: "admin",
|
|
94
|
-
title: "Admin override merge",
|
|
95
|
-
description: "Bypass unmet merge requirements with --admin.",
|
|
96
|
-
danger: true,
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return options
|
|
101
|
-
}
|
|
102
|
-
|
|
103
48
|
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
104
49
|
if (!info) return "Loading merge status from GitHub."
|
|
105
50
|
if (info.state !== "open") return "This pull request is not open."
|
|
@@ -108,13 +53,6 @@ const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
|
108
53
|
return "No merge actions are currently available."
|
|
109
54
|
}
|
|
110
55
|
|
|
111
|
-
export const mergeActionPastTense = (action: PullRequestMergeAction) => {
|
|
112
|
-
if (action === "auto") return "Enabled auto-merge"
|
|
113
|
-
if (action === "disable-auto") return "Disabled auto-merge"
|
|
114
|
-
if (action === "admin") return "Admin merged"
|
|
115
|
-
return "Merged"
|
|
116
|
-
}
|
|
117
|
-
|
|
118
56
|
export const LabelModal = ({
|
|
119
57
|
state,
|
|
120
58
|
currentLabels,
|
|
@@ -134,11 +72,14 @@ export const LabelModal = ({
|
|
|
134
72
|
}) => {
|
|
135
73
|
const innerWidth = Math.max(16, modalWidth - 2)
|
|
136
74
|
const contentWidth = Math.max(14, innerWidth - 2)
|
|
75
|
+
const rowWidth = innerWidth
|
|
137
76
|
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
138
77
|
const filtered = state.availableLabels.filter((label) =>
|
|
139
78
|
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
140
79
|
)
|
|
141
|
-
const maxVisible = Math.max(1, modalHeight -
|
|
80
|
+
const maxVisible = Math.max(1, modalHeight - 7)
|
|
81
|
+
const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
|
|
82
|
+
const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
|
|
142
83
|
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
143
84
|
const scrollStart = Math.min(
|
|
144
85
|
Math.max(0, filtered.length - maxVisible),
|
|
@@ -168,18 +109,26 @@ export const LabelModal = ({
|
|
|
168
109
|
</TextLine>
|
|
169
110
|
</box>
|
|
170
111
|
<Divider width={innerWidth} />
|
|
171
|
-
<box flexDirection="column"
|
|
112
|
+
<box height={maxVisible} flexDirection="column">
|
|
172
113
|
{state.loading ? (
|
|
173
|
-
|
|
114
|
+
<>
|
|
115
|
+
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
116
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
|
|
117
|
+
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
118
|
+
</>
|
|
174
119
|
) : visibleLabels.length === 0 ? (
|
|
175
|
-
|
|
120
|
+
<>
|
|
121
|
+
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
122
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
|
|
123
|
+
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
124
|
+
</>
|
|
176
125
|
) : (
|
|
177
126
|
visibleLabels.map((label, index) => {
|
|
178
127
|
const actualIndex = scrollStart + index
|
|
179
128
|
const isActive = currentNames.has(label.name.toLowerCase())
|
|
180
129
|
const isSelected = actualIndex === selectedIndex
|
|
181
130
|
const marker = isActive ? "✓" : " "
|
|
182
|
-
const nameWidth = Math.max(1,
|
|
131
|
+
const nameWidth = Math.max(1, rowWidth - 5)
|
|
183
132
|
return (
|
|
184
133
|
<box key={label.name} height={1}>
|
|
185
134
|
<TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
@@ -193,7 +142,6 @@ export const LabelModal = ({
|
|
|
193
142
|
})
|
|
194
143
|
)}
|
|
195
144
|
</box>
|
|
196
|
-
<box flexGrow={1} />
|
|
197
145
|
<Divider width={innerWidth} />
|
|
198
146
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
199
147
|
<TextLine>
|
|
@@ -225,7 +173,8 @@ export const MergeModal = ({
|
|
|
225
173
|
}) => {
|
|
226
174
|
const innerWidth = Math.max(16, modalWidth - 2)
|
|
227
175
|
const contentWidth = Math.max(14, innerWidth - 2)
|
|
228
|
-
const
|
|
176
|
+
const rowWidth = innerWidth
|
|
177
|
+
const options = availableMergeActions(state.info)
|
|
229
178
|
const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
|
|
230
179
|
const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
|
|
231
180
|
const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
|
|
@@ -234,8 +183,11 @@ export const MergeModal = ({
|
|
|
234
183
|
const statusLine = state.info
|
|
235
184
|
? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
|
|
236
185
|
: repo ? shortRepoName(repo) : ""
|
|
237
|
-
const
|
|
186
|
+
const optionAreaHeight = Math.max(1, modalHeight - 7)
|
|
187
|
+
const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
|
|
238
188
|
const visibleOptions = options.slice(0, optionRows)
|
|
189
|
+
const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
|
|
190
|
+
const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
|
|
239
191
|
|
|
240
192
|
return (
|
|
241
193
|
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
@@ -250,19 +202,23 @@ export const MergeModal = ({
|
|
|
250
202
|
<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
|
|
251
203
|
</box>
|
|
252
204
|
<Divider width={innerWidth} />
|
|
253
|
-
<box flexDirection="column"
|
|
205
|
+
<box height={optionAreaHeight} flexDirection="column">
|
|
254
206
|
{state.loading ? (
|
|
255
|
-
|
|
207
|
+
<>
|
|
208
|
+
{Array.from({ length: loadingTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
209
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
|
|
210
|
+
{Array.from({ length: loadingBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
211
|
+
</>
|
|
256
212
|
) : state.error ? (
|
|
257
|
-
<PlainLine text={centerCell(state.error,
|
|
213
|
+
<PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
|
|
258
214
|
) : visibleOptions.length === 0 ? (
|
|
259
|
-
<PlainLine text={centerCell(mergeUnavailableReason(state.info),
|
|
215
|
+
<PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
|
|
260
216
|
) : (
|
|
261
217
|
visibleOptions.map((option, index) => {
|
|
262
218
|
const isSelected = index === selectedIndex
|
|
263
219
|
const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
|
|
264
|
-
const titleWidth = Math.max(1,
|
|
265
|
-
const descriptionWidth = Math.max(1,
|
|
220
|
+
const titleWidth = Math.max(1, rowWidth - 1)
|
|
221
|
+
const descriptionWidth = Math.max(1, rowWidth - 1)
|
|
266
222
|
|
|
267
223
|
return (
|
|
268
224
|
<box key={option.action} height={2} flexDirection="column">
|
|
@@ -277,7 +233,6 @@ export const MergeModal = ({
|
|
|
277
233
|
})
|
|
278
234
|
)}
|
|
279
235
|
</box>
|
|
280
|
-
<box flexGrow={1} />
|
|
281
236
|
<Divider width={innerWidth} />
|
|
282
237
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
283
238
|
<TextLine>
|