@kitlangton/ghui 0.1.8 → 0.1.10

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.
@@ -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, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
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 - 8)
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" paddingLeft={1} paddingRight={1}>
112
+ <box height={maxVisible} flexDirection="column">
172
113
  {state.loading ? (
173
- <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
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
- <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
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, contentWidth - 5)
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 options = mergeModalOptions(state.info)
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 optionRows = Math.max(1, Math.floor((modalHeight - 9) / 2))
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" paddingLeft={1} paddingRight={1}>
205
+ <box height={optionAreaHeight} flexDirection="column">
254
206
  {state.loading ? (
255
- <PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, contentWidth)} fg={colors.muted} />
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, contentWidth)} fg={colors.error} />
213
+ <PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
258
214
  ) : visibleOptions.length === 0 ? (
259
- <PlainLine text={centerCell(mergeUnavailableReason(state.info), contentWidth)} fg={colors.muted} />
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, contentWidth - 1)
265
- const descriptionWidth = Math.max(1, contentWidth - 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>