@vintasoftware/pr-review-canvas 0.3.0 → 0.5.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/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
package/static/js/layers.js
CHANGED
|
@@ -12,17 +12,20 @@
|
|
|
12
12
|
/** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
|
|
13
13
|
/** @typedef {import('./threads.js').Thread} Thread */
|
|
14
14
|
import { askButtonHtml } from './ask.js'
|
|
15
|
-
import { applyCodeFolds, wireFoldReveal } from './code-folds.js'
|
|
15
|
+
import { applyCodeFolds, setCodeFoldLevel, wireFoldReveal } from './code-folds.js'
|
|
16
16
|
import { runCommand } from './commands.js'
|
|
17
17
|
import { diagramPlaceholderHtml } from './diagram.js'
|
|
18
|
-
import { applyDecorations } from './diff-decorations.js'
|
|
18
|
+
import { applyDecorations, refreshPendingRows, insertThreadRow } from './diff-decorations.js'
|
|
19
19
|
import { renderDiff } from './diff-renderer.js'
|
|
20
20
|
import { chevronHtml, detailsSummaryHtml, esc } from './dom.js'
|
|
21
|
+
import { collapsesAt, DEFAULT_FOLD_LEVEL, hiddenLines } from './fold-levels.js'
|
|
21
22
|
import { hunkForLine } from './hunks.js'
|
|
22
|
-
import { fileAnchorId, layerAnchorId, sanitizeKey } from './keys.js'
|
|
23
|
+
import { fileAnchorId, layerAnchorId, reviewedId, sanitizeKey } from './keys.js'
|
|
23
24
|
import { renderMarkdown } from './markdown.js'
|
|
25
|
+
import { pendingForPath } from './pending.js'
|
|
24
26
|
import { pointCardHtml, postedUrls } from './points.js'
|
|
25
27
|
import { filesReviewed, layerProgress } from './progress.js'
|
|
28
|
+
import { foldCountText } from './reading-level.js'
|
|
26
29
|
import { anchorKey, buildThreads } from './threads.js'
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -30,12 +33,25 @@ import { anchorKey, buildThreads } from './threads.js'
|
|
|
30
33
|
* artifact: ReviewArtifact,
|
|
31
34
|
* files: ReadonlyArray<FileEntry>,
|
|
32
35
|
* patches: Record<string, string> | null,
|
|
36
|
+
* headSha: string,
|
|
33
37
|
* comments: ReadonlyArray<ReviewComment>,
|
|
34
38
|
* state: PrState,
|
|
35
39
|
* now: Date,
|
|
36
40
|
* }} RenderContext
|
|
37
41
|
*/
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* How much code the reader hides. Page state: a review opens at the level saved in the settings
|
|
45
|
+
* file, light until the reader picks another, and the control or the `f` key changes it for this
|
|
46
|
+
* page only, so the file holds a default rather than the last thing the reader did.
|
|
47
|
+
* @type {import('./contract-types.js').FoldLevel}
|
|
48
|
+
*/
|
|
49
|
+
let foldLevel = DEFAULT_FOLD_LEVEL
|
|
50
|
+
|
|
51
|
+
export function getFoldLevel() {
|
|
52
|
+
return foldLevel
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
/** @type {RenderContext | null} */
|
|
40
56
|
let renderContext = null
|
|
41
57
|
|
|
@@ -77,7 +93,7 @@ export function pathSet(files) {
|
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
/**
|
|
80
|
-
* Layer title per hunk id, so a file card can say "2 more
|
|
96
|
+
* Layer title per hunk id, so a file card can say "2 more chunks in layer 4".
|
|
81
97
|
* @param {ReviewArtifact} artifact
|
|
82
98
|
* @returns {Map<string, { layer: Layer, index: number }>}
|
|
83
99
|
*/
|
|
@@ -241,6 +257,126 @@ export function layerPointsHtml(layer, points, paths, state, posted) {
|
|
|
241
257
|
return `<h3 class="lbl sub">Attention points · <span class="point-count">${active}</span></h3><ol class="findings">${cards.join('')}</ol>`
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Whether a card stays open whatever its `collapsed` level says: a discussion or an attention
|
|
262
|
+
* point on the file is what the reviewer came to see.
|
|
263
|
+
* @param {LayerFile} lf
|
|
264
|
+
* @param {string} layerId
|
|
265
|
+
* @param {ReviewArtifact} artifact
|
|
266
|
+
* @param {ReadonlySet<string> | undefined} commentPaths paths with a review comment
|
|
267
|
+
* @returns {boolean}
|
|
268
|
+
*/
|
|
269
|
+
export function cardKeepsOpen(lf, layerId, artifact, commentPaths) {
|
|
270
|
+
return (
|
|
271
|
+
commentPaths?.has(lf.path) === true ||
|
|
272
|
+
artifact.points.some(p => p.path === lf.path && p.layerId === layerId)
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What the counters and the discussion rule read of the page: the canvas, its diff, the comments,
|
|
278
|
+
* and the pending drafts in the state, which belong to `headSha`.
|
|
279
|
+
* @typedef {Pick<RenderContext, 'artifact' | 'files' | 'comments' | 'state' | 'headSha'>} CountContext
|
|
280
|
+
* @typedef {import('./reading-level.js').HiddenCounts} HiddenCounts
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* What the discussion keeps open in one card: a comment or an attention point stops it
|
|
285
|
+
* collapsing, and a thread or a pending draft in its chunks stops every fold. The card's folds and
|
|
286
|
+
* its counter both read this one answer, from the comments and drafts on the page now, so they
|
|
287
|
+
* cannot disagree.
|
|
288
|
+
* @param {LayerFile} lf
|
|
289
|
+
* @param {string} layerId
|
|
290
|
+
* @param {CountContext} ctx
|
|
291
|
+
* @param {ReadonlySet<string>} commentPaths paths with a review comment
|
|
292
|
+
* @returns {import('./fold-levels.js').Discussion}
|
|
293
|
+
*/
|
|
294
|
+
function cardDiscussion(lf, layerId, ctx, commentPaths) {
|
|
295
|
+
const entry = ctx.files.find(f => f.path === lf.path)
|
|
296
|
+
const hunkIds = new Set(lf.hunks)
|
|
297
|
+
return {
|
|
298
|
+
keepsOpen: cardKeepsOpen(lf, layerId, ctx.artifact, commentPaths),
|
|
299
|
+
discussed:
|
|
300
|
+
entry !== undefined &&
|
|
301
|
+
(threadsForHunks(ctx.comments, entry, hunkIds).length > 0 ||
|
|
302
|
+
draftsForCard(ctx, entry, hunkIds).length > 0),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {CountContext} ctx */
|
|
307
|
+
function commentPathsOf(ctx) {
|
|
308
|
+
return new Set(ctx.comments.map(comment => comment.path))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* How many diff lines a level hides in one layer, against what the layer shows in all. Counted
|
|
313
|
+
* from the model, so a card that has not drawn its diff yet still counts, and with the card's own
|
|
314
|
+
* discussion rule.
|
|
315
|
+
* @param {Layer} layer
|
|
316
|
+
* @param {CountContext} ctx
|
|
317
|
+
* @param {import('./contract-types.js').FoldLevel} level
|
|
318
|
+
* @returns {HiddenCounts}
|
|
319
|
+
*/
|
|
320
|
+
export function layerHiddenLines(layer, ctx, level) {
|
|
321
|
+
const commentPaths = commentPathsOf(ctx)
|
|
322
|
+
let total = 0
|
|
323
|
+
let hidden = 0
|
|
324
|
+
for (const lf of layer.files) {
|
|
325
|
+
const entry = ctx.files.find(f => f.path === lf.path)
|
|
326
|
+
const counts = hiddenLines(lf, entry?.hunks ?? [], level, cardDiscussion(lf, layer.id, ctx, commentPaths))
|
|
327
|
+
total += counts.total
|
|
328
|
+
hidden += counts.hidden
|
|
329
|
+
}
|
|
330
|
+
return { total, hidden }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The same over every layer, for the hint under the control and the sign-off note.
|
|
335
|
+
* @param {CountContext} ctx
|
|
336
|
+
* @param {import('./contract-types.js').FoldLevel} level
|
|
337
|
+
* @returns {HiddenCounts}
|
|
338
|
+
*/
|
|
339
|
+
export function canvasHiddenLines(ctx, level) {
|
|
340
|
+
return ctx.artifact.layers.reduce(
|
|
341
|
+
(sum, layer) => {
|
|
342
|
+
const counts = layerHiddenLines(layer, ctx, level)
|
|
343
|
+
return { total: sum.total + counts.total, hidden: sum.hidden + counts.hidden }
|
|
344
|
+
},
|
|
345
|
+
{ total: 0, hidden: 0 }
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The card a command belongs to, and the part of it that collapses.
|
|
351
|
+
* @param {Element} el
|
|
352
|
+
* @returns {{ card: HTMLElement, body: HTMLElement, chevron: Element | null } | null}
|
|
353
|
+
*/
|
|
354
|
+
export function cardOf(el) {
|
|
355
|
+
const card = el.closest('article.file, section.layer')
|
|
356
|
+
const body = card?.querySelector(':scope > .file-body, :scope > .layer-body')
|
|
357
|
+
if (!(card instanceof HTMLElement && body instanceof HTMLElement)) {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
return { card, body, chevron: card.querySelector(':scope > .file-h > .chev, :scope > .layer-h > .chev') }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Opens or collapses one card. Nothing else on the card changes, so clicking the chevron never
|
|
365
|
+
* touches the reviewed box and the other way round.
|
|
366
|
+
* @param {Element} el an element inside the card
|
|
367
|
+
* @param {boolean} [collapsed] the state to set; the opposite of the current one when omitted
|
|
368
|
+
*/
|
|
369
|
+
export function setCardCollapsed(el, collapsed) {
|
|
370
|
+
const parts = cardOf(el)
|
|
371
|
+
if (parts === null) {
|
|
372
|
+
return null
|
|
373
|
+
}
|
|
374
|
+
const next = collapsed ?? !parts.body.hidden
|
|
375
|
+
parts.body.toggleAttribute('hidden', next)
|
|
376
|
+
parts.chevron?.setAttribute('aria-expanded', next ? 'false' : 'true')
|
|
377
|
+
return next
|
|
378
|
+
}
|
|
379
|
+
|
|
244
380
|
/**
|
|
245
381
|
* @param {LayerFile} lf
|
|
246
382
|
* @param {FileEntry | undefined} entry
|
|
@@ -250,10 +386,9 @@ export function layerPointsHtml(layer, points, paths, state, posted) {
|
|
|
250
386
|
*/
|
|
251
387
|
export function renderFileCard(lf, entry, layer, ctx) {
|
|
252
388
|
const key = entry?.key ?? sanitizeKey(lf.path)
|
|
253
|
-
const cardReviewedId =
|
|
389
|
+
const cardReviewedId = reviewedId(layer.key, lf.path)
|
|
254
390
|
const cardReviewed = ctx.state?.reviewed[cardReviewedId] === true
|
|
255
|
-
const collapsed =
|
|
256
|
-
cardReviewed || (lf.collapsed === true && lf.annotations.length === 0 && ctx.keepOpen !== true)
|
|
391
|
+
const collapsed = cardReviewed || (collapsesAt(lf, foldLevel) && ctx.keepOpen !== true)
|
|
257
392
|
const isFirst = !ctx.firstCardFor.has(key)
|
|
258
393
|
ctx.firstCardFor.add(key)
|
|
259
394
|
const id = isFirst ? fileAnchorId(key) : `${fileAnchorId(key)}-${layer.key}`
|
|
@@ -281,7 +416,7 @@ export function renderFileCard(lf, entry, layer, ctx) {
|
|
|
281
416
|
}
|
|
282
417
|
|
|
283
418
|
/**
|
|
284
|
-
* "2 more
|
|
419
|
+
* "2 more chunks in layer 4 · title" when a file's other hunks live in other layers.
|
|
285
420
|
* @param {LayerFile} lf
|
|
286
421
|
* @param {FileEntry | undefined} entry
|
|
287
422
|
* @param {Layer} layer
|
|
@@ -310,7 +445,7 @@ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
|
|
|
310
445
|
}
|
|
311
446
|
const parts = [...others.values()].map(
|
|
312
447
|
o =>
|
|
313
|
-
`${o.n} more ${o.n === 1 ? '
|
|
448
|
+
`${o.n} more ${o.n === 1 ? 'chunk' : 'chunks'} 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>`
|
|
314
449
|
)
|
|
315
450
|
return `<div class="more-hunks">${parts.join(' · ')}</div>`
|
|
316
451
|
}
|
|
@@ -321,18 +456,17 @@ export function elsewhereHtml(lf, entry, layer, hunkIndex) {
|
|
|
321
456
|
* @param {ReviewArtifact} artifact
|
|
322
457
|
* @param {ReadonlyArray<FileEntry>} files
|
|
323
458
|
* @param {PrState} state
|
|
324
|
-
* @param {{ hunkIndex: Map<string, { layer: Layer, index: number }>, paths: ReadonlySet<string>, firstCardFor: Set<string>, state?: PrState, posted?: ReadonlyMap<string, string>,
|
|
459
|
+
* @param {{ hunkIndex: Map<string, { layer: Layer, index: number }>, paths: ReadonlySet<string>, firstCardFor: Set<string>, state?: PrState, posted?: ReadonlyMap<string, string>, comments: ReadonlyArray<ReviewComment>, headSha: string }} ctx
|
|
325
460
|
* @returns {string}
|
|
326
461
|
*/
|
|
327
462
|
export function renderLayerSection(layer, index, artifact, files, state, ctx) {
|
|
328
463
|
const byPath = new Map(files.map(f => [f.path, f]))
|
|
464
|
+
const commentPaths = new Set(ctx.comments.map(comment => comment.path))
|
|
329
465
|
const cards = layer.files
|
|
330
466
|
.map(lf =>
|
|
331
467
|
renderFileCard(lf, byPath.get(lf.path), layer, {
|
|
332
468
|
...ctx,
|
|
333
|
-
keepOpen:
|
|
334
|
-
ctx.commentPaths?.has(lf.path) === true ||
|
|
335
|
-
artifact.points.some(p => p.path === lf.path && p.layerId === layer.id),
|
|
469
|
+
keepOpen: cardKeepsOpen(lf, layer.id, artifact, commentPaths),
|
|
336
470
|
})
|
|
337
471
|
)
|
|
338
472
|
.join('')
|
|
@@ -355,7 +489,7 @@ export function renderLayerSection(layer, index, artifact, files, state, ctx) {
|
|
|
355
489
|
.join('')}</span>`
|
|
356
490
|
: ''
|
|
357
491
|
const reviewed = layerProgress(layer, state) === 'done'
|
|
358
|
-
const layerReviewedId =
|
|
492
|
+
const layerReviewedId = reviewedId(layer.key)
|
|
359
493
|
return (
|
|
360
494
|
`<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)}">` +
|
|
361
495
|
`<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>` +
|
|
@@ -365,7 +499,7 @@ export function renderLayerSection(layer, index, artifact, files, state, ctx) {
|
|
|
365
499
|
`<div class="body"><div class="rationale prose">${renderMarkdown(layer.rationale, { paths: ctx.paths, diagrams: true })}</div>${layerDiagramHtml(layer)}${judgmentHtml(layer, ctx.paths)}</div>` +
|
|
366
500
|
testMapHtml(layer, ctx.paths) +
|
|
367
501
|
layerPointsHtml(layer, artifact.points, ctx.paths, state, ctx.posted) +
|
|
368
|
-
`<h3 class="lbl sub">Files · ${layer.files.length}</h3><div class="files">${cards}</div>` +
|
|
502
|
+
`<h3 class="lbl sub">Files · ${layer.files.length}<span class="fold-count" data-layer="${esc(layer.id)}">${esc(foldCountText(layerHiddenLines(layer, { artifact, files, comments: ctx.comments, state, headSha: ctx.headSha }, foldLevel)))}</span></h3><div class="files">${cards}</div>` +
|
|
369
503
|
`<div class="layer-end"><button class="cmd" type="button" data-act="mark-layer" data-reviewed-id="${esc(layerReviewedId)}">mark layer as reviewed</button></div>` +
|
|
370
504
|
'</div></section></pr-layer>'
|
|
371
505
|
)
|
|
@@ -377,15 +511,17 @@ export function renderLayerSection(layer, index, artifact, files, state, ctx) {
|
|
|
377
511
|
* @param {ReadonlyArray<FileEntry>} files
|
|
378
512
|
* @param {PrState} state
|
|
379
513
|
* @param {ReadonlyArray<ReviewComment>} [comments] so a point that was posted says so
|
|
514
|
+
* @param {string} [headSha] the commit the page's drafts belong to, as in the render context
|
|
380
515
|
*/
|
|
381
|
-
export function renderLayers(artifact, files, state, comments = []) {
|
|
516
|
+
export function renderLayers(artifact, files, state, comments = [], headSha = artifact.pr.headSha) {
|
|
382
517
|
const ctx = {
|
|
383
518
|
hunkIndex: hunkLayerIndex(artifact),
|
|
384
519
|
paths: pathSet(files),
|
|
385
520
|
firstCardFor: new Set(),
|
|
386
521
|
state,
|
|
387
522
|
posted: postedUrls(state, comments),
|
|
388
|
-
|
|
523
|
+
comments,
|
|
524
|
+
headSha,
|
|
389
525
|
}
|
|
390
526
|
return artifact.layers.map((layer, i) => renderLayerSection(layer, i, artifact, files, state, ctx)).join('')
|
|
391
527
|
}
|
|
@@ -457,27 +593,87 @@ export function hydrateFileCard(card, ctx, opts = {}) {
|
|
|
457
593
|
}
|
|
458
594
|
const hunkIds = new Set((host.getAttribute('data-hunks') ?? '').split(',').filter(Boolean))
|
|
459
595
|
host.innerHTML = renderDiff({ key: entry.key, path: entry.path, lang: entry.lang }, patch, { hunkIds })
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
596
|
+
const lf = layerFileOf(ctx, layerId, entry.path)
|
|
597
|
+
const { placed, missed } = decorateCard(card, ctx, { key, layerId, entry, hunkIds })
|
|
598
|
+
const discussed = lf !== undefined && cardDiscussion(lf, layerId, ctx, commentPathsOf(ctx)).discussed
|
|
599
|
+
applyCodeFolds(card, key, lf?.folds ?? [], foldLevel, discussed)
|
|
600
|
+
wireFoldReveal(card)
|
|
601
|
+
cardRenderedHook?.(card)
|
|
602
|
+
return { rendered: true, deferred: false, placed, missed }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* @param {RenderContext} ctx
|
|
607
|
+
* @param {string} layerId
|
|
608
|
+
* @param {string} path
|
|
609
|
+
*/
|
|
610
|
+
function layerFileOf(ctx, layerId, path) {
|
|
611
|
+
return ctx.artifact.layers.find(l => l.id === layerId)?.files.find(f => f.path === path)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Puts every decoration on one drawn card: the annotations of its layer, the attention points and
|
|
616
|
+
* comment threads of the hunks it shows, and the pending comments anchored in it. The call is
|
|
617
|
+
* idempotent, so it is also how a card is brought up to date after the state changed.
|
|
618
|
+
* @param {HTMLElement} card
|
|
619
|
+
* @param {RenderContext} ctx
|
|
620
|
+
* @param {{ key: string, layerId: string, entry: FileEntry, hunkIds: ReadonlySet<string> }} at
|
|
621
|
+
*/
|
|
622
|
+
function decorateCard(card, ctx, at) {
|
|
623
|
+
return applyDecorations(card, at.key, {
|
|
624
|
+
annotations: layerFileOf(ctx, at.layerId, at.entry.path)?.annotations ?? [],
|
|
625
|
+
points: ctx.artifact.points.filter(
|
|
626
|
+
p => p.path === at.entry.path && at.hunkIds.has(hunkIdForPoint(p, at.entry))
|
|
627
|
+
),
|
|
628
|
+
threads: threadsForHunks(ctx.comments, at.entry, at.hunkIds),
|
|
629
|
+
pending: draftsForCard(ctx, at.entry, at.hunkIds),
|
|
471
630
|
paths: pathSet(ctx.files),
|
|
472
631
|
now: ctx.now,
|
|
473
632
|
state: ctx.state,
|
|
474
633
|
posted: postedUrls(ctx.state, ctx.comments),
|
|
475
634
|
hiddenThreads: new Set(Object.keys(ctx.state.hiddenThreads).map(Number)),
|
|
476
635
|
})
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Drafts only belong to the diff and hunk they were written on. Earlier-commit drafts are
|
|
640
|
+
* available in the pending bar, and the server checks diff equality before submitting them.
|
|
641
|
+
* @param {CountContext} ctx
|
|
642
|
+
* @param {FileEntry} entry
|
|
643
|
+
* @param {ReadonlySet<string>} hunkIds
|
|
644
|
+
*/
|
|
645
|
+
function draftsForCard(ctx, entry, hunkIds) {
|
|
646
|
+
return pendingForPath(ctx.state, entry.path).filter(
|
|
647
|
+
p => p.headSha === ctx.headSha && hunkIds.has(hunkForLine(entry.hunks, p.side, p.line)?.id ?? '')
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Update only drafts and newly posted threads; preserve existing decorations and editors.
|
|
652
|
+
* @param {ParentNode} root
|
|
653
|
+
* @param {RenderContext} ctx
|
|
654
|
+
* @param {ReadonlySet<string>} paths
|
|
655
|
+
* @param {ReadonlyArray<ReviewComment>} [submitted]
|
|
656
|
+
*/
|
|
657
|
+
export function refreshCardDecorations(root, ctx, paths = pathSet(ctx.files), submitted = []) {
|
|
658
|
+
let redrawn = 0
|
|
659
|
+
for (const card of root.querySelectorAll('article.file')) {
|
|
660
|
+
if (!(card instanceof HTMLElement) || !paths.has(card.getAttribute('data-path') ?? '')) continue
|
|
661
|
+
const key = card.getAttribute('data-key')
|
|
662
|
+
const host = card.querySelector('.diff-host')
|
|
663
|
+
const entry = ctx.files.find(f => f.key === key)
|
|
664
|
+
if (key === null || entry === undefined || host === null || host.querySelector('table.diff') === null)
|
|
665
|
+
continue
|
|
666
|
+
const hunkIds = new Set((host.getAttribute('data-hunks') ?? '').split(',').filter(Boolean))
|
|
667
|
+
refreshPendingRows(card, key, draftsForCard(ctx, entry, hunkIds), ctx.now)
|
|
668
|
+
for (const thread of threadsForHunks(submitted, entry, hunkIds)) {
|
|
669
|
+
if (card.querySelector(`[data-thread="${thread.root.id}"]`) === null) {
|
|
670
|
+
insertThreadRow(card, key, thread, { now: ctx.now })
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
cardRenderedHook?.(card)
|
|
674
|
+
redrawn++
|
|
675
|
+
}
|
|
676
|
+
return redrawn
|
|
481
677
|
}
|
|
482
678
|
|
|
483
679
|
/**
|
|
@@ -606,3 +802,70 @@ export function hydrateAll(root, ctx) {
|
|
|
606
802
|
}
|
|
607
803
|
return rendered
|
|
608
804
|
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Switches how much code the page hides. A card the reader opened or closed by hand follows the
|
|
808
|
+
* new level too: changing the level is a request to change exactly that. The folds and counters
|
|
809
|
+
* then follow as refreshFolds says.
|
|
810
|
+
* @param {ParentNode} root
|
|
811
|
+
* @param {import('./contract-types.js').FoldLevel} level
|
|
812
|
+
*/
|
|
813
|
+
export function setFoldLevel(root, level) {
|
|
814
|
+
foldLevel = level
|
|
815
|
+
const ctx = renderContext
|
|
816
|
+
if (ctx === null) {
|
|
817
|
+
return
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const commentPaths = commentPathsOf(ctx)
|
|
821
|
+
for (const card of Array.from(root.querySelectorAll('article.file'))) {
|
|
822
|
+
const lf =
|
|
823
|
+
card instanceof HTMLElement
|
|
824
|
+
? layerFileOf(ctx, card.getAttribute('data-layer') ?? '', card.getAttribute('data-path') ?? '')
|
|
825
|
+
: undefined
|
|
826
|
+
if (card instanceof HTMLElement && lf !== undefined) {
|
|
827
|
+
setCardCollapsed(
|
|
828
|
+
card,
|
|
829
|
+
card.classList.contains('is-reviewed') ||
|
|
830
|
+
(collapsesAt(lf, level) &&
|
|
831
|
+
!cardKeepsOpen(lf, card.getAttribute('data-layer') ?? '', ctx.artifact, commentPaths))
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
refreshFolds(root)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Points every drawn card's folds, and every layer's counter, at the level and at the comments
|
|
840
|
+
* on the page now. Each card switches which of its wired folds are active without rebuilding its
|
|
841
|
+
* diff, so an open composer or a thread keeps its place; a card with a thread folds nothing. A
|
|
842
|
+
* card waiting to be drawn picks both up when it draws. Called when the level changes and when a
|
|
843
|
+
* comment is posted, and leaves alone which cards the reader opened.
|
|
844
|
+
* @param {ParentNode} root
|
|
845
|
+
*/
|
|
846
|
+
export function refreshFolds(root) {
|
|
847
|
+
const ctx = renderContext
|
|
848
|
+
if (ctx === null) {
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
for (const counter of Array.from(root.querySelectorAll('.fold-count'))) {
|
|
853
|
+
const layer = ctx.artifact.layers.find(l => l.id === counter.getAttribute('data-layer'))
|
|
854
|
+
if (layer !== undefined) {
|
|
855
|
+
counter.textContent = foldCountText(layerHiddenLines(layer, ctx, foldLevel))
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const commentPaths = commentPathsOf(ctx)
|
|
860
|
+
for (const card of Array.from(root.querySelectorAll('article.file'))) {
|
|
861
|
+
const lf =
|
|
862
|
+
card instanceof HTMLElement
|
|
863
|
+
? layerFileOf(ctx, card.getAttribute('data-layer') ?? '', card.getAttribute('data-path') ?? '')
|
|
864
|
+
: undefined
|
|
865
|
+
if (card instanceof HTMLElement) {
|
|
866
|
+
const layerId = card.getAttribute('data-layer') ?? ''
|
|
867
|
+
const discussed = lf !== undefined && cardDiscussion(lf, layerId, ctx, commentPaths).discussed
|
|
868
|
+
setCodeFoldLevel(card, foldLevel, discussed)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
package/static/js/links.js
CHANGED
|
@@ -101,7 +101,7 @@ export function resolveLink(link, targets) {
|
|
|
101
101
|
if (link.kind === 'hunk' && (link.n < 1 || link.n > file.hunks.length)) {
|
|
102
102
|
return {
|
|
103
103
|
ok: false,
|
|
104
|
-
message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length}
|
|
104
|
+
message: `${link.path}#${link.n} does not exist (file has ${file.hunks.length} chunks)`,
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
if (link.kind === 'line') {
|
|
@@ -111,7 +111,7 @@ export function resolveLink(link, targets) {
|
|
|
111
111
|
const range = link.end === link.start ? `${link.start}` : `${link.start}-${link.end}`
|
|
112
112
|
return {
|
|
113
113
|
ok: false,
|
|
114
|
-
message: `${link.path}:${range} (${link.side}) is not inside one
|
|
114
|
+
message: `${link.path}:${range} (${link.side}) is not inside one chunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
@@ -149,7 +149,7 @@ export function linkLabel(link) {
|
|
|
149
149
|
case 'file':
|
|
150
150
|
return link.path
|
|
151
151
|
case 'hunk':
|
|
152
|
-
return `${link.path}
|
|
152
|
+
return `${link.path} chunk ${link.n}`
|
|
153
153
|
case 'line':
|
|
154
154
|
return `${link.path}:${link.start}${link.end !== link.start ? `-${link.end}` : ''}${link.side === 'old' ? ' (old)' : ''}`
|
|
155
155
|
}
|
package/static/js/markdown.js
CHANGED
|
@@ -147,6 +147,28 @@ function decorateLinks(root) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* GitHub images can require a signed-in browser session. Link to them so the browser
|
|
152
|
+
* can open them on their own origin, where that session is available.
|
|
153
|
+
* @param {HTMLImageElement} img
|
|
154
|
+
* @param {string} src
|
|
155
|
+
*/
|
|
156
|
+
function linkGitHubImage(img, src) {
|
|
157
|
+
const link = document.createElement('a')
|
|
158
|
+
link.href = src
|
|
159
|
+
const alt = img.alt.trim()
|
|
160
|
+
link.textContent = alt ? `View image on GitHub: ${alt}` : 'View image on GitHub'
|
|
161
|
+
if (img.title) link.title = img.title
|
|
162
|
+
const enclosingLink = img.closest('a')
|
|
163
|
+
if (enclosingLink) {
|
|
164
|
+
// Keep the original destination without creating nested links.
|
|
165
|
+
img.replaceWith(document.createTextNode(alt || 'Image'))
|
|
166
|
+
enclosingLink.after(document.createTextNode(' '), link)
|
|
167
|
+
} else {
|
|
168
|
+
img.replaceWith(link)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
/**
|
|
151
173
|
* One markdown text, without mermaid blocks.
|
|
152
174
|
* @param {string} src markdown
|
|
@@ -172,8 +194,13 @@ function renderProse(src, opts) {
|
|
|
172
194
|
if (!ALLOWED_TAGS.includes(element.tagName.toLowerCase())) element.remove()
|
|
173
195
|
}
|
|
174
196
|
for (const img of root.querySelectorAll('img')) {
|
|
175
|
-
|
|
197
|
+
const imageSrc = img.getAttribute('src') ?? ''
|
|
198
|
+
if (!/^https:\/\//i.test(imageSrc)) {
|
|
176
199
|
img.remove()
|
|
200
|
+
} else if (
|
|
201
|
+
/^https:\/\/(?:github\.com|(?:[a-z0-9-]+\.)*githubusercontent\.com)(?::443)?\//i.test(imageSrc)
|
|
202
|
+
) {
|
|
203
|
+
linkGitHubImage(img, imageSrc)
|
|
177
204
|
} else {
|
|
178
205
|
img.loading = 'lazy'
|
|
179
206
|
img.referrerPolicy = 'no-referrer'
|
package/static/js/nav.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { fileAnchorId, layerAnchorId, sanitizeKey } from './keys.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
+
* `layerId` names the layer for chat targets; `layerKey` is what a reviewed mark is keyed by, and
|
|
9
|
+
* a file item carries its layer's key so it can name the layer's mark without looking the layer up.
|
|
8
10
|
* @typedef {{ kind: 'overview', id: 'overview' }
|
|
9
|
-
* | { kind: 'layer', id: string, layerId: string, key: string, other: boolean }
|
|
10
|
-
* | { kind: 'file', id: string, layerId: string, key: string, path: string, isTest: boolean, other: boolean }} NavItem
|
|
11
|
+
* | { kind: 'layer', id: string, layerId: string, layerKey: string, key: string, other: boolean }
|
|
12
|
+
* | { kind: 'file', id: string, layerId: string, layerKey: string, key: string, path: string, isTest: boolean, other: boolean }} NavItem
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -26,13 +28,29 @@ export function buildNavOrder(artifact) {
|
|
|
26
28
|
const seen = new Set()
|
|
27
29
|
for (const layer of layers) {
|
|
28
30
|
const other = layer.kind === 'other'
|
|
29
|
-
out.push({
|
|
31
|
+
out.push({
|
|
32
|
+
kind: 'layer',
|
|
33
|
+
id: layerAnchorId(layer.key),
|
|
34
|
+
layerId: layer.id,
|
|
35
|
+
layerKey: layer.key,
|
|
36
|
+
key: layer.key,
|
|
37
|
+
other,
|
|
38
|
+
})
|
|
30
39
|
for (const f of layer.files) {
|
|
31
40
|
const key = sanitizeKey(f.path)
|
|
32
41
|
// The first card of a file carries the plain id; later cards of the same file add the layer key.
|
|
33
42
|
const id = seen.has(key) ? `${fileAnchorId(key)}-${layer.key}` : fileAnchorId(key)
|
|
34
43
|
seen.add(key)
|
|
35
|
-
out.push({
|
|
44
|
+
out.push({
|
|
45
|
+
kind: 'file',
|
|
46
|
+
id,
|
|
47
|
+
layerId: layer.id,
|
|
48
|
+
layerKey: layer.key,
|
|
49
|
+
key,
|
|
50
|
+
path: f.path,
|
|
51
|
+
isTest: f.isTest,
|
|
52
|
+
other,
|
|
53
|
+
})
|
|
36
54
|
}
|
|
37
55
|
}
|
|
38
56
|
return out
|