@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.
Files changed (137) hide show
  1. package/README.md +79 -31
  2. package/docs/reference.md +396 -101
  3. package/package.json +9 -4
  4. package/pr-review.config.example.yml +44 -4
  5. package/prompts/chat-seed.md +3 -0
  6. package/prompts/generation-format.md +123 -26
  7. package/prompts/generation-strict-incremental.md +53 -0
  8. package/prompts/generation-strict.md +1 -27
  9. package/prompts/generation-surfacing-incremental.md +56 -0
  10. package/prompts/generation-surfacing.md +1 -58
  11. package/prompts/judging-strict.md +27 -0
  12. package/prompts/judging-surfacing.md +58 -0
  13. package/skills/pr-review-canvas/SKILL.md +81 -42
  14. package/src/acpx/acpx.ts +98 -5
  15. package/src/acpx/models.ts +43 -0
  16. package/src/canvas/comment.ts +24 -0
  17. package/src/canvas/import.ts +30 -9
  18. package/src/canvas/name.ts +1 -0
  19. package/src/canvas/zip.ts +21 -1
  20. package/src/chat/chat-manager.ts +67 -40
  21. package/src/chat/threads.ts +27 -26
  22. package/src/cli.ts +70 -8
  23. package/src/commands.ts +50 -17
  24. package/src/config.ts +14 -26
  25. package/src/contract/api.ts +58 -2
  26. package/src/contract/canvas-manifest.ts +7 -0
  27. package/src/contract/comments.ts +5 -0
  28. package/src/contract/discovery.ts +5 -2
  29. package/src/contract/generation-context.ts +76 -2
  30. package/src/contract/keys.ts +1 -0
  31. package/src/contract/pending.ts +49 -0
  32. package/src/contract/review-artifact.ts +50 -7
  33. package/src/contract/review-key.ts +51 -0
  34. package/src/contract/reviews.ts +26 -0
  35. package/src/contract/settings.ts +7 -0
  36. package/src/contract/state.ts +53 -22
  37. package/src/contract/validation.ts +1 -0
  38. package/src/git/environment.mjs +27 -0
  39. package/src/git/git.ts +109 -9
  40. package/src/git/local-target.ts +138 -0
  41. package/src/git/patch-lines.ts +34 -2
  42. package/src/git/pr-refs.ts +36 -0
  43. package/src/github/attachments.ts +9 -257
  44. package/src/github/canvas-comment.ts +22 -0
  45. package/src/github/capabilities.ts +3 -41
  46. package/src/github/comments.ts +3 -24
  47. package/src/github/post-comment.ts +3 -36
  48. package/src/github/post-review.ts +64 -23
  49. package/src/github/pr.ts +6 -87
  50. package/src/github/threads.ts +2 -2
  51. package/src/gitlab/attachments.ts +40 -0
  52. package/src/gitlab/canvas-comment.ts +26 -0
  53. package/src/gitlab/capabilities.ts +64 -0
  54. package/src/gitlab/comments.ts +164 -0
  55. package/src/gitlab/mr.ts +115 -0
  56. package/src/gitlab/post-comment.ts +111 -0
  57. package/src/gitlab/post-review.ts +93 -0
  58. package/src/gitlab/project.ts +13 -0
  59. package/src/gitlab/publish-drafts.ts +69 -0
  60. package/src/host/attachments.ts +293 -0
  61. package/src/host/capabilities.ts +38 -0
  62. package/src/host/client.ts +246 -0
  63. package/src/host/host.ts +154 -0
  64. package/src/host/pr.ts +51 -0
  65. package/src/host/remote.ts +42 -0
  66. package/src/project-config.ts +26 -0
  67. package/src/review/carry-marks.ts +131 -0
  68. package/src/review/carry-over.ts +79 -0
  69. package/src/review/doctor.ts +82 -38
  70. package/src/review/incremental.ts +107 -0
  71. package/src/review/normalize.ts +14 -4
  72. package/src/review/prepare.ts +111 -10
  73. package/src/review/prompt.ts +112 -5
  74. package/src/review/publish.ts +62 -10
  75. package/src/{github → review}/review-body.ts +17 -5
  76. package/src/review/skill-command.ts +5 -3
  77. package/src/review/test-paths.ts +44 -4
  78. package/src/review/validate-folds.ts +349 -24
  79. package/src/review/validate.ts +14 -5
  80. package/src/server/bundle.ts +324 -111
  81. package/src/server/context.ts +10 -8
  82. package/src/server/errors.ts +30 -8
  83. package/src/server/html.ts +35 -11
  84. package/src/server/routes/api.ts +63 -26
  85. package/src/server/routes/chat-routes.ts +91 -44
  86. package/src/server/routes/pages.ts +26 -9
  87. package/src/server/routes/review-routes.ts +278 -67
  88. package/src/store/canvas-store.ts +93 -55
  89. package/src/store/data-dir.ts +2 -1
  90. package/src/store/derived-store.ts +41 -27
  91. package/src/store/pr-store.ts +23 -14
  92. package/src/store/settings-store.ts +9 -1
  93. package/src/store/state-store.ts +105 -36
  94. package/src/upgrade.ts +338 -0
  95. package/static/js/api.js +87 -25
  96. package/static/js/app.js +50 -15
  97. package/static/js/chat-panel.js +32 -9
  98. package/static/js/chat.js +30 -6
  99. package/static/js/code-folds.js +171 -44
  100. package/static/js/composer.js +132 -17
  101. package/static/js/contract-types.d.ts +7 -0
  102. package/static/js/diff-decorations.js +67 -1
  103. package/static/js/diff-renderer.js +1 -1
  104. package/static/js/download.js +1 -1
  105. package/static/js/empty-state.js +102 -18
  106. package/static/js/errors.js +22 -6
  107. package/static/js/fold-levels.js +176 -0
  108. package/static/js/header.js +65 -15
  109. package/static/js/host.js +40 -0
  110. package/static/js/import-zone.js +1 -1
  111. package/static/js/interactions.js +274 -46
  112. package/static/js/keyboard.js +4 -1
  113. package/static/js/keys.js +12 -0
  114. package/static/js/layers.js +295 -32
  115. package/static/js/links.js +3 -3
  116. package/static/js/markdown.js +28 -1
  117. package/static/js/nav.js +22 -4
  118. package/static/js/pending.js +161 -0
  119. package/static/js/points.js +70 -9
  120. package/static/js/progress.js +4 -5
  121. package/static/js/quick-questions.js +15 -2
  122. package/static/js/reading-level.js +97 -0
  123. package/static/js/review-session.js +113 -29
  124. package/static/js/settings.js +55 -24
  125. package/static/js/signoff.js +79 -10
  126. package/static/js/skin.js +2 -2
  127. package/static/styles/chat-panel.css +22 -24
  128. package/static/styles/chat.css +4 -0
  129. package/static/styles/commands.css +6 -0
  130. package/static/styles/header.css +21 -0
  131. package/static/styles/panels.css +4 -0
  132. package/static/styles/pending.css +102 -0
  133. package/static/styles/review-actions.css +1 -0
  134. package/static/styles/review.css +4 -0
  135. package/static/styles/skin-github.css +7 -1
  136. package/static/styles.css +1 -0
  137. package/src/github/gh.ts +0 -211
