@kitlangton/ghui 0.3.0 → 0.3.2

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.
Files changed (54) hide show
  1. package/bin/ghui.js +6 -1
  2. package/dist/index.js +9419 -0
  3. package/package.json +7 -4
  4. package/src/App.tsx +0 -2779
  5. package/src/appCommands.ts +0 -409
  6. package/src/commands.ts +0 -73
  7. package/src/config.ts +0 -20
  8. package/src/date.ts +0 -20
  9. package/src/domain.ts +0 -149
  10. package/src/errors.ts +0 -10
  11. package/src/index.tsx +0 -50
  12. package/src/keyboard/opentuiAdapter.ts +0 -43
  13. package/src/keymap/all.ts +0 -116
  14. package/src/keymap/changedFilesModal.ts +0 -23
  15. package/src/keymap/closeModal.ts +0 -13
  16. package/src/keymap/commandPalette.ts +0 -16
  17. package/src/keymap/commentModal.ts +0 -73
  18. package/src/keymap/commentThreadModal.ts +0 -24
  19. package/src/keymap/detailView.ts +0 -30
  20. package/src/keymap/diffView.ts +0 -93
  21. package/src/keymap/filterMode.ts +0 -13
  22. package/src/keymap/helpers.ts +0 -24
  23. package/src/keymap/labelModal.ts +0 -16
  24. package/src/keymap/listNav.ts +0 -119
  25. package/src/keymap/mergeModal.ts +0 -23
  26. package/src/keymap/openRepositoryModal.ts +0 -13
  27. package/src/keymap/submitReviewModal.ts +0 -48
  28. package/src/keymap/themeModal.ts +0 -49
  29. package/src/mergeActions.ts +0 -88
  30. package/src/observability.ts +0 -46
  31. package/src/pullRequestCache.ts +0 -19
  32. package/src/pullRequestViews.ts +0 -43
  33. package/src/services/BrowserOpener.ts +0 -22
  34. package/src/services/Clipboard.ts +0 -46
  35. package/src/services/CommandRunner.ts +0 -91
  36. package/src/services/GitHubService.ts +0 -756
  37. package/src/services/MockGitHubService.ts +0 -182
  38. package/src/themeStore.ts +0 -60
  39. package/src/ui/CommandPalette.tsx +0 -176
  40. package/src/ui/DetailsPane.tsx +0 -656
  41. package/src/ui/FooterHints.tsx +0 -90
  42. package/src/ui/LoadingLogo.tsx +0 -75
  43. package/src/ui/PullRequestDiffPane.tsx +0 -249
  44. package/src/ui/PullRequestList.tsx +0 -194
  45. package/src/ui/colors.ts +0 -821
  46. package/src/ui/commentEditor.ts +0 -126
  47. package/src/ui/comments.tsx +0 -143
  48. package/src/ui/diff.ts +0 -650
  49. package/src/ui/diffStats.tsx +0 -25
  50. package/src/ui/modals.tsx +0 -964
  51. package/src/ui/primitives.tsx +0 -350
  52. package/src/ui/pullRequests.ts +0 -106
  53. package/src/ui/singleLineInput.ts +0 -26
  54. package/src/ui/spinner.ts +0 -1
@@ -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
- }
@@ -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;