@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,596 @@
1
+ // @ts-check
2
+ // The rail, the layer sections, and the file cards. A layer section holds the judgment text
3
+ // (rationale, decisions, check by hand), its test map, its attention point cards, and its files.
4
+ // A file card shows only the hunks of its layer; the diff is rendered lazily by <pr-file> from
5
+ // the shared render context.
6
+ /** @typedef {import('./contract-types.js').FileEntry} FileEntry */
7
+ /** @typedef {import('./contract-types.js').Layer} Layer */
8
+ /** @typedef {import('./contract-types.js').LayerFile} LayerFile */
9
+ /** @typedef {import('./contract-types.js').Point} Point */
10
+ /** @typedef {import('./contract-types.js').PrState} PrState */
11
+ /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
12
+ /** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
13
+ /** @typedef {import('./threads.js').Thread} Thread */
14
+ import { askButtonHtml } from './ask.js'
15
+ import { applyCodeFolds, wireFoldReveal } from './code-folds.js'
16
+ import { runCommand } from './commands.js'
17
+ import { diagramPlaceholderHtml } from './diagram.js'
18
+ import { applyDecorations } from './diff-decorations.js'
19
+ import { renderDiff } from './diff-renderer.js'
20
+ import { chevronHtml, detailsSummaryHtml, esc } from './dom.js'
21
+ import { hunkForLine } from './hunks.js'
22
+ import { fileAnchorId, layerAnchorId, sanitizeKey } from './keys.js'
23
+ import { renderMarkdown } from './markdown.js'
24
+ import { pointCardHtml, postedUrls } from './points.js'
25
+ import { filesReviewed, layerProgress } from './progress.js'
26
+ import { anchorKey, buildThreads } from './threads.js'
27
+
28
+ /**
29
+ * @typedef {{
30
+ * artifact: ReviewArtifact,
31
+ * files: ReadonlyArray<FileEntry>,
32
+ * patches: Record<string, string> | null,
33
+ * comments: ReadonlyArray<ReviewComment>,
34
+ * state: PrState,
35
+ * now: Date,
36
+ * }} RenderContext
37
+ */
38
+
39
+ /** @type {RenderContext | null} */
40
+ let renderContext = null
41
+
42
+ /** @param {RenderContext | null} ctx */
43
+ export function setRenderContext(ctx) {
44
+ renderContext = ctx
45
+ }
46
+
47
+ /** @type {((card: HTMLElement) => void) | null} */
48
+ let cardRenderedHook = null
49
+
50
+ /**
51
+ * Runs after a file card draws its diff, so whatever a card needs after it exists is applied to
52
+ * the cards that are drawn later too.
53
+ * @param {((card: HTMLElement) => void) | null} fn
54
+ */
55
+ export function setCardRenderedHook(fn) {
56
+ cardRenderedHook = fn
57
+ }
58
+
59
+ export function getRenderContext() {
60
+ return renderContext
61
+ }
62
+
63
+ /**
64
+ * Keeps the context in step with the local state, so a card drawn later shows the threads the
65
+ * reader hid and the points they dismissed.
66
+ * @param {PrState} state
67
+ */
68
+ export function updateRenderState(state) {
69
+ if (renderContext !== null) {
70
+ renderContext = { ...renderContext, state }
71
+ }
72
+ }
73
+
74
+ /** @param {ReadonlyArray<FileEntry>} files */
75
+ export function pathSet(files) {
76
+ return new Set(files.map(f => f.path))
77
+ }
78
+
79
+ /**
80
+ * Layer title per hunk id, so a file card can say "2 more hunks in layer 4".
81
+ * @param {ReviewArtifact} artifact
82
+ * @returns {Map<string, { layer: Layer, index: number }>}
83
+ */
84
+ export function hunkLayerIndex(artifact) {
85
+ /** @type {Map<string, { layer: Layer, index: number }>} */
86
+ const out = new Map()
87
+ artifact.layers.forEach((layer, index) => {
88
+ for (const f of layer.files) {
89
+ for (const id of f.hunks) {
90
+ out.set(id, { layer, index })
91
+ }
92
+ }
93
+ })
94
+ return out
95
+ }
96
+
97
+ /**
98
+ * Stripe color for a layer position (cycles through the six bands).
99
+ * @param {number} index
100
+ */
101
+ export function dotColor(index) {
102
+ return `var(--s${(index % 6) + 1})`
103
+ }
104
+
105
+ /**
106
+ * @param {ReviewArtifact} artifact
107
+ * @param {PrState} state
108
+ * @param {{ activeId?: string }} [opts]
109
+ * @returns {string}
110
+ */
111
+ export function railInnerHtml(artifact, state, opts = {}) {
112
+ const active = opts.activeId ?? 'overview'
113
+ const current = /** @param {string} id */ id => (id === active ? ' aria-current="location"' : '')
114
+ const semantic = artifact.layers.filter(l => l.kind !== 'other')
115
+ const other = artifact.layers.find(l => l.kind === 'other')
116
+ const items = semantic
117
+ .map((layer, i) => {
118
+ const progress = layerProgress(layer, state)
119
+ const dotClass = progress === 'done' ? 'dot on' : progress === 'partial' ? 'dot half' : 'dot'
120
+ const dotLabel = progress === 'done' ? 'reviewed' : progress === 'partial' ? 'in progress' : 'not started'
121
+ const reviewed = filesReviewed(layer, state)
122
+ const files = layer.files.length
123
+ const meta = reviewed > 0 && reviewed < files ? `${reviewed} of ${files} files` : fileCount(files)
124
+ const risk = layer.risk.length > 0 ? ` · ${layer.risk.map(r => esc(r.label)).join(' · ')}` : ''
125
+ const id = layerAnchorId(layer.key)
126
+ return (
127
+ `<li><a href="#${esc(id)}"${current(id)}><span class="${dotClass}" style="--dc:${dotColor(i)}" role="img" aria-label="${dotLabel}"></span>` +
128
+ `<span class="n">${i + 1}</span><span class="t">${esc(layer.title)}</span><span class="m">${meta}${risk}</span></a></li>`
129
+ )
130
+ })
131
+ .join('')
132
+ const points = artifact.points.filter(p => state.dismissed[p.fingerprint] === undefined).length
133
+ const otherItem = other
134
+ ? `<li class="other"><a href="#${esc(layerAnchorId(other.key))}"${current(layerAnchorId(other.key))}><span class="t">${esc(other.title)}</span><span class="m">${fileCount(other.files.length)}, ignored</span></a></li>`
135
+ : ''
136
+ return (
137
+ '<ul class="tree">' +
138
+ `<li><a href="#overview"${current('overview')}><span class="t">Overview</span><span class="m">${points} attention ${points === 1 ? 'point' : 'points'}</span></a></li>` +
139
+ `<li class="grp"><span class="lbl grp-lbl">Layers</span><ul class="layers">${items}</ul></li>${otherItem}</ul>`
140
+ )
141
+ }
142
+
143
+ /**
144
+ * @param {ReviewArtifact} artifact
145
+ * @param {PrState} state
146
+ * @param {{ activeId?: string }} [opts]
147
+ * @returns {string}
148
+ */
149
+ export function renderRail(artifact, state, opts = {}) {
150
+ return `<nav class="rail" aria-label="Review layers">${railInnerHtml(artifact, state, opts)}</nav>`
151
+ }
152
+
153
+ /**
154
+ * Draws the rail again from the current state, keeping the item the scrollspy marked.
155
+ * @param {ParentNode} root
156
+ * @param {ReviewArtifact} artifact
157
+ * @param {PrState} state
158
+ */
159
+ export function refreshRail(root, artifact, state) {
160
+ const rail = root.querySelector('nav.rail')
161
+ if (!(rail instanceof HTMLElement)) {
162
+ return false
163
+ }
164
+ const active = rail.querySelector('a[aria-current]')?.getAttribute('href')?.slice(1)
165
+ rail.innerHTML = railInnerHtml(artifact, state, active === undefined ? {} : { activeId: active })
166
+ return true
167
+ }
168
+
169
+ /**
170
+ * "1 file" / "3 files".
171
+ * @param {number} n
172
+ */
173
+ export function fileCount(n) {
174
+ return `${n} ${n === 1 ? 'file' : 'files'}`
175
+ }
176
+
177
+ /**
178
+ * @param {Layer} layer
179
+ * @param {ReadonlySet<string>} paths
180
+ */
181
+ export function testMapHtml(layer, paths) {
182
+ if (layer.tests.length === 0) {
183
+ return ''
184
+ }
185
+ const rows = layer.tests
186
+ .map(t => {
187
+ const where = t.testPath ? `<a class="loc" href="#file:${esc(t.testPath)}">${esc(t.testPath)}</a>` : ''
188
+ const note = t.note ? `<div class="muted small">${renderMarkdown(t.note, { paths })}</div>` : ''
189
+ return `<tr><td>${esc(t.behavior)}${note}</td><td class="st ${t.status}">${esc(t.status)}</td><td>${where}</td></tr>`
190
+ })
191
+ .join('')
192
+ return `<h3 class="lbl sub">Tests</h3><table class="testmap"><thead><tr><th scope="col">Behavior</th><th scope="col">Status</th><th scope="col">Test</th></tr></thead><tbody>${rows}</tbody></table>`
193
+ }
194
+
195
+ /**
196
+ * The layer's diagram, as a placeholder `diagram.js` draws once the page has mermaid.
197
+ * @param {Layer} layer
198
+ */
199
+ export function layerDiagramHtml(layer) {
200
+ return layer.diagram === undefined ? '' : diagramPlaceholderHtml(layer.diagram.mermaid, layer.diagram.links)
201
+ }
202
+
203
+ /**
204
+ * "Decisions and trade-offs" and "Check by hand" as labelled prose; each is omitted when absent.
205
+ * @param {Layer} layer
206
+ * @param {ReadonlySet<string>} paths
207
+ */
208
+ export function judgmentHtml(layer, paths) {
209
+ const block = /** @param {string} cls @param {string} label @param {string | undefined} text */ (cls, label, text) =>
210
+ text === undefined
211
+ ? ''
212
+ : `<div class="judgment ${cls}"><h3 class="lbl">${label}</h3><div class="prose">${renderMarkdown(text, { paths })}</div></div>`
213
+ return (
214
+ block('decisions', 'Decisions and trade-offs', layer.decisions) +
215
+ block('check-by-hand', 'Check by hand', layer.checkByHand)
216
+ )
217
+ }
218
+
219
+ /**
220
+ * The attention point cards of one layer, in artifact order.
221
+ * @param {Layer} layer
222
+ * @param {ReadonlyArray<Point>} points
223
+ * @param {ReadonlySet<string>} paths
224
+ * @param {PrState} state
225
+ * @param {ReadonlyMap<string, string>} [posted] point fingerprint → the url it was posted as
226
+ */
227
+ export function layerPointsHtml(layer, points, paths, state, posted) {
228
+ const own = points.filter(p => p.layerId === layer.id)
229
+ if (own.length === 0) {
230
+ return ''
231
+ }
232
+ const active = own.filter(p => state.dismissed[p.fingerprint] === undefined).length
233
+ const cards = own.map(p => pointCardHtml(p, posted === undefined ? { paths, state } : { paths, state, posted }))
234
+ return `<h3 class="lbl sub">Attention points · <span class="point-count">${active}</span></h3><ol class="findings">${cards.join('')}</ol>`
235
+ }
236
+
237
+ /**
238
+ * @param {LayerFile} lf
239
+ * @param {FileEntry | undefined} entry
240
+ * @param {Layer} layer
241
+ * @param {{ hunkIndex: Map<string, { layer: Layer, index: number }>, paths: ReadonlySet<string>, firstCardFor: Set<string>, state?: PrState, keepOpen?: boolean }} ctx
242
+ * @returns {string}
243
+ */
244
+ export function renderFileCard(lf, entry, layer, ctx) {
245
+ const key = entry?.key ?? sanitizeKey(lf.path)
246
+ const cardReviewedId = `layer:${layer.id}/file:${sanitizeKey(lf.path)}`
247
+ const cardReviewed = ctx.state?.reviewed[cardReviewedId] === true
248
+ const collapsed = cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
249
+ const isFirst = !ctx.firstCardFor.has(key)
250
+ ctx.firstCardFor.add(key)
251
+ const id = isFirst ? fileAnchorId(key) : `${fileAnchorId(key)}-${layer.key}`
252
+ const pills = entry
253
+ ? `<span class="pill add">+${entry.additions}</span><span class="pill del">&minus;${entry.deletions}</span>`
254
+ : ''
255
+ const status = entry && entry.status !== 'modified' ? `<span class="status">${esc(entry.status)}</span>` : ''
256
+ const path =
257
+ entry?.oldPath !== undefined
258
+ ? `<span class="old">${esc(entry.oldPath)} &rarr; </span>${esc(lf.path)}`
259
+ : esc(lf.path)
260
+ const testTag = lf.isTest ? ' <span class="pill test-tag">test</span>' : ''
261
+ const note = lf.note
262
+ ? `<div class="note"><span class="lbl">Reviewer note</span><div class="prose">${renderMarkdown(lf.note, { paths: ctx.paths })}</div></div>`
263
+ : ''
264
+ const elsewhere = elsewhereHtml(lf, entry, layer, ctx.hunkIndex)
265
+ return (
266
+ `<pr-file><article class="file${lf.isTest ? ' test' : ''}${cardReviewed ? ' is-reviewed' : ''}" id="${esc(id)}" data-key="${esc(key)}" data-path="${esc(lf.path)}" data-layer="${esc(layer.id)}" aria-labelledby="${esc(id)}-h">` +
267
+ `<div class="file-h">${chevronHtml('Collapse file', !collapsed, { act: 'toggle-card' })}<h3 id="${esc(id)}-h" class="path">${path}${testTag}</h3>${status}${pills}` +
268
+ `<label class="chk"><input type="checkbox" data-reviewed-id="${esc(cardReviewedId)}"${cardReviewed ? ' checked' : ''}> reviewed</label>` +
269
+ `<span class="tbtns">${askButtonHtml({ kind: 'file', path: lf.path })}</span></div>` +
270
+ `<div class="file-body"${collapsed ? ' hidden' : ''}>${note}<div class="diff-host" data-key="${esc(key)}" data-hunks="${esc(lf.hunks.join(','))}"><div class="loading">Loading diff…</div></div>${elsewhere}</div></article></pr-file>`
271
+ )
272
+ }
273
+
274
+ /**
275
+ * "2 more hunks in layer 4 · title" when a file's other hunks live in other layers.
276
+ * @param {LayerFile} lf
277
+ * @param {FileEntry | undefined} entry
278
+ * @param {Layer} layer
279
+ * @param {Map<string, { layer: Layer, index: number }>} hunkIndex
280
+ */
281
+ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
282
+ if (!entry) {
283
+ return ''
284
+ }
285
+ /** @type {Map<string, { n: number, layer: Layer, index: number }>} */
286
+ const others = new Map()
287
+ for (const h of entry.hunks) {
288
+ if (lf.hunks.includes(h.id)) {
289
+ continue
290
+ }
291
+ const where = hunkIndex.get(h.id)
292
+ if (!where || where.layer.id === layer.id) {
293
+ continue
294
+ }
295
+ const cur = others.get(where.layer.id) ?? { n: 0, layer: where.layer, index: where.index }
296
+ cur.n++
297
+ others.set(where.layer.id, cur)
298
+ }
299
+ if (others.size === 0) {
300
+ return ''
301
+ }
302
+ const parts = [...others.values()].map(
303
+ o =>
304
+ `${o.n} more ${o.n === 1 ? 'hunk' : 'hunks'} in <a href="#${esc(layerAnchorId(o.layer.key))}">${o.layer.kind === 'other' ? esc(o.layer.title) : `layer ${o.index + 1} · ${esc(o.layer.title)}`}</a>`
305
+ )
306
+ return `<div class="more-hunks">${parts.join(' · ')}</div>`
307
+ }
308
+
309
+ /**
310
+ * @param {Layer} layer
311
+ * @param {number} index 0-based position among all layers
312
+ * @param {ReviewArtifact} artifact
313
+ * @param {ReadonlyArray<FileEntry>} files
314
+ * @param {PrState} state
315
+ * @param {{ hunkIndex: Map<string, { layer: Layer, index: number }>, paths: ReadonlySet<string>, firstCardFor: Set<string>, state?: PrState, posted?: ReadonlyMap<string, string>, commentPaths?: ReadonlySet<string> }} ctx
316
+ * @returns {string}
317
+ */
318
+ export function renderLayerSection(layer, index, artifact, files, state, ctx) {
319
+ const byPath = new Map(files.map(f => [f.path, f]))
320
+ const cards = layer.files
321
+ .map(lf =>
322
+ renderFileCard(lf, byPath.get(lf.path), layer, {
323
+ ...ctx,
324
+ keepOpen:
325
+ ctx.commentPaths?.has(lf.path) === true ||
326
+ artifact.points.some(p => p.path === lf.path && p.layerId === layer.id),
327
+ })
328
+ )
329
+ .join('')
330
+ const id = layerAnchorId(layer.key)
331
+ if (layer.kind === 'other') {
332
+ return (
333
+ `<pr-layer><section class="panel" id="${esc(id)}" aria-labelledby="${esc(id)}-h"><details>${detailsSummaryHtml(`<h2 id="${esc(id)}-h">${esc(layer.title)} <span class="muted">&mdash; ${fileCount(layer.files.length)}, ignored</span></h2>`, 'Toggle other changes')}` +
334
+ `<div class="body"><div class="rationale prose">${renderMarkdown(layer.rationale, { paths: ctx.paths, diagrams: true })}</div>${layerDiagramHtml(layer)}</div>${cards}</details></section></pr-layer>`
335
+ )
336
+ }
337
+ const semanticIndex = artifact.layers.filter(l => l.kind !== 'other').findIndex(l => l.id === layer.id)
338
+ const total = artifact.layers.filter(l => l.kind !== 'other').length
339
+ const risks =
340
+ layer.risk.length > 0
341
+ ? `<span class="risks">${layer.risk
342
+ .map(
343
+ r =>
344
+ `<span class="pill risk ${r.source}"${r.reason ? ` title="${esc(r.reason)}"` : ''}>${esc(r.label)}</span>`
345
+ )
346
+ .join('')}</span>`
347
+ : ''
348
+ const reviewed = layerProgress(layer, state) === 'done'
349
+ const layerReviewedId = `layer:${layer.id}`
350
+ return (
351
+ `<pr-layer><section class="layer${reviewed ? ' is-reviewed' : ''}" id="${esc(id)}" data-layer="${esc(layer.id)}" aria-labelledby="${esc(id)}-h" style="--dc:${dotColor(index)}">` +
352
+ `<div class="panel-h layer-h">${chevronHtml('Collapse layer', !reviewed, { act: 'toggle-card' })}<h2 id="${esc(id)}-h"><span class="lbl">Layer ${semanticIndex + 1} of ${total}</span>${esc(layer.title)}${risks}</h2>` +
353
+ `<div class="layer-ctl"><label class="chk"><input type="checkbox" data-reviewed-id="${esc(layerReviewedId)}"${reviewed ? ' checked' : ''}> reviewed</label>` +
354
+ `${askButtonHtml({ kind: 'layer', layerId: layer.id }, { label: 'ask about this layer' })}</div></div>` +
355
+ `<div class="layer-body"${reviewed ? ' hidden' : ''}>` +
356
+ `<div class="body"><div class="rationale prose">${renderMarkdown(layer.rationale, { paths: ctx.paths, diagrams: true })}</div>${layerDiagramHtml(layer)}${judgmentHtml(layer, ctx.paths)}</div>` +
357
+ testMapHtml(layer, ctx.paths) +
358
+ layerPointsHtml(layer, artifact.points, ctx.paths, state, ctx.posted) +
359
+ `<h3 class="lbl sub">Files · ${layer.files.length}</h3><div class="files">${cards}</div>` +
360
+ `<div class="layer-end"><button class="cmd" type="button" data-act="mark-layer" data-reviewed-id="${esc(layerReviewedId)}">mark layer as reviewed</button></div>` +
361
+ '</div></section></pr-layer>'
362
+ )
363
+ }
364
+
365
+ /**
366
+ * Every layer section in order.
367
+ * @param {ReviewArtifact} artifact
368
+ * @param {ReadonlyArray<FileEntry>} files
369
+ * @param {PrState} state
370
+ * @param {ReadonlyArray<ReviewComment>} [comments] so a point that was posted says so
371
+ */
372
+ export function renderLayers(artifact, files, state, comments = []) {
373
+ const ctx = {
374
+ hunkIndex: hunkLayerIndex(artifact),
375
+ paths: pathSet(files),
376
+ firstCardFor: new Set(),
377
+ state,
378
+ posted: postedUrls(state, comments),
379
+ commentPaths: new Set(comments.map(comment => comment.path)),
380
+ }
381
+ return artifact.layers.map((layer, i) => renderLayerSection(layer, i, artifact, files, state, ctx)).join('')
382
+ }
383
+
384
+ /** Above this many patch lines a file card waits for a click before it draws its diff. */
385
+ export const DEFERRED_DIFF_LINES = 2000
386
+
387
+ /**
388
+ * @param {string} patch
389
+ * @returns {number}
390
+ */
391
+ export function patchLineCount(patch) {
392
+ return patch === '' ? 0 : patch.split('\n').length
393
+ }
394
+
395
+ /**
396
+ * Renders the diff of one card from the render context and applies its decorations. A patch of
397
+ * more than DEFERRED_DIFF_LINES lines draws a `[ show diff ]` command instead, so one huge file
398
+ * does not stall the page; `force` draws it anyway.
399
+ * @param {HTMLElement} card the <article class="file">
400
+ * @param {RenderContext} ctx
401
+ * @param {{ force?: boolean }} [opts]
402
+ * @returns {{ rendered: boolean, deferred: boolean, placed: number, missed: number }}
403
+ */
404
+ export function hydrateFileCard(card, ctx, opts = {}) {
405
+ const host = card.querySelector('.diff-host')
406
+ const key = card.getAttribute('data-key')
407
+ const layerId = card.getAttribute('data-layer')
408
+ if (!(host instanceof HTMLElement) || key === null || layerId === null) {
409
+ return { rendered: false, deferred: false, placed: 0, missed: 0 }
410
+ }
411
+ const entry = ctx.files.find(f => f.key === key)
412
+ const patch = ctx.patches?.[key]
413
+ if (!entry || patch === undefined) {
414
+ host.innerHTML = '<div class="unavailable">Diff not available locally. Fetch the PR head and reload.</div>'
415
+ return { rendered: false, deferred: false, placed: 0, missed: 0 }
416
+ }
417
+ const lines = patchLineCount(patch)
418
+ if (opts.force !== true && lines > DEFERRED_DIFF_LINES) {
419
+ const panel = document.createElement('div')
420
+ panel.className = 'deferred'
421
+ const hint = document.createElement('p')
422
+ hint.className = 'hint'
423
+ hint.textContent = `${lines} diff lines, above the ${DEFERRED_DIFF_LINES}-line limit for drawing a file on sight.`
424
+ const show = document.createElement('button')
425
+ show.className = 'cmd fill'
426
+ show.type = 'button'
427
+ show.setAttribute('data-act', 'show-diff')
428
+ show.textContent = 'show diff'
429
+ show.addEventListener('click', () => {
430
+ void runCommand(
431
+ show,
432
+ async () => {
433
+ hydrateFileCard(card, ctx, { force: true })
434
+ // The host element now holds a drawn diff, so a later link into it leaves it alone.
435
+ const host = card.closest('pr-file')
436
+ if (host instanceof PrFileElement) {
437
+ host.rendered = true
438
+ host.deferred = false
439
+ }
440
+ },
441
+ { pendingLabel: 'drawing…' }
442
+ )
443
+ })
444
+ panel.append(hint, show)
445
+ host.replaceChildren(panel)
446
+ return { rendered: false, deferred: true, placed: 0, missed: 0 }
447
+ }
448
+ const hunkIds = new Set((host.getAttribute('data-hunks') ?? '').split(',').filter(Boolean))
449
+ host.innerHTML = renderDiff({ key: entry.key, path: entry.path, lang: entry.lang }, patch, { hunkIds })
450
+ const layer = ctx.artifact.layers.find(l => l.id === layerId)
451
+ const lf = layer?.files.find(f => f.path === entry.path)
452
+ const annotations = lf?.annotations ?? []
453
+ const points = ctx.artifact.points.filter(p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry)))
454
+ const threads = threadsForHunks(ctx.comments, entry, hunkIds)
455
+ const { placed, missed } = applyDecorations(card, key, {
456
+ annotations,
457
+ points,
458
+ threads,
459
+ paths: pathSet(ctx.files),
460
+ now: ctx.now,
461
+ state: ctx.state,
462
+ posted: postedUrls(ctx.state, ctx.comments),
463
+ hiddenThreads: new Set(Object.keys(ctx.state.hiddenThreads).map(Number)),
464
+ })
465
+ applyCodeFolds(card, key, lf?.folds ?? [])
466
+ wireFoldReveal(card)
467
+ cardRenderedHook?.(card)
468
+ return { rendered: true, deferred: false, placed, missed }
469
+ }
470
+
471
+ /**
472
+ * The hunk id that contains a point's line on its side, or '' when outside every hunk.
473
+ * @param {Point} p
474
+ * @param {FileEntry} entry
475
+ */
476
+ export function hunkIdForPoint(p, entry) {
477
+ return hunkForLine(entry.hunks, p.side ?? 'new', p.line)?.id ?? ''
478
+ }
479
+
480
+ /**
481
+ * Threads anchored on lines of the given hunks.
482
+ * @param {ReadonlyArray<ReviewComment>} comments
483
+ * @param {FileEntry} entry
484
+ * @param {ReadonlySet<string>} hunkIds
485
+ * @returns {Thread[]}
486
+ */
487
+ export function threadsForHunks(comments, entry, hunkIds) {
488
+ const { byAnchor } = buildThreads(comments.filter(c => c.path === entry.path))
489
+ /** @type {Thread[]} */
490
+ const out = []
491
+ for (const h of entry.hunks) {
492
+ if (!hunkIds.has(h.id)) {
493
+ continue
494
+ }
495
+ for (const side of /** @type {const} */ (['new', 'old'])) {
496
+ const start = side === 'new' ? h.newStart : h.oldStart
497
+ const count = side === 'new' ? h.newLines : h.oldLines
498
+ for (let line = start; line <= start + Math.max(count, 1) - 1; line++) {
499
+ out.push(...(byAnchor.get(anchorKey(entry.path, side, line)) ?? []))
500
+ }
501
+ }
502
+ }
503
+ return out
504
+ }
505
+
506
+ /**
507
+ * Light-DOM element: renders its diff once, when it first becomes visible (at once without
508
+ * IntersectionObserver). The render context must be set before the element connects.
509
+ */
510
+ export class PrFileElement extends HTMLElement {
511
+ rendered = false
512
+ /** The card shows the `[ show diff ]` command because its patch is over the line limit. */
513
+ deferred = false
514
+ /** @type {IntersectionObserver | null} */
515
+ observer = null
516
+
517
+ /**
518
+ * Draws the diff now instead of waiting to be seen. A link into a card the reader has not
519
+ * scrolled to calls this with `force`, so the row it points at exists before the jump even
520
+ * when the card is holding a huge patch back.
521
+ * @param {boolean} [force]
522
+ * @returns {boolean} true when this call drew it
523
+ */
524
+ renderNow(force = false) {
525
+ const card = this.querySelector('article.file')
526
+ const ctx = getRenderContext()
527
+ if (!(card instanceof HTMLElement) || ctx === null) {
528
+ return false
529
+ }
530
+ const redraws = force && this.deferred
531
+ if (this.rendered && redraws === false) {
532
+ return false
533
+ }
534
+ this.stopObserving()
535
+ const result = hydrateFileCard(card, ctx, force ? { force: true } : {})
536
+ this.rendered = true
537
+ this.deferred = result.deferred
538
+ return true
539
+ }
540
+
541
+ connectedCallback() {
542
+ if (this.rendered || !(this.querySelector('article.file') instanceof HTMLElement)) {
543
+ return
544
+ }
545
+ if (typeof IntersectionObserver === 'undefined') {
546
+ this.renderNow()
547
+ return
548
+ }
549
+ // Moving the element re-runs this callback; the watcher of the previous run is dropped first.
550
+ this.stopObserving()
551
+ const io = new IntersectionObserver(entries => {
552
+ if (entries.some(e => e.isIntersecting)) {
553
+ this.stopObserving()
554
+ this.renderNow()
555
+ }
556
+ })
557
+ this.observer = io
558
+ io.observe(this)
559
+ }
560
+
561
+ disconnectedCallback() {
562
+ this.stopObserving()
563
+ }
564
+
565
+ stopObserving() {
566
+ this.observer?.disconnect()
567
+ this.observer = null
568
+ }
569
+ }
570
+
571
+ export class PrLayerElement extends HTMLElement {}
572
+
573
+ /** Registers the custom elements once. */
574
+ export function defineLayerElements() {
575
+ if (!customElements.get('pr-file')) {
576
+ customElements.define('pr-file', PrFileElement)
577
+ }
578
+ if (!customElements.get('pr-layer')) {
579
+ customElements.define('pr-layer', PrLayerElement)
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Hydrates every card under root now. Tests use it to render a whole page in one call.
585
+ * @param {ParentNode} root
586
+ * @param {RenderContext} ctx
587
+ */
588
+ export function hydrateAll(root, ctx) {
589
+ let rendered = 0
590
+ for (const card of Array.from(root.querySelectorAll('article.file'))) {
591
+ if (card instanceof HTMLElement && hydrateFileCard(card, ctx).rendered) {
592
+ rendered++
593
+ }
594
+ }
595
+ return rendered
596
+ }