@kitlangton/ghui 0.1.16 → 0.1.18
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 +29 -27
- package/package.json +2 -1
- package/src/App.tsx +1006 -132
- package/src/domain.ts +47 -1
- package/src/services/CommandRunner.ts +8 -1
- package/src/services/GitHubService.ts +286 -184
- package/src/ui/DetailsPane.tsx +26 -25
- package/src/ui/FooterHints.tsx +60 -0
- package/src/ui/PullRequestDiffPane.tsx +105 -33
- package/src/ui/PullRequestList.tsx +38 -13
- package/src/ui/colors.ts +41 -0
- package/src/ui/commentEditor.ts +126 -0
- package/src/ui/diff.ts +204 -7
- package/src/ui/modals.tsx +322 -8
- package/src/ui/pullRequests.ts +2 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export interface CommentEditorValue {
|
|
2
|
+
readonly body: string
|
|
3
|
+
readonly cursor: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CommentEditorLine {
|
|
7
|
+
readonly text: string
|
|
8
|
+
readonly start: number
|
|
9
|
+
readonly end: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const clampCursor = (body: string, cursor: number) => Math.max(0, Math.min(cursor, body.length))
|
|
13
|
+
|
|
14
|
+
export const commentEditorLines = (body: string): readonly CommentEditorLine[] => body.split("\n").reduce<CommentEditorLine[]>((ranges, text) => {
|
|
15
|
+
const start = ranges.length === 0 ? 0 : ranges[ranges.length - 1]!.end + 1
|
|
16
|
+
ranges.push({ text, start, end: start + text.length })
|
|
17
|
+
return ranges
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
export const cursorLineIndexForLines = (lines: readonly CommentEditorLine[], cursor: number) =>
|
|
21
|
+
Math.max(0, lines.findIndex((line, index) => cursor <= line.end || index === lines.length - 1))
|
|
22
|
+
|
|
23
|
+
export const cursorLineIndex = (body: string, cursor: number) => {
|
|
24
|
+
const lines = commentEditorLines(body)
|
|
25
|
+
const safeCursor = clampCursor(body, cursor)
|
|
26
|
+
return cursorLineIndexForLines(lines, safeCursor)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const lineStartAt = (body: string, cursor: number) => body.lastIndexOf("\n", Math.max(0, clampCursor(body, cursor) - 1)) + 1
|
|
30
|
+
|
|
31
|
+
export const lineEndAt = (body: string, cursor: number) => {
|
|
32
|
+
const end = body.indexOf("\n", clampCursor(body, cursor))
|
|
33
|
+
return end === -1 ? body.length : end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const previousWordStart = (body: string, cursor: number) => {
|
|
37
|
+
let index = clampCursor(body, cursor)
|
|
38
|
+
while (index > 0 && /\s/.test(body[index - 1]!)) index--
|
|
39
|
+
while (index > 0 && !/\s/.test(body[index - 1]!)) index--
|
|
40
|
+
return index
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nextWordEnd = (body: string, cursor: number) => {
|
|
44
|
+
let index = clampCursor(body, cursor)
|
|
45
|
+
while (index < body.length && /\s/.test(body[index]!)) index++
|
|
46
|
+
while (index < body.length && !/\s/.test(body[index]!)) index++
|
|
47
|
+
return index
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const replaceRange = (state: CommentEditorValue, start: number, end: number, text = ""): CommentEditorValue => ({
|
|
51
|
+
body: `${state.body.slice(0, start)}${text}${state.body.slice(end)}`,
|
|
52
|
+
cursor: start + text.length,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const insertText = (state: CommentEditorValue, text: string): CommentEditorValue =>
|
|
56
|
+
replaceRange(state, clampCursor(state.body, state.cursor), clampCursor(state.body, state.cursor), text)
|
|
57
|
+
|
|
58
|
+
export const moveLeft = (state: CommentEditorValue): CommentEditorValue => ({
|
|
59
|
+
...state,
|
|
60
|
+
cursor: Math.max(0, clampCursor(state.body, state.cursor) - 1),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const moveRight = (state: CommentEditorValue): CommentEditorValue => ({
|
|
64
|
+
...state,
|
|
65
|
+
cursor: Math.min(state.body.length, clampCursor(state.body, state.cursor) + 1),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const moveLineStart = (state: CommentEditorValue): CommentEditorValue => ({
|
|
69
|
+
...state,
|
|
70
|
+
cursor: lineStartAt(state.body, state.cursor),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const moveLineEnd = (state: CommentEditorValue): CommentEditorValue => ({
|
|
74
|
+
...state,
|
|
75
|
+
cursor: lineEndAt(state.body, state.cursor),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const moveWordBackward = (state: CommentEditorValue): CommentEditorValue => ({
|
|
79
|
+
...state,
|
|
80
|
+
cursor: previousWordStart(state.body, state.cursor),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const moveWordForward = (state: CommentEditorValue): CommentEditorValue => ({
|
|
84
|
+
...state,
|
|
85
|
+
cursor: nextWordEnd(state.body, state.cursor),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const moveVertically = (state: CommentEditorValue, delta: number): CommentEditorValue => {
|
|
89
|
+
const lines = commentEditorLines(state.body)
|
|
90
|
+
const safeCursor = clampCursor(state.body, state.cursor)
|
|
91
|
+
const currentLineIndex = cursorLineIndexForLines(lines, safeCursor)
|
|
92
|
+
const currentLine = lines[currentLineIndex] ?? { text: state.body, start: 0, end: state.body.length }
|
|
93
|
+
const targetLine = lines[Math.max(0, Math.min(lines.length - 1, currentLineIndex + delta))] ?? currentLine
|
|
94
|
+
const column = safeCursor - currentLine.start
|
|
95
|
+
return { ...state, cursor: Math.min(targetLine.end, targetLine.start + column) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const backspace = (state: CommentEditorValue): CommentEditorValue => {
|
|
99
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
100
|
+
return cursor === 0 ? { ...state, cursor } : replaceRange({ ...state, cursor }, cursor - 1, cursor)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const deleteForward = (state: CommentEditorValue): CommentEditorValue => {
|
|
104
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
105
|
+
return cursor >= state.body.length ? { ...state, cursor } : replaceRange({ ...state, cursor }, cursor, cursor + 1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const deleteWordBackward = (state: CommentEditorValue): CommentEditorValue => {
|
|
109
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
110
|
+
return replaceRange({ ...state, cursor }, previousWordStart(state.body, cursor), cursor)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const deleteWordForward = (state: CommentEditorValue): CommentEditorValue => {
|
|
114
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
115
|
+
return replaceRange({ ...state, cursor }, cursor, nextWordEnd(state.body, cursor))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const deleteToLineStart = (state: CommentEditorValue): CommentEditorValue => {
|
|
119
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
120
|
+
return replaceRange({ ...state, cursor }, lineStartAt(state.body, cursor), cursor)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const deleteToLineEnd = (state: CommentEditorValue): CommentEditorValue => {
|
|
124
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
125
|
+
return replaceRange({ ...state, cursor }, cursor, lineEndAt(state.body, cursor))
|
|
126
|
+
}
|
package/src/ui/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
2
|
-
import type { PullRequestItem } from "../domain.js"
|
|
2
|
+
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
3
3
|
import { colors } from "./colors.js"
|
|
4
4
|
|
|
5
5
|
export interface DiffFilePatch {
|
|
@@ -8,6 +8,33 @@ export interface DiffFilePatch {
|
|
|
8
8
|
readonly patch: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface DiffFileStats {
|
|
12
|
+
readonly additions: number
|
|
13
|
+
readonly deletions: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StackedDiffFilePatch {
|
|
17
|
+
readonly file: DiffFilePatch
|
|
18
|
+
readonly index: number
|
|
19
|
+
readonly headerLine: number
|
|
20
|
+
readonly diffStartLine: number
|
|
21
|
+
readonly diffHeight: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiffCommentAnchor {
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly line: number
|
|
27
|
+
readonly side: DiffCommentSide
|
|
28
|
+
readonly kind: "addition" | "deletion" | "context"
|
|
29
|
+
readonly renderLine: number
|
|
30
|
+
readonly text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StackedDiffCommentAnchor = DiffCommentAnchor & {
|
|
34
|
+
readonly fileIndex: number
|
|
35
|
+
readonly localRenderLine: number
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
export type PullRequestDiffState =
|
|
12
39
|
| { readonly status: "loading" }
|
|
13
40
|
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
@@ -144,6 +171,32 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
|
144
171
|
|
|
145
172
|
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
146
173
|
|
|
174
|
+
export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
|
|
175
|
+
files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
|
|
176
|
+
|
|
177
|
+
export const buildStackedDiffFiles = (
|
|
178
|
+
files: readonly DiffFilePatch[],
|
|
179
|
+
view: "unified" | "split",
|
|
180
|
+
wrapMode: "none" | "word",
|
|
181
|
+
width: number,
|
|
182
|
+
): readonly StackedDiffFilePatch[] => {
|
|
183
|
+
let offset = 0
|
|
184
|
+
return files.map((file, index) => {
|
|
185
|
+
const diffHeight = patchRenderableLineCount(file.patch, view, wrapMode, width)
|
|
186
|
+
const separatorBefore = index === 0 ? 0 : 1
|
|
187
|
+
const headerLine = offset + separatorBefore
|
|
188
|
+
const stackedFile = {
|
|
189
|
+
file,
|
|
190
|
+
index,
|
|
191
|
+
headerLine,
|
|
192
|
+
diffStartLine: headerLine + 2,
|
|
193
|
+
diffHeight,
|
|
194
|
+
} satisfies StackedDiffFilePatch
|
|
195
|
+
offset += separatorBefore + 2 + diffHeight
|
|
196
|
+
return stackedFile
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
147
200
|
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
148
201
|
if (!pullRequest.detailLoaded) return "loading details"
|
|
149
202
|
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
@@ -154,6 +207,153 @@ export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
|
154
207
|
].filter((part): part is string => part !== null).join(" ")
|
|
155
208
|
}
|
|
156
209
|
|
|
210
|
+
export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
|
|
211
|
+
|
|
212
|
+
export const diffCommentAnchorKey = diffCommentLocationKey
|
|
213
|
+
|
|
214
|
+
type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
|
|
215
|
+
|
|
216
|
+
const diffContentWidth = (lines: readonly string[], view: "unified" | "split", width: number) => {
|
|
217
|
+
const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
|
|
218
|
+
return view === "split"
|
|
219
|
+
? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
220
|
+
: Math.max(1, width - lineNumberGutterWidth)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
|
|
224
|
+
let additions = 0
|
|
225
|
+
let deletions = 0
|
|
226
|
+
let inHunk = false
|
|
227
|
+
|
|
228
|
+
for (const line of file.patch.split("\n")) {
|
|
229
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
230
|
+
if (hunk) {
|
|
231
|
+
inHunk = true
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!inHunk) continue
|
|
236
|
+
const firstChar = line[0]
|
|
237
|
+
if (firstChar === "+") additions++
|
|
238
|
+
else if (firstChar === "-") deletions++
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { additions, deletions }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const diffFileStatText = (file: DiffFilePatch) => {
|
|
245
|
+
const stats = diffFileStats(file)
|
|
246
|
+
return [
|
|
247
|
+
stats.additions > 0 ? `+${stats.additions}` : null,
|
|
248
|
+
stats.deletions > 0 ? `-${stats.deletions}` : null,
|
|
249
|
+
].filter((part): part is string => part !== null).join(" ")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "split" = "unified", wrapMode: "none" | "word" = "none", width = 120): readonly DiffCommentAnchor[] => {
|
|
253
|
+
const anchors: DiffCommentAnchor[] = []
|
|
254
|
+
const lines = file.patch.split("\n")
|
|
255
|
+
const contentWidth = diffContentWidth(lines, view, width)
|
|
256
|
+
let oldLine = 0
|
|
257
|
+
let newLine = 0
|
|
258
|
+
let renderLine = 0
|
|
259
|
+
let inHunk = false
|
|
260
|
+
let deletions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
|
|
261
|
+
let additions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
|
|
262
|
+
|
|
263
|
+
const flushChangeBlock = () => {
|
|
264
|
+
if (deletions.length === 0 && additions.length === 0) return
|
|
265
|
+
if (view === "split") {
|
|
266
|
+
const rowCount = Math.max(deletions.length, additions.length)
|
|
267
|
+
for (let index = 0; index < rowCount; index++) {
|
|
268
|
+
const deletion = deletions[index]
|
|
269
|
+
const addition = additions[index]
|
|
270
|
+
if (deletion) anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
|
|
271
|
+
if (addition) anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
|
|
272
|
+
renderLine += Math.max(deletion?.height ?? 1, addition?.height ?? 1)
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
for (const deletion of deletions) {
|
|
276
|
+
anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
|
|
277
|
+
renderLine += deletion.height
|
|
278
|
+
}
|
|
279
|
+
for (const addition of additions) {
|
|
280
|
+
anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
|
|
281
|
+
renderLine += addition.height
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
deletions = []
|
|
285
|
+
additions = []
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
290
|
+
if (hunk) {
|
|
291
|
+
flushChangeBlock()
|
|
292
|
+
oldLine = Number(hunk[1])
|
|
293
|
+
newLine = Number(hunk[3])
|
|
294
|
+
inHunk = true
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!inHunk) continue
|
|
299
|
+
|
|
300
|
+
const firstChar = line[0]
|
|
301
|
+
if (firstChar === "\\") continue
|
|
302
|
+
|
|
303
|
+
if (firstChar === "+") {
|
|
304
|
+
const text = line.slice(1)
|
|
305
|
+
additions.push({ path: file.name, line: newLine, side: "RIGHT", kind: "addition", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
|
|
306
|
+
newLine++
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (firstChar === "-") {
|
|
311
|
+
const text = line.slice(1)
|
|
312
|
+
deletions.push({ path: file.name, line: oldLine, side: "LEFT", kind: "deletion", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
|
|
313
|
+
oldLine++
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (firstChar === " ") {
|
|
318
|
+
flushChangeBlock()
|
|
319
|
+
const text = line.slice(1)
|
|
320
|
+
anchors.push({ path: file.name, line: newLine, side: "RIGHT", kind: "context", renderLine, text })
|
|
321
|
+
oldLine++
|
|
322
|
+
newLine++
|
|
323
|
+
renderLine += estimatedWrappedLineCount(text, contentWidth, wrapMode)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
flushChangeBlock()
|
|
328
|
+
return anchors
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const getStackedDiffCommentAnchors = (
|
|
332
|
+
stackedFiles: readonly StackedDiffFilePatch[],
|
|
333
|
+
view: "unified" | "split" = "unified",
|
|
334
|
+
wrapMode: "none" | "word" = "none",
|
|
335
|
+
width = 120,
|
|
336
|
+
): readonly StackedDiffCommentAnchor[] =>
|
|
337
|
+
stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
|
|
338
|
+
...anchor,
|
|
339
|
+
fileIndex: stackedFile.index,
|
|
340
|
+
localRenderLine: anchor.renderLine,
|
|
341
|
+
renderLine: stackedFile.diffStartLine + anchor.renderLine,
|
|
342
|
+
})))
|
|
343
|
+
|
|
344
|
+
export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
|
|
345
|
+
if (anchors.length === 0) return 0
|
|
346
|
+
const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
|
|
347
|
+
return nextIndex >= 0 ? nextIndex : anchors.length - 1
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: number, line: number, margin = 1) => {
|
|
351
|
+
const safeViewportHeight = Math.max(1, viewportHeight)
|
|
352
|
+
if (line < currentTop + margin) return Math.max(0, line - margin)
|
|
353
|
+
if (line >= currentTop + safeViewportHeight - margin) return Math.max(0, line - safeViewportHeight + margin + 1)
|
|
354
|
+
return currentTop
|
|
355
|
+
}
|
|
356
|
+
|
|
157
357
|
const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
|
|
158
358
|
if (wrapMode === "none") return 1
|
|
159
359
|
return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
|
|
@@ -166,10 +366,10 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
|
166
366
|
let newLine = 0
|
|
167
367
|
|
|
168
368
|
for (const line of lines) {
|
|
169
|
-
const hunk = line.match(
|
|
369
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
170
370
|
if (hunk) {
|
|
171
371
|
oldLine = Number(hunk[1])
|
|
172
|
-
newLine = Number(hunk[
|
|
372
|
+
newLine = Number(hunk[3])
|
|
173
373
|
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
174
374
|
continue
|
|
175
375
|
}
|
|
@@ -196,10 +396,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
|
196
396
|
|
|
197
397
|
export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
|
|
198
398
|
const lines = patch.split("\n")
|
|
199
|
-
const
|
|
200
|
-
const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
201
|
-
const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
|
|
202
|
-
const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
|
|
399
|
+
const contentWidth = diffContentWidth(lines, view, width)
|
|
203
400
|
let count = 0
|
|
204
401
|
let inHunk = false
|
|
205
402
|
let deletions: number[] = []
|