@vintasoftware/pr-review-canvas 0.1.0

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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,860 @@
1
+ // @ts-check
2
+ // Unified diff → one <table class="diff"> per hunk. Port of the prior-art renderer.js: import
3
+ // filter, whitespace-only collapse, moved-code detection, per-hunk highlight.js; plus jsdiff
4
+ // word-level marks on paired changed lines. Line numbers come from the hunk headers and are
5
+ // never shifted by filtering: filtered lines are folded (hidden rows), not dropped.
6
+ import { diffWordsWithSpace } from 'diff'
7
+ import hljs from 'hljs'
8
+ import { esc } from './dom.js'
9
+ import { parseHunkHeader } from './hunks.js'
10
+ import { buildLineId, hunkAnchorId, hunkId } from './keys.js'
11
+
12
+ /**
13
+ * Where one line of a move sits. `side` is which end of the move this line is: `from` for the
14
+ * deleted block, `to` for the added one. `line` is the line it matches on the other end, so a
15
+ * fold summary can point the reader at it.
16
+ * @typedef {{ id: number, kind: 'exact' | 'edited', side: 'from' | 'to', line: number }} Move
17
+ */
18
+ /**
19
+ * @typedef {{
20
+ * type: 'add' | 'del' | 'ctx',
21
+ * code: string,
22
+ * oldLine: number | null,
23
+ * newLine: number | null,
24
+ * consecutive: boolean,
25
+ * noise: boolean,
26
+ * move: Move | null,
27
+ * html: string | null,
28
+ * marks: Array<[number, number]> | null,
29
+ * }} Entry
30
+ */
31
+ /**
32
+ * @typedef {{ header: string, oldStart: number, oldLines: number, newStart: number, newLines: number, entries: Entry[] }} HunkBlock
33
+ */
34
+ /**
35
+ * One matched move, as the changed lines of each side paired by position. Context lines the block
36
+ * bridged are compared but not paired: neither side changed them.
37
+ * @typedef {{ id: number, kind: 'exact' | 'edited', pairs: Array<{ del: Entry, add: Entry }> }} MoveBlock
38
+ */
39
+
40
+ /**
41
+ * @param {string} line
42
+ * @returns {boolean}
43
+ */
44
+ export function isImport(line) {
45
+ const s = line.trim()
46
+ return s.startsWith('import ') || s.startsWith('import{') || s.startsWith('} from ') || /^from ['"]/.test(s)
47
+ }
48
+
49
+ /**
50
+ * @param {string} a
51
+ * @param {string} b
52
+ */
53
+ export function sameIgnoringWhitespace(a, b) {
54
+ return a.replace(/\s/g, '') === b.replace(/\s/g, '')
55
+ }
56
+
57
+ /** @param {string} s */
58
+ function normWs(s) {
59
+ return s.replace(/\s+/g, ' ').trim()
60
+ }
61
+
62
+ /**
63
+ * Splits a patch (from its first `@@`) into hunks with numbered entries.
64
+ * @param {string} patch
65
+ * @returns {HunkBlock[]}
66
+ */
67
+ export function parsePatch(patch) {
68
+ /** @type {HunkBlock[]} */
69
+ const hunks = []
70
+ if (!patch) {
71
+ return hunks
72
+ }
73
+ /** @type {HunkBlock | null} */
74
+ let cur = null
75
+ let oL = 0
76
+ let nL = 0
77
+ let prevAdd = false
78
+ let prevDel = false
79
+ for (const line of patch.split('\n')) {
80
+ const head = parseHunkHeader(line)
81
+ if (head) {
82
+ oL = head.oldStart
83
+ nL = head.newStart
84
+ cur = { header: line, ...head, entries: [] }
85
+ hunks.push(cur)
86
+ prevAdd = false
87
+ prevDel = false
88
+ continue
89
+ }
90
+ if (cur === null || line.startsWith('\\')) {
91
+ continue
92
+ }
93
+ /** @type {Entry} */
94
+ const base = {
95
+ type: 'ctx',
96
+ code: '',
97
+ oldLine: null,
98
+ newLine: null,
99
+ consecutive: false,
100
+ noise: false,
101
+ move: null,
102
+ html: null,
103
+ marks: null,
104
+ }
105
+ if (line.startsWith('+')) {
106
+ cur.entries.push({ ...base, type: 'add', code: line.slice(1), newLine: nL, consecutive: prevAdd })
107
+ nL++
108
+ prevAdd = true
109
+ prevDel = false
110
+ } else if (line.startsWith('-')) {
111
+ cur.entries.push({ ...base, type: 'del', code: line.slice(1), oldLine: oL, consecutive: prevDel })
112
+ oL++
113
+ prevDel = true
114
+ prevAdd = false
115
+ } else {
116
+ cur.entries.push({ ...base, code: line.startsWith(' ') ? line.slice(1) : line, oldLine: oL, newLine: nL })
117
+ oL++
118
+ nL++
119
+ prevAdd = false
120
+ prevDel = false
121
+ }
122
+ }
123
+ return hunks
124
+ }
125
+
126
+ /**
127
+ * Marks the lines a reviewer rarely needs: import-only changes and whitespace-only rewrites.
128
+ * They stay in the table, folded behind a "show N hidden lines" row.
129
+ * @param {HunkBlock[]} hunks
130
+ */
131
+ export function markNoise(hunks) {
132
+ for (const h of hunks) {
133
+ const e = h.entries
134
+ for (let i = 0; i < e.length; i++) {
135
+ const cur = e[i]
136
+ if (cur === undefined || cur.type !== 'del') {
137
+ continue
138
+ }
139
+ let j = i
140
+ while (j < e.length && e[j]?.type === 'del') {
141
+ j++
142
+ }
143
+ let k = j
144
+ while (k < e.length && e[k]?.type === 'add') {
145
+ k++
146
+ }
147
+ const dels = e.slice(i, j)
148
+ const adds = e.slice(j, k)
149
+ if (
150
+ dels.length === adds.length &&
151
+ dels.every((d, idx) => sameIgnoringWhitespace(d.code, adds[idx]?.code ?? ''))
152
+ ) {
153
+ for (const x of [...dels, ...adds]) {
154
+ x.noise = true
155
+ }
156
+ }
157
+ i = k - 1
158
+ }
159
+ for (const x of e) {
160
+ if (x.type !== 'ctx' && isImport(x.code)) {
161
+ x.noise = true
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ /** A move must carry this many changed lines on each side. */
168
+ const MOVE_MIN_LINES = 3
169
+ /** How many context lines a move block bridges before it ends. */
170
+ const MOVE_MAX_BRIDGE = 3
171
+ /** The longest block a move is looked for in. */
172
+ const MOVE_MAX_BLOCK = 40
173
+
174
+ /**
175
+ * The block a move candidate starts with: one run of lines of the file this side belongs to.
176
+ * A deleted block is a run of the old file, so it is made of deleted and context lines and the
177
+ * added lines in between are skipped — they are not in the old file at all. An added block is
178
+ * the same over the new file. Git routinely leaves a line the move did not touch as context and
179
+ * splits the changed lines around it, so a block that stopped at the first context line would
180
+ * find no move; a short run of context is bridged instead. The block ends on a `type` line.
181
+ * @param {Entry[]} entries one hunk's entries
182
+ * @param {number} from index of a `type` entry
183
+ * @param {'add' | 'del'} type
184
+ * @param {ReadonlySet<Entry>} taken lines another move already claimed
185
+ * @returns {Entry[]}
186
+ */
187
+ function blockFrom(entries, from, type, taken) {
188
+ /** @type {Entry[]} */
189
+ const block = []
190
+ let bridge = 0
191
+ for (let i = from; i < entries.length && block.length < MOVE_MAX_BLOCK; i++) {
192
+ const e = entries[i]
193
+ if (e === undefined) {
194
+ break
195
+ }
196
+ if (e.type === type) {
197
+ if (taken.has(e)) {
198
+ break
199
+ }
200
+ block.push(e)
201
+ bridge = 0
202
+ continue
203
+ }
204
+ if (e.type !== 'ctx') {
205
+ continue
206
+ }
207
+ bridge++
208
+ if (bridge > MOVE_MAX_BRIDGE) {
209
+ break
210
+ }
211
+ block.push(e)
212
+ }
213
+ while (block.length > 0 && block[block.length - 1]?.type === 'ctx') {
214
+ block.pop()
215
+ }
216
+ return block
217
+ }
218
+
219
+ /**
220
+ * How far along the alignment the move actually reaches. A bridge is only part of the move when
221
+ * the context line it crossed says the same thing as the line it lines up with; the first one
222
+ * that does not is where the block ran on past the move, so the match stops there.
223
+ * @param {ReadonlyArray<{ entry: Entry, text: string }>} dc
224
+ * @param {ReadonlyArray<{ entry: Entry, text: string }>} ac
225
+ * @param {number} ml
226
+ * @returns {number}
227
+ */
228
+ function alignedLength(dc, ac, ml) {
229
+ for (let k = 0; k < ml; k++) {
230
+ const d = dc[k]
231
+ const a = ac[k]
232
+ if (d?.text !== a?.text && (d?.entry.type === 'ctx' || a?.entry.type === 'ctx')) {
233
+ return k
234
+ }
235
+ }
236
+ return ml
237
+ }
238
+
239
+ /**
240
+ * The lines of a block the comparison sees, with the text it compares them by. A blank line says
241
+ * nothing about a move, and leaving it out keeps the two sides aligned when one of them gained
242
+ * or lost one.
243
+ * @param {Entry[]} block
244
+ * @returns {Array<{ entry: Entry, text: string }>}
245
+ */
246
+ function comparable(block) {
247
+ /** @type {Array<{ entry: Entry, text: string }>} */
248
+ const out = []
249
+ for (const entry of block) {
250
+ const text = normWs(entry.code)
251
+ if (text !== '') {
252
+ out.push({ entry, text })
253
+ }
254
+ }
255
+ return out
256
+ }
257
+
258
+ /**
259
+ * How many of these lines were added or deleted. A match made of context alone is no move.
260
+ * @param {ReadonlyArray<{ entry: Entry }>} lines
261
+ * @returns {number}
262
+ */
263
+ function changedCount(lines) {
264
+ return lines.filter(l => l.entry.type !== 'ctx').length
265
+ }
266
+
267
+ /**
268
+ * The line number of the other end of a move. A `from` line points at the added line it became,
269
+ * a `to` line at the deleted line it came from.
270
+ * @param {{ entry: Entry } | undefined} counterpart
271
+ * @param {'from' | 'to'} side
272
+ * @returns {number}
273
+ */
274
+ function counterpartLine(counterpart, side) {
275
+ return (side === 'from' ? counterpart?.entry.newLine : counterpart?.entry.oldLine) ?? 0
276
+ }
277
+
278
+ /**
279
+ * @typedef {{ entries: Entry[], index: number, start: Entry }} AddStart
280
+ */
281
+
282
+ /**
283
+ * Every added block of the file, in reading order, under the first line it would be compared by.
284
+ * A move begins on a line that moved, so a deleted block only has to look at the added blocks
285
+ * that open on the same line instead of at all of them.
286
+ * @param {HunkBlock[]} hunks
287
+ * @returns {Map<string, AddStart[]>}
288
+ */
289
+ function indexAddStarts(hunks) {
290
+ /** @type {Map<string, AddStart[]>} */
291
+ const byFirstLine = new Map()
292
+ /** @type {Set<Entry>} */
293
+ const none = new Set()
294
+ for (const hunk of hunks) {
295
+ for (let index = 0; index < hunk.entries.length; index++) {
296
+ const start = hunk.entries[index]
297
+ if (start?.type !== 'add') {
298
+ continue
299
+ }
300
+ const text = comparable(blockFrom(hunk.entries, index, 'add', none))[0]?.text
301
+ if (text === undefined) {
302
+ continue
303
+ }
304
+ const starts = byFirstLine.get(text)
305
+ if (starts === undefined) {
306
+ byFirstLine.set(text, [{ entries: hunk.entries, index, start }])
307
+ } else {
308
+ starts.push({ entries: hunk.entries, index, start })
309
+ }
310
+ }
311
+ }
312
+ return byFirstLine
313
+ }
314
+
315
+ /**
316
+ * The first added block that matches these deleted lines.
317
+ * @param {Map<string, AddStart[]>} addStarts
318
+ * @param {ReadonlyArray<{ entry: Entry, text: string }>} dc the deleted side, as compared
319
+ * @param {ReadonlySet<Entry>} taken
320
+ * @returns {{ block: Entry[], ac: Array<{ entry: Entry, text: string }>, ml: number, mc: number } | null}
321
+ */
322
+ function findAddMatch(addStarts, dc, taken) {
323
+ for (const { entries, index, start } of addStarts.get(dc[0]?.text ?? '') ?? []) {
324
+ if (taken.has(start)) {
325
+ continue
326
+ }
327
+ const block = blockFrom(entries, index, 'add', taken)
328
+ const ac = comparable(block)
329
+ if (dc[0]?.text !== ac[0]?.text) {
330
+ continue
331
+ }
332
+ const ml = alignedLength(dc, ac, Math.min(dc.length, ac.length))
333
+ if (ml < MOVE_MIN_LINES) {
334
+ continue
335
+ }
336
+ let mc = 0
337
+ for (let k = 0; k < ml; k++) {
338
+ if (dc[k]?.text === ac[k]?.text) {
339
+ mc++
340
+ }
341
+ }
342
+ if (mc < MOVE_MIN_LINES || mc < ml * 0.7) {
343
+ continue
344
+ }
345
+ if (changedCount(dc.slice(0, ml)) < MOVE_MIN_LINES || changedCount(ac.slice(0, ml)) < MOVE_MIN_LINES) {
346
+ continue
347
+ }
348
+ return { block, ac, ml, mc }
349
+ }
350
+ return null
351
+ }
352
+
353
+ /**
354
+ * Marks one side of a match. Every added or deleted line of the matched range points at the line
355
+ * it matches on the other side; a bridged context line is left alone, because it is still where
356
+ * it was and folding it here would hide it from the other side too.
357
+ * @param {Entry[]} block
358
+ * @param {ReadonlyArray<{ entry: Entry, text: string }>} own
359
+ * @param {ReadonlyArray<{ entry: Entry, text: string }>} other
360
+ * @param {number} ml
361
+ * @param {{ id: number, kind: 'exact' | 'edited', side: 'from' | 'to' }} move
362
+ * @param {Set<Entry>} taken
363
+ */
364
+ function markSide(block, own, other, ml, move, taken) {
365
+ const last = own[ml - 1]?.entry
366
+ const end = last === undefined ? -1 : block.indexOf(last)
367
+ let k = 0
368
+ let line = counterpartLine(other[0], move.side)
369
+ for (let i = 0; i <= end; i++) {
370
+ const entry = block[i]
371
+ if (entry === undefined) {
372
+ continue
373
+ }
374
+ if (own[k]?.entry === entry) {
375
+ line = counterpartLine(other[k], move.side)
376
+ k++
377
+ }
378
+ if (entry.type === 'ctx') {
379
+ continue
380
+ }
381
+ taken.add(entry)
382
+ entry.move = { ...move, line }
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Blocks of three or more deleted lines that reappear (70%+ equal after whitespace
388
+ * normalization) as a block of additions are moves, not edits. Runs across all hunks of a file.
389
+ * Both ends of a match learn which line of the other end they match, so each can be folded behind
390
+ * a summary that says where the code went or came from.
391
+ * @param {HunkBlock[]} hunks
392
+ * @returns {MoveBlock[]}
393
+ */
394
+ export function detectMoves(hunks) {
395
+ const addStarts = indexAddStarts(hunks)
396
+ /** @type {Set<Entry>} */
397
+ const taken = new Set()
398
+ /** @type {MoveBlock[]} */
399
+ const moves = []
400
+ for (const hunk of hunks) {
401
+ for (let di = 0; di < hunk.entries.length; di++) {
402
+ const start = hunk.entries[di]
403
+ if (start === undefined || start.type !== 'del' || taken.has(start)) {
404
+ continue
405
+ }
406
+ const block = blockFrom(hunk.entries, di, 'del', taken)
407
+ const dc = comparable(block)
408
+ if (dc.length < MOVE_MIN_LINES) {
409
+ continue
410
+ }
411
+ const match = findAddMatch(addStarts, dc, taken)
412
+ if (match === null) {
413
+ continue
414
+ }
415
+ const id = moves.length + 1
416
+ const kind = /** @type {'exact' | 'edited'} */ (match.mc === match.ml ? 'exact' : 'edited')
417
+ markSide(block, dc, match.ac, match.ml, { id, kind, side: 'from' }, taken)
418
+ markSide(match.block, match.ac, dc, match.ml, { id, kind, side: 'to' }, taken)
419
+ /** @type {MoveBlock} */
420
+ const move = { id, kind, pairs: [] }
421
+ for (let k = 0; k < match.ml; k++) {
422
+ const del = dc[k]?.entry
423
+ const add = match.ac[k]?.entry
424
+ if (del?.type === 'del' && add?.type === 'add') {
425
+ move.pairs.push({ del, add })
426
+ }
427
+ }
428
+ moves.push(move)
429
+ }
430
+ }
431
+ return moves
432
+ }
433
+
434
+ /**
435
+ * Adjacent runs of deletions and additions of equal length are pairs: line i of the deletions
436
+ * changed into line i of the additions.
437
+ * @param {Entry[]} entries
438
+ * @returns {Array<{ del: Entry, add: Entry }>}
439
+ */
440
+ export function pairChangedLines(entries) {
441
+ /** @type {Array<{ del: Entry, add: Entry }>} */
442
+ const pairs = []
443
+ for (let i = 0; i < entries.length; i++) {
444
+ if (entries[i]?.type !== 'del') {
445
+ continue
446
+ }
447
+ let j = i
448
+ while (j < entries.length && entries[j]?.type === 'del') {
449
+ j++
450
+ }
451
+ let k = j
452
+ while (k < entries.length && entries[k]?.type === 'add') {
453
+ k++
454
+ }
455
+ if (j - i === k - j) {
456
+ for (let n = 0; n < j - i; n++) {
457
+ const del = entries[i + n]
458
+ const add = entries[j + n]
459
+ if (del && add) {
460
+ pairs.push({ del, add })
461
+ }
462
+ }
463
+ }
464
+ i = k - 1
465
+ }
466
+ return pairs
467
+ }
468
+
469
+ /**
470
+ * Character ranges that differ between two lines, as [start, end) offsets into each line.
471
+ * Empty when the lines share nothing (a rewrite), so the rows are not covered in marks.
472
+ * @param {string} oldCode
473
+ * @param {string} newCode
474
+ * @returns {{ del: Array<[number, number]>, add: Array<[number, number]> }}
475
+ */
476
+ export function wordDiff(oldCode, newCode) {
477
+ /** @type {Array<[number, number]>} */
478
+ const del = []
479
+ /** @type {Array<[number, number]>} */
480
+ const add = []
481
+ if (oldCode === '' || newCode === '') {
482
+ return { del, add }
483
+ }
484
+ let o = 0
485
+ let n = 0
486
+ let common = 0
487
+ for (const part of diffWordsWithSpace(oldCode, newCode)) {
488
+ const len = part.value.length
489
+ if (part.added) {
490
+ add.push([n, n + len])
491
+ n += len
492
+ } else if (part.removed) {
493
+ del.push([o, o + len])
494
+ o += len
495
+ } else {
496
+ common += len
497
+ o += len
498
+ n += len
499
+ }
500
+ }
501
+ const longest = Math.max(oldCode.length, newCode.length)
502
+ if (common / longest < 0.3) {
503
+ return { del: [], add: [] }
504
+ }
505
+ return { del, add }
506
+ }
507
+
508
+ /**
509
+ * Highlights a block, then cuts it into lines and re-opens every span that crosses a newline,
510
+ * because highlight.js v11 has no continuation state between calls.
511
+ * @param {string} html
512
+ * @returns {string[]}
513
+ */
514
+ export function splitHighlightedLines(html) {
515
+ /** @type {string[]} */
516
+ const lines = []
517
+ /** @type {string[]} */
518
+ const open = []
519
+ let buf = ''
520
+ let i = 0
521
+ while (i < html.length) {
522
+ const ch = html[i]
523
+ if (ch === '<') {
524
+ const end = html.indexOf('>', i)
525
+ if (end === -1) {
526
+ buf += html.slice(i)
527
+ break
528
+ }
529
+ const tag = html.slice(i, end + 1)
530
+ if (tag.startsWith('</span')) {
531
+ open.pop()
532
+ } else if (tag.startsWith('<span')) {
533
+ open.push(tag)
534
+ }
535
+ buf += tag
536
+ i = end + 1
537
+ } else if (ch === '\n') {
538
+ lines.push(buf + '</span>'.repeat(open.length))
539
+ buf = open.join('')
540
+ i++
541
+ } else {
542
+ buf += ch
543
+ i++
544
+ }
545
+ }
546
+ lines.push(buf + '</span>'.repeat(open.length))
547
+ return lines
548
+ }
549
+
550
+ /**
551
+ * @param {Entry[]} side
552
+ * @param {string} lang
553
+ * @param {'old' | 'new'} which
554
+ */
555
+ function highlightSide(side, lang, which) {
556
+ if (side.length === 0) {
557
+ return
558
+ }
559
+ /** @type {string[]} */
560
+ let out
561
+ try {
562
+ out = splitHighlightedLines(
563
+ hljs.highlight(side.map(e => e.code).join('\n'), { language: lang, ignoreIllegals: true }).value
564
+ )
565
+ } catch {
566
+ return
567
+ }
568
+ if (out.length !== side.length) {
569
+ return
570
+ }
571
+ side.forEach((e, i) => {
572
+ if (e.type === 'ctx' && which !== 'new') {
573
+ return
574
+ }
575
+ e.html = out[i] ?? null
576
+ })
577
+ }
578
+
579
+ /**
580
+ * A hunk holds two versions of the code. Each is rebuilt and highlighted on its own.
581
+ * @param {HunkBlock} hunk
582
+ * @param {string | undefined} lang
583
+ */
584
+ export function highlightHunk(hunk, lang) {
585
+ if (!(lang && hljs.getLanguage(lang))) {
586
+ return
587
+ }
588
+ const oldSide = hunk.entries.filter(e => e.type !== 'add')
589
+ const newSide = hunk.entries.filter(e => e.type !== 'del')
590
+ highlightSide(oldSide, lang, 'old')
591
+ highlightSide(newSide, lang, 'new')
592
+ }
593
+
594
+ /**
595
+ * Wraps character ranges of the text inside an HTML string in <span class="cls">, keeping the
596
+ * existing markup. Offsets count text characters only.
597
+ * @param {string} html
598
+ * @param {ReadonlyArray<[number, number]>} ranges
599
+ * @param {string} cls
600
+ * @returns {string}
601
+ */
602
+ export function wrapRanges(html, ranges, cls) {
603
+ if (ranges.length === 0) {
604
+ return html
605
+ }
606
+ const root = document.createElement('div')
607
+ root.innerHTML = html
608
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
609
+ /** @type {Array<{ node: Text, start: number }>} */
610
+ const texts = []
611
+ let pos = 0
612
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
613
+ const t = /** @type {Text} */ (n)
614
+ texts.push({ node: t, start: pos })
615
+ pos += t.data.length
616
+ }
617
+ for (const { node, start } of texts) {
618
+ const end = start + node.data.length
619
+ /** @type {Array<[number, number]>} */
620
+ const local = []
621
+ for (const [s, e] of ranges) {
622
+ const ls = Math.max(s, start) - start
623
+ const le = Math.min(e, end) - start
624
+ if (le > ls) {
625
+ local.push([ls, le])
626
+ }
627
+ }
628
+ if (local.length === 0) {
629
+ continue
630
+ }
631
+ const frag = document.createDocumentFragment()
632
+ let cursor = 0
633
+ for (const [ls, le] of local) {
634
+ if (ls > cursor) {
635
+ frag.append(document.createTextNode(node.data.slice(cursor, ls)))
636
+ }
637
+ const span = document.createElement('span')
638
+ span.className = cls
639
+ span.textContent = node.data.slice(ls, le)
640
+ frag.append(span)
641
+ cursor = le
642
+ }
643
+ if (cursor < node.data.length) {
644
+ frag.append(document.createTextNode(node.data.slice(cursor)))
645
+ }
646
+ node.replaceWith(frag)
647
+ }
648
+ return root.innerHTML
649
+ }
650
+
651
+ /**
652
+ * Parse + noise + moves + word pairs, in one call. Noise and moves need the whole file, so this
653
+ * runs over every hunk; highlighting is per hunk and happens in renderDiff for the shown ones.
654
+ * @param {string} patch
655
+ * @returns {HunkBlock[]}
656
+ */
657
+ export function prepareHunks(patch) {
658
+ const hunks = parsePatch(patch)
659
+ markNoise(hunks)
660
+ const moves = detectMoves(hunks)
661
+ // A move that changed on the way is shown rather than folded, so its two ends carry word marks
662
+ // and the reader sees the edit instead of re-reading the block.
663
+ for (const move of moves) {
664
+ if (move.kind === 'exact') {
665
+ continue
666
+ }
667
+ for (const { del, add } of move.pairs) {
668
+ if (normWs(del.code) === normWs(add.code)) {
669
+ continue
670
+ }
671
+ const marks = wordDiff(del.code, add.code)
672
+ del.marks = marks.del
673
+ add.marks = marks.add
674
+ }
675
+ }
676
+ for (const h of hunks) {
677
+ for (const { del, add } of pairChangedLines(h.entries)) {
678
+ if (del.noise || add.noise || del.move || add.move) {
679
+ continue
680
+ }
681
+ const marks = wordDiff(del.code, add.code)
682
+ del.marks = marks.del
683
+ add.marks = marks.add
684
+ }
685
+ }
686
+ return hunks
687
+ }
688
+
689
+ /** @param {Entry} e */
690
+ export function codeHtml(e) {
691
+ const base = e.html ?? esc(e.code)
692
+ if (e.marks && e.marks.length > 0) {
693
+ return wrapRanges(base, e.marks, e.type === 'del' ? 'wd' : 'wa')
694
+ }
695
+ return base
696
+ }
697
+
698
+ /**
699
+ * @param {Entry} e
700
+ * @param {string} key
701
+ * @returns {string}
702
+ */
703
+ export function rowHtml(e, key) {
704
+ /** @type {string[]} */
705
+ const classes = [e.type]
706
+ if (foldKey(e) !== null) {
707
+ // `folded` is what the stylesheet hides and what the summary row's toggle walks over. An
708
+ // edited move starts open, so it also carries `shown`.
709
+ classes.push('folded')
710
+ if (e.move !== null && e.move.kind === 'edited') {
711
+ classes.push('shown')
712
+ }
713
+ }
714
+ if (e.move !== null) {
715
+ classes.push(`move-${e.move.side}`)
716
+ }
717
+ if (e.noise) {
718
+ classes.push('noise')
719
+ }
720
+ const idSide = e.type === 'del' ? 'old' : 'new'
721
+ const idLine = e.type === 'del' ? e.oldLine : e.newLine
722
+ const id = idLine === null ? '' : ` id="${esc(buildLineId(key, idSide, idLine))}"`
723
+ const dataOld = e.type === 'ctx' && e.oldLine !== null ? ` data-old="${e.oldLine}"` : ''
724
+ const marker = e.type === 'add' ? '+' : e.type === 'del' ? '-' : ' '
725
+ // A deleted line is commented on the old side; every other row on the new side.
726
+ const commentSide = e.type === 'del' ? 'old' : 'new'
727
+ const commentLine = commentSide === 'old' ? e.oldLine : e.newLine
728
+ const plus =
729
+ commentLine === null
730
+ ? ''
731
+ : `<button class="plus" type="button" data-act="comment-line" data-needs-post data-key="${esc(key)}" data-side="${commentSide}" data-line="${commentLine}" aria-label="Comment on line ${commentLine}">+</button>`
732
+ return (
733
+ `<tr class="${classes.join(' ')}"${id}${dataOld}>` +
734
+ `<td class="ln">${e.oldLine ?? ''}</td>` +
735
+ `<td class="ln">${e.newLine ?? ''}</td>` +
736
+ `<td class="gut">${plus}</td>` +
737
+ `<td class="code">${marker}${codeHtml(e)}</td></tr>`
738
+ )
739
+ }
740
+
741
+ /**
742
+ * Which fold a row belongs to, as a key that groups a run of rows behind one summary. A move wins
743
+ * over noise, because an import line that only moved reads better under the move summary.
744
+ * @param {Entry} e
745
+ * @returns {string | null}
746
+ */
747
+ export function foldKey(e) {
748
+ if (e.move !== null) {
749
+ return `move-${e.move.id}-${e.move.side}`
750
+ }
751
+ return e.noise ? 'noise' : null
752
+ }
753
+
754
+ /**
755
+ * The summary row a run of folded rows sits behind. A move names the other end of the move and
756
+ * offers a jump to it; anything else counts the lines it hides.
757
+ * @param {Entry} first the first row of the run
758
+ * @param {number} n how many rows the run holds
759
+ * @param {string} key
760
+ * @returns {string}
761
+ */
762
+ export function foldSummaryHtml(first, n, key) {
763
+ const lines = `${n} ${n === 1 ? 'line' : 'lines'}`
764
+ const move = first.move
765
+ let label = `&#8943; ${lines} hidden (imports, whitespace)`
766
+ let shown = false
767
+ if (move !== null) {
768
+ // The other end of a move is on the opposite side of the diff: a deleted block moved to added
769
+ // lines, an added block came from deleted ones.
770
+ const side = move.side === 'from' ? 'new' : 'old'
771
+ const jump =
772
+ `<button class="cmd" type="button" data-act="jump-line" data-key="${esc(key)}" data-side="${side}"` +
773
+ ` data-line="${move.line}">line ${move.line}</button>`
774
+ const where = move.side === 'from' ? 'moved to' : 'moved from'
775
+ label = `&#8943; ${lines} ${where} ${jump}${move.kind === 'edited' ? ', edited' : ''}`
776
+ shown = move.kind === 'edited'
777
+ }
778
+ const toggle =
779
+ `<button class="cmd" type="button" data-act="show-fold" aria-expanded="${shown}">` +
780
+ `${shown ? 'hide' : 'show'}</button>`
781
+ return (
782
+ `<tr class="more fold${move === null ? '' : ' move'}"><td class="ln" colspan="2"></td><td class="gut"></td>` +
783
+ `<td class="code">${label} &middot; ${toggle}</td></tr>`
784
+ )
785
+ }
786
+
787
+ /**
788
+ * Rows for one hunk: the header row, folds, and one row per line.
789
+ * @param {HunkBlock} hunk
790
+ * @param {string} key
791
+ * @returns {string}
792
+ */
793
+ export function buildRows(hunk, key) {
794
+ /** @type {string[]} */
795
+ const rows = []
796
+ rows.push(
797
+ `<tr class="hunk"><td class="ln" colspan="2"></td><td class="gut"></td><td class="code">${esc(hunk.header)}</td></tr>`
798
+ )
799
+ let i = 0
800
+ while (i < hunk.entries.length) {
801
+ const e = hunk.entries[i]
802
+ if (e === undefined) {
803
+ break
804
+ }
805
+ const fold = foldKey(e)
806
+ if (fold !== null) {
807
+ let j = i
808
+ while (j < hunk.entries.length) {
809
+ const next = hunk.entries[j]
810
+ if (next === undefined || foldKey(next) !== fold) {
811
+ break
812
+ }
813
+ j++
814
+ }
815
+ rows.push(foldSummaryHtml(e, j - i, key))
816
+ for (let k = i; k < j; k++) {
817
+ const x = hunk.entries[k]
818
+ if (x) {
819
+ rows.push(rowHtml(x, key))
820
+ }
821
+ }
822
+ i = j
823
+ continue
824
+ }
825
+ rows.push(rowHtml(e, key))
826
+ i++
827
+ }
828
+ return rows.join('')
829
+ }
830
+
831
+ const THEAD =
832
+ '<thead><tr><th scope="col" class="ln">Old</th><th scope="col" class="ln">New</th><th scope="col" class="gut"><span class="sr">Comment</span></th><th scope="col" class="code">Change</th></tr></thead>'
833
+
834
+ /**
835
+ * @param {{ key: string, path: string, lang?: string | undefined }} file
836
+ * @param {string} patch
837
+ * @param {{ hunkIds?: ReadonlySet<string> }} [opts] only these hunk ids; all when omitted
838
+ * @returns {string} HTML: one table per hunk inside .diff-wrap
839
+ */
840
+ export function renderDiff(file, patch, opts = {}) {
841
+ const hunks = prepareHunks(patch)
842
+ /** @type {string[]} */
843
+ const tables = []
844
+ hunks.forEach((h, i) => {
845
+ const n = i + 1
846
+ const id = hunkId(file.key, n)
847
+ if (opts.hunkIds && !opts.hunkIds.has(id)) {
848
+ return
849
+ }
850
+ highlightHunk(h, file.lang)
851
+ tables.push(
852
+ `<table class="diff" id="${esc(hunkAnchorId(file.key, n))}" data-hunk="${esc(id)}" data-key="${esc(file.key)}">` +
853
+ `<caption class="sr">Hunk ${n} of ${esc(file.path)}</caption>${THEAD}<tbody>${buildRows(h, file.key)}</tbody></table>`
854
+ )
855
+ })
856
+ if (tables.length === 0) {
857
+ return '<div class="unavailable">No diff to show.</div>'
858
+ }
859
+ return `<div class="diff-wrap">${tables.join('')}</div>`
860
+ }