@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
@@ -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
+ }
@@ -5,6 +5,8 @@
5
5
  import { askButtonHtml } from './ask.js'
6
6
  import { viewCommentHtml } from './comment-link.js'
7
7
  import { esc } from './dom.js'
8
+ import { pendingForPoint } from './pending.js'
9
+ import { postToLabel } from './host.js'
8
10
  import { layerAnchorId, pointAnchorId } from './keys.js'
9
11
  import { renderMarkdown } from './markdown.js'
10
12
 
@@ -64,11 +66,33 @@ export function pointContext(p) {
64
66
  return { kind: 'point', fingerprint: p.fingerprint }
65
67
  }
66
68
 
69
+ /**
70
+ * What a point offers for getting its text onto the forge: the link to the comment it was posted
71
+ * as, the note that it is waiting in the review, or the two ways to send it. Unlike the box on a
72
+ * diff line, a point keeps both ways while a review is open: its text is written in advance, so
73
+ * firing one off on its own is a use of its own, not a comment jumping the queue.
74
+ * @param {Point} p
75
+ * @param {{ postedUrl?: string | undefined, queued?: boolean }} opts
76
+ */
77
+ function pointSendHtml(p, opts) {
78
+ if (opts.postedUrl !== undefined) {
79
+ return viewCommentHtml(opts.postedUrl)
80
+ }
81
+ if (opts.queued === true) {
82
+ return '<span class="pill pending queued">in your review</span>'
83
+ }
84
+ return (
85
+ `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>${postToLabel()}</button>` +
86
+ `<button class="cmd" type="button" data-act="point-queue" data-point="${esc(p.id)}">add to review</button>`
87
+ )
88
+ }
89
+
67
90
  /**
68
91
  * The commands every point carries. `copy` puts the markdown on the clipboard, `post to github`
69
- * opens nothing and posts it at the anchor, `dismiss` takes the point off the page.
92
+ * opens nothing and posts it at the anchor, `add to review` holds it as a draft instead, and
93
+ * `dismiss` takes the point off the page.
70
94
  * @param {Point} p
71
- * @param {{ dismissed?: boolean, postedUrl?: string | undefined }} [opts]
95
+ * @param {{ dismissed?: boolean, postedUrl?: string | undefined, queued?: boolean }} [opts]
72
96
  * @returns {string}
73
97
  */
