@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
@@ -17,32 +17,48 @@ import { replacePostButton } from './comment-link.js'
17
17
  import {
18
18
  applyCapabilityGating,
19
19
  closeComposers,
20
+ composerBody,
20
21
  composerHtml,
21
22
  composerInput,
23
+ composerPendingInput,
22
24
  composerRowHtml,
25
+ concealForComposer,
23
26
  focusComposer,
27
+ revealConcealed,
28
+ refreshComposerCommands,
24
29
  } from './composer.js'
25
30
  import { commentHtml, setThreadCollapsed, threadRowHtml } from './diff-decorations.js'
26
31
  import { flash, scrollIntoViewSafe } from './dom.js'
32
+ import { isFoldLevel, nextFoldLevel } from './fold-levels.js'
27
33
  import { refreshProgress } from './header.js'
28
34
  import { keyAction, openHelpDialog } from './keyboard.js'
29
- import { pointAnchorId, sanitizeKey } from './keys.js'
35
+ import { pointAnchorId, reviewedId } from './keys.js'
30
36
  import {
37
+ canvasHiddenLines,
38
+ cardOf,
39
+ getFoldLevel,
31
40
  getRenderContext,
32
41
  pathSet,
42
+ refreshCardDecorations,
43
+ refreshFolds,
44
+ setCardCollapsed,
33
45
  setCardRenderedHook,
46
+ setFoldLevel,
34
47
  setRenderContext,
35
48
  updateRenderState,
36
49
  } from './layers.js'
37
50
  import { buildNavOrder, layerOf, nextFile, nextLayer, prevFile, prevLayer } from './nav.js'
38
51
  import { issueCommentHtml } from './overview.js'
52
+ import { pendingCount, refreshPendingBar } from './pending.js'
39
53
  import { applyDismissed, pointToMarkdown, postedUrls } from './points.js'
40
54
  import { layerProgress } from './progress.js'
55
+ import { FOLD_LEVEL_SELECT_ID, hiddenLabel, refreshFoldLevel } from './reading-level.js'
41
56
  import { lineRefFromEvent, markSelection, selectionReducer } from './selection.js'
42
57
  import {
43
58
  fillSignoffDialog,
44
59
  openSignoffDialog,
45
60
  showSignoffError,
61
+ setSignoffFolds,
46
62
  showSignoffResult,
47
63
  signoffBody,
48
64
  } from './signoff.js'
