@vintasoftware/pr-review-canvas 0.4.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 +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -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 +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- 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/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -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 +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- 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/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
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
|
|
|
@@ -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}`
|
|
@@ -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/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
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The pending review: the comments the reviewer has written and not submitted yet. They live in
|
|
3
|
+
// the local state, are drawn on the diff with a "pending" badge, and are counted in a bar that
|
|
4
|
+
// stays on screen for as long as any of them is waiting.
|
|
5
|
+
/** @typedef {import('./contract-types.js').PendingComment} PendingComment */
|
|
6
|
+
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
7
|
+
/** @typedef {import('./contract-types.js').Side} Side */
|
|
8
|
+
import { esc, timeAgo } from './dom.js'
|
|
9
|
+
import { hostLabel } from './host.js'
|
|
10
|
+
import { renderMarkdown } from './markdown.js'
|
|
11
|
+
|
|
12
|
+
export const PENDING_BAR_ID = 'pending-bar'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The drafts of one target, oldest first. A state written by an older tool version has no list
|
|
16
|
+
* at all, which reads as none waiting.
|
|
17
|
+
* @param {PrState | null | undefined} state
|
|
18
|
+
* @returns {ReadonlyArray<PendingComment>}
|
|
19
|
+
*/
|
|
20
|
+
export function pendingComments(state) {
|
|
21
|
+
return state?.pending ?? []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {PrState | null | undefined} state
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function pendingCount(state) {
|
|
29
|
+
return pendingComments(state).length
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The drafts anchored to one file, by the diff key the rows carry.
|
|
34
|
+
* @param {PrState | null | undefined} state
|
|
35
|
+
* @param {string} path
|
|
36
|
+
* @returns {ReadonlyArray<PendingComment>}
|
|
37
|
+
*/
|
|
38
|
+
export function pendingForPath(state, path) {
|
|
39
|
+
return pendingComments(state).filter(p => p.path === path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The draft an attention point was added to the review as, when it was. A point is queued at most
|
|
44
|
+
* once: its fingerprint is what ties the two together.
|
|
45
|
+
* @param {PrState | null | undefined} state
|
|
46
|
+
* @param {string} fingerprint
|
|
47
|
+
* @returns {PendingComment | undefined}
|
|
48
|
+
*/
|
|
49
|
+
export function pendingForPoint(state, fingerprint) {
|
|
50
|
+
return pendingComments(state).find(p => p.pointFingerprint === fingerprint)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {number} count */
|
|
54
|
+
export function pendingLabel(count) {
|
|
55
|
+
return count === 1 ? '1 pending comment' : `${count} pending comments`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a draft sits, said the way the selection toolbar says it.
|
|
60
|
+
* @param {PendingComment} p
|
|
61
|
+
*/
|
|
62
|
+
export function pendingRange(p) {
|
|
63
|
+
const range = p.startLine !== undefined && p.startLine !== p.line ? `${p.startLine}–${p.line}` : `${p.line}`
|
|
64
|
+
return `${p.path}:${range}${p.side === 'old' ? ' (old)' : ''}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One draft as it is drawn under the line it comments on: the body, when it was written, and the
|
|
69
|
+
* commands that edit or drop it. The badge is what tells the reviewer it is not on the forge yet.
|
|
70
|
+
* @param {PendingComment} p
|
|
71
|
+
* @param {Date} now
|
|
72
|
+
*/
|
|
73
|
+
export function pendingCommentHtml(p, now) {
|
|
74
|
+
// The same two-column shape a posted comment has, so drafts and comments line up on the diff:
|
|
75
|
+
// the marker takes the avatar's place, and the commands span both columns under them.
|
|
76
|
+
return (
|
|
77
|
+
`<div class="cmt pending-cmt" data-pending-id="${esc(p.id)}">` +
|
|
78
|
+
'<span class="av pending-av" aria-hidden="true">…</span>' +
|
|
79
|
+
`<span class="who"><span class="pill pending">pending</span> ` +
|
|
80
|
+
`<span class="muted">${esc(timeAgo(p.createdAt, now))} · not posted to ${esc(hostLabel())} yet</span></span>` +
|
|
81
|
+
`<div class="prose">${renderMarkdown(p.body, { github: true })}</div>` +
|
|
82
|
+
'<span class="tbtns">' +
|
|
83
|
+
`<button class="cmd" type="button" data-act="pending-edit" data-pending-id="${esc(p.id)}">edit</button>` +
|
|
84
|
+
`<button class="cmd" type="button" data-act="pending-delete" data-pending-id="${esc(p.id)}">delete</button>` +
|
|
85
|
+
'</span></div>'
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The row the draft is drawn in, so it can sit under its line in the diff table.
|
|
91
|
+
* @param {ReadonlyArray<PendingComment>} drafts the drafts anchored to one line
|
|
92
|
+
* @param {Date} now
|
|
93
|
+
*/
|
|
94
|
+
export function pendingRowHtml(drafts, now) {
|
|
95
|
+
return (
|
|
96
|
+
'<tr class="pending-row"><td class="code x" colspan="4">' +
|
|
97
|
+
drafts.map(p => pendingCommentHtml(p, now)).join('') +
|
|
98
|
+
'</td></tr>'
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The bar that says a review is being written. It is drawn only while something is waiting, and
|
|
104
|
+
* carries the two ways out: submit the review, or throw the drafts away.
|
|
105
|
+
* @param {number} count
|
|
106
|
+
* @param {ReadonlyArray<PendingComment>} [earlier]
|
|
107
|
+
*/
|
|
108
|
+
export function pendingBarHtml(count, earlier = []) {
|
|
109
|
+
if (count === 0) {
|
|
110
|
+
return ''
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
`<div class="pending-bar" id="${PENDING_BAR_ID}" role="status">` +
|
|
114
|
+
`<span class="pending-mark" aria-hidden="true"></span>` +
|
|
115
|
+
`<span class="pending-text"><b>${esc(pendingLabel(count))}</b> waiting in your review. ` +
|
|
116
|
+
`Nothing is on ${esc(hostLabel())} until you submit it.</span>` +
|
|
117
|
+
'<span class="pending-actions">' +
|
|
118
|
+
'<button class="cmd fill" type="button" data-act="pending-finish" data-needs-post>finish your review</button>' +
|
|
119
|
+
'<button class="cmd" type="button" data-act="pending-discard">discard</button>' +
|
|
120
|
+
'</span></div>' +
|
|
121
|
+
(earlier.length === 0
|
|
122
|
+
? ''
|
|
123
|
+
: '<details class="earlier-pending"><summary>Comments written on earlier commits</summary>' +
|
|
124
|
+
'<p>These drafts are kept at their original locations. They can be submitted only if the diff is unchanged. Otherwise copy the text, delete the draft, and comment on the current code.</p>' +
|
|
125
|
+
earlier
|
|
126
|
+
.map(
|
|
127
|
+
p =>
|
|
128
|
+
`<div class="earlier-draft"><p>${esc(pendingRange(p))} · commit ${esc(p.headSha.slice(0, 7))}</p>` +
|
|
129
|
+
`<div class="prose">${renderMarkdown(p.body, { github: true })}</div>` +
|
|
130
|
+
`<button class="cmd" type="button" data-copy="${esc(p.body)}">copy</button>` +
|
|
131
|
+
`<button class="cmd" type="button" data-act="pending-delete" data-pending-id="${esc(p.id)}">delete</button></div>`
|
|
132
|
+
)
|
|
133
|
+
.join('') +
|
|
134
|
+
'</details>')
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Draws the bar from the current state, adding it when the first draft appears and taking it away
|
|
140
|
+
* with the last one. The host element is where the bar lives; it is emptied when nothing waits.
|
|
141
|
+
* @param {ParentNode} root
|
|
142
|
+
* @param {PrState} state
|
|
143
|
+
* @param {string} [headSha]
|
|
144
|
+
* @returns {number} how many drafts are waiting
|
|
145
|
+
*/
|
|
146
|
+
export function refreshPendingBar(root, state, headSha) {
|
|
147
|
+
const count = pendingCount(state)
|
|
148
|
+
const host = root.querySelector('.pending-bar-host')
|
|
149
|
+
if (host !== null) {
|
|
150
|
+
const earlier = headSha === undefined ? [] : state.pending.filter(p => p.headSha !== headSha)
|
|
151
|
+
const signature = JSON.stringify([count, earlier])
|
|
152
|
+
if (host.getAttribute('data-pending-state') !== signature) {
|
|
153
|
+
const open = host.querySelector('details')?.open ?? false
|
|
154
|
+
host.innerHTML = pendingBarHtml(count, earlier)
|
|
155
|
+
host.setAttribute('data-pending-state', signature)
|
|
156
|
+
if (open) host.querySelector('details')?.setAttribute('open', '')
|
|
157
|
+
}
|
|
158
|
+
host.classList.toggle('has-pending', count > 0)
|
|
159
|
+
}
|
|
160
|
+
return count
|
|
161
|
+
}
|