@kitlangton/ghui 0.1.19 → 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 +818 -403
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +68 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +29 -20
- 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 +290 -126
- 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 +84 -179
- package/src/ui/PullRequestDiffPane.tsx +29 -50
- package/src/ui/PullRequestList.tsx +99 -45
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +34 -44
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +263 -295
- package/src/ui/primitives.tsx +90 -0
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
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,4 +1,4 @@
|
|
|
1
|
-
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
1
|
+
import { parseColor, pathToFiletype, SyntaxStyle } from "@opentui/core"
|
|
2
2
|
import { Data, Schema } from "effect"
|
|
3
3
|
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
4
4
|
import { colors } from "./colors.js"
|
|
@@ -73,55 +73,45 @@ export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
|
|
|
73
73
|
default: { fg: parseColor(colors.text) },
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
md: "markdown",
|
|
92
|
-
mjs: "javascript",
|
|
93
|
-
py: "python",
|
|
94
|
-
rs: "rust",
|
|
95
|
-
rb: "ruby",
|
|
96
|
-
sh: "bash",
|
|
97
|
-
svelte: "svelte",
|
|
98
|
-
toml: "toml",
|
|
99
|
-
ts: "typescript",
|
|
100
|
-
tsx: "typescript",
|
|
101
|
-
txt: "text",
|
|
102
|
-
vue: "vue",
|
|
103
|
-
yaml: "yaml",
|
|
104
|
-
yml: "yaml",
|
|
105
|
-
zig: "zig",
|
|
106
|
-
}
|
|
76
|
+
const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
|
|
77
|
+
|
|
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
|
+
}
|
|
107
91
|
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
|
|
112
|
-
return extension ? extensionFiletypes[extension] : undefined
|
|
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 }
|
|
113
95
|
}
|
|
114
96
|
|
|
115
|
-
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
|
+
}
|
|
116
106
|
|
|
117
107
|
const patchFileName = (patch: string) => {
|
|
118
108
|
const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
|
|
119
109
|
if (diffLine) {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
const next = unquoteDiffPath(
|
|
110
|
+
const paths = parseDiffGitPaths(diffLine)
|
|
111
|
+
if (paths) {
|
|
112
|
+
const next = unquoteDiffPath(paths[1])
|
|
123
113
|
if (next !== "/dev/null") return next
|
|
124
|
-
return unquoteDiffPath(
|
|
114
|
+
return unquoteDiffPath(paths[0])
|
|
125
115
|
}
|
|
126
116
|
}
|
|
127
117
|
|
|
@@ -178,11 +168,11 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
|
178
168
|
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
179
169
|
const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
|
|
180
170
|
const name = patchFileName(filePatch)
|
|
181
|
-
return { name, filetype:
|
|
171
|
+
return { name, filetype: pathToFiletype(name), patch: filePatch }
|
|
182
172
|
})
|
|
183
173
|
}
|
|
184
174
|
|
|
185
|
-
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
175
|
+
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}:${pullRequest.headRefOid}`
|
|
186
176
|
|
|
187
177
|
export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
|
|
188
178
|
files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PullRequestItem } from "../domain.js"
|
|
2
|
+
import { colors } from "./colors.js"
|
|
3
|
+
|
|
4
|
+
type DiffStatsPart = { readonly key: string; readonly text: string; readonly color: string }
|
|
5
|
+
|
|
6
|
+
const diffStatsParts = (pullRequest: PullRequestItem): readonly DiffStatsPart[] => {
|
|
7
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
8
|
+
return [
|
|
9
|
+
pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
|
|
10
|
+
pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
|
|
11
|
+
{ key: "files", text: files, color: colors.muted },
|
|
12
|
+
].filter((part): part is DiffStatsPart => part !== null)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
16
|
+
if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
|
|
17
|
+
const parts = diffStatsParts(pullRequest)
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{parts.map((part, index) => (
|
|
21
|
+
<span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
|
|
22
|
+
))}
|
|
23
|
+
</>
|
|
24
|
+
)
|
|
25
|
+
}
|