@@ -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 hunks in layer 4".
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 = `layer:${layer.id}/file:${sanitizeKey(lf.path)}`
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 hunks in layer 4 · title" when a file's other hunks live in other layers.
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 ? '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>`
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>, commentPaths?: ReadonlySet<string> }} ctx
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 = `layer:${layer.id}`
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
- commentPaths: new Set(comments.map(comment => comment.path)),
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 layer = ctx.artifact.layers.find(l => l.id === layerId)
461
- const lf = layer?.files.find(f => f.path === entry.path)
462
- const annotations = lf?.annotations ?? []
463
- const points = ctx.artifact.points.filter(
464
- p => p.path === entry.path && hunkIds.has(hunkIdForPoint(p, entry))
465
- )
466
- const threads = threadsForHunks(ctx.comments, entry, hunkIds)
467
- const { placed, missed } = applyDecorations(card, key, {
468
- annotations,
469
- points,
470
- threads,
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
- applyCodeFolds(card, key, lf?.folds ?? [])
478
- wireFoldReveal(card)
479
- cardRenderedHook?.(card)
480
- return { rendered: true, deferred: false, placed, missed }
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
+ }
@@ -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} hunks)`,
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 hunk of the diff (${hunkLineRanges(file.hunks, link.side)})`,
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} hunk ${link.n}`
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
  }
@@ -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
- if (!/^https:\/\//i.test(img.getAttribute('src') ?? '')) {
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({ kind: 'layer', id: layerAnchorId(layer.key), layerId: layer.id, key: layer.key, other })
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({ kind: 'file', id, layerId: layer.id, key, path: f.path, isTest: f.isTest, other })
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