@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.
- package/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- 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
|
+
}
|