@kitlangton/ghui 0.1.18 → 0.1.20
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 +14 -3
- package/bin/ghui.js +64 -1
- package/package.json +6 -2
- package/src/App.tsx +932 -510
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +68 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +43 -13
- package/src/errors.ts +10 -0
- package/src/index.tsx +23 -2
- package/src/mergeActions.ts +1 -6
- package/src/pullRequestCache.ts +19 -0
- package/src/pullRequestViews.ts +45 -0
- package/src/services/BrowserOpener.ts +22 -0
- package/src/services/Clipboard.ts +46 -0
- package/src/services/CommandRunner.ts +14 -6
- package/src/services/GitHubService.ts +327 -161
- package/src/services/MockGitHubService.ts +146 -0
- package/src/ui/CommandPalette.tsx +143 -0
- package/src/ui/DetailsPane.tsx +49 -63
- package/src/ui/FooterHints.tsx +91 -182
- package/src/ui/PullRequestDiffPane.tsx +105 -87
- package/src/ui/PullRequestList.tsx +102 -49
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +69 -63
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +270 -302
- package/src/ui/primitives.tsx +92 -2
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
package/src/ui/primitives.tsx
CHANGED
|
@@ -29,8 +29,8 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
|
|
|
29
29
|
</box>
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
|
|
33
|
-
<box height={1}>
|
|
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
34
|
{bg ? (
|
|
35
35
|
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
36
36
|
{children}
|
|
@@ -51,6 +51,13 @@ export const SectionTitle = ({ title }: { title: string }) => (
|
|
|
51
51
|
</TextLine>
|
|
52
52
|
)
|
|
53
53
|
|
|
54
|
+
export const Filler = ({ rows, prefix }: { rows: number; prefix: string }) =>
|
|
55
|
+
<>{Array.from({ length: rows }, (_, index) => <box key={`${prefix}-${index}`} height={1} />)}</>
|
|
56
|
+
|
|
57
|
+
export const PaddedRow = ({ children, backgroundColor }: { children: React.ReactNode; backgroundColor?: string }) => (
|
|
58
|
+
<box height={1} paddingLeft={1} paddingRight={1} {...(backgroundColor ? { backgroundColor } : {})}>{children}</box>
|
|
59
|
+
)
|
|
60
|
+
|
|
54
61
|
export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
55
62
|
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
56
63
|
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
@@ -70,6 +77,89 @@ export const SeparatorColumn = ({ height, junctionRows }: { height: number; junc
|
|
|
70
77
|
)
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
export type StandardModalDims = {
|
|
81
|
+
readonly innerWidth: number
|
|
82
|
+
readonly contentWidth: number
|
|
83
|
+
readonly bodyHeight: number
|
|
84
|
+
readonly rowWidth: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const standardModalDims = (modalWidth: number, modalHeight: number): StandardModalDims => {
|
|
88
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
89
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
90
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
91
|
+
return { innerWidth, contentWidth, bodyHeight, rowWidth: innerWidth }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type HintItem = {
|
|
95
|
+
readonly key: string
|
|
96
|
+
readonly label: string
|
|
97
|
+
readonly when?: boolean
|
|
98
|
+
readonly keyFg?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const HintRow = ({ items }: { items: readonly HintItem[] }) => {
|
|
102
|
+
const visible = items.filter((item) => item.when !== false)
|
|
103
|
+
return (
|
|
104
|
+
<TextLine>
|
|
105
|
+
{visible.flatMap((item, index) => [
|
|
106
|
+
<span key={`k${index}`} fg={item.keyFg ?? colors.count}>{item.key}</span>,
|
|
107
|
+
<span key={`l${index}`} fg={colors.muted}>{` ${item.label}${index < visible.length - 1 ? " " : ""}`}</span>,
|
|
108
|
+
])}
|
|
109
|
+
</TextLine>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const StandardModal = ({
|
|
114
|
+
left,
|
|
115
|
+
top,
|
|
116
|
+
width,
|
|
117
|
+
height,
|
|
118
|
+
title,
|
|
119
|
+
titleFg = colors.accent,
|
|
120
|
+
headerRight,
|
|
121
|
+
subtitle,
|
|
122
|
+
footer,
|
|
123
|
+
bodyPadding = 0,
|
|
124
|
+
children,
|
|
125
|
+
}: {
|
|
126
|
+
left: number
|
|
127
|
+
top: number
|
|
128
|
+
width: number
|
|
129
|
+
height: number
|
|
130
|
+
title: string
|
|
131
|
+
titleFg?: string
|
|
132
|
+
headerRight?: { readonly text: string; readonly pending?: boolean }
|
|
133
|
+
subtitle: React.ReactNode
|
|
134
|
+
footer: React.ReactNode
|
|
135
|
+
bodyPadding?: number
|
|
136
|
+
children: React.ReactNode
|
|
137
|
+
}) => {
|
|
138
|
+
const { innerWidth, contentWidth, bodyHeight } = standardModalDims(width, height)
|
|
139
|
+
const rightText = headerRight?.text ?? ""
|
|
140
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
141
|
+
return (
|
|
142
|
+
<ModalFrame left={left} top={top} width={width} height={height} junctionRows={[2, height - 4]}>
|
|
143
|
+
<PaddedRow>
|
|
144
|
+
<TextLine>
|
|
145
|
+
<span fg={titleFg} attributes={TextAttributes.BOLD}>{title}</span>
|
|
146
|
+
{headerRight ? (
|
|
147
|
+
<>
|
|
148
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
149
|
+
<span fg={headerRight.pending ? colors.status.pending : colors.muted}>{headerRight.text}</span>
|
|
150
|
+
</>
|
|
151
|
+
) : null}
|
|
152
|
+
</TextLine>
|
|
153
|
+
</PaddedRow>
|
|
154
|
+
<PaddedRow>{subtitle}</PaddedRow>
|
|
155
|
+
<Divider width={innerWidth} />
|
|
156
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={bodyPadding} paddingRight={bodyPadding}>{children}</box>
|
|
157
|
+
<Divider width={innerWidth} />
|
|
158
|
+
<PaddedRow>{footer}</PaddedRow>
|
|
159
|
+
</ModalFrame>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
73
163
|
export const ModalFrame = ({
|
|
74
164
|
children,
|
|
75
165
|
left,
|
package/src/ui/pullRequests.ts
CHANGED
|
@@ -1,31 +1,63 @@
|
|
|
1
|
-
import type { PullRequestItem, PullRequestLabel } from "../domain.js"
|
|
1
|
+
import type { PullRequestItem, PullRequestLabel, ReviewStatus } from "../domain.js"
|
|
2
2
|
import { colors } from "./colors.js"
|
|
3
3
|
|
|
4
4
|
export const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
5
5
|
|
|
6
6
|
export const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return null
|
|
8
|
+
const REVIEW_LABEL: Partial<Record<ReviewStatus, string>> = {
|
|
9
|
+
draft: "draft",
|
|
10
|
+
approved: "approved",
|
|
11
|
+
changes: "changes",
|
|
12
|
+
review: "review",
|
|
14
13
|
}
|
|
15
14
|
|
|
15
|
+
export const reviewLabel = (pullRequest: PullRequestItem) => REVIEW_LABEL[pullRequest.reviewStatus] ?? null
|
|
16
|
+
|
|
16
17
|
export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
|
|
17
18
|
|
|
18
19
|
export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
|
|
19
20
|
|
|
21
|
+
const REVIEW_ICON: Record<ReviewStatus, string> = {
|
|
22
|
+
draft: "◌",
|
|
23
|
+
approved: "✓",
|
|
24
|
+
changes: "!",
|
|
25
|
+
review: "◐",
|
|
26
|
+
none: "·",
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
export const reviewIcon = (pullRequest: PullRequestItem) => {
|
|
21
30
|
if (pullRequest.state === "merged") return "✓"
|
|
22
31
|
if (pullRequest.state === "closed") return "×"
|
|
23
32
|
if (pullRequest.autoMergeEnabled) return "↻"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
const fallbackLabelColor = (name: string) => {
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
return /^[^\x00-\x1f\x7f]+$/.test(key.sequence) ? key.sequence : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const editSingleLineInput = (value: string, key: SingleLineInputKey) => {
|
|
18
|
+
if (key.ctrl && key.name === "u") return ""
|
|
19
|
+
if (key.ctrl && key.name === "w") return deleteLastWord(value)
|
|
20
|
+
if (key.name === "backspace") return value.slice(0, -1)
|
|
21
|
+
const text = printableKeyText(key)
|
|
22
|
+
return text ? value + text : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const isSingleLineInputKey = (key: SingleLineInputKey) => editSingleLineInput("", key) !== null
|