@kitlangton/ghui 0.3.0 → 0.3.3
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/README.md +13 -3
- package/bin/ghui.js +6 -1
- package/dist/index.js +9456 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2779
- package/src/appCommands.ts +0 -409
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -20
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -149
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -116
- package/src/keymap/changedFilesModal.ts +0 -23
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -93
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/submitReviewModal.ts +0 -48
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -43
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -756
- package/src/services/MockGitHubService.ts +0 -182
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -176
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -249
- package/src/ui/PullRequestList.tsx +0 -194
- package/src/ui/colors.ts +0 -821
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -964
- package/src/ui/primitives.tsx +0 -350
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- package/src/ui/spinner.ts +0 -1
package/src/ui/primitives.tsx
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
import { TextAttributes, type MouseEvent } 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, width }: { children: React.ReactNode; fg?: string; bg?: string | undefined; width?: number }) => (
|
|
33
|
-
<box height={1} {...(width === undefined ? {} : { width })}>
|
|
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 MatchedCell = ({ text, width, query, align = "left", matchIndexes }: { text: string; width: number; query: string; align?: "left" | "right"; matchIndexes?: readonly number[] }) => {
|
|
47
|
-
const fitted = fitCell(text, width, align)
|
|
48
|
-
if (matchIndexes && matchIndexes.length > 0) {
|
|
49
|
-
const highlighted = new Set(matchIndexes.filter((index) => index >= 0 && index < fitted.length))
|
|
50
|
-
if (highlighted.size > 0) {
|
|
51
|
-
const segments: Array<{ text: string; highlight: boolean }> = []
|
|
52
|
-
for (let index = 0; index < fitted.length; index++) {
|
|
53
|
-
const char = fitted[index]!
|
|
54
|
-
const highlight = highlighted.has(index)
|
|
55
|
-
const previous = segments[segments.length - 1]
|
|
56
|
-
if (previous && previous.highlight === highlight) previous.text += char
|
|
57
|
-
else segments.push({ text: char, highlight })
|
|
58
|
-
}
|
|
59
|
-
return (
|
|
60
|
-
<>
|
|
61
|
-
{segments.map((segment, index) => segment.highlight
|
|
62
|
-
? <span key={index} fg={colors.accent} attributes={TextAttributes.BOLD}>{segment.text}</span>
|
|
63
|
-
: <span key={index}>{segment.text}</span>)}
|
|
64
|
-
</>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const needle = query.trim().toLowerCase()
|
|
69
|
-
const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
|
|
70
|
-
if (index < 0) return <span>{fitted}</span>
|
|
71
|
-
|
|
72
|
-
const end = Math.min(fitted.length, index + needle.length)
|
|
73
|
-
return (
|
|
74
|
-
<>
|
|
75
|
-
{index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
|
|
76
|
-
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
|
|
77
|
-
{end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
|
|
78
|
-
</>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export const SectionTitle = ({ title }: { title: string }) => (
|
|
83
|
-
<TextLine>
|
|
84
|
-
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
85
|
-
{title}
|
|
86
|
-
</span>
|
|
87
|
-
</TextLine>
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
export const Filler = ({ rows, prefix }: { rows: number; prefix: string }) =>
|
|
91
|
-
<>{Array.from({ length: rows }, (_, index) => <box key={`${prefix}-${index}`} height={1} />)}</>
|
|
92
|
-
|
|
93
|
-
export const PaddedRow = ({ children, backgroundColor }: { children: React.ReactNode; backgroundColor?: string }) => (
|
|
94
|
-
<box height={1} paddingLeft={1} paddingRight={1} {...(backgroundColor ? { backgroundColor } : {})}>{children}</box>
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
98
|
-
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
99
|
-
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
|
|
106
|
-
const junctions = new Set(junctionRows)
|
|
107
|
-
return (
|
|
108
|
-
<box width={1} height={height} flexDirection="column">
|
|
109
|
-
{Array.from({ length: height }, (_, index) => (
|
|
110
|
-
<PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
|
|
111
|
-
))}
|
|
112
|
-
</box>
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export type StandardModalDims = {
|
|
117
|
-
readonly innerWidth: number
|
|
118
|
-
readonly contentWidth: number
|
|
119
|
-
readonly bodyHeight: number
|
|
120
|
-
readonly rowWidth: number
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export const standardModalDims = (modalWidth: number, modalHeight: number): StandardModalDims => {
|
|
124
|
-
const innerWidth = Math.max(16, modalWidth - 2)
|
|
125
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
126
|
-
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
127
|
-
return { innerWidth, contentWidth, bodyHeight, rowWidth: innerWidth }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export type HintItem = {
|
|
131
|
-
readonly key: string
|
|
132
|
-
readonly label: string
|
|
133
|
-
readonly when?: boolean
|
|
134
|
-
readonly keyFg?: string
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export const HintRow = ({ items }: { items: readonly HintItem[] }) => {
|
|
138
|
-
const visible = items.filter((item) => item.when !== false)
|
|
139
|
-
return (
|
|
140
|
-
<TextLine>
|
|
141
|
-
{visible.flatMap((item, index) => [
|
|
142
|
-
<span key={`k${index}`} fg={item.keyFg ?? colors.count}>{item.key}</span>,
|
|
143
|
-
<span key={`l${index}`} fg={colors.muted}>{` ${item.label}${index < visible.length - 1 ? " " : ""}`}</span>,
|
|
144
|
-
])}
|
|
145
|
-
</TextLine>
|
|
146
|
-
)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export const StandardModal = ({
|
|
150
|
-
left,
|
|
151
|
-
top,
|
|
152
|
-
width,
|
|
153
|
-
height,
|
|
154
|
-
title,
|
|
155
|
-
titleFg = colors.accent,
|
|
156
|
-
headerRight,
|
|
157
|
-
subtitle,
|
|
158
|
-
footer,
|
|
159
|
-
bodyPadding = 0,
|
|
160
|
-
children,
|
|
161
|
-
}: {
|
|
162
|
-
left: number
|
|
163
|
-
top: number
|
|
164
|
-
width: number
|
|
165
|
-
height: number
|
|
166
|
-
title: string
|
|
167
|
-
titleFg?: string
|
|
168
|
-
headerRight?: { readonly text: string; readonly pending?: boolean }
|
|
169
|
-
subtitle: React.ReactNode
|
|
170
|
-
footer: React.ReactNode
|
|
171
|
-
bodyPadding?: number
|
|
172
|
-
children: React.ReactNode
|
|
173
|
-
}) => {
|
|
174
|
-
const { innerWidth, contentWidth, bodyHeight } = standardModalDims(width, height)
|
|
175
|
-
const rightText = headerRight?.text ?? ""
|
|
176
|
-
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
177
|
-
return (
|
|
178
|
-
<ModalFrame left={left} top={top} width={width} height={height} junctionRows={[2, height - 4]}>
|
|
179
|
-
<PaddedRow>
|
|
180
|
-
<TextLine>
|
|
181
|
-
<span fg={titleFg} attributes={TextAttributes.BOLD}>{title}</span>
|
|
182
|
-
{headerRight ? (
|
|
183
|
-
<>
|
|
184
|
-
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
185
|
-
<span fg={headerRight.pending ? colors.status.pending : colors.muted}>{headerRight.text}</span>
|
|
186
|
-
</>
|
|
187
|
-
) : null}
|
|
188
|
-
</TextLine>
|
|
189
|
-
</PaddedRow>
|
|
190
|
-
<PaddedRow>{subtitle}</PaddedRow>
|
|
191
|
-
<Divider width={innerWidth} />
|
|
192
|
-
<box height={bodyHeight} flexDirection="column" paddingLeft={bodyPadding} paddingRight={bodyPadding}>{children}</box>
|
|
193
|
-
<Divider width={innerWidth} />
|
|
194
|
-
<PaddedRow>{footer}</PaddedRow>
|
|
195
|
-
</ModalFrame>
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export type SearchModalDims = StandardModalDims
|
|
200
|
-
|
|
201
|
-
export const searchModalDims = (modalWidth: number, modalHeight: number): SearchModalDims => {
|
|
202
|
-
const innerWidth = Math.max(16, modalWidth - 2)
|
|
203
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
204
|
-
const bodyHeight = Math.max(1, modalHeight - 6)
|
|
205
|
-
return { innerWidth, contentWidth, bodyHeight, rowWidth: innerWidth }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const searchModalTitleText = (title: string, contentWidth: number, countText: string) => {
|
|
209
|
-
const reserved = 1 + 1 + 1 + 8 + (countText.length > 0 ? countText.length + 2 : 0)
|
|
210
|
-
return trimCell(title, Math.max(6, Math.min(title.length, contentWidth - reserved)))
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export const SearchModalHeader = ({
|
|
214
|
-
title,
|
|
215
|
-
query,
|
|
216
|
-
placeholder,
|
|
217
|
-
countText = "",
|
|
218
|
-
contentWidth,
|
|
219
|
-
}: {
|
|
220
|
-
title: string
|
|
221
|
-
query: string
|
|
222
|
-
placeholder: string
|
|
223
|
-
countText?: string
|
|
224
|
-
contentWidth: number
|
|
225
|
-
}) => {
|
|
226
|
-
const titleText = searchModalTitleText(title, contentWidth, countText)
|
|
227
|
-
const headerGap = 1
|
|
228
|
-
const headerDivider = "│"
|
|
229
|
-
const searchGap = 1
|
|
230
|
-
const searchStart = titleText.length + headerGap + headerDivider.length + searchGap
|
|
231
|
-
const countGap = countText.length > 0 ? 2 : 0
|
|
232
|
-
const searchWidth = Math.max(1, contentWidth - searchStart - countGap - countText.length)
|
|
233
|
-
const queryText = trimCell(query, Math.max(0, searchWidth - 1))
|
|
234
|
-
const queryPadding = Math.max(0, searchWidth - queryText.length - 1)
|
|
235
|
-
const caretFg = colors.background === "transparent" ? colors.text : colors.background
|
|
236
|
-
|
|
237
|
-
return (
|
|
238
|
-
<TextLine>
|
|
239
|
-
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{titleText}</span>
|
|
240
|
-
<span>{" ".repeat(headerGap)}</span>
|
|
241
|
-
<span fg={colors.separator}>{headerDivider}</span>
|
|
242
|
-
<span>{" ".repeat(searchGap)}</span>
|
|
243
|
-
{query.length > 0 ? (
|
|
244
|
-
<>
|
|
245
|
-
<span fg={colors.text}>{queryText}</span>
|
|
246
|
-
<span bg={colors.muted} fg={caretFg}> </span>
|
|
247
|
-
{queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
|
|
248
|
-
</>
|
|
249
|
-
) : (
|
|
250
|
-
<>
|
|
251
|
-
<span bg={colors.muted} fg={caretFg}>{placeholder[0] ?? " "}</span>
|
|
252
|
-
<span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, searchWidth - 1))}</span>
|
|
253
|
-
</>
|
|
254
|
-
)}
|
|
255
|
-
{countText.length > 0 && searchWidth > placeholder.length ? (
|
|
256
|
-
<>
|
|
257
|
-
<span>{" ".repeat(countGap)}</span>
|
|
258
|
-
<span fg={colors.muted}>{countText}</span>
|
|
259
|
-
</>
|
|
260
|
-
) : null}
|
|
261
|
-
</TextLine>
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export const SearchModalFrame = ({
|
|
266
|
-
left,
|
|
267
|
-
top,
|
|
268
|
-
width,
|
|
269
|
-
height,
|
|
270
|
-
title,
|
|
271
|
-
query,
|
|
272
|
-
placeholder,
|
|
273
|
-
countText = "",
|
|
274
|
-
footer,
|
|
275
|
-
bodyPadding = 0,
|
|
276
|
-
onBodyMouseScroll,
|
|
277
|
-
children,
|
|
278
|
-
}: {
|
|
279
|
-
left: number
|
|
280
|
-
top: number
|
|
281
|
-
width: number
|
|
282
|
-
height: number
|
|
283
|
-
title: string
|
|
284
|
-
query: string
|
|
285
|
-
placeholder: string
|
|
286
|
-
countText?: string
|
|
287
|
-
footer: React.ReactNode
|
|
288
|
-
bodyPadding?: number
|
|
289
|
-
onBodyMouseScroll?: (event: MouseEvent) => void
|
|
290
|
-
children: React.ReactNode
|
|
291
|
-
}) => {
|
|
292
|
-
const { innerWidth, contentWidth, bodyHeight } = searchModalDims(width, height)
|
|
293
|
-
const titleText = searchModalTitleText(title, contentWidth, countText)
|
|
294
|
-
const dividerColumn = 1 + titleText.length + 1
|
|
295
|
-
return (
|
|
296
|
-
<ModalFrame left={left} top={top} width={width} height={height} junctionRows={[1, height - 4]} topJunctionColumns={[dividerColumn]}>
|
|
297
|
-
<PaddedRow>
|
|
298
|
-
<SearchModalHeader title={title} query={query} placeholder={placeholder} countText={countText} contentWidth={contentWidth} />
|
|
299
|
-
</PaddedRow>
|
|
300
|
-
<Divider width={innerWidth} junctionAt={dividerColumn} junctionChar="┴" />
|
|
301
|
-
<box height={bodyHeight} flexDirection="column" paddingLeft={bodyPadding} paddingRight={bodyPadding} {...(onBodyMouseScroll ? { onMouseScroll: onBodyMouseScroll } : {})}>{children}</box>
|
|
302
|
-
<Divider width={innerWidth} />
|
|
303
|
-
<PaddedRow>{footer}</PaddedRow>
|
|
304
|
-
</ModalFrame>
|
|
305
|
-
)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export const ModalFrame = ({
|
|
309
|
-
children,
|
|
310
|
-
left,
|
|
311
|
-
top,
|
|
312
|
-
width,
|
|
313
|
-
height,
|
|
314
|
-
junctionRows = [],
|
|
315
|
-
topJunctionColumns = [],
|
|
316
|
-
backgroundColor = colors.modalBackground,
|
|
317
|
-
}: {
|
|
318
|
-
children: React.ReactNode
|
|
319
|
-
left: number
|
|
320
|
-
top: number
|
|
321
|
-
width: number
|
|
322
|
-
height: number
|
|
323
|
-
junctionRows?: readonly number[]
|
|
324
|
-
topJunctionColumns?: readonly number[]
|
|
325
|
-
backgroundColor?: string
|
|
326
|
-
}) => {
|
|
327
|
-
const innerWidth = Math.max(1, width - 2)
|
|
328
|
-
const innerHeight = Math.max(1, height - 2)
|
|
329
|
-
const junctions = new Set(junctionRows)
|
|
330
|
-
const topJunctions = new Set(topJunctionColumns)
|
|
331
|
-
const topBorder = Array.from({ length: innerWidth }, (_, index) => topJunctions.has(index) ? "┬" : "─").join("")
|
|
332
|
-
|
|
333
|
-
return (
|
|
334
|
-
<box position="absolute" left={left} top={top} width={width} height={height} flexDirection="column" backgroundColor={backgroundColor}>
|
|
335
|
-
<PlainLine text={`┌${topBorder}┐`} fg={colors.separator} />
|
|
336
|
-
<box height={innerHeight} flexDirection="row">
|
|
337
|
-
<box width={1} height={innerHeight} flexDirection="column">
|
|
338
|
-
{Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />)}
|
|
339
|
-
</box>
|
|
340
|
-
<box width={innerWidth} height={innerHeight} flexDirection="column">
|
|
341
|
-
{children}
|
|
342
|
-
</box>
|
|
343
|
-
<box width={1} height={innerHeight} flexDirection="column">
|
|
344
|
-
{Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "┤" : "│"} fg={colors.separator} />)}
|
|
345
|
-
</box>
|
|
346
|
-
</box>
|
|
347
|
-
<PlainLine text={`└${"─".repeat(innerWidth)}┘`} fg={colors.separator} />
|
|
348
|
-
</box>
|
|
349
|
-
)
|
|
350
|
-
}
|
package/src/ui/pullRequests.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { PullRequestItem, PullRequestLabel, ReviewStatus } 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
|
-
const REVIEW_LABEL: Partial<Record<ReviewStatus, string>> = {
|
|
9
|
-
draft: "draft",
|
|
10
|
-
approved: "approved",
|
|
11
|
-
changes: "changes",
|
|
12
|
-
review: "review",
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const reviewLabel = (pullRequest: PullRequestItem) => REVIEW_LABEL[pullRequest.reviewStatus] ?? null
|
|
16
|
-
|
|
17
|
-
export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
|
|
18
|
-
|
|
19
|
-
export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
|
|
20
|
-
|
|
21
|
-
const REVIEW_ICON: Record<ReviewStatus, string> = {
|
|
22
|
-
draft: "◌",
|
|
23
|
-
approved: "✓",
|
|
24
|
-
changes: "!",
|
|
25
|
-
review: "◐",
|
|
26
|
-
none: "·",
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const reviewIcon = (pullRequest: PullRequestItem) => {
|
|
30
|
-
if (pullRequest.state === "merged") return "✓"
|
|
31
|
-
if (pullRequest.state === "closed") return "×"
|
|
32
|
-
if (pullRequest.autoMergeEnabled) return "↻"
|
|
33
|
-
return REVIEW_ICON[pullRequest.reviewStatus]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface PullRequestRowDisplay {
|
|
37
|
-
readonly indicatorFg: string
|
|
38
|
-
readonly rowFg: string
|
|
39
|
-
readonly numberFg: string
|
|
40
|
-
readonly checkFg: string
|
|
41
|
-
readonly checkText: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const pullRequestRowDisplay = (pullRequest: PullRequestItem, selected: boolean): PullRequestRowDisplay => {
|
|
45
|
-
const isMerged = pullRequest.state === "merged"
|
|
46
|
-
const isClosed = pullRequest.state === "closed"
|
|
47
|
-
const isFinal = isMerged || isClosed
|
|
48
|
-
const indicatorFg = isMerged ? colors.status.passing
|
|
49
|
-
: isClosed ? colors.muted
|
|
50
|
-
: pullRequest.autoMergeEnabled ? colors.accent
|
|
51
|
-
: statusColor(pullRequest.reviewStatus)
|
|
52
|
-
const checkFg = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
|
|
53
|
-
const checkText = isMerged ? "merged" : isClosed ? "closed" : pullRequest.checkSummary?.replace(/^checks\s+/, "") ?? ""
|
|
54
|
-
return {
|
|
55
|
-
indicatorFg,
|
|
56
|
-
rowFg: selected ? colors.selectedText : isFinal ? colors.muted : colors.text,
|
|
57
|
-
numberFg: selected ? colors.accent : isFinal ? colors.muted : colors.count,
|
|
58
|
-
checkFg,
|
|
59
|
-
checkText,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const fallbackLabelColor = (name: string) => {
|
|
64
|
-
let hash = 0
|
|
65
|
-
for (const char of name) {
|
|
66
|
-
hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
|
67
|
-
}
|
|
68
|
-
const hue = hash % 360
|
|
69
|
-
return `hsl(${hue} 55% 35%)`
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
|
|
73
|
-
|
|
74
|
-
export const labelTextColor = (color: string) => {
|
|
75
|
-
if (color.startsWith("#") && color.length === 7) {
|
|
76
|
-
const red = Number.parseInt(color.slice(1, 3), 16)
|
|
77
|
-
const green = Number.parseInt(color.slice(3, 5), 16)
|
|
78
|
-
const blue = Number.parseInt(color.slice(5, 7), 16)
|
|
79
|
-
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
|
|
80
|
-
return luminance > 0.6 ? "#111111" : "#f8fafc"
|
|
81
|
-
}
|
|
82
|
-
return "#f8fafc"
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
|
|
86
|
-
const groups = new Map<string, T[]>()
|
|
87
|
-
for (const item of items) {
|
|
88
|
-
const key = getKey(item)
|
|
89
|
-
const existing = groups.get(key)
|
|
90
|
-
if (existing) {
|
|
91
|
-
existing.push(item)
|
|
92
|
-
} else {
|
|
93
|
-
groups.set(key, [item])
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const order = new Map(orderedKeys.map((key, index) => [key, index]))
|
|
98
|
-
return [...groups.entries()].sort((left, right) => {
|
|
99
|
-
const leftIndex = order.get(left[0])
|
|
100
|
-
const rightIndex = order.get(right[0])
|
|
101
|
-
if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
|
|
102
|
-
if (leftIndex !== undefined) return -1
|
|
103
|
-
if (rightIndex !== undefined) return 1
|
|
104
|
-
return left[0].localeCompare(right[0])
|
|
105
|
-
})
|
|
106
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export interface SingleLineInputKey {
|
|
2
|
-
readonly name: string
|
|
3
|
-
readonly sequence: string
|
|
4
|
-
readonly ctrl?: boolean
|
|
5
|
-
readonly meta?: boolean
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
9
|
-
|
|
10
|
-
export const singleLineText = (text: string) => text.replace(/[\r\n]+/g, " ")
|
|
11
|
-
|
|
12
|
-
export const printableKeyText = (key: Pick<SingleLineInputKey, "ctrl" | "meta" | "sequence">) => {
|
|
13
|
-
if (key.ctrl || key.meta || key.sequence.length === 0) return null
|
|
14
|
-
// oxlint-disable-next-line no-control-regex -- intentional: skip control characters
|
|
15
|
-
return /^[^\u0000-\u001f\u007f]+$/.test(key.sequence) ? key.sequence : null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const editSingleLineInput = (value: string, key: SingleLineInputKey) => {
|
|
19
|
-
if (key.ctrl && key.name === "u") return ""
|
|
20
|
-
if (key.ctrl && key.name === "w") return deleteLastWord(value)
|
|
21
|
-
if (key.name === "backspace") return value.slice(0, -1)
|
|
22
|
-
const text = printableKeyText(key)
|
|
23
|
-
return text ? value + text : null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const isSingleLineInputKey = (key: SingleLineInputKey) => editSingleLineInput("", key) !== null
|
package/src/ui/spinner.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|