@vintasoftware/pr-review-canvas 0.1.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 (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,952 @@
1
+ import { toggleMarkdownPreview } from './composer.js'
2
+ // @ts-check
3
+ // Everything the reader can do on the review screen, wired once. One delegated click handler,
4
+ // one change handler, one pointer pair for line selection, and one key handler; each command
5
+ // carries a `data-act` that names what it does.
6
+ /** @typedef {import('./contract-types.js').Point} Point */
7
+ /** @typedef {import('./contract-types.js').PrState} PrState */
8
+ /** @typedef {import('./contract-types.js').ReviewComment} ReviewComment */
9
+ /** @typedef {import('./review-session.js').ReviewSession} ReviewSession */
10
+ /** @typedef {import('./selection.js').Selection} Selection */
11
+ import { cssEscape, findRow } from './anchors.js'
12
+ import { fetchReviewBody } from './api.js'
13
+ import { chatContextFromElement, WHOLE_PR } from './chat-context.js'
14
+ import { setFoldShown } from './code-folds.js'
15
+ import { runCommand, runControl, showCommandError } from './commands.js'
16
+ import { replacePostButton } from './comment-link.js'
17
+ import {
18
+ applyCapabilityGating,
19
+ closeComposers,
20
+ composerHtml,
21
+ composerInput,
22
+ composerRowHtml,
23
+ focusComposer,
24
+ } from './composer.js'
25
+ import { commentHtml, setThreadCollapsed, threadRowHtml } from './diff-decorations.js'
26
+ import { flash, scrollIntoViewSafe } from './dom.js'
27
+ import { refreshProgress } from './header.js'
28
+ import { keyAction, openHelpDialog } from './keyboard.js'
29
+ import { pointAnchorId, sanitizeKey } from './keys.js'
30
+ import { getRenderContext, pathSet, setCardRenderedHook, setRenderContext, updateRenderState } from './layers.js'
31
+ import { buildNavOrder, layerOf, nextFile, nextLayer, prevFile, prevLayer } from './nav.js'
32
+ import { issueCommentHtml } from './overview.js'
33
+ import { applyDismissed, pointToMarkdown, postedUrls } from './points.js'
34
+ import { layerProgress } from './progress.js'
35
+ import { lineRefFromEvent, markSelection, selectionReducer } from './selection.js'
36
+ import { fillSignoffDialog, openSignoffDialog, showSignoffError, showSignoffResult, signoffBody } from './signoff.js'
37
+
38
+ /** @typedef {NonNullable<ReturnType<typeof import('./chat.js').wireChat>>} ChatHandle */
39
+
40
+ const NO_CHAT_NOTE = 'the AI Chat pane is off; press ? for the key map'
41
+
42
+ /** @type {WeakMap<Element, ReturnType<typeof setTimeout>>} */
43
+ const toastTimers = new WeakMap()
44
+
45
+ /**
46
+ * The first element of an HTML string built by this app's renderers.
47
+ * @param {Document} doc
48
+ * @param {string} html
49
+ * @returns {Element | null}
50
+ */
51
+ export function nodeFrom(doc, html) {
52
+ const template = doc.createElement('template')
53
+ template.innerHTML = html
54
+ return template.content.firstElementChild
55
+ }
56
+
57
+ /**
58
+ * The row of an HTML string that is a `<tr>`, which needs a table around it to parse.
59
+ * @param {Document} doc
60
+ * @param {string} html
61
+ * @returns {HTMLTableRowElement | null}
62
+ */
63
+ export function rowFrom(doc, html) {
64
+ return nodeFrom(doc, `<table><tbody>${html}</tbody></table>`)?.querySelector('tr') ?? null
65
+ }
66
+
67
+ /**
68
+ * The one place the page says what just happened. Screen readers get it through `aria-live`.
69
+ * @param {HTMLElement} root
70
+ * @param {string} message
71
+ */
72
+ export function toast(root, message) {
73
+ let box = root.querySelector('.toast')
74
+ if (!(box instanceof HTMLElement)) {
75
+ box = document.createElement('div')
76
+ box.className = 'toast'
77
+ box.setAttribute('role', 'status')
78
+ box.setAttribute('aria-live', 'polite')
79
+ root.appendChild(box)
80
+ }
81
+ clearTimeout(toastTimers.get(box))
82
+ box.textContent = message
83
+ const region = box
84
+ toastTimers.set(
85
+ region,
86
+ setTimeout(() => {
87
+ region.textContent = ''
88
+ toastTimers.delete(region)
89
+ }, 5000)
90
+ )
91
+ return box
92
+ }
93
+
94
+ /**
95
+ * The card a command belongs to, and the part of it that collapses.
96
+ * @param {Element} el
97
+ * @returns {{ card: HTMLElement, body: HTMLElement, chevron: Element | null } | null}
98
+ */
99
+ export function cardOf(el) {
100
+ const card = el.closest('article.file, section.layer')
101
+ const body = card?.querySelector(':scope > .file-body, :scope > .layer-body')
102
+ if (!(card instanceof HTMLElement && body instanceof HTMLElement)) {
103
+ return null
104
+ }
105
+ return { card, body, chevron: card.querySelector(':scope > .file-h > .chev, :scope > .layer-h > .chev') }
106
+ }
107
+
108
+ /**
109
+ * Opens or collapses one card. Nothing else on the card changes, so clicking the chevron never
110
+ * touches the reviewed box and the other way round.
111
+ * @param {Element} el an element inside the card
112
+ * @param {boolean} [collapsed] the state to set; the opposite of the current one when omitted
113
+ */
114
+ export function setCardCollapsed(el, collapsed) {
115
+ const parts = cardOf(el)
116
+ if (parts === null) {
117
+ return null
118
+ }
119
+ const next = collapsed ?? !parts.body.hidden
120
+ parts.body.toggleAttribute('hidden', next)
121
+ parts.chevron?.setAttribute('aria-expanded', next ? 'false' : 'true')
122
+ return next
123
+ }
124
+
125
+ /**
126
+ * The layer or file card a reviewed id belongs to.
127
+ * @param {ParentNode} root
128
+ * @param {string} id
129
+ */
130
+ export function cardForReviewedId(root, id) {
131
+ const input = root.querySelector(`input[data-reviewed-id="${cssEscape(id)}"]`)
132
+ return input === null ? null : cardOf(input)
133
+ }
134
+
135
+ /**
136
+ * Where the reader goes after marking something reviewed: the first layer or file that is still
137
+ * open, after the one they just finished.
138
+ * @param {ParentNode} root
139
+ * @param {ReviewSession} session
140
+ * @param {string} fromId the anchor id of the card that was just marked
141
+ * @param {'layer' | 'file'} kind
142
+ */
143
+ export function nextUnreviewedTarget(root, session, fromId, kind) {
144
+ const order = buildNavOrder(session.artifact)
145
+ const from = order.findIndex(i => i.id === fromId)
146
+ for (let i = from + 1; i < order.length; i++) {
147
+ const item = order[i]
148
+ if (item === undefined || item.kind !== kind || item.other) {
149
+ continue
150
+ }
151
+ const id = item.kind === 'file' ? `layer:${item.layerId}/file:${sanitizeKey(item.path)}` : `layer:${item.layerId}`
152
+ if (!session.isReviewed(id)) {
153
+ return root.querySelector(`#${cssEscape(item.id)}`)
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
159
+ /**
160
+ * What the `a` key asks about: the selection if there is one, else the attention point in focus,
161
+ * else the card in focus, else the whole pull request.
162
+ * @param {ParentNode} root
163
+ * @param {string | null} focusId
164
+ * @param {string | null} focusPointId
165
+ * @param {Selection | null} selection
166
+ * @returns {import('./chat-context.js').ChatContext}
167
+ */
168
+ export function askTargetFor(root, focusId, focusPointId, selection) {
169
+ if (selection !== null) {
170
+ return { kind: 'lines', path: selection.path, side: selection.side, start: selection.start, end: selection.end }
171
+ }
172
+ if (focusPointId !== null) {
173
+ const el = root.querySelector(`[data-point="${cssEscape(focusPointId)}"] [data-act="ask"]`)
174
+ if (el instanceof HTMLElement) {
175
+ return chatContextFromElement(el)
176
+ }
177
+ }
178
+ if (focusId !== null) {
179
+ const card = root.querySelector(`#${cssEscape(focusId)}`)
180
+ const ask = card?.querySelector('[data-act="ask"]')
181
+ if (ask instanceof HTMLElement) {
182
+ return chatContextFromElement(ask)
183
+ }
184
+ }
185
+ return WHOLE_PR
186
+ }
187
+
188
+ /**
189
+ * Wires the whole review screen. Returns a stop function, so a re-render never leaves two sets
190
+ * of listeners behind.
191
+ * @param {HTMLElement} root
192
+ * @param {ReviewSession} session
193
+ * @param {{
194
+ * document?: Document,
195
+ * fetchReviewBody?: typeof fetchReviewBody,
196
+ * chat?: () => ChatHandle | null,
197
+ * quickQuestions?: () => { openFor: (el: HTMLElement) => void } | null,
198
+ * openSettings?: (el: HTMLElement) => void,
199
+ * }} [opts]
200
+ */
201
+ export function wireReview(root, session, opts = {}) {
202
+ const doc = opts.document ?? document
203
+ const readReviewBody = opts.fetchReviewBody ?? fetchReviewBody
204
+ /** @type {Selection | null} */
205
+ let selection = null
206
+ /** @type {string | null} */
207
+ let focusId = null
208
+ /** @type {string | null} */
209
+ let focusPointId = null
210
+ let pendingG = false
211
+ let composerSeq = 0
212
+ let signoffOpening = 0
213
+
214
+ const paths = () => pathSet(getRenderContext()?.files ?? [])
215
+ /** The diff key of a path, which is what the row ids are built from. */
216
+ const keyForPath = (/** @type {string} */ path) => getRenderContext()?.files.find(f => f.path === path)?.key ?? null
217
+ /** The time the page was drawn, which the comments it adds are timed against. */
218
+ const renderNow = () => getRenderContext()?.now ?? new Date()
219
+
220
+ /**
221
+ * Whether a card counts as reviewed, read the same way the page was first drawn: a layer is
222
+ * reviewed when its own mark is set or when every one of its files is.
223
+ * @param {string} id
224
+ * @param {PrState} state
225
+ */
226
+ const isCardReviewed = (id, state) => {
227
+ if (id.includes('/file:')) {
228
+ return state.reviewed[id] === true
229
+ }
230
+ const layer = session.artifact.layers.find(l => `layer:${l.id}` === id)
231
+ return layer === undefined ? state.reviewed[id] === true : layerProgress(layer, state) === 'done'
232
+ }
233
+
234
+ /** Draws everything the local state decides, after it changed. */
235
+ const onState = (/** @type {PrState} */ state) => {
236
+ updateRenderState(state)
237
+ refreshProgress(root, session.artifact, state)
238
+ applyDismissed(root, session.artifact.points, state, {
239
+ paths: paths(),
240
+ layers: session.artifact.layers,
241
+ posted: postedUrls(state, getRenderContext()?.comments ?? []),
242
+ })
243
+ for (const input of Array.from(root.querySelectorAll('input[data-reviewed-id]'))) {
244
+ const id = input.getAttribute('data-reviewed-id')
245
+ if (!(input instanceof HTMLInputElement) || id === null) {
246
+ continue
247
+ }
248
+ const reviewed = isCardReviewed(id, state)
249
+ input.checked = reviewed
250
+ // A card the server reopened (marking a layer reopens its files) opens here as well.
251
+ const parts = cardOf(input)
252
+ if (parts !== null && parts.card.classList.contains('is-reviewed') !== reviewed) {
253
+ parts.card.classList.toggle('is-reviewed', reviewed)
254
+ setCardCollapsed(parts.card, reviewed)
255
+ }
256
+ }
257
+ applyCapabilityGating(root, session.capabilities)
258
+ }
259
+ const unsubscribe = session.subscribe(onState)
260
+
261
+ /** @param {import('./contract-types.js').PostCommentInput} input */
262
+ const postComment = async input => {
263
+ const answer = await session.postComment(input)
264
+ const ctx = getRenderContext()
265
+ if (answer.kind === 'review' && ctx !== null) {
266
+ setRenderContext({ ...ctx, comments: [...ctx.comments.filter(c => c.id !== answer.comment.id), answer.comment] })
267
+ onState(session.state)
268
+ }
269
+ return answer
270
+ }
271
+ // A card drawn later carries posting commands of its own, which the same rules apply to.
272
+ setCardRenderedHook(card => applyCapabilityGating(card, session.capabilities))
273
+
274
+ /**
275
+ * @param {string} id
276
+ * @param {Element | null} [element] where to put the ring when the id names nothing on screen
277
+ */
278
+ const focusItem = (id, element) => {
279
+ focusId = id
280
+ const el = root.querySelector(`#${cssEscape(id)}`) ?? element
281
+ for (const marked of Array.from(root.querySelectorAll('.is-focused'))) {
282
+ marked.classList.remove('is-focused')
283
+ }
284
+ if (el instanceof HTMLElement) {
285
+ el.classList.add('is-focused')
286
+ // The ring follows the keyboard, so the focus does too: a screen reader reads the card
287
+ // the reader moved to instead of the command they pressed the key on.
288
+ el.tabIndex = -1
289
+ el.focus({ preventScroll: true })
290
+ scrollIntoViewSafe(el)
291
+ }
292
+ return el
293
+ }
294
+
295
+ /**
296
+ * The attention point the keys act on, kept by id so that dismissing one does not move the
297
+ * next key press onto its neighbour.
298
+ * @param {ReadonlyArray<Point>} points
299
+ * @param {1 | -1} direction
300
+ */
301
+ const stepPoint = (points, direction) => {
302
+ const at = points.findIndex(p => p.id === focusPointId)
303
+ const next = at === -1 ? (direction === 1 ? 0 : points.length - 1) : at + direction
304
+ const point = points[Math.min(Math.max(next, 0), points.length - 1)]
305
+ if (point === undefined) {
306
+ return
307
+ }
308
+ focusPointId = point.id
309
+ // A point of the Other layer has no card, so the ring lands on its row in the diff.
310
+ focusItem(pointAnchorId(point.id), root.querySelector(`[data-point="${cssEscape(point.id)}"]`))
311
+ }
312
+
313
+ /** @param {Selection | null} next */
314
+ const setSelection = next => {
315
+ selection = next
316
+ markSelection(root, selection)
317
+ applyCapabilityGating(root, session.capabilities)
318
+ }
319
+
320
+ /**
321
+ * Opens one composer, closing whatever was open before it.
322
+ * @param {Element} anchor the row or host the composer goes under
323
+ * @param {import('./composer.js').ComposerOptions} options
324
+ * @param {'row' | 'block'} shape
325
+ */
326
+ const openComposer = (anchor, options, shape) => {
327
+ closeComposers(root)
328
+ const node = shape === 'row' ? rowFrom(doc, composerRowHtml(options)) : nodeFrom(doc, composerHtml(options))
329
+ if (node === null) {
330
+ return null
331
+ }
332
+ if (shape === 'row') {
333
+ anchor.insertAdjacentElement('afterend', node)
334
+ } else {
335
+ anchor.append(node)
336
+ }
337
+ applyCapabilityGating(root, session.capabilities)
338
+ focusComposer(root, options.id)
339
+ return node
340
+ }
341
+
342
+ /**
343
+ * @param {{ key: string, path: string, side: 'new' | 'old', line: number, startLine?: number }} target
344
+ */
345
+ const openLineComposer = target => {
346
+ const row = findRow(root, target.key, target.side, target.line)
347
+ if (row === null) {
348
+ return null
349
+ }
350
+ composerSeq += 1
351
+ /** @type {import('./composer.js').ComposerOptions} */
352
+ const options = {
353
+ id: `composer-${composerSeq}`,
354
+ label: `Comment on ${target.path}:${target.line}`,
355
+ kind: 'inline',
356
+ path: target.path,
357
+ line: target.line,
358
+ side: target.side,
359
+ }
360
+ if (target.startLine !== undefined && target.startLine !== target.line) {
361
+ options.startLine = target.startLine
362
+ }
363
+ return openComposer(row, options, 'row')
364
+ }
365
+
366
+ /** The inline comment that a posted review comment becomes on the page. */
367
+ const insertPostedReviewComment = (/** @type {Element} */ composerNode, /** @type {ReviewComment} */ comment) => {
368
+ const thread = root.querySelector(
369
+ `tr.thread[data-thread="${cssEscape(String(comment.inReplyToId ?? comment.id))}"]`
370
+ )
371
+ const now = getRenderContext()?.now ?? new Date()
372
+ if (comment.inReplyToId !== undefined && thread !== null) {
373
+ const full = thread.querySelector('.thread-full')
374
+ const tbtns = full?.querySelector('.tbtns')
375
+ const template = doc.createElement('template')
376
+ template.innerHTML = commentHtml(comment, now)
377
+ const node = template.content.firstElementChild
378
+ if (node !== null) {
379
+ tbtns?.before(node)
380
+ }
381
+ composerNode.remove()
382
+ return
383
+ }
384
+ const template = doc.createElement('template')
385
+ template.innerHTML = `<table><tbody>${threadRowHtml({ root: comment, replies: [], resolved: false, outdated: comment.outdated, path: comment.path, side: comment.side, line: comment.line }, { now })}</tbody></table>`
386
+ const row = template.content.querySelector('tr')
387
+ const host = composerNode.closest('tr') ?? composerNode
388
+ if (row !== null) {
389
+ host.insertAdjacentElement('afterend', row)
390
+ }
391
+ host.remove()
392
+ }
393
+
394
+ /**
395
+ * @param {HTMLElement} button
396
+ * @param {Element} box
397
+ */
398
+ const postFromComposer = (button, box) => {
399
+ const input = composerInput(box)
400
+ if (input === null) {
401
+ showCommandError(button, 'write something first')
402
+ return
403
+ }
404
+ // While it waits for GitHub the box stays, even if the reader opens another one.
405
+ box.setAttribute('data-posting', '1')
406
+ void runCommand(
407
+ button,
408
+ async () => {
409
+ const answer = await postComment(input)
410
+ if (answer.kind === 'issue') {
411
+ const node = nodeFrom(doc, issueCommentHtml(answer.comment, renderNow()))
412
+ if (node !== null) {
413
+ root.querySelector('.conversation .pr-composer-host')?.before(node)
414
+ }
415
+ // Only the box that posted goes away; a draft the reader started meanwhile stays.
416
+ box.remove()
417
+ } else {
418
+ insertPostedReviewComment(box, answer.comment)
419
+ }
420
+ toast(root, 'comment posted to github')
421
+ applyCapabilityGating(root, session.capabilities)
422
+ },
423
+ { pendingLabel: 'posting…' }
424
+ ).finally(() => box.removeAttribute('data-posting'))
425
+ }
426
+
427
+ /** @param {string} pointId */
428
+ const pointById = pointId => session.artifact.points.find(p => p.id === pointId)
429
+
430
+ /**
431
+ * @param {HTMLElement} button
432
+ * @param {Point} point
433
+ */
434
+ const postPoint = (button, point) => {
435
+ void runCommand(
436
+ button,
437
+ async () => {
438
+ const answer = await postComment({
439
+ kind: 'inline',
440
+ path: point.path,
441
+ line: point.line,
442
+ side: point.side ?? 'new',
443
+ body: pointToMarkdown(point),
444
+ pointFingerprint: point.fingerprint,
445
+ })
446
+ for (const control of Array.from(
447
+ root.querySelectorAll(`[data-act="point-post"][data-point="${cssEscape(point.id)}"]`)
448
+ )) {
449
+ if (control instanceof HTMLElement) {
450
+ replacePostButton(control, answer.comment.url)
451
+ }
452
+ }
453
+ toast(root, 'attention point posted to github')
454
+ },
455
+ { pendingLabel: 'posting…' }
456
+ )
457
+ }
458
+
459
+ /**
460
+ * @param {HTMLElement} button
461
+ * @param {string} id
462
+ * @param {boolean} reviewed
463
+ * @param {'layer' | 'file'} kind
464
+ * @param {{ quiet?: boolean }} [opts] `quiet` keeps a checkbox label intact while it runs
465
+ */
466
+ const markReviewed = (button, id, reviewed, kind, opts = {}) => {
467
+ const run = async () => {
468
+ await session.setReviewed(id, reviewed)
469
+ const parts = cardForReviewedId(root, id)
470
+ if (parts !== null) {
471
+ setCardCollapsed(parts.card, reviewed)
472
+ parts.card.classList.toggle('is-reviewed', reviewed)
473
+ if (reviewed) {
474
+ const next = nextUnreviewedTarget(root, session, parts.card.id, kind)
475
+ if (next instanceof HTMLElement) {
476
+ focusItem(next.id)
477
+ }
478
+ }
479
+ }
480
+ toast(root, reviewed ? `${kind} marked reviewed` : `${kind} reopened`)
481
+ }
482
+ void (opts.quiet === true
483
+ ? runControl(button, run)
484
+ : runCommand(button, run, { pendingLabel: reviewed ? 'marking…' : 'unmarking…' }))
485
+ }
486
+
487
+ /** @param {HTMLElement} button @param {string} fingerprint @param {boolean} dismissed */
488
+ const setDismissed = (button, fingerprint, dismissed) => {
489
+ void runCommand(
490
+ button,
491
+ async () => {
492
+ await session.setDismissed(fingerprint, dismissed)
493
+ toast(root, dismissed ? 'attention point dismissed' : 'attention point restored')
494
+ },
495
+ { pendingLabel: dismissed ? 'dismissing…' : 'restoring…' }
496
+ )
497
+ }
498
+
499
+ /** @param {HTMLElement} button @param {'APPROVE' | 'REQUEST_CHANGES'} event */
500
+ const openSignoff = (button, event) => {
501
+ const dialog = openSignoffDialog(root, { event })
502
+ applyCapabilityGating(root, session.capabilities)
503
+ signoffOpening += 1
504
+ const opening = signoffOpening
505
+ void runCommand(
506
+ button,
507
+ async () => {
508
+ try {
509
+ const preview = await readReviewBody(session.prNumber)
510
+ // A body that belongs to an earlier opening is not put in the dialog on screen.
511
+ if (opening === signoffOpening) {
512
+ fillSignoffDialog(dialog, preview)
513
+ }
514
+ } catch (err) {
515
+ // The command that opened the dialog sits behind it, so the reason is shown inside.
516
+ if (opening === signoffOpening) {
517
+ showSignoffError(dialog, err instanceof Error ? err.message : String(err))
518
+ }
519
+ throw err
520
+ }
521
+ },
522
+ { pendingLabel: 'loading…' }
523
+ )
524
+ }
525
+
526
+ /** @param {HTMLElement} button */
527
+ const postSignoff = button => {
528
+ const dialog = button.closest('dialog')
529
+ if (!(dialog instanceof HTMLDialogElement)) {
530
+ return
531
+ }
532
+ const event = dialog.getAttribute('data-event') === 'APPROVE' ? 'APPROVE' : 'REQUEST_CHANGES'
533
+ const body = signoffBody(dialog)
534
+ void runCommand(
535
+ button,
536
+ async () => {
537
+ const review = await session.postReview(event, body === '' ? undefined : body)
538
+ showSignoffResult(dialog, review)
539
+ toast(root, event === 'APPROVE' ? 'approved on github' : 'changes requested on github')
540
+ },
541
+ { pendingLabel: 'posting…' }
542
+ )
543
+ }
544
+
545
+ /** @type {Record<string, (el: HTMLElement, event: MouseEvent) => void>} */
546
+ const actions = {
547
+ 'toggle-card': el => {
548
+ setCardCollapsed(el)
549
+ },
550
+ 'mark-layer': el => {
551
+ const id = el.getAttribute('data-reviewed-id') ?? ''
552
+ markReviewed(el, id, !session.isReviewed(id), 'layer')
553
+ },
554
+ 'point-dismiss': el => setDismissed(el, el.getAttribute('data-fingerprint') ?? '', true),
555
+ 'point-restore': el => setDismissed(el, el.getAttribute('data-fingerprint') ?? '', false),
556
+ 'show-dismissed': el => {
557
+ const list = el.closest('.dismissed-list')?.querySelector('ol.findings.dismissed')
558
+ if (list === null || list === undefined) {
559
+ return
560
+ }
561
+ const open = list.hasAttribute('hidden')
562
+ list.toggleAttribute('hidden', !open)
563
+ el.setAttribute('aria-expanded', open ? 'true' : 'false')
564
+ el.textContent = open ? 'hide' : 'show'
565
+ },
566
+ 'point-post': el => {
567
+ const point = pointById(el.getAttribute('data-point') ?? '')
568
+ if (point !== undefined) {
569
+ postPoint(el, point)
570
+ }
571
+ },
572
+ 'comment-line': el => {
573
+ const key = el.getAttribute('data-key') ?? ''
574
+ const path = session.pathForKey(key)
575
+ const line = Number(el.getAttribute('data-line'))
576
+ if (path === undefined || !Number.isInteger(line) || line <= 0) {
577
+ return
578
+ }
579
+ openLineComposer({ key, path, side: el.getAttribute('data-side') === 'old' ? 'old' : 'new', line })
580
+ },
581
+ 'comment-selection': () => {
582
+ if (selection !== null) {
583
+ openLineComposer({ ...selection, line: selection.end, startLine: selection.start })
584
+ }
585
+ },
586
+ 'composer-post': el => {
587
+ const box = el.closest('.composer-box')
588
+ if (box !== null) {
589
+ postFromComposer(el, box)
590
+ }
591
+ },
592
+ 'markdown-preview': el => toggleMarkdownPreview(el, true),
593
+ 'markdown-write': el => toggleMarkdownPreview(el, false),
594
+ 'composer-cancel': () => {
595
+ closeComposers(root)
596
+ },
597
+ 'thread-hide': el => {
598
+ const id = Number(el.getAttribute('data-thread'))
599
+ const row = el.closest('tr.thread')
600
+ void runCommand(
601
+ el,
602
+ async () => {
603
+ await session.setThreadHidden(id, true)
604
+ if (row !== null) {
605
+ setThreadCollapsed(row, true)
606
+ }
607
+ },
608
+ { pendingLabel: 'hiding…' }
609
+ )
610
+ },
611
+ 'thread-collapse': el => {
612
+ const row = el.closest('tr.thread')
613
+ if (row !== null) {
614
+ setThreadCollapsed(row, true)
615
+ }
616
+ },
617
+ 'thread-show': el => {
618
+ const row = el.closest('tr.thread')
619
+ if (row === null) {
620
+ return
621
+ }
622
+ if (!row.classList.contains('hidden-thread')) {
623
+ // A resolved thread is only folded away on screen, so opening it is local.
624
+ setThreadCollapsed(row, false)
625
+ return
626
+ }
627
+ const id = Number(el.getAttribute('data-thread'))
628
+ void runCommand(
629
+ el,
630
+ async () => {
631
+ await session.setThreadHidden(id, false)
632
+ setThreadCollapsed(row, false)
633
+ },
634
+ { pendingLabel: 'showing…' }
635
+ )
636
+ },
637
+ 'thread-reply': el => {
638
+ const full = el.closest('.thread-full')
639
+ const id = Number(el.getAttribute('data-thread'))
640
+ if (full === null || !Number.isInteger(id)) {
641
+ return
642
+ }
643
+ composerSeq += 1
644
+ openComposer(full, { id: `composer-${composerSeq}`, label: 'Reply', kind: 'reply', inReplyToId: id }, 'block')
645
+ },
646
+ 'pr-comment': () => {
647
+ const host = root.querySelector('.conversation .pr-composer-host')
648
+ if (host !== null) {
649
+ composerSeq += 1
650
+ openComposer(
651
+ host,
652
+ { id: `composer-${composerSeq}`, label: 'Comment on this pull request', kind: 'issue' },
653
+ 'block'
654
+ )
655
+ }
656
+ },
657
+ signoff: el => {
658
+ openSignoff(el, el.getAttribute('data-event') === 'APPROVE' ? 'APPROVE' : 'REQUEST_CHANGES')
659
+ },
660
+ 'signoff-post': el => postSignoff(el),
661
+ 'signoff-close': el => {
662
+ el.closest('dialog')?.close()
663
+ },
664
+ ask: el => {
665
+ const chat = opts.chat?.() ?? null
666
+ if (chat === null) {
667
+ toast(root, NO_CHAT_NOTE)
668
+ return
669
+ }
670
+ chat.ask(chatContextFromElement(el))
671
+ },
672
+ 'show-fold': el => {
673
+ const summary = el.closest('tr.more.fold')
674
+ if (summary instanceof HTMLTableRowElement) {
675
+ setFoldShown(summary, el.getAttribute('aria-expanded') !== 'true')
676
+ }
677
+ },
678
+ 'jump-line': el => {
679
+ const line = Number(el.getAttribute('data-line'))
680
+ if (!Number.isInteger(line) || line <= 0) {
681
+ return
682
+ }
683
+ const side = el.getAttribute('data-side') === 'old' ? 'old' : 'new'
684
+ const row = findRow(root, el.getAttribute('data-key') ?? '', side, line)
685
+ if (row === null) {
686
+ return
687
+ }
688
+ // scrollIntoViewSafe raises `reveal-code` first, so a target inside a fold opens on the way.
689
+ scrollIntoViewSafe(row)
690
+ flash(row)
691
+ },
692
+ settings: el => {
693
+ opts.openSettings?.(el)
694
+ },
695
+ help: () => {
696
+ openHelpDialog(root)
697
+ },
698
+ }
699
+
700
+ /** @param {Event} event */
701
+ const onClick = event => {
702
+ const el = event.target instanceof Element ? event.target.closest('[data-act]') : null
703
+ if (!(el instanceof HTMLElement) || el.hasAttribute('disabled')) {
704
+ return
705
+ }
706
+ const act = actions[el.getAttribute('data-act') ?? '']
707
+ if (act !== undefined) {
708
+ event.preventDefault()
709
+ act(el, /** @type {MouseEvent} */ (event))
710
+ }
711
+ }
712
+
713
+ /** @param {Event} event */
714
+ const onChange = event => {
715
+ const input = event.target
716
+ if (!(input instanceof HTMLInputElement)) {
717
+ return
718
+ }
719
+ const id = input.getAttribute('data-reviewed-id')
720
+ if (id === null) {
721
+ return
722
+ }
723
+ const kind = id.includes('/file:') ? 'file' : 'layer'
724
+ const label = input.closest('label')
725
+ markReviewed(label instanceof HTMLElement ? label : input, id, input.checked, kind, { quiet: true })
726
+ }
727
+
728
+ /** @param {PointerEvent} event */
729
+ const onPointerDown = event => {
730
+ const target = lineRefFromEvent(event, key => session.pathForKey(key))
731
+ if (target === null) {
732
+ return
733
+ }
734
+ event.preventDefault()
735
+ setSelection(
736
+ selectionReducer(selection, event.shiftKey ? { type: 'shift-click', target } : { type: 'click', target })
737
+ )
738
+ if (selection !== null && !event.shiftKey) {
739
+ setSelection(selectionReducer(selection, { type: 'drag-start', target }))
740
+ }
741
+ }
742
+
743
+ /** @param {PointerEvent} event */
744
+ const onPointerOver = event => {
745
+ if (selection?.dragging !== true) {
746
+ return
747
+ }
748
+ const target = lineRefFromEvent(event, key => session.pathForKey(key))
749
+ if (target !== null) {
750
+ setSelection(selectionReducer(selection, { type: 'drag-over', target }))
751
+ }
752
+ }
753
+
754
+ const onPointerUp = () => {
755
+ if (selection?.dragging === true) {
756
+ setSelection(selectionReducer(selection, { type: 'commit' }))
757
+ }
758
+ }
759
+
760
+ /** @param {KeyboardEvent} event */
761
+ const onKeyDown = event => {
762
+ const decided = keyAction(event, { pendingG })
763
+ pendingG = decided.pendingG
764
+ if (decided.action === null) {
765
+ return
766
+ }
767
+ // While a dialog is open it owns the keyboard, Esc included: the browser closes it, and the
768
+ // page behind it keeps its selection and its open composer.
769
+ if (doc.querySelector('dialog[open]') !== null) {
770
+ return
771
+ }
772
+ const order = buildNavOrder(session.artifact)
773
+ const points = session.artifact.points.filter(p => session.state.dismissed[p.fingerprint] === undefined)
774
+ switch (decided.action) {
775
+ case 'next-layer':
776
+ case 'prev-layer':
777
+ case 'next-file':
778
+ case 'prev-file': {
779
+ const step =
780
+ decided.action === 'next-layer'
781
+ ? nextLayer
782
+ : decided.action === 'prev-layer'
783
+ ? prevLayer
784
+ : decided.action === 'next-file'
785
+ ? nextFile
786
+ : prevFile
787
+ const item = step(order, focusId)
788
+ if (item !== null) {
789
+ focusPointId = null
790
+ focusItem(item.id)
791
+ }
792
+ break
793
+ }
794
+ case 'next-point':
795
+ case 'prev-point':
796
+ stepPoint(points, decided.action === 'next-point' ? 1 : -1)
797
+ break
798
+ case 'toggle': {
799
+ const card = focusId === null ? null : root.querySelector(`#${cssEscape(focusId)}`)
800
+ if (card !== null) {
801
+ setCardCollapsed(card)
802
+ }
803
+ break
804
+ }
805
+ case 'reviewed-file':
806
+ case 'reviewed-layer': {
807
+ const item = order.find(i => i.id === focusId)
808
+ const wanted = decided.action === 'reviewed-file' ? 'file' : 'layer'
809
+ const found =
810
+ item?.kind === wanted ? item : wanted === 'layer' && focusId !== null ? layerOf(order, focusId) : null
811
+ if (found === null || found === undefined || found.kind === 'overview') {
812
+ break
813
+ }
814
+ const id =
815
+ found.kind === 'file' ? `layer:${found.layerId}/file:${sanitizeKey(found.path)}` : `layer:${found.layerId}`
816
+ const parts = cardForReviewedId(root, id)
817
+ const box = parts?.card.querySelector('input[data-reviewed-id]')
818
+ if (box instanceof HTMLElement) {
819
+ markReviewed(box, id, !session.isReviewed(id), found.kind, { quiet: true })
820
+ }
821
+ break
822
+ }
823
+ case 'comment': {
824
+ if (selection !== null) {
825
+ openLineComposer({ ...selection, line: selection.end, startLine: selection.start })
826
+ }
827
+ break
828
+ }
829
+ case 'dismiss': {
830
+ const point = points.find(p => p.id === focusPointId)
831
+ const button =
832
+ point === undefined
833
+ ? null
834
+ : root.querySelector(`[data-point="${cssEscape(point.id)}"] [data-act="point-dismiss"]`)
835
+ if (button instanceof HTMLElement && point !== undefined) {
836
+ focusPointId = null
837
+ setDismissed(button, point.fingerprint, true)
838
+ }
839
+ break
840
+ }
841
+ case 'overview':
842
+ focusPointId = null
843
+ focusItem('overview')
844
+ break
845
+ case 'help':
846
+ openHelpDialog(root)
847
+ break
848
+ case 'ask': {
849
+ // `a` asks about whatever is in focus: an attention point, a selection, or the card.
850
+ const chat = opts.chat?.() ?? null
851
+ const target = askTargetFor(root, focusId, focusPointId, selection)
852
+ if (chat === null) {
853
+ toast(root, NO_CHAT_NOTE)
854
+ break
855
+ }
856
+ chat.ask(target)
857
+ break
858
+ }
859
+ case 'focus-chat':
860
+ opts.chat?.()?.focusInput()
861
+ break
862
+ case 'escape':
863
+ if (closeComposers(root) === 0) {
864
+ setSelection(selectionReducer(selection, { type: 'clear' }))
865
+ }
866
+ break
867
+ }
868
+ }
869
+
870
+ root.addEventListener('click', onClick)
871
+ root.addEventListener('change', onChange)
872
+ root.addEventListener('pointerdown', /** @type {EventListener} */ (onPointerDown))
873
+ root.addEventListener('pointerover', /** @type {EventListener} */ (onPointerOver))
874
+ doc.addEventListener('pointerup', onPointerUp)
875
+ doc.addEventListener('pointercancel', onPointerUp)
876
+ doc.addEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
877
+ applyCapabilityGating(root, session.capabilities)
878
+
879
+ return {
880
+ /**
881
+ * What the chat's proposed-comment card does: post it straight away, or open the same
882
+ * composer the rest of the page uses, prefilled.
883
+ * @param {'post' | 'edit'} what
884
+ * @param {import('./proposed-comment.js').ProposedComment} comment
885
+ * @param {HTMLElement} el
886
+ */
887
+ onProposedComment(what, comment, el) {
888
+ const key = keyForPath(comment.path)
889
+ if (what === 'edit') {
890
+ if (key === null) {
891
+ showCommandError(el, `${comment.path} is not a file of this pull request`)
892
+ return
893
+ }
894
+ composerSeq += 1
895
+ /** @type {import('./composer.js').ComposerOptions} */
896
+ const options = {
897
+ id: `composer-${composerSeq}`,
898
+ label: `Comment on ${comment.path}:${comment.line}`,
899
+ kind: 'inline',
900
+ path: comment.path,
901
+ line: comment.line,
902
+ side: comment.side,
903
+ body: comment.body,
904
+ }
905
+ if (comment.startLine !== undefined && comment.startLine !== comment.line) {
906
+ options.startLine = comment.startLine
907
+ }
908
+ const row = findRow(root, key, comment.side, comment.line)
909
+ if (row === null) {
910
+ showCommandError(el, 'that line is not on screen; open the file card first')
911
+ return
912
+ }
913
+ openComposer(row, options, 'row')
914
+ return
915
+ }
916
+ void runCommand(
917
+ el,
918
+ async () => {
919
+ const input = {
920
+ kind: /** @type {const} */ ('inline'),
921
+ path: comment.path,
922
+ line: comment.line,
923
+ side: comment.side,
924
+ body: comment.body,
925
+ ...(comment.startLine === undefined || comment.startLine === comment.line
926
+ ? {}
927
+ : { startLine: comment.startLine }),
928
+ }
929
+ const answer = await postComment(input)
930
+ replacePostButton(el, answer.comment.url)
931
+ toast(root, 'comment posted to github')
932
+ },
933
+ { pendingLabel: 'posting…' }
934
+ )
935
+ },
936
+ /** Lets the caller re-apply the gating after the probe answers. */
937
+ refreshCapabilities() {
938
+ applyCapabilityGating(root, session.capabilities)
939
+ },
940
+ stop() {
941
+ unsubscribe()
942
+ setCardRenderedHook(null)
943
+ root.removeEventListener('click', onClick)
944
+ root.removeEventListener('change', onChange)
945
+ root.removeEventListener('pointerdown', /** @type {EventListener} */ (onPointerDown))
946
+ root.removeEventListener('pointerover', /** @type {EventListener} */ (onPointerOver))
947
+ doc.removeEventListener('pointerup', onPointerUp)
948
+ doc.removeEventListener('pointercancel', onPointerUp)
949
+ doc.removeEventListener('keydown', /** @type {EventListener} */ (onKeyDown))
950
+ },
951
+ }
952
+ }