@kitlangton/ghui 0.1.17 → 0.1.19

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,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,17 +1,57 @@
1
1
  import { parseColor, SyntaxStyle } from "@opentui/core"
2
- import type { PullRequestItem } from "../domain.js"
2
+ import { Data, Schema } from "effect"
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
8
18
  readonly patch: string
9
19
  }
10
20
 
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 }
21
+ export interface DiffFileStats {
22
+ readonly additions: number
23
+ readonly deletions: number
24
+ }
25
+
26
+ export interface StackedDiffFilePatch {
27
+ readonly file: DiffFilePatch
28
+ readonly index: number
29
+ readonly headerLine: number
30
+ readonly diffStartLine: number
31
+ readonly diffHeight: number
32
+ }
33
+
34
+ export interface DiffCommentAnchor {
35
+ readonly path: string
36
+ readonly line: number
37
+ readonly side: DiffCommentSide
38
+ readonly kind: DiffCommentKind
39
+ readonly renderLine: number
40
+ readonly text: string
41
+ }
42
+
43
+ export type StackedDiffCommentAnchor = DiffCommentAnchor & {
44
+ readonly fileIndex: number
45
+ readonly localRenderLine: number
46
+ }
47
+
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>()
15
55
 
16
56
  export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
17
57
  keyword: { fg: parseColor(colors.accent), bold: true },
@@ -144,17 +184,193 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
144
184
 
145
185
  export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
146
186
 
187
+ export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
188
+ files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
189
+
190
+ export const buildStackedDiffFiles = (
191
+ files: readonly DiffFilePatch[],
192
+ view: DiffView,
193
+ wrapMode: DiffWrapMode,
194
+ width: number,
195
+ ): readonly StackedDiffFilePatch[] => {
196
+ let offset = 0
197
+ return files.map((file, index) => {
198
+ const diffHeight = patchRenderableLineCount(file.patch, view, wrapMode, width)
199
+ const separatorBefore = index === 0 ? 0 : 1
200
+ const headerLine = offset + separatorBefore
201
+ const stackedFile = {
202
+ file,
203
+ index,
204
+ headerLine,
205
+ diffStartLine: headerLine + 2,
206
+ diffHeight,
207
+ } satisfies StackedDiffFilePatch
208
+ offset += separatorBefore + 2 + diffHeight
209
+ return stackedFile
210
+ })
211
+ }
212
+
213
+ export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
214
+ stackedFiles.reduce<StackedDiffFilePatch | undefined>((current, file) => file.headerLine <= line ? file : current, undefined)
215
+
147
216
  export const diffStatText = (pullRequest: PullRequestItem) => {
148
217
  if (!pullRequest.detailLoaded) return "loading details"
149
218
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
219
+ const stats = diffFileStatsText(pullRequest)
220
+ return stats ? `${stats} ${files}` : files
221
+ }
222
+
223
+ export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
224
+
225
+ export const diffCommentAnchorKey = diffCommentLocationKey
226
+
227
+ type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
228
+
229
+ const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {
230
+ const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
231
+ return view === "split"
232
+ ? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
233
+ : Math.max(1, width - lineNumberGutterWidth)
234
+ }
235
+
236
+ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
237
+ let additions = 0
238
+ let deletions = 0
239
+ let inHunk = false
240
+
241
+ for (const line of file.patch.split("\n")) {
242
+ const hunk = line.match(hunkHeaderPattern)
243
+ if (hunk) {
244
+ inHunk = true
245
+ continue
246
+ }
247
+
248
+ if (!inHunk) continue
249
+ const firstChar = line[0]
250
+ if (firstChar === "+") additions++
251
+ else if (firstChar === "-") deletions++
252
+ }
253
+
254
+ return { additions, deletions }
255
+ }
256
+
257
+ export const diffFileStatText = (file: DiffFilePatch) => {
258
+ return diffFileStatsText(diffFileStats(file))
259
+ }
260
+
261
+ export const diffFileStatsText = (stats: DiffFileStats) => {
150
262
  return [
151
- pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
152
- pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
153
- files,
263
+ stats.additions > 0 ? `+${stats.additions}` : null,
264
+ stats.deletions > 0 ? `-${stats.deletions}` : null,
154
265
  ].filter((part): part is string => part !== null).join(" ")
155
266
  }
156
267
 
