@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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 = `⋯ ${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 = `⋯ ${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} · ${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
|
+
}
|