@kitlangton/ghui 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
3
+ import { availableMergeActions } from "../mergeActions.js"
4
+ import { colors } from "./colors.js"
5
+ import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
6
+ import { labelColor, shortRepoName } from "./pullRequests.js"
7
+
8
+ export interface LabelModalState {
9
+ readonly open: boolean
10
+ readonly repository: string | null
11
+ readonly query: string
12
+ readonly selectedIndex: number
13
+ readonly availableLabels: readonly PullRequestLabel[]
14
+ readonly loading: boolean
15
+ }
16
+
17
+ export interface MergeModalState {
18
+ readonly open: boolean
19
+ readonly repository: string | null
20
+ readonly number: number | null
21
+ readonly selectedIndex: number
22
+ readonly loading: boolean
23
+ readonly running: boolean
24
+ readonly info: PullRequestMergeInfo | null
25
+ readonly error: string | null
26
+ }
27
+
28
+ export const initialLabelModalState: LabelModalState = {
29
+ open: false,
30
+ repository: null,
31
+ query: "",
32
+ selectedIndex: 0,
33
+ availableLabels: [],
34
+ loading: false,
35
+ }
36
+
37
+ export const initialMergeModalState: MergeModalState = {
38
+ open: false,
39
+ repository: null,
40
+ number: null,
41
+ selectedIndex: 0,
42
+ loading: false,
43
+ running: false,
44
+ info: null,
45
+ error: null,
46
+ }
47
+
48
+ const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
49
+ if (!info) return "Loading merge status from GitHub."
50
+ if (info.state !== "open") return "This pull request is not open."
51
+ if (info.isDraft) return "Draft pull requests cannot be merged."
52
+ if (info.mergeable === "conflicting") return "This branch has merge conflicts."
53
+ return "No merge actions are currently available."
54
+ }
55
+
56
+ export const LabelModal = ({
57
+ state,
58
+ currentLabels,
59
+ modalWidth,
60
+ modalHeight,
61
+ offsetLeft,
62
+ offsetTop,
63
+ loadingIndicator,
64
+ }: {
65
+ state: LabelModalState
66
+ currentLabels: readonly PullRequestLabel[]
67
+ modalWidth: number
68
+ modalHeight: number
69
+ offsetLeft: number
70
+ offsetTop: number
71
+ loadingIndicator: string
72
+ }) => {
73
+ const innerWidth = Math.max(16, modalWidth - 2)
74
+ const contentWidth = Math.max(14, innerWidth - 2)
75
+ const rowWidth = innerWidth
76
+ const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
77
+ const filtered = state.availableLabels.filter((label) =>
78
+ state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
79
+ )
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)
83
+ const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
84
+ const scrollStart = Math.min(
85
+ Math.max(0, filtered.length - maxVisible),
86
+ Math.max(0, selectedIndex - maxVisible + 1),
87
+ )
88
+ const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
89
+ const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
90
+ const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
91
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
92
+ const queryText = state.query.length > 0 ? state.query : "type to filter labels"
93
+ const queryPrefix = state.query.length > 0 ? "/ " : "/ "
94
+ const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
95
+
96
+ return (
97
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
98
+ <box height={1} paddingLeft={1} paddingRight={1}>
99
+ <TextLine>
100
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
101
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
102
+ <span fg={colors.muted}>{countText}</span>
103
+ </TextLine>
104
+ </box>
105
+ <box height={1} paddingLeft={1} paddingRight={1}>
106
+ <TextLine>
107
+ <span fg={colors.count}>{queryPrefix}</span>
108
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
109
+ </TextLine>
110
+ </box>
111
+ <Divider width={innerWidth} />
112
+ <box height={maxVisible} flexDirection="column">
113
+ {state.loading ? (
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
+ </>
119
+ ) : visibleLabels.length === 0 ? (
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
+ </>
125
+ ) : (
126
+ visibleLabels.map((label, index) => {
127
+ const actualIndex = scrollStart + index
128
+ const isActive = currentNames.has(label.name.toLowerCase())
129
+ const isSelected = actualIndex === selectedIndex
130
+ const marker = isActive ? "✓" : " "
131
+ const nameWidth = Math.max(1, rowWidth - 5)
132
+ return (
133
+ <box key={label.name} height={1}>
134
+ <TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
135
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
136
+ <span> </span>
137
+ <span bg={labelColor(label)}> </span>
138
+ <span> {fitCell(label.name, nameWidth)}</span>
139
+ </TextLine>
140
+ </box>
141
+ )
142
+ })
143
+ )}
144
+ </box>
145
+ <Divider width={innerWidth} />
146
+ <box height={1} paddingLeft={1} paddingRight={1}>
147
+ <TextLine>
148
+ <span fg={colors.count}>↑↓</span>
149
+ <span fg={colors.muted}> move </span>
150
+ <span fg={colors.count}>esc</span>
151
+ <span fg={colors.muted}> close</span>
152
+ {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
153
+ </TextLine>
154
+ </box>
155
+ </ModalFrame>
156
+ )
157
+ }
158
+
159
+ export const MergeModal = ({
160
+ state,
161
+ modalWidth,
162
+ modalHeight,
163
+ offsetLeft,
164
+ offsetTop,
165
+ loadingIndicator,
166
+ }: {
167
+ state: MergeModalState
168
+ modalWidth: number
169
+ modalHeight: number
170
+ offsetLeft: number
171
+ offsetTop: number
172
+ loadingIndicator: string
173
+ }) => {
174
+ const innerWidth = Math.max(16, modalWidth - 2)
175
+ const contentWidth = Math.max(14, innerWidth - 2)
176
+ const rowWidth = innerWidth
177
+ const options = availableMergeActions(state.info)
178
+ const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
179
+ const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
180
+ const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
181
+ const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
182
+ const repo = state.info?.repository ?? state.repository
183
+ const statusLine = state.info
184
+ ? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
185
+ : repo ? shortRepoName(repo) : ""
186
+ const optionAreaHeight = Math.max(1, modalHeight - 7)
187
+ const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
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)
191
+
192
+ return (
193
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
194
+ <box height={1} paddingLeft={1} paddingRight={1}>
195
+ <TextLine>
196
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
197
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
198
+ <span fg={state.running || state.loading ? colors.status.pending : colors.muted}>{rightText}</span>
199
+ </TextLine>
200
+ </box>
201
+ <box height={1} paddingLeft={1} paddingRight={1}>
202
+ <PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
203
+ </box>
204
+ <Divider width={innerWidth} />
205
+ <box height={optionAreaHeight} flexDirection="column">
206
+ {state.loading ? (
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
+ </>
212
+ ) : state.error ? (
213
+ <PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
214
+ ) : visibleOptions.length === 0 ? (
215
+ <PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
216
+ ) : (
217
+ visibleOptions.map((option, index) => {
218
+ const isSelected = index === selectedIndex
219
+ const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
220
+ const titleWidth = Math.max(1, rowWidth - 1)
221
+ const descriptionWidth = Math.max(1, rowWidth - 1)
222
+
223
+ return (
224
+ <box key={option.action} height={2} flexDirection="column">
225
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
226
+ <span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
227
+ </TextLine>
228
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
229
+ <span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
230
+ </TextLine>
231
+ </box>
232
+ )
233
+ })
234
+ )}
235
+ </box>
236
+ <Divider width={innerWidth} />
237
+ <box height={1} paddingLeft={1} paddingRight={1}>
238
+ <TextLine>
239
+ <span fg={colors.count}>↑↓</span>
240
+ <span fg={colors.muted}> move </span>
241
+ <span fg={colors.count}>enter</span>
242
+ <span fg={colors.muted}> confirm </span>
243
+ <span fg={colors.count}>esc</span>
244
+ <span fg={colors.muted}> close</span>
245
+ </TextLine>
246
+ </box>
247
+ </ModalFrame>
248
+ )
249
+ }
@@ -0,0 +1,111 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type React from "react"
3
+ import { colors } from "./colors.js"
4
+
5
+ export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
6
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
7
+ return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
8
+ }
9
+
10
+ export const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
11
+
12
+ export const centerCell = (text: string, width: number) => {
13
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
14
+ const left = Math.floor((width - trimmed.length) / 2)
15
+ return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
16
+ }
17
+
18
+ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
19
+ <box height={1}>
20
+ {bold ? (
21
+ <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
22
+ {text}
23
+ </text>
24
+ ) : (
25
+ <text wrapMode="none" truncate fg={fg}>
26
+ {text}
27
+ </text>
28
+ )}
29
+ </box>
30
+ )
31
+
32
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
33
+ <box height={1}>
34
+ {bg ? (
35
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
36
+ {children}
37
+ </text>
38
+ ) : (
39
+ <text wrapMode="none" truncate fg={fg}>
40
+ {children}
41
+ </text>
42
+ )}
43
+ </box>
44
+ )
45
+
46
+ export const SectionTitle = ({ title }: { title: string }) => (
47
+ <TextLine>
48
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
49
+ {title}
50
+ </span>
51
+ </TextLine>
52
+ )
53
+
54
+ export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
55
+ if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
56
+ return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
57
+ }
58
+
59
+ return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
60
+ }
61
+
62
+ export const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
63
+ const junctions = new Set(junctionRows)
64
+ return (
65
+ <box width={1} height={height} flexDirection="column">
66
+ {Array.from({ length: height }, (_, index) => (
67
+ <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
68
+ ))}
69
+ </box>
70
+ )
71
+ }
72
+
73
+ export const ModalFrame = ({
74
+ children,
75
+ left,
76
+ top,
77
+ width,
78
+ height,
79
+ junctionRows = [],
80
+ backgroundColor = "#1a1a2e",
81
+ }: {
82
+ children: React.ReactNode
83
+ left: number
84
+ top: number
85
+ width: number
86
+ height: number
87
+ junctionRows?: readonly number[]
88
+ backgroundColor?: string
89
+ }) => {
90
+ const innerWidth = Math.max(1, width - 2)
91
+ const innerHeight = Math.max(1, height - 2)
92
+ const junctions = new Set(junctionRows)
93
+
94
+ return (
95
+ <box position="absolute" left={left} top={top} width={width} height={height} flexDirection="column" backgroundColor={backgroundColor}>
96
+ <PlainLine text={`┌${"─".repeat(innerWidth)}┐`} fg={colors.separator} />
97
+ <box height={innerHeight} flexDirection="row">
98
+ <box width={1} height={innerHeight} flexDirection="column">
99
+ {Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />)}
100
+ </box>
101
+ <box width={innerWidth} height={innerHeight} flexDirection="column">
102
+ {children}
103
+ </box>
104
+ <box width={1} height={innerHeight} flexDirection="column">
105
+ {Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "┤" : "│"} fg={colors.separator} />)}
106
+ </box>
107
+ </box>
108
+ <PlainLine text={`└${"─".repeat(innerWidth)}┘`} fg={colors.separator} />
109
+ </box>
110
+ )
111
+ }
@@ -0,0 +1,72 @@
1
+ import type { PullRequestItem, PullRequestLabel } from "../domain.js"
2
+ import { colors } from "./colors.js"
3
+
4
+ export const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
5
+
6
+ export const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
7
+
8
+ export const reviewLabel = (pullRequest: PullRequestItem) => {
9
+ if (pullRequest.reviewStatus === "draft") return "draft"
10
+ if (pullRequest.reviewStatus === "approved") return "approved"
11
+ if (pullRequest.reviewStatus === "changes") return "changes"
12
+ if (pullRequest.reviewStatus === "review") return "review"
13
+ return null
14
+ }
15
+
16
+ export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
17
+
18
+ export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
19
+
20
+ export const reviewIcon = (pullRequest: PullRequestItem) => {
21
+ if (pullRequest.autoMergeEnabled) return "↻"
22
+ if (pullRequest.reviewStatus === "draft") return "◌"
23
+ if (pullRequest.reviewStatus === "approved") return "✓"
24
+ if (pullRequest.reviewStatus === "changes") return "!"
25
+ if (pullRequest.reviewStatus === "review") return "◐"
26
+ return "·"
27
+ }
28
+
29
+ const fallbackLabelColor = (name: string) => {
30
+ let hash = 0
31
+ for (const char of name) {
32
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0
33
+ }
34
+ const hue = hash % 360
35
+ return `hsl(${hue} 55% 35%)`
36
+ }
37
+
38
+ export const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
39
+
40
+ export const labelTextColor = (color: string) => {
41
+ if (color.startsWith("#") && color.length === 7) {
42
+ const red = Number.parseInt(color.slice(1, 3), 16)
43
+ const green = Number.parseInt(color.slice(3, 5), 16)
44
+ const blue = Number.parseInt(color.slice(5, 7), 16)
45
+ const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
46
+ return luminance > 0.6 ? "#111111" : "#f8fafc"
47
+ }
48
+ return "#f8fafc"
49
+ }
50
+
51
+ export const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
52
+ const groups = new Map<string, T[]>()
53
+ for (const item of items) {
54
+ const key = getKey(item)
55
+ const existing = groups.get(key)
56
+ if (existing) {
57
+ existing.push(item)
58
+ } else {
59
+ groups.set(key, [item])
60
+ }
61
+ }
62
+
63
+ const order = new Map(orderedKeys.map((key, index) => [key, index]))
64
+ return [...groups.entries()].sort((left, right) => {
65
+ const leftIndex = order.get(left[0])
66
+ const rightIndex = order.get(right[0])
67
+ if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
68
+ if (leftIndex !== undefined) return -1
69
+ if (rightIndex !== undefined) return 1
70
+ return left[0].localeCompare(right[0])
71
+ })
72
+ }