157
- const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
268
+ export const getDiffCommentAnchors = (file: DiffFilePatch, view: DiffView = "unified", wrapMode: DiffWrapMode = "none", width = 120): readonly DiffCommentAnchor[] => {
269
+ const anchors: DiffCommentAnchor[] = []
270
+ const lines = file.patch.split("\n")
271
+ const contentWidth = diffContentWidth(lines, view, width)
272
+ let oldLine = 0
273
+ let newLine = 0
274
+ let renderLine = 0
275
+ let inHunk = false
276
+ let deletions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
277
+ let additions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
278
+
279
+ const flushChangeBlock = () => {
280
+ if (deletions.length === 0 && additions.length === 0) return
281
+ if (view === "split") {
282
+ const rowCount = Math.max(deletions.length, additions.length)
283
+ for (let index = 0; index < rowCount; index++) {
284
+ const deletion = deletions[index]
285
+ const addition = additions[index]
286
+ if (deletion) anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
287
+ if (addition) anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
288
+ renderLine += Math.max(deletion?.height ?? 1, addition?.height ?? 1)
289
+ }
290
+ } else {
291
+ for (const deletion of deletions) {
292
+ anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
293
+ renderLine += deletion.height
294
+ }
295
+ for (const addition of additions) {
296
+ anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
297
+ renderLine += addition.height
298
+ }
299
+ }
300
+ deletions = []
301
+ additions = []
302
+ }
303
+
304
+ for (const line of lines) {
305
+ const hunk = line.match(hunkHeaderPattern)
306
+ if (hunk) {
307
+ flushChangeBlock()
308
+ oldLine = Number(hunk[1])
309
+ newLine = Number(hunk[3])
310
+ inHunk = true
311
+ continue
312
+ }
313
+
314
+ if (!inHunk) continue
315
+
316
+ const firstChar = line[0]
317
+ if (firstChar === "\\") continue
318
+
319
+ if (firstChar === "+") {
320
+ const text = line.slice(1)
321
+ additions.push({ path: file.name, line: newLine, side: "RIGHT", kind: "addition", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
322
+ newLine++
323
+ continue
324
+ }
325
+
326
+ if (firstChar === "-") {
327
+ const text = line.slice(1)
328
+ deletions.push({ path: file.name, line: oldLine, side: "LEFT", kind: "deletion", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
329
+ oldLine++
330
+ continue
331
+ }
332
+
333
+ if (firstChar === " ") {
334
+ flushChangeBlock()
335
+ const text = line.slice(1)
336
+ anchors.push({ path: file.name, line: newLine, side: "RIGHT", kind: "context", renderLine, text })
337
+ oldLine++
338
+ newLine++
339
+ renderLine += estimatedWrappedLineCount(text, contentWidth, wrapMode)
340
+ }
341
+ }
342
+
343
+ flushChangeBlock()
344
+ return anchors
345
+ }
346
+
347
+ export const getStackedDiffCommentAnchors = (
348
+ stackedFiles: readonly StackedDiffFilePatch[],
349
+ view: DiffView = "unified",
350
+ wrapMode: DiffWrapMode = "none",
351
+ width = 120,
352
+ ): readonly StackedDiffCommentAnchor[] =>
353
+ stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
354
+ ...anchor,
355
+ fileIndex: stackedFile.index,
356
+ localRenderLine: anchor.renderLine,
357
+ renderLine: stackedFile.diffStartLine + anchor.renderLine,
358
+ })))
359
+
360
+ export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
361
+ if (anchors.length === 0) return 0
362
+ const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
363
+ return nextIndex >= 0 ? nextIndex : anchors.length - 1
364
+ }
365
+
366
+ export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: number, line: number, margin = 1) => {
367
+ const safeViewportHeight = Math.max(1, viewportHeight)
368
+ if (line < currentTop + margin) return Math.max(0, line - margin)
369
+ if (line >= currentTop + safeViewportHeight - margin) return Math.max(0, line - safeViewportHeight + margin + 1)
370
+ return currentTop
371
+ }
372
+
373
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: DiffWrapMode) => {
158
374
  if (wrapMode === "none") return 1
159
375
  return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
160
376
  }
@@ -166,10 +382,10 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
166
382
  let newLine = 0
167
383
 
168
384
  for (const line of lines) {
169
- const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
385
+ const hunk = line.match(hunkHeaderPattern)
170
386
  if (hunk) {
171
387
  oldLine = Number(hunk[1])
172
- newLine = Number(hunk[2])
388
+ newLine = Number(hunk[3])
173
389
  maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
174
390
  continue
175
391
  }
@@ -194,12 +410,9 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
194
410
  return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
195
411
  }
196
412
 
197
- export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
413
+ export const patchRenderableLineCount = (patch: string, view: DiffView, wrapMode: DiffWrapMode, width: number) => {
198
414
  const lines = patch.split("\n")
199
- const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
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
415
+ const contentWidth = diffContentWidth(lines, view, width)
203
416
  let count = 0
204
417
  let inHunk = false
205
418
  let deletions: number[] = []