@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,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">−${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)} → </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">— ${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
|
+
}
|