@kitlangton/ghui 0.1.6 → 0.1.8
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/.env.example +4 -0
- package/package.json +1 -1
- package/src/App.tsx +221 -809
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/index.tsx +1 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +161 -14
- package/src/ui/FooterHints.tsx +145 -0
- package/src/ui/PullRequestList.tsx +128 -0
- package/src/ui/colors.ts +28 -0
- package/src/ui/diff.ts +220 -0
- package/src/ui/modals.tsx +294 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
package/src/ui/colors.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
text: "#ede7da",
|
|
3
|
+
muted: "#9f9788",
|
|
4
|
+
separator: "#6f685d",
|
|
5
|
+
accent: "#f4a51c",
|
|
6
|
+
inlineCode: "#d7c5a1",
|
|
7
|
+
error: "#f97316",
|
|
8
|
+
selectedBg: "#1d2430",
|
|
9
|
+
selectedText: "#f8fafc",
|
|
10
|
+
count: "#d7c5a1",
|
|
11
|
+
status: {
|
|
12
|
+
draft: "#f59e0b",
|
|
13
|
+
approved: "#7dd3a3",
|
|
14
|
+
changes: "#f87171",
|
|
15
|
+
review: "#93c5fd",
|
|
16
|
+
none: "#9f9788",
|
|
17
|
+
passing: "#7dd3a3",
|
|
18
|
+
pending: "#f4a51c",
|
|
19
|
+
failing: "#f87171",
|
|
20
|
+
},
|
|
21
|
+
repos: {
|
|
22
|
+
opencode: "#60a5fa",
|
|
23
|
+
"effect-smol": "#34d399",
|
|
24
|
+
"opencode-console": "#f472b6",
|
|
25
|
+
opencontrol: "#f59e0b",
|
|
26
|
+
default: "#93c5fd",
|
|
27
|
+
},
|
|
28
|
+
} as const
|
package/src/ui/diff.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
2
|
+
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import { colors } from "./colors.js"
|
|
4
|
+
|
|
5
|
+
export interface DiffFilePatch {
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly filetype: string | undefined
|
|
8
|
+
readonly patch: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PullRequestDiffState =
|
|
12
|
+
| { readonly status: "loading" }
|
|
13
|
+
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
14
|
+
| { readonly status: "error"; readonly error: string }
|
|
15
|
+
|
|
16
|
+
export const diffSyntaxStyle = SyntaxStyle.fromStyles({
|
|
17
|
+
keyword: { fg: parseColor("#f4a51c"), bold: true },
|
|
18
|
+
"keyword.import": { fg: parseColor("#f4a51c"), bold: true },
|
|
19
|
+
string: { fg: parseColor("#d7c5a1") },
|
|
20
|
+
comment: { fg: parseColor(colors.muted), italic: true },
|
|
21
|
+
number: { fg: parseColor("#93c5fd") },
|
|
22
|
+
boolean: { fg: parseColor("#93c5fd") },
|
|
23
|
+
constant: { fg: parseColor("#93c5fd") },
|
|
24
|
+
function: { fg: parseColor("#7dd3a3") },
|
|
25
|
+
"function.call": { fg: parseColor("#7dd3a3") },
|
|
26
|
+
constructor: { fg: parseColor("#f59e0b") },
|
|
27
|
+
type: { fg: parseColor("#f59e0b") },
|
|
28
|
+
operator: { fg: parseColor("#f87171") },
|
|
29
|
+
variable: { fg: parseColor(colors.text) },
|
|
30
|
+
property: { fg: parseColor("#93c5fd") },
|
|
31
|
+
bracket: { fg: parseColor(colors.text) },
|
|
32
|
+
punctuation: { fg: parseColor(colors.text) },
|
|
33
|
+
default: { fg: parseColor(colors.text) },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const extensionFiletypes: Record<string, string> = {
|
|
37
|
+
c: "c",
|
|
38
|
+
cc: "cpp",
|
|
39
|
+
cpp: "cpp",
|
|
40
|
+
cs: "csharp",
|
|
41
|
+
css: "css",
|
|
42
|
+
go: "go",
|
|
43
|
+
h: "c",
|
|
44
|
+
hpp: "cpp",
|
|
45
|
+
html: "html",
|
|
46
|
+
java: "java",
|
|
47
|
+
js: "javascript",
|
|
48
|
+
jsx: "javascript",
|
|
49
|
+
json: "json",
|
|
50
|
+
kt: "kotlin",
|
|
51
|
+
md: "markdown",
|
|
52
|
+
mjs: "javascript",
|
|
53
|
+
py: "python",
|
|
54
|
+
rs: "rust",
|
|
55
|
+
rb: "ruby",
|
|
56
|
+
sh: "bash",
|
|
57
|
+
svelte: "svelte",
|
|
58
|
+
toml: "toml",
|
|
59
|
+
ts: "typescript",
|
|
60
|
+
tsx: "typescript",
|
|
61
|
+
txt: "text",
|
|
62
|
+
vue: "vue",
|
|
63
|
+
yaml: "yaml",
|
|
64
|
+
yml: "yaml",
|
|
65
|
+
zig: "zig",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const filetypeForPath = (path: string) => {
|
|
69
|
+
const basename = path.split("/").at(-1) ?? path
|
|
70
|
+
if (basename === "Dockerfile") return "dockerfile"
|
|
71
|
+
const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
|
|
72
|
+
return extension ? extensionFiletypes[extension] : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
|
|
76
|
+
|
|
77
|
+
const patchFileName = (patch: string) => {
|
|
78
|
+
const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
|
|
79
|
+
if (diffLine) {
|
|
80
|
+
const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
|
|
81
|
+
if (match) {
|
|
82
|
+
const next = unquoteDiffPath(match[2]!)
|
|
83
|
+
if (next !== "/dev/null") return next
|
|
84
|
+
return unquoteDiffPath(match[1]!)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
|
|
89
|
+
return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
93
|
+
const trimmed = patch.trimEnd()
|
|
94
|
+
if (trimmed.length === 0) return []
|
|
95
|
+
|
|
96
|
+
const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
return [{ name: "diff", filetype: undefined, patch: trimmed }]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return matches.map((match, index) => {
|
|
102
|
+
const start = match.index ?? 0
|
|
103
|
+
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
104
|
+
const filePatch = trimmed.slice(start, end).trimEnd()
|
|
105
|
+
const name = patchFileName(filePatch)
|
|
106
|
+
return { name, filetype: filetypeForPath(name), patch: filePatch }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
111
|
+
|
|
112
|
+
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
113
|
+
if (!pullRequest.detailLoaded) return "loading details"
|
|
114
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
115
|
+
return [
|
|
116
|
+
pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
|
|
117
|
+
pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
|
|
118
|
+
files,
|
|
119
|
+
].filter((part): part is string => part !== null).join(" ")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
|
|
123
|
+
if (wrapMode === "none") return 1
|
|
124
|
+
return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
128
|
+
let maxLineNumber = 1
|
|
129
|
+
let hasSigns = false
|
|
130
|
+
let oldLine = 0
|
|
131
|
+
let newLine = 0
|
|
132
|
+
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
|
135
|
+
if (hunk) {
|
|
136
|
+
oldLine = Number(hunk[1])
|
|
137
|
+
newLine = Number(hunk[2])
|
|
138
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const firstChar = line[0]
|
|
143
|
+
if (firstChar === "-") {
|
|
144
|
+
hasSigns = true
|
|
145
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine)
|
|
146
|
+
oldLine++
|
|
147
|
+
} else if (firstChar === "+") {
|
|
148
|
+
hasSigns = true
|
|
149
|
+
maxLineNumber = Math.max(maxLineNumber, newLine)
|
|
150
|
+
newLine++
|
|
151
|
+
} else if (firstChar === " ") {
|
|
152
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
153
|
+
oldLine++
|
|
154
|
+
newLine++
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const digits = Math.floor(Math.log10(maxLineNumber)) + 1
|
|
159
|
+
return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
|
|
163
|
+
const lines = patch.split("\n")
|
|
164
|
+
const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
|
|
165
|
+
const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
166
|
+
const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
|
|
167
|
+
const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
|
|
168
|
+
let count = 0
|
|
169
|
+
let inHunk = false
|
|
170
|
+
let deletions: number[] = []
|
|
171
|
+
let additions: number[] = []
|
|
172
|
+
|
|
173
|
+
const flushChangeBlock = () => {
|
|
174
|
+
if (deletions.length === 0 && additions.length === 0) return
|
|
175
|
+
if (view === "split") {
|
|
176
|
+
const rows = Math.max(deletions.length, additions.length)
|
|
177
|
+
for (let index = 0; index < rows; index++) {
|
|
178
|
+
const deletionCount = index < deletions.length ? deletions[index]! : 1
|
|
179
|
+
const additionCount = index < additions.length ? additions[index]! : 1
|
|
180
|
+
count += Math.max(deletionCount, additionCount)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
for (const deletion of deletions) count += deletion
|
|
184
|
+
for (const addition of additions) count += addition
|
|
185
|
+
}
|
|
186
|
+
deletions = []
|
|
187
|
+
additions = []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (line.startsWith("@@")) {
|
|
192
|
+
flushChangeBlock()
|
|
193
|
+
inHunk = true
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!inHunk) continue
|
|
198
|
+
|
|
199
|
+
const firstChar = line[0]
|
|
200
|
+
if (firstChar === "\\") continue
|
|
201
|
+
|
|
202
|
+
if (firstChar === "-") {
|
|
203
|
+
deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (firstChar === "+") {
|
|
208
|
+
additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (firstChar === " ") {
|
|
213
|
+
flushChangeBlock()
|
|
214
|
+
count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
flushChangeBlock()
|
|
219
|
+
return Math.max(1, count)
|
|
220
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { PullRequestLabel, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
|
|
3
|
+
import { colors } from "./colors.js"
|
|
4
|
+
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
5
|
+
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
6
|
+
|
|
7
|
+
export interface LabelModalState {
|
|
8
|
+
readonly open: boolean
|
|
9
|
+
readonly repository: string | null
|
|
10
|
+
readonly query: string
|
|
11
|
+
readonly selectedIndex: number
|
|
12
|
+
readonly availableLabels: readonly PullRequestLabel[]
|
|
13
|
+
readonly loading: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MergeModalState {
|
|
17
|
+
readonly open: boolean
|
|
18
|
+
readonly repository: string | null
|
|
19
|
+
readonly number: number | null
|
|
20
|
+
readonly selectedIndex: number
|
|
21
|
+
readonly loading: boolean
|
|
22
|
+
readonly running: boolean
|
|
23
|
+
readonly info: PullRequestMergeInfo | null
|
|
24
|
+
readonly error: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MergeModalOption {
|
|
28
|
+
readonly action: PullRequestMergeAction
|
|
29
|
+
readonly title: string
|
|
30
|
+
readonly description: string
|
|
31
|
+
readonly danger?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const initialLabelModalState: LabelModalState = {
|
|
35
|
+
open: false,
|
|
36
|
+
repository: null,
|
|
37
|
+
query: "",
|
|
38
|
+
selectedIndex: 0,
|
|
39
|
+
availableLabels: [],
|
|
40
|
+
loading: false,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const initialMergeModalState: MergeModalState = {
|
|
44
|
+
open: false,
|
|
45
|
+
repository: null,
|
|
46
|
+
number: null,
|
|
47
|
+
selectedIndex: 0,
|
|
48
|
+
loading: false,
|
|
49
|
+
running: false,
|
|
50
|
+
info: null,
|
|
51
|
+
error: null,
|
|
52
|
+
}
|
|
53
|
+
|
|
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
|
+
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
104
|
+
if (!info) return "Loading merge status from GitHub."
|
|
105
|
+
if (info.state !== "open") return "This pull request is not open."
|
|
106
|
+
if (info.isDraft) return "Draft pull requests cannot be merged."
|
|
107
|
+
if (info.mergeable === "conflicting") return "This branch has merge conflicts."
|
|
108
|
+
return "No merge actions are currently available."
|
|
109
|
+
}
|
|
110
|
+
|
|
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
|
+
export const LabelModal = ({
|
|
119
|
+
state,
|
|
120
|
+
currentLabels,
|
|
121
|
+
modalWidth,
|
|
122
|
+
modalHeight,
|
|
123
|
+
offsetLeft,
|
|
124
|
+
offsetTop,
|
|
125
|
+
loadingIndicator,
|
|
126
|
+
}: {
|
|
127
|
+
state: LabelModalState
|
|
128
|
+
currentLabels: readonly PullRequestLabel[]
|
|
129
|
+
modalWidth: number
|
|
130
|
+
modalHeight: number
|
|
131
|
+
offsetLeft: number
|
|
132
|
+
offsetTop: number
|
|
133
|
+
loadingIndicator: string
|
|
134
|
+
}) => {
|
|
135
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
136
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
137
|
+
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
138
|
+
const filtered = state.availableLabels.filter((label) =>
|
|
139
|
+
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
140
|
+
)
|
|
141
|
+
const maxVisible = Math.max(1, modalHeight - 8)
|
|
142
|
+
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
143
|
+
const scrollStart = Math.min(
|
|
144
|
+
Math.max(0, filtered.length - maxVisible),
|
|
145
|
+
Math.max(0, selectedIndex - maxVisible + 1),
|
|
146
|
+
)
|
|
147
|
+
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
148
|
+
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
149
|
+
const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
|
|
150
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
151
|
+
const queryText = state.query.length > 0 ? state.query : "type to filter labels"
|
|
152
|
+
const queryPrefix = state.query.length > 0 ? "/ " : "/ "
|
|
153
|
+
const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
157
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
158
|
+
<TextLine>
|
|
159
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
160
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
161
|
+
<span fg={colors.muted}>{countText}</span>
|
|
162
|
+
</TextLine>
|
|
163
|
+
</box>
|
|
164
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
165
|
+
<TextLine>
|
|
166
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
167
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
|
|
168
|
+
</TextLine>
|
|
169
|
+
</box>
|
|
170
|
+
<Divider width={innerWidth} />
|
|
171
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
172
|
+
{state.loading ? (
|
|
173
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
|
|
174
|
+
) : visibleLabels.length === 0 ? (
|
|
175
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
|
|
176
|
+
) : (
|
|
177
|
+
visibleLabels.map((label, index) => {
|
|
178
|
+
const actualIndex = scrollStart + index
|
|
179
|
+
const isActive = currentNames.has(label.name.toLowerCase())
|
|
180
|
+
const isSelected = actualIndex === selectedIndex
|
|
181
|
+
const marker = isActive ? "✓" : " "
|
|
182
|
+
const nameWidth = Math.max(1, contentWidth - 5)
|
|
183
|
+
return (
|
|
184
|
+
<box key={label.name} height={1}>
|
|
185
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
186
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
187
|
+
<span> </span>
|
|
188
|
+
<span bg={labelColor(label)}> </span>
|
|
189
|
+
<span> {fitCell(label.name, nameWidth)}</span>
|
|
190
|
+
</TextLine>
|
|
191
|
+
</box>
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
)}
|
|
195
|
+
</box>
|
|
196
|
+
<box flexGrow={1} />
|
|
197
|
+
<Divider width={innerWidth} />
|
|
198
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
199
|
+
<TextLine>
|
|
200
|
+
<span fg={colors.count}>↑↓</span>
|
|
201
|
+
<span fg={colors.muted}> move </span>
|
|
202
|
+
<span fg={colors.count}>esc</span>
|
|
203
|
+
<span fg={colors.muted}> close</span>
|
|
204
|
+
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
205
|
+
</TextLine>
|
|
206
|
+
</box>
|
|
207
|
+
</ModalFrame>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const MergeModal = ({
|
|
212
|
+
state,
|
|
213
|
+
modalWidth,
|
|
214
|
+
modalHeight,
|
|
215
|
+
offsetLeft,
|
|
216
|
+
offsetTop,
|
|
217
|
+
loadingIndicator,
|
|
218
|
+
}: {
|
|
219
|
+
state: MergeModalState
|
|
220
|
+
modalWidth: number
|
|
221
|
+
modalHeight: number
|
|
222
|
+
offsetLeft: number
|
|
223
|
+
offsetTop: number
|
|
224
|
+
loadingIndicator: string
|
|
225
|
+
}) => {
|
|
226
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
227
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
228
|
+
const options = mergeModalOptions(state.info)
|
|
229
|
+
const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
|
|
230
|
+
const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
|
|
231
|
+
const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
|
|
232
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
233
|
+
const repo = state.info?.repository ?? state.repository
|
|
234
|
+
const statusLine = state.info
|
|
235
|
+
? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
|
|
236
|
+
: repo ? shortRepoName(repo) : ""
|
|
237
|
+
const optionRows = Math.max(1, Math.floor((modalHeight - 9) / 2))
|
|
238
|
+
const visibleOptions = options.slice(0, optionRows)
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
242
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
243
|
+
<TextLine>
|
|
244
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
245
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
246
|
+
<span fg={state.running || state.loading ? colors.status.pending : colors.muted}>{rightText}</span>
|
|
247
|
+
</TextLine>
|
|
248
|
+
</box>
|
|
249
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
250
|
+
<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
|
|
251
|
+
</box>
|
|
252
|
+
<Divider width={innerWidth} />
|
|
253
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
254
|
+
{state.loading ? (
|
|
255
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, contentWidth)} fg={colors.muted} />
|
|
256
|
+
) : state.error ? (
|
|
257
|
+
<PlainLine text={centerCell(state.error, contentWidth)} fg={colors.error} />
|
|
258
|
+
) : visibleOptions.length === 0 ? (
|
|
259
|
+
<PlainLine text={centerCell(mergeUnavailableReason(state.info), contentWidth)} fg={colors.muted} />
|
|
260
|
+
) : (
|
|
261
|
+
visibleOptions.map((option, index) => {
|
|
262
|
+
const isSelected = index === selectedIndex
|
|
263
|
+
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)
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<box key={option.action} height={2} flexDirection="column">
|
|
269
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
270
|
+
<span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
|
|
271
|
+
</TextLine>
|
|
272
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
273
|
+
<span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
|
|
274
|
+
</TextLine>
|
|
275
|
+
</box>
|
|
276
|
+
)
|
|
277
|
+
})
|
|
278
|
+
)}
|
|
279
|
+
</box>
|
|
280
|
+
<box flexGrow={1} />
|
|
281
|
+
<Divider width={innerWidth} />
|
|
282
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
283
|
+
<TextLine>
|
|
284
|
+
<span fg={colors.count}>↑↓</span>
|
|
285
|
+
<span fg={colors.muted}> move </span>
|
|
286
|
+
<span fg={colors.count}>enter</span>
|
|
287
|
+
<span fg={colors.muted}> confirm </span>
|
|
288
|
+
<span fg={colors.count}>esc</span>
|
|
289
|
+
<span fg={colors.muted}> close</span>
|
|
290
|
+
</TextLine>
|
|
291
|
+
</box>
|
|
292
|
+
</ModalFrame>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
@@ -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
|
+
}
|