74
98
  export function pointCommandsHtml(p, opts = {}) {
@@ -77,17 +101,24 @@ export function pointCommandsHtml(p, opts = {}) {
77
101
  ? `<button class="cmd" type="button" data-act="point-restore" data-fingerprint="${fp}">restore</button>`
78
102
  : `<button class="cmd" type="button" data-act="point-dismiss" data-fingerprint="${fp}">dismiss</button>`
79
103
  return (
80
- '<span class="tbtns">' +
104
+ `<span class="tbtns" data-queued="${opts.queued === true ? '1' : '0'}">` +
81
105
  `<button class="cmd" type="button" data-copy="${esc(pointToMarkdown(p))}">copy</button>` +
82
- (opts.postedUrl === undefined
83
- ? `<button class="cmd" type="button" data-act="point-post" data-point="${esc(p.id)}" data-needs-post>post to github</button>`
84
- : viewCommentHtml(opts.postedUrl)) +
106
+ pointSendHtml(p, opts) +
85
107
  askButtonHtml(pointContext(p)) +
86
108
  toggle +
87
109
  '</span>'
88
110
  )
89
111
  }
90
112
 
113
+ /**
114
+ * Whether this point is waiting in the review, read from the state the renderer was given.
115
+ * @param {Point} p
116
+ * @param {{ state?: PrState }} ctx
117
+ */
118
+ export function queuedFor(p, ctx) {
119
+ return pendingForPoint(ctx.state, p.fingerprint) !== undefined
120
+ }
121
+
91
122
  /**
92
123
  * The comment this point was posted as, when it was.
93
124
  * @param {Point} p
@@ -129,7 +160,7 @@ export function pointCardHtml(p, ctx) {
129
160
  `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
130
161
  `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
131
162
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
132
- `${pointCommandsHtml(p, { postedUrl: posted })}</div></li>`
163
+ `${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</div></li>`
133
164
  )
134
165
  }
135
166
 
@@ -153,7 +184,7 @@ export function dismissedListHtml(points, state, ctx, expanded = false) {
153
184
  `<div class="f-title"><span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span>` +
154
185
  `<a class="loc" href="${esc(pointLink(p))}">${esc(pointLocation(p))}</a></div>` +
155
186
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
156
- `${pointCommandsHtml(p, { dismissed: true, postedUrl: posted })}</div></li>`
187
+ `${pointCommandsHtml(p, { dismissed: true, postedUrl: posted, queued: queuedFor(p, { state }) })}</div></li>`
157
188
  )
158
189
  })
159
190
  .join('')
@@ -182,6 +213,7 @@ export function applyDismissed(root, points, state, ctx) {
182
213
  }
183
214
  const dismissed = state.dismissed[point.fingerprint] !== undefined
184
215
  el.toggleAttribute('hidden', dismissed)
216
+ refreshPointCommands(el, point, { dismissed, state, ...ctx })
185
217
  }
186
218
  for (const counter of Array.from(root.querySelectorAll('.point-count'))) {
187
219
  const layerId = counter.closest('[data-layer]')?.getAttribute('data-layer')
@@ -211,6 +243,35 @@ export function applyDismissed(root, points, state, ctx) {
211
243
  }
212
244
  }
213
245
 
246
+ /**
247
+ * Draws a point's commands again when it has just joined the review or just left it, wherever the
248
+ * point is on the page. Nothing else is touched: a command that is mid-request keeps its state,
249
+ * because neither posting nor dismissing changes whether the point is waiting in the review.
250
+ * @param {Element} el the element that carries `data-point`
251
+ * @param {Point} p
252
+ * @param {{ dismissed: boolean, state: PrState, posted?: ReadonlyMap<string, string> }} ctx
253
+ * @returns {boolean} true when the commands were drawn again
254
+ */
255
+ export function refreshPointCommands(el, p, ctx) {
256
+ const tbtns = el.querySelector('.tbtns')
257
+ const queued = queuedFor(p, ctx)
258
+ if (tbtns === null || (tbtns.getAttribute('data-queued') === '1') === queued) {
259
+ return false
260
+ }
261
+ const template = document.createElement('template')
262
+ template.innerHTML = pointCommandsHtml(p, {
263
+ dismissed: ctx.dismissed,
264
+ queued,
265
+ ...(postedFor(p, ctx) === undefined ? {} : { postedUrl: postedFor(p, ctx) }),
266
+ })
267
+ const next = template.content.firstElementChild
268
+ if (next === null) {
269
+ return false
270
+ }
271
+ tbtns.replaceWith(next)
272
+ return true
273
+ }
274
+
214
275
  /**
215
276
  * The inline row under a diff line.
216
277
  * @param {Point} p
@@ -223,7 +284,7 @@ export function pointRowHtml(p, ctx) {
223
284
  `<tr class="ifind ${p.level}" data-point="${esc(p.id)}" data-fingerprint="${esc(p.fingerprint)}"${dismissed ? ' hidden' : ''}><td class="code x" colspan="4">` +
224
285
  `<div class="f-title">${squareHtml(p)}<span>${esc(p.title)}</span><span class="pill kind">${esc(p.kind)}</span></div>` +
225
286
  `<div class="prose">${renderMarkdown(p.body, { paths: ctx.paths })}</div>` +
226
- `${pointCommandsHtml(p, { postedUrl: posted })}</td></tr>`
287
+ `${pointCommandsHtml(p, { postedUrl: posted, queued: queuedFor(p, ctx) })}</td></tr>`
227
288
  )
228
289
  }
229
290
 
@@ -3,7 +3,7 @@
3
3
  /** @typedef {import('./contract-types.js').Layer} Layer */
4
4
  /** @typedef {import('./contract-types.js').PrState} PrState */
5
5
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
6
- import { sanitizeKey } from './keys.js'
6
+ import { reviewedId } from './keys.js'
7
7
 
8
8
  /**
9
9
  * @param {Layer} layer
@@ -11,13 +11,13 @@ import { sanitizeKey } from './keys.js'
11
11
  * @returns {'done' | 'partial' | 'none'}
12
12
  */
13
13
  export function layerProgress(layer, state) {
14
- if (state.reviewed[`layer:${layer.id}`] === true) {
14
+ if (state.reviewed[reviewedId(layer.key)] === true) {
15
15
  return 'done'
16
16
  }
17
17
  const files = layer.files.length
18
18
  let reviewedFiles = 0
19
19
  for (const f of layer.files) {
20
- if (state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true) {
20
+ if (state.reviewed[reviewedId(layer.key, f.path)] === true) {
21
21
  reviewedFiles++
22
22
  }
23
23
  }
@@ -45,6 +45,5 @@ export function progressSummary(artifact, state) {
45
45
  * @param {PrState} state
46
46
  */
47
47
  export function filesReviewed(layer, state) {
48
- return layer.files.filter(f => state.reviewed[`layer:${layer.id}/file:${sanitizeKey(f.path)}`] === true)
49
- .length
48
+ return layer.files.filter(f => state.reviewed[reviewedId(layer.key, f.path)] === true).length
50
49
  }
@@ -12,6 +12,9 @@ export const HOVER_DELAY_MS = 150
12
12
  /** The pointer crosses a gap between the command and the menu, so closing waits out the crossing. */
13
13
  export const CLOSE_DELAY_MS = 200
14
14
  export const QQ_MENU_ID = 'qq-menu'
15
+ /** The gap between a command and its menu, and the room the menu keeps from the window edge. */
16
+ export const MENU_GAP_PX = 4
17
+ export const MENU_EDGE_PX = 8
15
18
  export const ASK_SOMETHING_ELSE = 'ask something else…'
16
19
 
17
20
  /** @returns {string} */
@@ -77,8 +80,18 @@ export function wireQuickQuestions(root, options) {
77
80
  el.setAttribute('aria-expanded', 'true')
78
81
  menu.hidden = false
79
82
  const rect = el.getBoundingClientRect()
80
- menu.style.left = `${Math.round(rect.left)}px`
81
- menu.style.top = `${Math.round(rect.bottom + 4)}px`
83
+ // The menu is positioned against the window, so a command near an edge would push it off
84
+ // screen: keep it inside, and hang it above a command with no room below.
85
+ const view = doc.defaultView ?? window
86
+ // `clientWidth` leaves out the scrollbar; a document that does not report it falls back.
87
+ const roomAcross = doc.documentElement.clientWidth || view.innerWidth
88
+ const roomDown = doc.documentElement.clientHeight || view.innerHeight
89
+ const below = rect.bottom + MENU_GAP_PX
90
+ const top =
91
+ below + menu.offsetHeight > roomDown - MENU_EDGE_PX ? rect.top - MENU_GAP_PX - menu.offsetHeight : below
92
+ const left = Math.min(rect.left, roomAcross - menu.offsetWidth - MENU_EDGE_PX)
93
+ menu.style.left = `${Math.round(Math.max(MENU_EDGE_PX, left))}px`
94
+ menu.style.top = `${Math.round(Math.max(MENU_EDGE_PX, top))}px`
82
95
  }
83
96
 
84
97
  const clearTimer = () => {
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ // The reader's control over how much code the canvas hides: the copy that says what each level
3
+ // takes off the screen, the control under the header, and the labels that say how much of the
4
+ // diff that is. The level itself, and the counts, belong to layers.js, which owns the file cards
5
+ // and the rule for what a card keeps open.
6
+ import { esc } from './dom.js'
7
+ import { FOLD_LEVELS } from './fold-levels.js'
8
+
9
+ /** @typedef {import('./contract-types.js').FoldLevel} FoldLevel */
10
+ /** @typedef {{ total: number, hidden: number }} HiddenCounts */
11
+
12
+ export const FOLD_LEVEL_SELECT_ID = 'fold-level'
13
+
14
+ /** What each level adds to the one below it, said in the reader's terms. */
15
+ const FOLD_LEVEL_WHAT = {
16
+ light: 'the diff as always: imports, whitespace, moved blocks, and generated files',
17
+ moderate: 'also test bodies, helpers, wiring, templates, and boilerplate',
18
+ aggressive: 'only the code you have to judge stays open',
19
+ }
20
+
21
+ /**
22
+ * What the counter says, or '' when this level hides nothing.
23
+ * @param {HiddenCounts} counts
24
+ * @returns {string}
25
+ */
26
+ export function hiddenLabel(counts) {
27
+ return counts.hidden === 0 ? '' : `${counts.hidden} of ${counts.total} lines hidden`
28
+ }
29
+
30
+ /**
31
+ * The same with the separator the Files heading needs, so an empty counter adds nothing.
32
+ * @param {HiddenCounts} counts
33
+ * @returns {string}
34
+ */
35
+ export function foldCountText(counts) {
36
+ const label = hiddenLabel(counts)
37
+ return label === '' ? '' : ` · ${label}`
38
+ }
39
+
40
+ /**
41
+ * The line under the control: what this level hides, and how much of the diff that is. The reader
42
+ * changes one setting, so the page says what it did rather than leaving them to scroll and find out.
43
+ * @param {FoldLevel} level
44
+ * @param {HiddenCounts} counts what the level hides across the canvas
45
+ * @returns {string}
46
+ */
47
+ export function foldLevelHint(level, counts) {
48
+ const amount = counts.hidden === 0 ? 'nothing hidden yet' : `${hiddenLabel(counts)} of the diff`
49
+ return `${FOLD_LEVEL_WHAT[level]} · ${amount}`
50
+ }
51
+
52
+ /**
53
+ * The levels as options, with one selected. The control on the page and the default field of the
54
+ * settings dialog are the same list.
55
+ * @param {FoldLevel} level
56
+ * @returns {string}
57
+ */
58
+ export function foldLevelOptionsHtml(level) {
59
+ return FOLD_LEVELS.map(
60
+ l => `<option value="${esc(l)}"${l === level ? ' selected' : ''}>${esc(l)}</option>`
61
+ ).join('')
62
+ }
63
+
64
+ /**
65
+ * How much of the diff the page hides. One control for the whole canvas, next to the review
66
+ * progress rather than among the commands, because it changes what the reader sees and does not
67
+ * act on the pull request. The page opens at the level saved in the settings dialog; a change
68
+ * here holds for this page only.
69
+ * @param {FoldLevel} level
70
+ * @param {HiddenCounts} counts what the level hides across the canvas
71
+ * @returns {string}
72
+ */
73
+ export function foldLevelControlHtml(level, counts) {
74
+ const options = foldLevelOptionsHtml(level)
75
+ return (
76
+ `<div class="reading"><label for="${FOLD_LEVEL_SELECT_ID}">Hide code</label>` +
77
+ `<select id="${FOLD_LEVEL_SELECT_ID}" title="How much of the diff this page hides. Press f to step through the levels; the settings dialog sets the default.">${options}</select>` +
78
+ `<span class="fold-hint" role="status">${esc(foldLevelHint(level, counts))}</span></div>`
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Points the control and its hint at a level the reader chose elsewhere, such as with the `f` key.
84
+ * @param {ParentNode} root
85
+ * @param {FoldLevel} level
86
+ * @param {HiddenCounts} counts what the level hides across the canvas
87
+ */
88
+ export function refreshFoldLevel(root, level, counts) {
89
+ const select = root.querySelector(`#${FOLD_LEVEL_SELECT_ID}`)
90
+ if (select instanceof HTMLSelectElement) {
91
+ select.value = level
92
+ }
93
+ const hint = root.querySelector('.reading .fold-hint')
94
+ if (hint !== null) {
95
+ hint.textContent = foldLevelHint(level, counts)
96
+ }
97
+ }
@@ -9,8 +9,18 @@
9
9
  /** @typedef {import('./contract-types.js').PrState} PrState */
10
10
  /** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
11
11
  /** @typedef {import('./contract-types.js').ReviewSummary} ReviewSummary */
12
- import { postComment, postReview, putDismissed, putReviewed, putThreadHidden } from './api.js'
13
- import { sanitizeKey } from './keys.js'
12
+ /** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
13
+ import {
14
+ addPending,
15
+ deletePending,
16
+ discardPending,
17
+ editPending,
18
+ postComment,
19
+ postReview,
20
+ putDismissed,
21
+ putReviewed,
22
+ putThreadHidden,
23
+ } from './api.js'
14
24
 
15
25
  /**
16
26
  * @typedef {{
@@ -19,17 +29,22 @@ import { sanitizeKey } from './keys.js'
19
29
  * putThreadHidden: typeof putThreadHidden,
20
30
  * postComment: typeof postComment,
21
31
  * postReview: typeof postReview,
32
+ * addPending: typeof addPending,
33
+ * editPending: typeof editPending,
34
+ * deletePending: typeof deletePending,
35
+ * discardPending: typeof discardPending,
22
36
  * }} SessionApi
23
37
  */
24
38
 
25
39
  /**
26
40
  * @typedef {{
27
- * prNumber: number,
41
+ * prNumber: import('./contract-types.js').ReviewKey,
28
42
  * artifact: ReviewArtifact,
29
43
  * files: ReadonlyArray<FileEntry>,
30
44
  * state: PrState,
31
45
  * capabilities: Capabilities,
32
46
  * headSha: string,
47
+ * canvasSha?: string,
33
48
  * api?: Partial<SessionApi>,
34
49
  * onState?: (state: PrState) => void,
35
50
  * }} SessionOptions
@@ -37,22 +52,29 @@ import { sanitizeKey } from './keys.js'
37
52
 
38
53
  /** @param {Partial<SessionApi> | undefined} overrides */
39
54
  function withDefaults(overrides) {
40
- return { putReviewed, putDismissed, putThreadHidden, postComment, postReview, ...overrides }
55
+ return {
56
+ putReviewed,
57
+ putDismissed,
58
+ putThreadHidden,
59
+ postComment,
60
+ postReview,
61
+ addPending,
62
+ editPending,
63
+ deletePending,
64
+ discardPending,
65
+ ...overrides,
66
+ }
41
67
  }
42
68
 
43
- /**
44
- * @param {string} layerId
45
- * @param {string} [path]
46
- */
47
- export function reviewedId(layerId, path) {
48
- return path === undefined ? `layer:${layerId}` : `layer:${layerId}/file:${sanitizeKey(path)}`
49
- }
69
+ export { reviewedId } from './keys.js'
50
70
 
51
71
  /** @param {SessionOptions} options */
52
72
  export function createReviewSession(options) {
53
73
  const api = withDefaults(options.api)
54
74
  /** @type {PrState} */
55
75
  let state = options.state
76
+ /** @type {ReviewComment[]} */
77
+ let submittedComments = []
56
78
  /** @type {Capabilities} */
57
79
  let capabilities = options.capabilities
58
80
  const keyToPath = new Map(options.files.map(f => [f.key, f.path]))
@@ -130,6 +152,28 @@ export function createReviewSession(options) {
130
152
  }
131
153
  }
132
154
 
155
+ /**
156
+ * A change the server owns outright: the answer it sends is the new state. Unlike `change`
157
+ * there is nothing to show first and nothing to take back, because the page never guessed.
158
+ * @template {{ state: PrState }} T
159
+ * @param {() => Promise<T>} request
160
+ * @returns {Promise<T>}
161
+ */
162
+ const run = async request => {
163
+ inFlight += 1
164
+ try {
165
+ const answer = await request()
166
+ inFlight -= 1
167
+ takeAnswer(answer.state)
168
+ settle()
169
+ return answer
170
+ } catch (err) {
171
+ inFlight -= 1
172
+ settle()
173
+ throw err
174
+ }
175
+ }
176
+
133
177
  /**
134
178
  * @param {PrState} current
135
179
  * @param {'reviewed' | 'dismissed' | 'hiddenThreads'} field
@@ -193,7 +237,11 @@ export function createReviewSession(options) {
193
237
  return change(
194
238
  current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
195
239
  current => withEntry(current, 'reviewed', id, before),
196
- () => api.putReviewed(options.prNumber, id, reviewed, { headSha: options.headSha })
240
+ () =>
241
+ api.putReviewed(options.prNumber, id, reviewed, {
242
+ headSha: options.headSha,
243
+ ...(options.canvasSha === undefined ? {} : { canvasSha: options.canvasSha }),
244
+ })
197
245
  )
198
246
  },
199
247
  /**
@@ -229,27 +277,63 @@ export function createReviewSession(options) {
229
277
  * @returns {Promise<PostCommentResponse>}
230
278
  */
231
279
  async postComment(input) {
232
- inFlight += 1
233
- try {
234
- const answer = await api.postComment(options.prNumber, { ...input, headSha: options.headSha })
235
- inFlight -= 1
236
- takeAnswer(answer.state)
237
- settle()
238
- return answer
239
- } catch (err) {
240
- inFlight -= 1
241
- settle()
242
- throw err
243
- }
280
+ return run(() => api.postComment(options.prNumber, { ...input, headSha: options.headSha }))
281
+ },
282
+ /** Comments returned by reviews submitted during this session. */
283
+ get submittedComments() {
284
+ return submittedComments
285
+ },
286
+ /** The comments waiting in the pending review, oldest first. */
287
+ get pending() {
288
+ return state.pending ?? []
244
289
  },
245
290
  /**
246
- * @param {'APPROVE' | 'REQUEST_CHANGES'} event
291
+ * Writes one comment into the pending review. The answer carries the state that holds it, so
292
+ * the page draws the draft from the same place a reload would read it from.
293
+ * @param {Omit<import('./contract-types.js').AddPendingInput, 'headSha'>} input
294
+ * @returns {Promise<PrState>}
295
+ */
296
+ async addPending(input) {
297
+ return (await run(() => api.addPending(options.prNumber, { ...input, headSha: options.headSha }))).state
298
+ },
299
+ /**
300
+ * @param {string} id
301
+ * @param {string} body
302
+ * @returns {Promise<PrState>}
303
+ */
304
+ async editPending(id, body) {
305
+ return (await run(() => api.editPending(options.prNumber, id, body))).state
306
+ },
307
+ /**
308
+ * @param {string} id
309
+ * @returns {Promise<PrState>}
310
+ */
311
+ async deletePending(id) {
312
+ return (await run(() => api.deletePending(options.prNumber, id))).state
313
+ },
314
+ /** @returns {Promise<PrState>} */
315
+ async discardPending() {
316
+ return (await run(() => api.discardPending(options.prNumber))).state
317
+ },
318
+ /**
319
+ * Submits the review. The pending comments go out with it unless the caller says otherwise,
320
+ * and the answer's state is the one with them cleared.
321
+ * @param {import('./contract-types.js').ReviewEvent} event
247
322
  * @param {string} [body]
248
- * @returns {Promise<ReviewSummary>}
323
+ * @param {{ includePending?: boolean }} [opts]
324
+ * @returns {Promise<import('./contract-types.js').PostReviewResponse>}
249
325
  */
250
- async postReview(event, body) {
251
- const input = body === undefined ? { event } : { event, body }
252
- return (await api.postReview(options.prNumber, { ...input, headSha: options.headSha })).review
326
+ async postReview(event, body, opts = {}) {
327
+ const input = {
328
+ event,
329
+ ...(body === undefined ? {} : { body }),
330
+ ...(opts.includePending === undefined ? {} : { includePending: opts.includePending }),
331
+ }
332
+ return run(async () => {
333
+ const answer = await api.postReview(options.prNumber, { ...input, headSha: options.headSha })
334
+ submittedComments = [...submittedComments, ...answer.comments]
335
+ return answer
336
+ })
253
337
  },
254
338
  }
255
339
  }