@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.
@@ -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(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
369
+ const hunk = line.match(hunkHeaderPattern)
170
370
  if (hunk) {
171
371
  oldLine = Number(hunk[1])
172
- newLine = Number(hunk[2])
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 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
399
+ const contentWidth = diffContentWidth(lines, view, width)
203
400
  let count = 0
204
401
  let inHunk = false
205
402
  let deletions: number[] = []