@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
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import type { PullRequestItem } from "../domain.js"
|
|
2
|
+
import type { LoadStatus, PullRequestItem } from "../domain.js"
|
|
3
3
|
import { daysOpen } from "../date.js"
|
|
4
4
|
import { colors } from "./colors.js"
|
|
5
5
|
import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
|
|
6
|
-
import {
|
|
6
|
+
import { pullRequestRowDisplay, repoColor, reviewIcon } from "./pullRequests.js"
|
|
7
7
|
|
|
8
|
-
export type LoadStatus = "loading" | "ready" | "error"
|
|
9
8
|
export type PullRequestGroups = Array<[string, PullRequestItem[]]>
|
|
10
9
|
|
|
10
|
+
export type PullRequestListRow =
|
|
11
|
+
| { readonly _tag: "title" }
|
|
12
|
+
| { readonly _tag: "filter" }
|
|
13
|
+
| { readonly _tag: "message"; readonly text: string; readonly color: string }
|
|
14
|
+
| { readonly _tag: "group"; readonly repository: string; readonly pullRequests: readonly PullRequestItem[] }
|
|
15
|
+
| { readonly _tag: "pull-request"; readonly pullRequest: PullRequestItem; readonly groupPullRequests: readonly PullRequestItem[] }
|
|
16
|
+
| { readonly _tag: "load-more"; readonly text: string }
|
|
17
|
+
|
|
11
18
|
const GROUP_ICON = "◆"
|
|
12
19
|
|
|
13
|
-
const getRowLayout = (contentWidth: number, numberWidth
|
|
20
|
+
const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number) => {
|
|
14
21
|
const reviewWidth = 1
|
|
15
22
|
const checkWidth = 6
|
|
16
|
-
const ageWidth = 4
|
|
17
23
|
const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
|
|
18
24
|
const titleWidth = Math.max(8, contentWidth - fixedWidth)
|
|
19
25
|
return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
|
|
@@ -25,6 +31,12 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
|
25
31
|
return maxLen + 1
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
const groupAgeWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
35
|
+
if (pullRequests.length === 0) return 4
|
|
36
|
+
const maxLen = Math.max(...pullRequests.map((pr) => `${daysOpen(pr.createdAt)}d`.length))
|
|
37
|
+
return Math.max(4, maxLen + 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
|
|
29
41
|
const fitted = fitCell(text, width, align)
|
|
30
42
|
const needle = query.trim().toLowerCase()
|
|
@@ -48,11 +60,53 @@ const GroupTitle = ({ label, color, filterText }: { label: string; color: string
|
|
|
48
60
|
</TextLine>
|
|
49
61
|
)
|
|
50
62
|
|
|
63
|
+
export const buildPullRequestListRows = ({
|
|
64
|
+
groups,
|
|
65
|
+
status,
|
|
66
|
+
error,
|
|
67
|
+
filterText,
|
|
68
|
+
showFilterBar,
|
|
69
|
+
loadedCount,
|
|
70
|
+
hasMore,
|
|
71
|
+
isLoadingMore,
|
|
72
|
+
}: {
|
|
73
|
+
readonly groups: PullRequestGroups
|
|
74
|
+
readonly status: LoadStatus
|
|
75
|
+
readonly error: string | null
|
|
76
|
+
readonly filterText: string
|
|
77
|
+
readonly showFilterBar: boolean
|
|
78
|
+
readonly loadedCount: number
|
|
79
|
+
readonly hasMore: boolean
|
|
80
|
+
readonly isLoadingMore: boolean
|
|
81
|
+
}): readonly PullRequestListRow[] => {
|
|
82
|
+
const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
|
|
83
|
+
const rows: PullRequestListRow[] = [{ _tag: "title" }]
|
|
84
|
+
if (showFilterBar) rows.push({ _tag: "filter" })
|
|
85
|
+
if (status === "loading" && itemCount === 0) rows.push({ _tag: "message", text: "- Loading pull requests...", color: colors.muted })
|
|
86
|
+
if (status === "error") rows.push({ _tag: "message", text: `- ${error ?? "Could not load pull requests."}`, color: colors.error })
|
|
87
|
+
if (status === "ready" && itemCount === 0) rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests.", color: colors.muted })
|
|
88
|
+
for (const [repository, pullRequests] of groups) {
|
|
89
|
+
rows.push({ _tag: "group", repository, pullRequests })
|
|
90
|
+
for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
|
|
91
|
+
}
|
|
92
|
+
if (status === "ready" && itemCount > 0 && (hasMore || isLoadingMore)) {
|
|
93
|
+
rows.push({ _tag: "load-more", text: isLoadingMore ? `- Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
|
|
94
|
+
}
|
|
95
|
+
return rows
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const pullRequestListRowIndex = (rows: readonly PullRequestListRow[], url: string | null) => {
|
|
99
|
+
if (!url) return null
|
|
100
|
+
const index = rows.findIndex((row) => row._tag === "pull-request" && row.pullRequest.url === url)
|
|
101
|
+
return index >= 0 ? index : null
|
|
102
|
+
}
|
|
103
|
+
|
|
51
104
|
const PullRequestRow = ({
|
|
52
105
|
pullRequest,
|
|
53
106
|
selected,
|
|
54
107
|
contentWidth,
|
|
55
108
|
numWidth,
|
|
109
|
+
ageColWidth,
|
|
56
110
|
filterText,
|
|
57
111
|
onSelect,
|
|
58
112
|
}: {
|
|
@@ -60,31 +114,25 @@ const PullRequestRow = ({
|
|
|
60
114
|
selected: boolean
|
|
61
115
|
contentWidth: number
|
|
62
116
|
numWidth: number
|
|
117
|
+
ageColWidth: number
|
|
63
118
|
filterText: string
|
|
64
119
|
onSelect: () => void
|
|
65
120
|
}) => {
|
|
66
|
-
const isClosed = pullRequest.state === "closed"
|
|
67
|
-
const isMerged = pullRequest.state === "merged"
|
|
68
|
-
const isFinal = isClosed || isMerged
|
|
69
|
-
const checkText = isMerged ? "merged" : isClosed ? "closed" : checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
70
121
|
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
71
|
-
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
122
|
+
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth, ageColWidth)
|
|
72
123
|
const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
|
|
73
124
|
const fillerWidth = Math.max(0, contentWidth - rowWidth)
|
|
74
|
-
const
|
|
75
|
-
const rowTextColor = selected ? colors.selectedText : isFinal ? colors.muted : colors.text
|
|
76
|
-
const numberColor = selected ? colors.accent : isFinal ? colors.muted : colors.count
|
|
77
|
-
const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
|
|
125
|
+
const display = pullRequestRowDisplay(pullRequest, selected)
|
|
78
126
|
|
|
79
127
|
return (
|
|
80
|
-
<box height={1} onMouseDown={onSelect}>
|
|
81
|
-
<TextLine fg={
|
|
82
|
-
<span fg={
|
|
128
|
+
<box width={contentWidth} height={1} onMouseDown={onSelect}>
|
|
129
|
+
<TextLine width={contentWidth} fg={display.rowFg} bg={selected ? colors.selectedBg : undefined}>
|
|
130
|
+
<span fg={display.indicatorFg}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
83
131
|
<span> </span>
|
|
84
|
-
<span fg={
|
|
132
|
+
<span fg={display.numberFg}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
|
|
85
133
|
<span> </span>
|
|
86
134
|
<span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
|
|
87
|
-
<span fg={
|
|
135
|
+
<span fg={display.checkFg}>{fitCell(display.checkText, checkWidth, "right")}</span>
|
|
88
136
|
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
89
137
|
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
90
138
|
</TextLine>
|
|
@@ -101,6 +149,9 @@ export const PullRequestList = ({
|
|
|
101
149
|
filterText,
|
|
102
150
|
showFilterBar,
|
|
103
151
|
isFilterEditing,
|
|
152
|
+
loadedCount,
|
|
153
|
+
hasMore,
|
|
154
|
+
isLoadingMore,
|
|
104
155
|
onSelectPullRequest,
|
|
105
156
|
}: {
|
|
106
157
|
groups: PullRequestGroups
|
|
@@ -111,41 +162,43 @@ export const PullRequestList = ({
|
|
|
111
162
|
filterText: string
|
|
112
163
|
showFilterBar: boolean
|
|
113
164
|
isFilterEditing: boolean
|
|
165
|
+
loadedCount: number
|
|
166
|
+
hasMore: boolean
|
|
167
|
+
isLoadingMore: boolean
|
|
114
168
|
onSelectPullRequest: (url: string) => void
|
|
115
169
|
}) => {
|
|
116
|
-
const
|
|
117
|
-
const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
|
|
170
|
+
const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore })
|
|
118
171
|
|
|
119
172
|
return (
|
|
120
|
-
<box flexDirection="column">
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
173
|
+
<box width={contentWidth} flexDirection="column">
|
|
174
|
+
{rows.map((row, index) => {
|
|
175
|
+
if (row._tag === "title") return <SectionTitle key="title" title="PULL REQUESTS" />
|
|
176
|
+
if (row._tag === "filter") {
|
|
177
|
+
return (
|
|
178
|
+
<TextLine key="filter">
|
|
179
|
+
<span fg={colors.count}>/</span>
|
|
180
|
+
<span fg={colors.muted}> </span>
|
|
181
|
+
<span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
|
|
182
|
+
</TextLine>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
if (row._tag === "message") return <PlainLine key={`message-${index}`} text={row.text} fg={row.color} />
|
|
186
|
+
if (row._tag === "load-more") return <PlainLine key="load-more" text={row.text} fg={colors.muted} />
|
|
187
|
+
if (row._tag === "group") return <GroupTitle key={`group-${row.repository}`} label={row.repository} color={repoColor(row.repository)} filterText={filterText} />
|
|
188
|
+
|
|
189
|
+
const numWidth = groupNumberWidth(row.groupPullRequests)
|
|
190
|
+
const ageColWidth = groupAgeWidth(row.groupPullRequests)
|
|
134
191
|
return (
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
146
|
-
/>
|
|
147
|
-
))}
|
|
148
|
-
</box>
|
|
192
|
+
<PullRequestRow
|
|
193
|
+
key={row.pullRequest.url}
|
|
194
|
+
pullRequest={row.pullRequest}
|
|
195
|
+
selected={row.pullRequest.url === selectedUrl}
|
|
196
|
+
contentWidth={contentWidth}
|
|
197
|
+
numWidth={numWidth}
|
|
198
|
+
ageColWidth={ageColWidth}
|
|
199
|
+
filterText={filterText}
|
|
200
|
+
onSelect={() => onSelectPullRequest(row.pullRequest.url)}
|
|
201
|
+
/>
|
|
149
202
|
)
|
|
150
203
|
})}
|
|
151
204
|
</box>
|
package/src/ui/colors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type ThemeId =
|
|
2
|
+
| "system"
|
|
2
3
|
| "ghui"
|
|
3
4
|
| "tokyo-night"
|
|
4
5
|
| "catppuccin"
|
|
@@ -60,6 +61,99 @@ export interface ThemeDefinition {
|
|
|
60
61
|
readonly colors: ColorPalette
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
interface TerminalThemeColors {
|
|
65
|
+
readonly palette: readonly (string | null)[]
|
|
66
|
+
readonly defaultForeground: string | null
|
|
67
|
+
readonly defaultBackground: string | null
|
|
68
|
+
readonly highlightBackground: string | null
|
|
69
|
+
readonly highlightForeground: string | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readableHex = (value: string | null | undefined, fallback: string) =>
|
|
73
|
+
typeof value === "string" && /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(value) ? value : fallback
|
|
74
|
+
|
|
75
|
+
const hexToRgb = (hex: string) => {
|
|
76
|
+
const value = hex.replace(/^#/, "").slice(0, 6)
|
|
77
|
+
return {
|
|
78
|
+
r: parseInt(value.slice(0, 2), 16),
|
|
79
|
+
g: parseInt(value.slice(2, 4), 16),
|
|
80
|
+
b: parseInt(value.slice(4, 6), 16),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const luminance = (hex: string) => {
|
|
85
|
+
const { r, g, b } = hexToRgb(hex)
|
|
86
|
+
return 0.299 * r + 0.587 * g + 0.114 * b
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rgbToHex = ({ r, g, b }: { readonly r: number; readonly g: number; readonly b: number }) =>
|
|
90
|
+
`#${[r, g, b].map((component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, "0")).join("")}`
|
|
91
|
+
|
|
92
|
+
export const mixHex = (base: string, overlay: string, amount: number) => {
|
|
93
|
+
const from = hexToRgb(base)
|
|
94
|
+
const to = hexToRgb(overlay)
|
|
95
|
+
return rgbToHex({
|
|
96
|
+
r: from.r + (to.r - from.r) * amount,
|
|
97
|
+
g: from.g + (to.g - from.g) * amount,
|
|
98
|
+
b: from.b + (to.b - from.b) * amount,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const grayscaleRamp = (background: string) => {
|
|
103
|
+
const bg = hexToRgb(background)
|
|
104
|
+
const bgLum = luminance(background)
|
|
105
|
+
const isDark = bgLum < 128
|
|
106
|
+
const grays: Record<number, string> = {}
|
|
107
|
+
|
|
108
|
+
for (let i = 1; i <= 12; i++) {
|
|
109
|
+
const factor = i / 12
|
|
110
|
+
let r: number
|
|
111
|
+
let g: number
|
|
112
|
+
let b: number
|
|
113
|
+
|
|
114
|
+
if (isDark) {
|
|
115
|
+
if (bgLum < 10) {
|
|
116
|
+
const value = Math.floor(factor * 0.4 * 255)
|
|
117
|
+
r = value
|
|
118
|
+
g = value
|
|
119
|
+
b = value
|
|
120
|
+
} else {
|
|
121
|
+
const nextLum = bgLum + (255 - bgLum) * factor * 0.4
|
|
122
|
+
const ratio = nextLum / bgLum
|
|
123
|
+
r = Math.min(bg.r * ratio, 255)
|
|
124
|
+
g = Math.min(bg.g * ratio, 255)
|
|
125
|
+
b = Math.min(bg.b * ratio, 255)
|
|
126
|
+
}
|
|
127
|
+
} else if (bgLum > 245) {
|
|
128
|
+
const value = Math.floor(255 - factor * 0.4 * 255)
|
|
129
|
+
r = value
|
|
130
|
+
g = value
|
|
131
|
+
b = value
|
|
132
|
+
} else {
|
|
133
|
+
const nextLum = bgLum * (1 - factor * 0.4)
|
|
134
|
+
const ratio = nextLum / bgLum
|
|
135
|
+
r = Math.max(bg.r * ratio, 0)
|
|
136
|
+
g = Math.max(bg.g * ratio, 0)
|
|
137
|
+
b = Math.max(bg.b * ratio, 0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
grays[i] = rgbToHex({ r, g, b })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return grays
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mutedTextColor = (background: string) => {
|
|
147
|
+
const bgLum = luminance(background)
|
|
148
|
+
const isDark = bgLum < 128
|
|
149
|
+
const value = isDark
|
|
150
|
+
? bgLum < 10 ? 180 : Math.min(Math.floor(160 + bgLum * 0.3), 200)
|
|
151
|
+
: bgLum > 245 ? 75 : Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
|
|
152
|
+
return rgbToHex({ r: value, g: value, b: value })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const contrastText = (background: string) => luminance(background) > 128 ? "#000000" : "#ffffff"
|
|
156
|
+
|
|
63
157
|
const ghuiColors: ColorPalette = {
|
|
64
158
|
background: "#111018",
|
|
65
159
|
modalBackground: "#1a1a2e",
|
|
@@ -99,6 +193,70 @@ const ghuiColors: ColorPalette = {
|
|
|
99
193
|
},
|
|
100
194
|
}
|
|
101
195
|
|
|
196
|
+
const makeSystemColors = (terminal?: TerminalThemeColors): ColorPalette => {
|
|
197
|
+
const palette = terminal?.palette ?? []
|
|
198
|
+
const terminalBackground = readableHex(terminal?.defaultBackground, readableHex(palette[0], "#000000"))
|
|
199
|
+
const text = readableHex(terminal?.defaultForeground, readableHex(palette[7], "#ffffff"))
|
|
200
|
+
const grays = grayscaleRamp(terminalBackground)
|
|
201
|
+
const isDark = luminance(terminalBackground) < 128
|
|
202
|
+
const red = readableHex(palette[1], "#cc0000")
|
|
203
|
+
const green = readableHex(palette[2], "#4e9a06")
|
|
204
|
+
const yellow = readableHex(palette[3], "#c4a000")
|
|
205
|
+
const blue = readableHex(palette[4], "#3465a4")
|
|
206
|
+
const magenta = readableHex(palette[5], "#75507b")
|
|
207
|
+
const brightBlack = readableHex(palette[8], mutedTextColor(terminalBackground))
|
|
208
|
+
const brightGreen = readableHex(palette[10], green)
|
|
209
|
+
const brightBlue = readableHex(palette[12], blue)
|
|
210
|
+
const brightMagenta = readableHex(palette[13], magenta)
|
|
211
|
+
const primary = brightBlue
|
|
212
|
+
const panel = grays[2] ?? mixHex(terminalBackground, text, isDark ? 0.07 : 0.08)
|
|
213
|
+
const element = grays[3] ?? mixHex(terminalBackground, text, isDark ? 0.1 : 0.1)
|
|
214
|
+
const border = grays[7] ?? mixHex(terminalBackground, text, isDark ? 0.24 : 0.24)
|
|
215
|
+
const borderSubtle = grays[6] ?? border
|
|
216
|
+
const diffAlpha = isDark ? 0.22 : 0.14
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
background: "transparent",
|
|
220
|
+
modalBackground: panel,
|
|
221
|
+
text,
|
|
222
|
+
muted: mutedTextColor(terminalBackground),
|
|
223
|
+
separator: border,
|
|
224
|
+
accent: primary,
|
|
225
|
+
inlineCode: brightGreen,
|
|
226
|
+
error: red,
|
|
227
|
+
selectedBg: primary,
|
|
228
|
+
selectedText: contrastText(primary),
|
|
229
|
+
count: primary,
|
|
230
|
+
status: {
|
|
231
|
+
draft: yellow,
|
|
232
|
+
approved: green,
|
|
233
|
+
changes: red,
|
|
234
|
+
review: primary,
|
|
235
|
+
none: brightBlack,
|
|
236
|
+
passing: green,
|
|
237
|
+
pending: yellow,
|
|
238
|
+
failing: red,
|
|
239
|
+
},
|
|
240
|
+
repos: {
|
|
241
|
+
opencode: primary,
|
|
242
|
+
"effect-smol": green,
|
|
243
|
+
"opencode-console": brightMagenta,
|
|
244
|
+
opencontrol: yellow,
|
|
245
|
+
default: blue,
|
|
246
|
+
},
|
|
247
|
+
diff: {
|
|
248
|
+
addedBg: mixHex(terminalBackground, green, diffAlpha),
|
|
249
|
+
removedBg: mixHex(terminalBackground, red, diffAlpha),
|
|
250
|
+
contextBg: panel,
|
|
251
|
+
lineNumberBg: borderSubtle,
|
|
252
|
+
addedLineNumberBg: mixHex(element, green, diffAlpha),
|
|
253
|
+
removedLineNumberBg: mixHex(element, red, diffAlpha),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const systemColors: ColorPalette = makeSystemColors()
|
|
259
|
+
|
|
102
260
|
const tokyoNightColors: ColorPalette = {
|
|
103
261
|
background: "#1a1b26",
|
|
104
262
|
modalBackground: "#24283b",
|
|
@@ -607,6 +765,7 @@ const vesperColors: ColorPalette = {
|
|
|
607
765
|
}
|
|
608
766
|
|
|
609
767
|
export const themeDefinitions: readonly ThemeDefinition[] = [
|
|
768
|
+
{ id: "system", name: "System", description: "Use the terminal foreground, background, and ANSI palette", colors: systemColors },
|
|
610
769
|
{ id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
|
|
611
770
|
{ id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
|
|
612
771
|
{ id: "catppuccin", name: "Catppuccin", description: "Mocha lavender, peach, and soft pastel contrast", colors: catppuccinColors },
|
|
@@ -623,7 +782,7 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
|
|
|
623
782
|
{ id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
|
|
624
783
|
] as const
|
|
625
784
|
|
|
626
|
-
let activeTheme = themeDefinitions[0]!
|
|
785
|
+
let activeTheme = themeDefinitions.find((theme) => theme.id === "ghui") ?? themeDefinitions[0]!
|
|
627
786
|
|
|
628
787
|
export const colors: ColorPalette = { ...ghuiColors }
|
|
629
788
|
|
|
@@ -646,3 +805,10 @@ export const setActiveTheme = (id: ThemeId) => {
|
|
|
646
805
|
activeTheme = getThemeDefinition(id)
|
|
647
806
|
Object.assign(colors, activeTheme.colors)
|
|
648
807
|
}
|
|
808
|
+
|
|
809
|
+
export const setSystemThemeColors = (terminalColors: TerminalThemeColors) => {
|
|
810
|
+
Object.assign(systemColors, makeSystemColors(terminalColors))
|
|
811
|
+
if (activeTheme.id === "system") {
|
|
812
|
+
Object.assign(colors, systemColors)
|
|
813
|
+
}
|
|
814
|
+
}
|
package/src/ui/diff.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
1
|
+
import { parseColor, pathToFiletype, SyntaxStyle } from "@opentui/core"
|
|
2
|
+
import { Data, Schema } from "effect"
|
|
2
3
|
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
3
4
|
import { colors } from "./colors.js"
|
|
4
5
|
|
|
6
|
+
export const DiffView = Schema.Literals(["unified", "split"])
|
|
7
|
+
export type DiffView = Schema.Schema.Type<typeof DiffView>
|
|
8
|
+
|
|
9
|
+
export const DiffWrapMode = Schema.Literals(["none", "word"])
|
|
10
|
+
export type DiffWrapMode = Schema.Schema.Type<typeof DiffWrapMode>
|
|
11
|
+
|
|
12
|
+
export const DiffCommentKind = Schema.Literals(["addition", "deletion", "context"])
|
|
13
|
+
export type DiffCommentKind = Schema.Schema.Type<typeof DiffCommentKind>
|
|
14
|
+
|
|
5
15
|
export interface DiffFilePatch {
|
|
6
16
|
readonly name: string
|
|
7
17
|
readonly filetype: string | undefined
|
|
@@ -25,7 +35,7 @@ export interface DiffCommentAnchor {
|
|
|
25
35
|
readonly path: string
|
|
26
36
|
readonly line: number
|
|
27
37
|
readonly side: DiffCommentSide
|
|
28
|
-
readonly kind:
|
|
38
|
+
readonly kind: DiffCommentKind
|
|
29
39
|
readonly renderLine: number
|
|
30
40
|
readonly text: string
|
|
31
41
|
}
|
|
@@ -35,10 +45,13 @@ export type StackedDiffCommentAnchor = DiffCommentAnchor & {
|
|
|
35
45
|
readonly localRenderLine: number
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
export type PullRequestDiffState =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
48
|
+
export type PullRequestDiffState = Data.TaggedEnum<{
|
|
49
|
+
Loading: {}
|
|
50
|
+
Ready: { readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
51
|
+
Error: { readonly error: string }
|
|
52
|
+
}>
|
|
53
|
+
|
|
54
|
+
export const PullRequestDiffState = Data.taggedEnum<PullRequestDiffState>()
|
|
42
55
|
|
|
43
56
|
export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
|
|
44
57
|
keyword: { fg: parseColor(colors.accent), bold: true },
|
|
@@ -60,55 +73,45 @@ export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
|
|
|
60
73
|
default: { fg: parseColor(colors.text) },
|
|
61
74
|
})
|
|
62
75
|
|
|
63
|
-
const
|
|
64
|
-
c: "c",
|
|
65
|
-
cc: "cpp",
|
|
66
|
-
cpp: "cpp",
|
|
67
|
-
cs: "csharp",
|
|
68
|
-
css: "css",
|
|
69
|
-
go: "go",
|
|
70
|
-
h: "c",
|
|
71
|
-
hpp: "cpp",
|
|
72
|
-
html: "html",
|
|
73
|
-
java: "java",
|
|
74
|
-
js: "javascript",
|
|
75
|
-
jsx: "javascript",
|
|
76
|
-
json: "json",
|
|
77
|
-
kt: "kotlin",
|
|
78
|
-
md: "markdown",
|
|
79
|
-
mjs: "javascript",
|
|
80
|
-
py: "python",
|
|
81
|
-
rs: "rust",
|
|
82
|
-
rb: "ruby",
|
|
83
|
-
sh: "bash",
|
|
84
|
-
svelte: "svelte",
|
|
85
|
-
toml: "toml",
|
|
86
|
-
ts: "typescript",
|
|
87
|
-
tsx: "typescript",
|
|
88
|
-
txt: "text",
|
|
89
|
-
vue: "vue",
|
|
90
|
-
yaml: "yaml",
|
|
91
|
-
yml: "yaml",
|
|
92
|
-
zig: "zig",
|
|
93
|
-
}
|
|
76
|
+
const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
|
|
94
77
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
78
|
+
const readDiffPath = (value: string, start: number) => {
|
|
79
|
+
if (value[start] === '"') {
|
|
80
|
+
for (let index = start + 1; index < value.length; index++) {
|
|
81
|
+
if (value[index] === '"' && value[index - 1] !== "\\") {
|
|
82
|
+
const raw = value.slice(start, index + 1)
|
|
83
|
+
try {
|
|
84
|
+
return { path: JSON.parse(raw) as string, end: index + 1 }
|
|
85
|
+
} catch {
|
|
86
|
+
return { path: raw, end: index + 1 }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const end = value.slice(start).search(/\s/)
|
|
93
|
+
const pathEnd = end >= 0 ? start + end : value.length
|
|
94
|
+
return { path: value.slice(start, pathEnd), end: pathEnd }
|
|
100
95
|
}
|
|
101
96
|
|
|
102
|
-
const
|
|
97
|
+
const parseDiffGitPaths = (line: string) => {
|
|
98
|
+
const prefix = "diff --git "
|
|
99
|
+
if (!line.startsWith(prefix)) return null
|
|
100
|
+
const left = readDiffPath(line, prefix.length)
|
|
101
|
+
const rightStart = line.slice(left.end).search(/\S/)
|
|
102
|
+
if (rightStart < 0) return null
|
|
103
|
+
const right = readDiffPath(line, left.end + rightStart)
|
|
104
|
+
return [left.path, right.path] as const
|
|
105
|
+
}
|
|
103
106
|
|
|
104
107
|
const patchFileName = (patch: string) => {
|
|
105
108
|
const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
|
|
106
109
|
if (diffLine) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
const next = unquoteDiffPath(
|
|
110
|
+
const paths = parseDiffGitPaths(diffLine)
|
|
111
|
+
if (paths) {
|
|
112
|
+
const next = unquoteDiffPath(paths[1])
|
|
110
113
|
if (next !== "/dev/null") return next
|
|
111
|
-
return unquoteDiffPath(
|
|
114
|
+
return unquoteDiffPath(paths[0])
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
|
|
@@ -165,19 +168,19 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
|
165
168
|
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
166
169
|
const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
|
|
167
170
|
const name = patchFileName(filePatch)
|
|
168
|
-
return { name, filetype:
|
|
171
|
+
return { name, filetype: pathToFiletype(name), patch: filePatch }
|
|
169
172
|
})
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
175
|
+
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}:${pullRequest.headRefOid}`
|
|
173
176
|
|
|
174
177
|
export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
|
|
175
178
|
files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
|
|
176
179
|
|
|
177
180
|
export const buildStackedDiffFiles = (
|
|
178
181
|
files: readonly DiffFilePatch[],
|
|
179
|
-
view:
|
|
180
|
-
wrapMode:
|
|
182
|
+
view: DiffView,
|
|
183
|
+
wrapMode: DiffWrapMode,
|
|
181
184
|
width: number,
|
|
182
185
|
): readonly StackedDiffFilePatch[] => {
|
|
183
186
|
let offset = 0
|
|
@@ -197,14 +200,14 @@ export const buildStackedDiffFiles = (
|
|
|
197
200
|
})
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
|
|
204
|
+
stackedFiles.reduce<StackedDiffFilePatch | undefined>((current, file) => file.headerLine <= line ? file : current, undefined)
|
|
205
|
+
|
|
200
206
|
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
201
207
|
if (!pullRequest.detailLoaded) return "loading details"
|
|
202
208
|
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
|
|
206
|
-
files,
|
|
207
|
-
].filter((part): part is string => part !== null).join(" ")
|
|
209
|
+
const stats = diffFileStatsText(pullRequest)
|
|
210
|
+
return stats ? `${stats} ${files}` : files
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
|
|
@@ -213,7 +216,7 @@ export const diffCommentAnchorKey = diffCommentLocationKey
|
|
|
213
216
|
|
|
214
217
|
type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
|
|
215
218
|
|
|
216
|
-
const diffContentWidth = (lines: readonly string[], view:
|
|
219
|
+
const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {
|
|
217
220
|
const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
|
|
218
221
|
return view === "split"
|
|
219
222
|
? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
@@ -242,14 +245,17 @@ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
|
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
export const diffFileStatText = (file: DiffFilePatch) => {
|
|
245
|
-
|
|
248
|
+
return diffFileStatsText(diffFileStats(file))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const diffFileStatsText = (stats: DiffFileStats) => {
|
|
246
252
|
return [
|
|
247
253
|
stats.additions > 0 ? `+${stats.additions}` : null,
|
|
248
254
|
stats.deletions > 0 ? `-${stats.deletions}` : null,
|
|
249
255
|
].filter((part): part is string => part !== null).join(" ")
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
export const getDiffCommentAnchors = (file: DiffFilePatch, view:
|
|
258
|
+
export const getDiffCommentAnchors = (file: DiffFilePatch, view: DiffView = "unified", wrapMode: DiffWrapMode = "none", width = 120): readonly DiffCommentAnchor[] => {
|
|
253
259
|
const anchors: DiffCommentAnchor[] = []
|
|
254
260
|
const lines = file.patch.split("\n")
|
|
255
261
|
const contentWidth = diffContentWidth(lines, view, width)
|
|
@@ -330,8 +336,8 @@ export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "sp
|
|
|
330
336
|
|
|
331
337
|
export const getStackedDiffCommentAnchors = (
|
|
332
338
|
stackedFiles: readonly StackedDiffFilePatch[],
|
|
333
|
-
view:
|
|
334
|
-
wrapMode:
|
|
339
|
+
view: DiffView = "unified",
|
|
340
|
+
wrapMode: DiffWrapMode = "none",
|
|
335
341
|
width = 120,
|
|
336
342
|
): readonly StackedDiffCommentAnchor[] =>
|
|
337
343
|
stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
|
|
@@ -354,7 +360,7 @@ export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: numb
|
|
|
354
360
|
return currentTop
|
|
355
361
|
}
|
|
356
362
|
|
|
357
|
-
const estimatedWrappedLineCount = (text: string, width: number, wrapMode:
|
|
363
|
+
const estimatedWrappedLineCount = (text: string, width: number, wrapMode: DiffWrapMode) => {
|
|
358
364
|
if (wrapMode === "none") return 1
|
|
359
365
|
return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
|
|
360
366
|
}
|
|
@@ -394,7 +400,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
|
394
400
|
return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
|
|
395
401
|
}
|
|
396
402
|
|
|
397
|
-
export const patchRenderableLineCount = (patch: string, view:
|
|
403
|
+
export const patchRenderableLineCount = (patch: string, view: DiffView, wrapMode: DiffWrapMode, width: number) => {
|
|
398
404
|
const lines = patch.split("\n")
|
|
399
405
|
const contentWidth = diffContentWidth(lines, view, width)
|
|
400
406
|
let count = 0
|