@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.
Files changed (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
@@ -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 = `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}`
@@ -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
+ }
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
@@ -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">&hellip;</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
+ }