@kitlangton/ghui 0.3.0 → 0.3.3

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.
Files changed (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
package/src/ui/diff.ts DELETED
@@ -1,650 +0,0 @@
1
- import { parseColor, pathToFiletype, SyntaxStyle } from "@opentui/core"
2
- import { Data, Schema } from "effect"
3
- import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
- import { colors } from "./colors.js"
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 DiffWhitespaceMode = Schema.Literals(["ignore", "show"])
13
- export type DiffWhitespaceMode = Schema.Schema.Type<typeof DiffWhitespaceMode>
14
-
15
- export const DiffCommentKind = Schema.Literals(["addition", "deletion", "context"])
16
- export type DiffCommentKind = Schema.Schema.Type<typeof DiffCommentKind>
17
-
18
- export interface DiffFilePatch {
19
- readonly name: string
20
- readonly filetype: string | undefined
21
- readonly patch: string
22
- }
23
-
24
- export interface DiffFileStats {
25
- readonly additions: number
26
- readonly deletions: number
27
- }
28
-
29
- export interface StackedDiffFilePatch {
30
- readonly file: DiffFilePatch
31
- readonly index: number
32
- readonly headerLine: number
33
- readonly diffStartLine: number
34
- readonly diffHeight: number
35
- }
36
-
37
- export interface DiffCommentAnchor {
38
- readonly path: string
39
- readonly line: number
40
- readonly side: DiffCommentSide
41
- readonly kind: DiffCommentKind
42
- readonly renderLine: number
43
- readonly text: string
44
- }
45
-
46
- export type StackedDiffCommentAnchor = DiffCommentAnchor & {
47
- readonly fileIndex: number
48
- readonly localRenderLine: number
49
- }
50
-
51
- export type PullRequestDiffState = Data.TaggedEnum<{
52
- Loading: {}
53
- Ready: { readonly patch: string; readonly files: readonly DiffFilePatch[] }
54
- Error: { readonly error: string }
55
- }>
56
-
57
- export const PullRequestDiffState = Data.taggedEnum<PullRequestDiffState>()
58
-
59
- export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
60
- keyword: { fg: parseColor(colors.accent), bold: true },
61
- "keyword.import": { fg: parseColor(colors.accent), bold: true },
62
- string: { fg: parseColor(colors.inlineCode) },
63
- comment: { fg: parseColor(colors.muted), italic: true },
64
- number: { fg: parseColor(colors.status.review) },
65
- boolean: { fg: parseColor(colors.status.review) },
66
- constant: { fg: parseColor(colors.status.review) },
67
- function: { fg: parseColor(colors.status.passing) },
68
- "function.call": { fg: parseColor(colors.status.passing) },
69
- constructor: { fg: parseColor(colors.status.draft) },
70
- type: { fg: parseColor(colors.status.draft) },
71
- operator: { fg: parseColor(colors.status.failing) },
72
- variable: { fg: parseColor(colors.text) },
73
- property: { fg: parseColor(colors.status.review) },
74
- bracket: { fg: parseColor(colors.text) },
75
- punctuation: { fg: parseColor(colors.text) },
76
- default: { fg: parseColor(colors.text) },
77
- })
78
-
79
- const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
80
-
81
- const readDiffPath = (value: string, start: number) => {
82
- if (value[start] === '"') {
83
- for (let index = start + 1; index < value.length; index++) {
84
- if (value[index] === '"' && value[index - 1] !== "\\") {
85
- const raw = value.slice(start, index + 1)
86
- try {
87
- return { path: JSON.parse(raw) as string, end: index + 1 }
88
- } catch {
89
- return { path: raw, end: index + 1 }
90
- }
91
- }
92
- }
93
- }
94
-
95
- const end = value.slice(start).search(/\s/)
96
- const pathEnd = end >= 0 ? start + end : value.length
97
- return { path: value.slice(start, pathEnd), end: pathEnd }
98
- }
99
-
100
- const parseDiffGitPaths = (line: string) => {
101
- const prefix = "diff --git "
102
- if (!line.startsWith(prefix)) return null
103
- const left = readDiffPath(line, prefix.length)
104
- const rightStart = line.slice(left.end).search(/\S/)
105
- if (rightStart < 0) return null
106
- const right = readDiffPath(line, left.end + rightStart)
107
- return [left.path, right.path] as const
108
- }
109
-
110
- const patchFileName = (patch: string) => {
111
- const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
112
- if (diffLine) {
113
- const paths = parseDiffGitPaths(diffLine)
114
- if (paths) {
115
- const next = unquoteDiffPath(paths[1])
116
- if (next !== "/dev/null") return next
117
- return unquoteDiffPath(paths[0])
118
- }
119
- }
120
-
121
- const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
122
- return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
123
- }
124
-
125
- const hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
126
-
127
- const formatHunkRange = (start: string, count: number) => `${start},${count}`
128
-
129
- const normalizeHunkLineCounts = (patch: string) => {
130
- const lines = patch.split("\n")
131
- const normalized = [...lines]
132
-
133
- for (let index = 0; index < lines.length; index++) {
134
- const match = lines[index]!.match(hunkHeaderPattern)
135
- if (!match) continue
136
-
137
- let oldCount = 0
138
- let newCount = 0
139
- for (let lineIndex = index + 1; lineIndex < lines.length; lineIndex++) {
140
- const line = lines[lineIndex]!
141
- if (line.startsWith("@@ ") || line.startsWith("diff --git ")) break
142
-
143
- const prefix = line[0]
144
- if (prefix === " ") {
145
- oldCount += 1
146
- newCount += 1
147
- } else if (prefix === "-") {
148
- oldCount += 1
149
- } else if (prefix === "+") {
150
- newCount += 1
151
- }
152
- }
153
-
154
- normalized[index] = `@@ -${formatHunkRange(match[1]!, oldCount)} +${formatHunkRange(match[3]!, newCount)} @@${match[5]!}`
155
- }
156
-
157
- return normalized.join("\n")
158
- }
159
-
160
- const whitespaceComparableText = (text: string) => text.replace(/\s+/g, "")
161
- const MAX_WHITESPACE_LCS_CELLS = 40_000
162
-
163
- const linearWhitespaceEquivalentMatches = (deletions: readonly string[], additions: readonly string[]) => {
164
- const additionsByKey = new Map<string, number[]>()
165
- const cursors = new Map<string, number>()
166
- for (let index = 0; index < additions.length; index++) {
167
- const key = whitespaceComparableText(additions[index]!.slice(1))
168
- let bucket = additionsByKey.get(key)
169
- if (!bucket) {
170
- bucket = []
171
- additionsByKey.set(key, bucket)
172
- }
173
- bucket.push(index)
174
- }
175
-
176
- const matches: Array<{ readonly oldIndex: number; readonly newIndex: number }> = []
177
- let minimumNewIndex = 0
178
- for (let oldIndex = 0; oldIndex < deletions.length; oldIndex++) {
179
- const key = whitespaceComparableText(deletions[oldIndex]!.slice(1))
180
- const candidates = additionsByKey.get(key)
181
- if (!candidates) continue
182
- let cursor = cursors.get(key) ?? 0
183
- while (cursor < candidates.length && candidates[cursor]! < minimumNewIndex) cursor++
184
- const newIndex = candidates[cursor]
185
- if (newIndex === undefined) continue
186
- matches.push({ oldIndex, newIndex })
187
- cursors.set(key, cursor + 1)
188
- minimumNewIndex = newIndex + 1
189
- }
190
- return matches
191
- }
192
-
193
- const whitespaceEquivalentMatches = (deletions: readonly string[], additions: readonly string[]) => {
194
- if ((deletions.length + 1) * (additions.length + 1) > MAX_WHITESPACE_LCS_CELLS) {
195
- return linearWhitespaceEquivalentMatches(deletions, additions)
196
- }
197
-
198
- const oldKeys = deletions.map((line) => whitespaceComparableText(line.slice(1)))
199
- const newKeys = additions.map((line) => whitespaceComparableText(line.slice(1)))
200
- const lengths = Array.from({ length: oldKeys.length + 1 }, () => Array<number>(newKeys.length + 1).fill(0))
201
-
202
- for (let oldIndex = oldKeys.length - 1; oldIndex >= 0; oldIndex--) {
203
- for (let newIndex = newKeys.length - 1; newIndex >= 0; newIndex--) {
204
- lengths[oldIndex]![newIndex] = oldKeys[oldIndex] === newKeys[newIndex]
205
- ? lengths[oldIndex + 1]![newIndex + 1]! + 1
206
- : Math.max(lengths[oldIndex + 1]![newIndex]!, lengths[oldIndex]![newIndex + 1]!)
207
- }
208
- }
209
-
210
- const matches: Array<{ readonly oldIndex: number; readonly newIndex: number }> = []
211
- let oldIndex = 0
212
- let newIndex = 0
213
- while (oldIndex < oldKeys.length && newIndex < newKeys.length) {
214
- if (oldKeys[oldIndex] === newKeys[newIndex]) {
215
- matches.push({ oldIndex, newIndex })
216
- oldIndex++
217
- newIndex++
218
- } else if (lengths[oldIndex + 1]![newIndex]! >= lengths[oldIndex]![newIndex + 1]!) {
219
- oldIndex++
220
- } else {
221
- newIndex++
222
- }
223
- }
224
-
225
- return matches
226
- }
227
-
228
- const mergeWhitespaceEquivalentChanges = (deletions: readonly string[], additions: readonly string[]) => {
229
- const matches = whitespaceEquivalentMatches(deletions, additions)
230
- const merged: string[] = []
231
- let oldCursor = 0
232
- let newCursor = 0
233
-
234
- for (const match of matches) {
235
- while (oldCursor < match.oldIndex) merged.push(deletions[oldCursor++]!)
236
- while (newCursor < match.newIndex) merged.push(additions[newCursor++]!)
237
- merged.push(` ${additions[match.newIndex]!.slice(1)}`)
238
- oldCursor = match.oldIndex + 1
239
- newCursor = match.newIndex + 1
240
- }
241
-
242
- while (oldCursor < deletions.length) merged.push(deletions[oldCursor++]!)
243
- while (newCursor < additions.length) merged.push(additions[newCursor++]!)
244
- return merged
245
- }
246
-
247
- const minimizeWhitespaceHunk = (header: string, body: readonly string[]) => {
248
- const minimized: string[] = []
249
- let deletions: string[] = []
250
- let additions: string[] = []
251
-
252
- const flushChangeBlock = () => {
253
- if (deletions.length === 0 && additions.length === 0) return
254
- minimized.push(...mergeWhitespaceEquivalentChanges(deletions, additions))
255
- deletions = []
256
- additions = []
257
- }
258
-
259
- for (const line of body) {
260
- const firstChar = line[0]
261
- if (firstChar === "-") {
262
- deletions.push(line)
263
- continue
264
- }
265
-
266
- if (firstChar === "+") {
267
- additions.push(line)
268
- continue
269
- }
270
-
271
- flushChangeBlock()
272
- minimized.push(line)
273
- }
274
-
275
- flushChangeBlock()
276
- return minimized.some((line) => line[0] === "-" || line[0] === "+") ? [header, ...minimized] : []
277
- }
278
-
279
- export const minimizeWhitespacePatch = (patch: string) => {
280
- const lines = patch.split("\n")
281
- const minimized: string[] = []
282
-
283
- for (let index = 0; index < lines.length;) {
284
- const line = lines[index]!
285
- if (!line.match(hunkHeaderPattern)) {
286
- minimized.push(line)
287
- index++
288
- continue
289
- }
290
-
291
- let end = index + 1
292
- while (end < lines.length && !lines[end]!.match(hunkHeaderPattern) && !lines[end]!.startsWith("diff --git ")) end++
293
- minimized.push(...minimizeWhitespaceHunk(line, lines.slice(index + 1, end)))
294
- index = end
295
- }
296
-
297
- return normalizeHunkLineCounts(minimized.join("\n")).trimEnd()
298
- }
299
-
300
- export const minimizeWhitespaceDiffFiles = (files: readonly DiffFilePatch[]): readonly DiffFilePatch[] =>
301
- files.flatMap((file) => {
302
- const patch = minimizeWhitespacePatch(file.patch)
303
- if (file.patch.split("\n").some((line) => line.match(hunkHeaderPattern)) && !patch.split("\n").some((line) => line.match(hunkHeaderPattern))) return []
304
- return [{ ...file, patch }]
305
- })
306
-
307
- export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
308
- const trimmed = patch.trimEnd()
309
- if (trimmed.length === 0) return []
310
-
311
- const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
312
- if (matches.length === 0) {
313
- return [{ name: "diff", filetype: undefined, patch: trimmed }]
314
- }
315
-
316
- return matches.map((match, index) => {
317
- const start = match.index ?? 0
318
- const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
319
- const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
320
- const name = patchFileName(filePatch)
321
- return { name, filetype: pathToFiletype(name), patch: filePatch }
322
- })
323
- }
324
-
325
- export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}:${pullRequest.headRefOid}`
326
-
327
- export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
328
- files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
329
-
330
- export const buildStackedDiffFiles = (
331
- files: readonly DiffFilePatch[],
332
- view: DiffView,
333
- wrapMode: DiffWrapMode,
334
- width: number,
335
- ): readonly StackedDiffFilePatch[] => {
336
- let offset = 0
337
- return files.map((file, index) => {
338
- const diffHeight = patchRenderableLineCount(file.patch, view, wrapMode, width)
339
- const separatorBefore = index === 0 ? 0 : 1
340
- const headerLine = offset + separatorBefore
341
- const stackedFile = {
342
- file,
343
- index,
344
- headerLine,
345
- diffStartLine: headerLine + 2,
346
- diffHeight,
347
- } satisfies StackedDiffFilePatch
348
- offset += separatorBefore + 2 + diffHeight
349
- return stackedFile
350
- })
351
- }
352
-
353
- export const stackedDiffFileIndexAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) => {
354
- let low = 0
355
- let high = stackedFiles.length - 1
356
- let match = -1
357
- while (low <= high) {
358
- const mid = (low + high) >>> 1
359
- if (stackedFiles[mid]!.headerLine <= line) {
360
- match = mid
361
- low = mid + 1
362
- } else {
363
- high = mid - 1
364
- }
365
- }
366
- return match
367
- }
368
-
369
- export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
370
- stackedFiles[stackedDiffFileIndexAtLine(stackedFiles, line)]
371
-
372
- export const diffStatText = (pullRequest: PullRequestItem) => {
373
- if (!pullRequest.detailLoaded) return "loading details"
374
- const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
375
- const stats = diffFileStatsText(pullRequest)
376
- return stats ? `${stats} ${files}` : files
377
- }
378
-
379
- export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
380
-
381
- export const diffCommentSideLabel = (anchor: Pick<DiffCommentAnchor, "side">) => anchor.side === "RIGHT" ? "→" : "←"
382
-
383
- export const diffCommentLineLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${anchor.side === "RIGHT" ? "+" : "-"}${anchor.line}`
384
-
385
- export const diffCommentAnchorLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${diffCommentSideLabel(anchor)} ${diffCommentLineLabel(anchor)}`
386
-
387
- type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
388
-
389
- const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {
390
- const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
391
- return view === "split"
392
- ? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
393
- : Math.max(1, width - lineNumberGutterWidth)
394
- }
395
-
396
- export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
397
- let additions = 0
398
- let deletions = 0
399
- let inHunk = false
400
-
401
- for (const line of file.patch.split("\n")) {
402
- const hunk = line.match(hunkHeaderPattern)
403
- if (hunk) {
404
- inHunk = true
405
- continue
406
- }
407
-
408
- if (!inHunk) continue
409
- const firstChar = line[0]
410
- if (firstChar === "+") additions++
411
- else if (firstChar === "-") deletions++
412
- }
413
-
414
- return { additions, deletions }
415
- }
416
-
417
- export const diffFileStatsText = (stats: DiffFileStats) => {
418
- return [
419
- stats.additions > 0 ? `+${stats.additions}` : null,
420
- stats.deletions > 0 ? `-${stats.deletions}` : null,
421
- ].filter((part): part is string => part !== null).join(" ")
422
- }
423
-
424
- export const getDiffCommentAnchors = (file: DiffFilePatch, view: DiffView = "unified", wrapMode: DiffWrapMode = "none", width = 120): readonly DiffCommentAnchor[] => {
425
- const anchors: DiffCommentAnchor[] = []
426
- const lines = file.patch.split("\n")
427
- const contentWidth = diffContentWidth(lines, view, width)
428
- let oldLine = 0
429
- let newLine = 0
430
- let renderLine = 0
431
- let inHunk = false
432
- let deletions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
433
- let additions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
434
-
435
- const flushChangeBlock = () => {
436
- if (deletions.length === 0 && additions.length === 0) return
437
- if (view === "split") {
438
- const rowCount = Math.max(deletions.length, additions.length)
439
- for (let index = 0; index < rowCount; index++) {
440
- const deletion = deletions[index]
441
- const addition = additions[index]
442
- if (deletion) anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
443
- if (addition) anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
444
- renderLine += Math.max(deletion?.height ?? 1, addition?.height ?? 1)
445
- }
446
- } else {
447
- for (const deletion of deletions) {
448
- anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
449
- renderLine += deletion.height
450
- }
451
- for (const addition of additions) {
452
- anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
453
- renderLine += addition.height
454
- }
455
- }
456
- deletions = []
457
- additions = []
458
- }
459
-
460
- for (const line of lines) {
461
- const hunk = line.match(hunkHeaderPattern)
462
- if (hunk) {
463
- flushChangeBlock()
464
- oldLine = Number(hunk[1])
465
- newLine = Number(hunk[3])
466
- inHunk = true
467
- continue
468
- }
469
-
470
- if (!inHunk) continue
471
-
472
- const firstChar = line[0]
473
- if (firstChar === "\\") continue
474
-
475
- if (firstChar === "+") {
476
- const text = line.slice(1)
477
- additions.push({ path: file.name, line: newLine, side: "RIGHT", kind: "addition", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
478
- newLine++
479
- continue
480
- }
481
-
482
- if (firstChar === "-") {
483
- const text = line.slice(1)
484
- deletions.push({ path: file.name, line: oldLine, side: "LEFT", kind: "deletion", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
485
- oldLine++
486
- continue
487
- }
488
-
489
- if (firstChar === " ") {
490
- flushChangeBlock()
491
- const text = line.slice(1)
492
- anchors.push({ path: file.name, line: newLine, side: "RIGHT", kind: "context", renderLine, text })
493
- oldLine++
494
- newLine++
495
- renderLine += estimatedWrappedLineCount(text, contentWidth, wrapMode)
496
- }
497
- }
498
-
499
- flushChangeBlock()
500
- return anchors
501
- }
502
-
503
- export const getStackedDiffCommentAnchors = (
504
- stackedFiles: readonly StackedDiffFilePatch[],
505
- view: DiffView = "unified",
506
- wrapMode: DiffWrapMode = "none",
507
- width = 120,
508
- ): readonly StackedDiffCommentAnchor[] =>
509
- stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
510
- ...anchor,
511
- fileIndex: stackedFile.index,
512
- localRenderLine: anchor.renderLine,
513
- renderLine: stackedFile.diffStartLine + anchor.renderLine,
514
- })))
515
-
516
- export const verticalDiffAnchor = <Anchor extends Pick<DiffCommentAnchor, "renderLine" | "side">>(
517
- anchors: readonly Anchor[],
518
- currentAnchor: Anchor | null,
519
- delta: number,
520
- preferredSide: DiffCommentSide | null = null,
521
- ) => {
522
- if (anchors.length === 0) return null
523
- const rows = [...new Set(anchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right)
524
- const current = currentAnchor && anchors.includes(currentAnchor) ? currentAnchor : anchors[0]!
525
- const currentRowIndex = Math.max(0, rows.indexOf(current.renderLine))
526
- const nextRow = rows[Math.max(0, Math.min(rows.length - 1, currentRowIndex + delta))]
527
- if (nextRow === undefined) return null
528
- const targetSide = preferredSide ?? current.side
529
- return anchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === targetSide)
530
- ?? anchors.find((anchor) => anchor.renderLine === nextRow)
531
- ?? null
532
- }
533
-
534
- export const diffAnchorOnSide = <Anchor extends Pick<DiffCommentAnchor, "renderLine" | "side">>(
535
- anchors: readonly Anchor[],
536
- currentAnchor: Anchor | null,
537
- side: DiffCommentSide,
538
- ) => currentAnchor
539
- ? anchors.find((anchor) => anchor.renderLine === currentAnchor.renderLine && anchor.side === side) ?? null
540
- : null
541
-
542
- export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
543
- if (anchors.length === 0) return 0
544
- const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
545
- return nextIndex >= 0 ? nextIndex : anchors.length - 1
546
- }
547
-
548
- export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: number, line: number, margin = 1) => {
549
- const safeViewportHeight = Math.max(1, viewportHeight)
550
- if (line < currentTop + margin) return Math.max(0, line - margin)
551
- if (line >= currentTop + safeViewportHeight - margin) return Math.max(0, line - safeViewportHeight + margin + 1)
552
- return currentTop
553
- }
554
-
555
- const estimatedWrappedLineCount = (text: string, width: number, wrapMode: DiffWrapMode) => {
556
- if (wrapMode === "none") return 1
557
- return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
558
- }
559
-
560
- const patchLineNumberGutterWidth = (lines: readonly string[]) => {
561
- let maxLineNumber = 1
562
- let hasSigns = false
563
- let oldLine = 0
564
- let newLine = 0
565
-
566
- for (const line of lines) {
567
- const hunk = line.match(hunkHeaderPattern)
568
- if (hunk) {
569
- oldLine = Number(hunk[1])
570
- newLine = Number(hunk[3])
571
- maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
572
- continue
573
- }
574
-
575
- const firstChar = line[0]
576
- if (firstChar === "-") {
577
- hasSigns = true
578
- maxLineNumber = Math.max(maxLineNumber, oldLine)
579
- oldLine++
580
- } else if (firstChar === "+") {
581
- hasSigns = true
582
- maxLineNumber = Math.max(maxLineNumber, newLine)
583
- newLine++
584
- } else if (firstChar === " ") {
585
- maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
586
- oldLine++
587
- newLine++
588
- }
589
- }
590
-
591
- const digits = Math.floor(Math.log10(maxLineNumber)) + 1
592
- return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
593
- }
594
-
595
- export const patchRenderableLineCount = (patch: string, view: DiffView, wrapMode: DiffWrapMode, width: number) => {
596
- const lines = patch.split("\n")
597
- const contentWidth = diffContentWidth(lines, view, width)
598
- let count = 0
599
- let inHunk = false
600
- let deletions: number[] = []
601
- let additions: number[] = []
602
-
603
- const flushChangeBlock = () => {
604
- if (deletions.length === 0 && additions.length === 0) return
605
- if (view === "split") {
606
- const rows = Math.max(deletions.length, additions.length)
607
- for (let index = 0; index < rows; index++) {
608
- const deletionCount = index < deletions.length ? deletions[index]! : 1
609
- const additionCount = index < additions.length ? additions[index]! : 1
610
- count += Math.max(deletionCount, additionCount)
611
- }
612
- } else {
613
- for (const deletion of deletions) count += deletion
614
- for (const addition of additions) count += addition
615
- }
616
- deletions = []
617
- additions = []
618
- }
619
-
620
- for (const line of lines) {
621
- if (line.startsWith("@@")) {
622
- flushChangeBlock()
623
- inHunk = true
624
- continue
625
- }
626
-
627
- if (!inHunk) continue
628
-
629
- const firstChar = line[0]
630
- if (firstChar === "\\") continue
631
-
632
- if (firstChar === "-") {
633
- deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
634
- continue
635
- }
636
-
637
- if (firstChar === "+") {
638
- additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
639
- continue
640
- }
641
-
642
- if (firstChar === " ") {
643
- flushChangeBlock()
644
- count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
645
- }
646
- }
647
-
648
- flushChangeBlock()
649
- return Math.max(1, count)
650
- }
@@ -1,25 +0,0 @@
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
- }