@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,148 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import { useMemo, type Ref } from "react"
3
+ import type { PullRequestItem } from "../domain.js"
4
+ import { colors } from "./colors.js"
5
+ import { diffStatText, diffSyntaxStyle, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
6
+ import { LoadingPane, StatusCard } from "./DetailsPane.js"
7
+ import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
8
+ import { shortRepoName } from "./pullRequests.js"
9
+
10
+ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
11
+ if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
12
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
13
+ type Part = { key: string; text: string; color: string }
14
+ const rawParts: Array<Part | null> = [
15
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
16
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
17
+ { key: "files", text: files, color: colors.muted },
18
+ ]
19
+ const parts = rawParts.filter((part): part is Part => part !== null)
20
+
21
+ return (
22
+ <>
23
+ {parts.map((part, index) => (
24
+ <span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
25
+ ))}
26
+ </>
27
+ )
28
+ }
29
+
30
+ export const PullRequestDiffPane = ({
31
+ pullRequest,
32
+ diffState,
33
+ fileIndex,
34
+ view,
35
+ wrapMode,
36
+ paneWidth,
37
+ height,
38
+ loadingIndicator,
39
+ scrollRef,
40
+ }: {
41
+ pullRequest: PullRequestItem | null
42
+ diffState: PullRequestDiffState | undefined
43
+ fileIndex: number
44
+ view: "unified" | "split"
45
+ wrapMode: "none" | "word"
46
+ paneWidth: number
47
+ height: number
48
+ loadingIndicator: string
49
+ scrollRef: Ref<ScrollBoxRenderable>
50
+ }) => {
51
+ const readyFiles = diffState?.status === "ready" ? diffState.files : []
52
+ const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
53
+ const file = readyFiles[safeIndex] ?? null
54
+ const diffHeight = useMemo(
55
+ () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
56
+ [file?.patch, view, wrapMode, paneWidth],
57
+ )
58
+
59
+ if (!pullRequest) {
60
+ return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
61
+ }
62
+
63
+ const stats = diffStatText(pullRequest)
64
+ const headerWidth = Math.max(24, paneWidth - 2)
65
+ const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
66
+ const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
67
+
68
+ if (!diffState || diffState.status === "loading") {
69
+ return (
70
+ <box height={height} flexDirection="column">
71
+ <box height={1} paddingLeft={1} paddingRight={1}>
72
+ <TextLine>
73
+ <span fg={colors.count}>#{pullRequest.number}</span>
74
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
75
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
76
+ <DiffStats pullRequest={pullRequest} />
77
+ </TextLine>
78
+ </box>
79
+ <Divider width={paneWidth} />
80
+ <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
81
+ </box>
82
+ )
83
+ }
84
+
85
+ if (diffState.status === "error") {
86
+ return (
87
+ <box height={height} flexDirection="column">
88
+ <box height={1} paddingLeft={1} paddingRight={1}>
89
+ <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
90
+ </box>
91
+ <Divider width={paneWidth} />
92
+ <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
93
+ </box>
94
+ )
95
+ }
96
+
97
+ if (readyFiles.length === 0 || !file) {
98
+ return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
99
+ }
100
+
101
+ const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
102
+ const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
103
+
104
+ return (
105
+ <box height={height} flexDirection="column">
106
+ <box height={1} paddingLeft={1} paddingRight={1}>
107
+ <TextLine>
108
+ <span fg={colors.count}>#{pullRequest.number}</span>
109
+ <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
110
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
111
+ <DiffStats pullRequest={pullRequest} />
112
+ </TextLine>
113
+ </box>
114
+ <box height={1} paddingLeft={1} paddingRight={1}>
115
+ <TextLine>
116
+ <span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
117
+ <span fg={colors.muted}> {fileCounter}</span>
118
+ </TextLine>
119
+ </box>
120
+ <Divider width={paneWidth} />
121
+ <scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
122
+ <diff
123
+ key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
124
+ diff={file.patch}
125
+ view={view}
126
+ syncScroll
127
+ filetype={file.filetype ?? "text"}
128
+ syntaxStyle={diffSyntaxStyle}
129
+ showLineNumbers
130
+ wrapMode={wrapMode}
131
+ addedBg="#17351f"
132
+ removedBg="#3a1e22"
133
+ contextBg="transparent"
134
+ addedSignColor={colors.status.passing}
135
+ removedSignColor={colors.status.failing}
136
+ lineNumberFg={colors.muted}
137
+ lineNumberBg="#151515"
138
+ addedLineNumberBg="#12301a"
139
+ removedLineNumberBg="#35171b"
140
+ selectionBg={colors.selectedBg}
141
+ selectionFg={colors.selectedText}
142
+ height={diffHeight}
143
+ style={{ flexShrink: 0 }}
144
+ />
145
+ </scrollbox>
146
+ </box>
147
+ )
148
+ }
@@ -0,0 +1,128 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { PullRequestItem } from "../domain.js"
3
+ import { daysOpen } from "../date.js"
4
+ import { colors } from "./colors.js"
5
+ import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
+ import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
7
+
8
+ export type LoadStatus = "loading" | "ready" | "error"
9
+ export type PullRequestGroups = Array<[string, PullRequestItem[]]>
10
+
11
+ const GROUP_ICON = "◆"
12
+
13
+ const getRowLayout = (contentWidth: number, numberWidth = 6) => {
14
+ const reviewWidth = 1
15
+ const checkWidth = 6
16
+ const ageWidth = 4
17
+ const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
18
+ const titleWidth = Math.max(8, contentWidth - fixedWidth)
19
+ return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
20
+ }
21
+
22
+ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
23
+ if (pullRequests.length === 0) return 4
24
+ const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
25
+ return maxLen + 1
26
+ }
27
+
28
+ const GroupTitle = ({ label, color }: { label: string; color: string }) => (
29
+ <TextLine>
30
+ <span fg={color}>{GROUP_ICON} </span>
31
+ <span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
32
+ </TextLine>
33
+ )
34
+
35
+ const PullRequestRow = ({
36
+ pullRequest,
37
+ selected,
38
+ contentWidth,
39
+ numWidth,
40
+ onSelect,
41
+ }: {
42
+ pullRequest: PullRequestItem
43
+ selected: boolean
44
+ contentWidth: number
45
+ numWidth: number
46
+ onSelect: () => void
47
+ }) => {
48
+ const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
49
+ const ageText = `${daysOpen(pullRequest.createdAt)}d`
50
+ const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
51
+ const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
52
+ const fillerWidth = Math.max(0, contentWidth - rowWidth)
53
+ const indicatorColor = pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
54
+
55
+ return (
56
+ <box height={1} onMouseDown={onSelect}>
57
+ <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
58
+ <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
59
+ <span> </span>
60
+ <span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
61
+ <span> </span>
62
+ <span>{fitCell(pullRequest.title, titleWidth)}</span>
63
+ <span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
64
+ <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
65
+ {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
66
+ </TextLine>
67
+ </box>
68
+ )
69
+ }
70
+
71
+ export const PullRequestList = ({
72
+ groups,
73
+ selectedUrl,
74
+ status,
75
+ error,
76
+ contentWidth,
77
+ filterText,
78
+ showFilterBar,
79
+ isFilterEditing,
80
+ onSelectPullRequest,
81
+ }: {
82
+ groups: PullRequestGroups
83
+ selectedUrl: string | null
84
+ status: LoadStatus
85
+ error: string | null
86
+ contentWidth: number
87
+ filterText: string
88
+ showFilterBar: boolean
89
+ isFilterEditing: boolean
90
+ onSelectPullRequest: (url: string) => void
91
+ }) => {
92
+ const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
93
+ const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
94
+
95
+ return (
96
+ <box flexDirection="column">
97
+ <SectionTitle title="PULL REQUESTS" />
98
+ {showFilterBar ? (
99
+ <TextLine>
100
+ <span fg={colors.count}>/</span>
101
+ <span fg={colors.muted}> </span>
102
+ <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
103
+ </TextLine>
104
+ ) : null}
105
+ {status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
106
+ {status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
107
+ {status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
108
+ {groups.map(([repo, pullRequests]) => {
109
+ const numWidth = groupNumberWidth(pullRequests)
110
+ return (
111
+ <box key={repo} flexDirection="column">
112
+ <GroupTitle label={repo} color={repoColor(repo)} />
113
+ {pullRequests.map((pullRequest) => (
114
+ <PullRequestRow
115
+ key={pullRequest.url}
116
+ pullRequest={pullRequest}
117
+ selected={pullRequest.url === selectedUrl}
118
+ contentWidth={contentWidth}
119
+ numWidth={numWidth}
120
+ onSelect={() => onSelectPullRequest(pullRequest.url)}
121
+ />
122
+ ))}
123
+ </box>
124
+ )
125
+ })}
126
+ </box>
127
+ )
128
+ }
@@ -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
+ }