@@ -51,6 +67,16 @@ import {
51
67
 
52
68
  const NO_CHAT_NOTE = 'the AI Chat pane is off; press ? for the key map'
53
69
 
70
+ /**
71
+ * The event a sign-off dialog is set to. An attribute that names none reads as a comment-only
72
+ * review, the one of the three that claims nothing.
73
+ * @param {string | null} raw
74
+ * @returns {import('./contract-types.js').ReviewEvent}
75
+ */
76
+ export function signoffEvent(raw) {
77
+ return raw === 'APPROVE' || raw === 'REQUEST_CHANGES' ? raw : 'COMMENT'
78
+ }
79
+
54
80
  /** @type {WeakMap<Element, ReturnType<typeof setTimeout>>} */
55
81
  const toastTimers = new WeakMap()
56
82
 
@@ -103,37 +129,6 @@ export function toast(root, message) {
103
129
  return box
104
130
  }
105
131
 
106
- /**
107
- * The card a command belongs to, and the part of it that collapses.
108
- * @param {Element} el
109
- * @returns {{ card: HTMLElement, body: HTMLElement, chevron: Element | null } | null}
110
- */
111
- export function cardOf(el) {
112
- const card = el.closest('article.file, section.layer')
113
- const body = card?.querySelector(':scope > .file-body, :scope > .layer-body')
114
- if (!(card instanceof HTMLElement && body instanceof HTMLElement)) {
115
- return null
116
- }
117
- return { card, body, chevron: card.querySelector(':scope > .file-h > .chev, :scope > .layer-h > .chev') }
118
- }
119
-
120
- /**
121
- * Opens or collapses one card. Nothing else on the card changes, so clicking the chevron never
122
- * touches the reviewed box and the other way round.
123
- * @param {Element} el an element inside the card
124
- * @param {boolean} [collapsed] the state to set; the opposite of the current one when omitted
125
- */
126
- export function setCardCollapsed(el, collapsed) {
127
- const parts = cardOf(el)
128
- if (parts === null) {
129
- return null
130
- }
131
- const next = collapsed ?? !parts.body.hidden
132
- parts.body.toggleAttribute('hidden', next)
133
- parts.chevron?.setAttribute('aria-expanded', next ? 'false' : 'true')
134
- return next
135
- }
136
-
137
132
  /**
138
133
  * The layer or file card a reviewed id belongs to.
139
134
  * @param {ParentNode} root
@@ -160,8 +155,7 @@ export function nextUnreviewedTarget(root, session, fromId, kind) {
160
155
  if (item === undefined || item.kind !== kind || item.other) {
161
156
  continue
162
157
  }
163
- const id =
164
- item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
158
+ const id = item.kind === 'file' ? reviewedId(item.layerKey, item.path) : reviewedId(item.layerKey)
165
159
  if (!session.isReviewed(id)) {
166
160
  return root.querySelector(`#${cssEscape(item.id)}`)
167
161
  }
@@ -236,6 +230,18 @@ export function wireReview(root, session, opts = {}) {
236
230
  getRenderContext()?.files.find(f => f.path === path)?.key ?? null
237
231
  /** The time the page was drawn, which the comments it adds are timed against. */
238
232
  const renderNow = () => getRenderContext()?.now ?? new Date()
233
+ /** @param {import('./contract-types.js').FoldLevel} level */
234
+ const hiddenAt = level =>
235
+ canvasHiddenLines(
236
+ getRenderContext() ?? {
237
+ artifact: session.artifact,
238
+ files: [],
239
+ comments: [],
240
+ state: session.state,
241
+ headSha: session.headSha,
242
+ },
243
+ level
244
+ )
239
245
 
240
246
  /**
241
247
  * Whether a card counts as reviewed, read the same way the page was first drawn: a layer is
@@ -247,13 +253,39 @@ export function wireReview(root, session, opts = {}) {
247
253
  if (id.includes('/file:')) {
248
254
  return state.reviewed[id] === true
249
255
  }
250
- const layer = session.artifact.layers.find(l => `layer:${l.id}` === id)
256
+ const layer = session.artifact.layers.find(l => reviewedId(l.key) === id)
251
257
  return layer === undefined ? state.reviewed[id] === true : layerProgress(layer, state) === 'done'
252
258
  }
253
259
 
260
+ let drawnPending = session.pending
261
+ let drawnComments = session.submittedComments
254
262
  /** Draws everything the local state decides, after it changed. */
255
263
  const onState = (/** @type {PrState} */ state) => {
256
264
  updateRenderState(state)
265
+ const ctx = getRenderContext()
266
+ if (ctx !== null) {
267
+ const changedPaths = new Set()
268
+ const previous = new Map(drawnPending.map(p => [p.id, p]))
269
+ for (const draft of state.pending) {
270
+ const old = previous.get(draft.id)
271
+ if (old === undefined || old.body !== draft.body) changedPaths.add(draft.path)
272
+ previous.delete(draft.id)
273
+ }
274
+ for (const draft of previous.values()) changedPaths.add(draft.path)
275
+ const added = session.submittedComments.filter(c => !drawnComments.some(old => old.id === c.id))
276
+ for (const comment of added) changedPaths.add(comment.path)
277
+ const next = { ...ctx, comments: [...ctx.comments, ...added] }
278
+ setRenderContext(next)
279
+ refreshCardDecorations(root, next, changedPaths, added)
280
+ if (changedPaths.size > 0) {
281
+ // A thread a submitted review adds, or a draft saved or deleted, changes what keeps a
282
+ // card's code open, as a posted comment does.
283
+ refreshFolds(root)
284
+ refreshFoldLevel(root, getFoldLevel(), hiddenAt(getFoldLevel()))
285
+ }
286
+ drawnPending = state.pending
287
+ drawnComments = session.submittedComments
288
+ }
257
289
  refreshProgress(root, session.artifact, state)
258
290
  applyDismissed(root, session.artifact.points, state, {
259
291
  paths: paths(),
@@ -274,6 +306,8 @@ export function wireReview(root, session, opts = {}) {
274
306
  setCardCollapsed(parts.card, reviewed)
275
307
  }
276
308
  }
309
+ refreshPendingBar(root, state, session.headSha)
310
+ refreshComposerCommands(root, state.pending.length > 0)
277
311
  applyCapabilityGating(root, session.capabilities)
278
312
  }
279
313
  const unsubscribe = session.subscribe(onState)
@@ -287,6 +321,9 @@ export function wireReview(root, session, opts = {}) {
287
321
  ...ctx,
288
322
  comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment],
289
323
  })
