@kitlangton/ghui 0.2.1 → 0.3.2
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 +1 -4
- package/bin/ghui.js +6 -1
- package/dist/index.js +9419 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2604
- package/src/appCommands.ts +0 -384
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -28
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -139
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -103
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -91
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -45
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -741
- package/src/services/MockGitHubService.ts +0 -181
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -216
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -248
- package/src/ui/PullRequestList.tsx +0 -210
- package/src/ui/colors.ts +0 -814
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -614
- package/src/ui/primitives.tsx +0 -205
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- 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
|
-
}
|
package/src/ui/diffStats.tsx
DELETED
|
@@ -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
|
-
}
|