324
+ // A new thread keeps its card's code open at every level, and the counters say so.
325
+ refreshFolds(root)
326
+ refreshFoldLevel(root, getFoldLevel(), hiddenAt(getFoldLevel()))
290
327
  onState(session.state)
291
328
  }
292
329
  return answer
@@ -348,6 +385,7 @@ export function wireReview(root, session, opts = {}) {
348
385
  */
349
386
  const openComposer = (anchor, options, shape) => {
350
387
  closeComposers(root)
388
+ options = { ...options, pendingActive: pendingCount(session.state) > 0 }
351
389
  const node =
352
390
  shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
353
391
  if (node === null) {
@@ -523,9 +561,12 @@ export function wireReview(root, session, opts = {}) {
523
561
  )
524
562
  }
525
563
 
526
- /** @param {HTMLElement} button @param {'APPROVE' | 'REQUEST_CHANGES'} event */
564
+ /** @param {HTMLElement} button @param {import('./contract-types.js').ReviewEvent} event */
527
565
  const openSignoff = (button, event) => {
528
566
  const dialog = openSignoffDialog(root, { event })
567
+ const level = getFoldLevel()
568
+ const counts = hiddenAt(level)
569
+ setSignoffFolds(dialog, counts.hidden === 0 ? '' : `Read at the ${level} level · ${hiddenLabel(counts)}`)
529
570
  applyCapabilityGating(root, session.capabilities)
530
571
  signoffOpening += 1
531
572
  const opening = signoffOpening
@@ -556,19 +597,138 @@ export function wireReview(root, session, opts = {}) {
556
597
  if (!(dialog instanceof HTMLDialogElement)) {
557
598
  return
558
599
  }
559
- const event = dialog.getAttribute('data-event') === 'APPROVE' ? 'APPROVE' : 'REQUEST_CHANGES'
600
+ const event = signoffEvent(dialog.getAttribute('data-event'))
560
601
  const body = signoffBody(dialog)
561
602
  void runCommand(
562
603
  button,
563
604
  async () => {
564
- const review = await session.postReview(event, body === '' ? undefined : body)
605
+ const { review, submitted, warnings } = await session.postReview(
606
+ event,
607
+ body === '' ? undefined : body
608
+ )
565
609
  showSignoffResult(dialog, review)
566
- toast(root, event === 'APPROVE' ? 'approved on github' : 'changes requested on github')
610
+ for (const warning of warnings) {
611
+ dialog.querySelector('.signoff-result')?.append(document.createTextNode(` ${warning}`))
612
+ }
613
+ const verdict =
614
+ review.state === 'APPROVED'
615
+ ? 'approved'
616
+ : review.state === 'CHANGES_REQUESTED'
617
+ ? 'changes requested'
618
+ : 'review posted'
619
+ toast(
620
+ root,
621
+ `${verdict}${submitted > 0 ? ` with ${submitted} comment${submitted === 1 ? '' : 's'}` : ''}`
622
+ )
567
623
  },
568
624
  { pendingLabel: 'posting…' }
569
625
  )
570
626
  }
571
627
 
628
+ /**
629
+ * Switches how much code the page hides, from the control or from the `f` key. The hint under
630
+ * the control says what the new level hides and how much of the diff that is.
631
+ * @param {string} value
632
+ */
633
+ const applyFoldLevel = value => {
634
+ if (!isFoldLevel(value) || value === getFoldLevel()) {
635
+ return
636
+ }
637
+ setFoldLevel(root, value)
638
+ refreshFoldLevel(root, value, hiddenAt(value))
639
+ toast(root, `hiding code: ${value}`)
640
+ }
641
+
642
+ /**
643
+ * Adds what a composer holds to the pending review instead of posting it.
644
+ * @param {HTMLElement} button
645
+ * @param {Element} box
646
+ */
647
+ const queueFromComposer = (button, box) => {
648
+ const input = composerPendingInput(box)
649
+ if (input === null) {
650
+ showCommandError(button, 'write something first')
651
+ return
652
+ }
653
+ box.setAttribute('data-posting', '1')
654
+ void runCommand(
655
+ button,
656
+ async () => {
657
+ await session.addPending(input)
658
+ box.closest('tr.composer')?.remove()
659
+ box.remove()
660
+ toast(root, 'comment added to your review')
661
+ },
662
+ { pendingLabel: 'adding…' }
663
+ ).finally(() => box.removeAttribute('data-posting'))
664
+ }
665
+
666
+ /**
667
+ * Saves an edit to a draft that is already in the review.
668
+ * @param {HTMLElement} button
669
+ * @param {Element} box
670
+ */
671
+ const savePending = (button, box) => {
672
+ const id = box.getAttribute('data-pending-id')
673
+ const body = composerBody(box)
674
+ if (id === null) {
675
+ return
676
+ }
677
+ if (body === '') {
678
+ showCommandError(button, 'write something first')
679
+ return
680
+ }
681
+ void runCommand(
682
+ button,
683
+ async () => {
684
+ await session.editPending(id, body)
685
+ const host = box.parentElement
686
+ box.remove()
687
+ if (host !== null) revealConcealed(host.parentNode ?? root)
688
+ toast(root, 'draft updated')
689
+ },
690
+ { pendingLabel: 'saving…' }
691
+ )
692
+ }
693
+
694
+ /**
695
+ * Puts an attention point's text in the pending review instead of posting it. The point is
696
+ * tagged with its fingerprint, so once the review lands the point shows the comment it became.
697
+ * @param {HTMLElement} button
698
+ * @param {Point} point
699
+ */
700
+ const queuePoint = (button, point) => {
701
+ void runCommand(
702
+ button,
703
+ async () => {
704
+ await session.addPending({
705
+ path: point.path,
706
+ line: point.line,
707
+ side: point.side ?? 'new',
708
+ body: pointToMarkdown(point),
709
+ pointFingerprint: point.fingerprint,
710
+ })
711
+ toast(root, 'attention point added to your review')
712
+ },
713
+ { pendingLabel: 'adding…' }
714
+ )
715
+ }
716
+
717
+ /**
718
+ * @param {HTMLElement} button
719
+ * @param {string} id
720
+ */
721
+ const deletePending = (button, id) => {
722
+ void runCommand(
723
+ button,
724
+ async () => {
725
+ await session.deletePending(id)
726
+ toast(root, 'draft deleted')
727
+ },
728
+ { pendingLabel: 'deleting…' }
729
+ )
730
+ }
731
+
572
732
  /** @type {Record<string, (el: HTMLElement, event: MouseEvent) => void>} */
573
733
  const actions = {
574
734
  'toggle-card': el => {
@@ -596,6 +756,12 @@ export function wireReview(root, session, opts = {}) {
596
756
  postPoint(el, point)
597
757
  }
598
758
  },
759
+ 'point-queue': el => {
760
+ const point = pointById(el.getAttribute('data-point') ?? '')
761
+ if (point !== undefined) {
762
+ queuePoint(el, point)
763
+ }
764
+ },
599
765
  'comment-line': el => {
600
766
  const key = el.getAttribute('data-key') ?? ''
601
767
  const path = session.pathForKey(key)
@@ -616,8 +782,65 @@ export function wireReview(root, session, opts = {}) {
616
782
  postFromComposer(el, box)
617
783
  }
618
784
  },
619
- 'markdown-preview': el => toggleMarkdownPreview(el, true),
620
- 'markdown-write': el => toggleMarkdownPreview(el, false),
785
+ 'composer-queue': el => {
786
+ const box = el.closest('.composer-box')
787
+ if (box !== null) {
788
+ queueFromComposer(el, box)
789
+ }
790
+ },
791
+ 'pending-edit': el => {
792
+ const id = el.getAttribute('data-pending-id')
793
+ const draft = session.pending.find(p => p.id === id)
794
+ const host = el.closest('.pending-cmt')
795
+ if (draft === undefined || host === null) {
796
+ return
797
+ }
798
+ composerSeq += 1
799
+ // The box replaces the draft it edits, so the reader sees one of the two at a time.
800
+ const node = openComposer(
801
+ host,
802
+ {
803
+ id: `composer-${composerSeq}`,
804
+ label: `Edit your comment on ${draft.path}:${draft.line}`,
805
+ kind: 'inline',
806
+ body: draft.body,
807
+ pendingId: draft.id,
808
+ },
809
+ 'block'
810
+ )
811
+ if (node !== null) {
812
+ concealForComposer(host)
813
+ }
814
+ },
815
+ 'pending-save': el => {
816
+ const box = el.closest('.composer-box')
817
+ if (box !== null) {
818
+ savePending(el, box)
819
+ }
820
+ },
821
+ 'pending-delete': el => {
822
+ const id = el.getAttribute('data-pending-id')
823
+ if (id !== null) {
824
+ deletePending(el, id)
825
+ }
826
+ },
827
+ 'pending-finish': el => {
828
+ // Finishing a review is the same dialog the sign-off commands open; a review being written
829
+ // usually has something to say, so it opens on the verdict that claims nothing.
830
+ openSignoff(el, 'COMMENT')
831
+ },
832
+ 'pending-discard': el => {
833
+ void runCommand(
834
+ el,
835
+ async () => {
836
+ const count = pendingCount(session.state)
837
+ await session.discardPending()
838
+ toast(root, `${count} pending comment${count === 1 ? '' : 's'} discarded`)
839
+ },
840
+ { pendingLabel: 'discarding…' }
841
+ )
842
+ },
843
+ 'markdown-toggle': el => toggleMarkdownPreview(el),
621
844
  'composer-cancel': () => {
622
845
  closeComposers(root)
623
846
  },
@@ -686,7 +909,7 @@ export function wireReview(root, session, opts = {}) {
686
909
  }
687
910
  },
688
911
  signoff: el => {
689
- openSignoff(el, el.getAttribute('data-event') === 'APPROVE' ? 'APPROVE' : 'REQUEST_CHANGES')
912
+ openSignoff(el, signoffEvent(el.getAttribute('data-event')))
690
913
  },
691
914
  'signoff-post': el => postSignoff(el),
692
915
  'signoff-close': el => {
@@ -744,6 +967,10 @@ export function wireReview(root, session, opts = {}) {
744
967
  /** @param {Event} event */
745
968
  const onChange = event => {
746
969
  const input = event.target
970
+ if (input instanceof HTMLSelectElement && input.id === FOLD_LEVEL_SELECT_ID) {
971
+ applyFoldLevel(input.value)
972
+ return
973
+ }
747
974
  if (!(input instanceof HTMLInputElement)) {
748
975
  return
749
976
  }
@@ -849,10 +1076,7 @@ export function wireReview(root, session, opts = {}) {
849
1076
  if (found === null || found === undefined || found.kind === 'overview') {
850
1077
  break
851
1078
  }
852
- const id =
853
- found.kind === 'file'
854
- ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}`
855
- : `layer:${found.layerId}`
1079
+ const id = found.kind === 'file' ? reviewedId(found.layerKey, found.path) : reviewedId(found.layerKey)
856
1080
  const parts = cardForReviewedId(root, id)
857
1081
  const box = parts?.card.querySelector('input[data-reviewed-id]')
858
1082
  if (box instanceof HTMLElement) {
@@ -878,6 +1102,10 @@ export function wireReview(root, session, opts = {}) {
878
1102
  }
879
1103
  break
880
1104
  }
1105
+ case 'fold-level':
1106
+ // The key steps through the levels, so the reader can open the code up without the mouse.
1107
+ applyFoldLevel(nextFoldLevel(getFoldLevel()))
1108
+ break
881
1109
  case 'overview':
882
1110
  focusPointId = null
883
1111
  focusItem('overview')
@@ -6,7 +6,7 @@ import { esc } from './dom.js'
6
6
  /**
7
7
  * @typedef {'next-layer' | 'prev-layer' | 'next-file' | 'prev-file' | 'next-point' | 'prev-point'
8
8
  * | 'toggle' | 'reviewed-file' | 'reviewed-layer' | 'comment' | 'dismiss' | 'overview'
9
- * | 'help' | 'escape' | 'ask' | 'focus-chat'} KeyAction
9
+ * | 'help' | 'escape' | 'ask' | 'focus-chat' | 'fold-level'} KeyAction
10
10
  */
11
11
 
12
12
  export const KEY_HELP = [
@@ -18,6 +18,7 @@ export const KEY_HELP = [
18
18
  { keys: 'R', what: 'mark the layer in focus reviewed and move on' },
19
19
  { keys: 'c', what: 'comment on the line in focus or on the selection' },
20
20
  { keys: 'd', what: 'dismiss the attention point in focus' },
21
+ { keys: 'f', what: 'step through how much code is hidden: light, moderate, aggressive' },
21
22
  { keys: 'g o', what: 'go to the overview' },
22
23
  { keys: '?', what: 'this help' },
23
24
  { keys: 'Esc', what: 'clear the selection, close a composer or a dialog' },
@@ -84,6 +85,8 @@ export function keyAction(event, opts = {}) {
84
85
  return { action: 'comment', pendingG: false }
85
86
  case 'd':
86
87
  return { action: 'dismiss', pendingG: false }
88
+ case 'f':
89
+ return { action: 'fold-level', pendingG: false }
87
90
  case 'g':
88
91
  return { action: null, pendingG: true }
89
92
  case '?':
package/static/js/keys.js CHANGED
@@ -95,3 +95,15 @@ export function layerAnchorId(layerKey) {
95
95
  export function pointAnchorId(pointId) {
96
96
  return `point-${pointId}`
97
97
  }
98
+
99
+ /**
100
+ * The id of a reviewed mark: one layer, or one file of it. The layer's key names it, so a
101
+ * regenerated canvas that reorders its layers still points a reviewer's progress at the same
102
+ * concern. The server (`src/contract/keys.ts`) and the page both call this, so the two sides
103
+ * cannot drift; `REVIEWED_ID_RE` in the state store validates this grammar at the route.
104
+ * @param {string} layerKey
105
+ * @param {string} [path]
106
+ */
107
+ export function reviewedId(layerKey, path) {
108
+ return path === undefined ? `layer:${layerKey}` : `layer:${layerKey}/file:${sanitizeKey(path)}`
109
+ }