@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,69 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The regenerate dialog: the skill command with --force and a copy command. The page keeps polling
|
|
3
|
+
// while it is open and swaps in the new canvas when one is indexed for the head.
|
|
4
|
+
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
5
|
+
import { esc } from './dom.js'
|
|
6
|
+
|
|
7
|
+
export const REGENERATE_DIALOG_ID = 'regenerate-dialog'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} command
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function regenerateDialogHtml(command) {
|
|
14
|
+
return (
|
|
15
|
+
`<dialog id="${REGENERATE_DIALOG_ID}" class="regen" aria-labelledby="regen-h"><form method="dialog">` +
|
|
16
|
+
'<h2 id="regen-h">Regenerate the canvas</h2>' +
|
|
17
|
+
'<p class="hint">Run this in Claude Code or Codex from this repo. The current canvas stays until the new one is published; this page then updates on its own.</p>' +
|
|
18
|
+
`<div class="cmdbox"><code>${esc(command)}</code><button class="cmd fill" type="button" data-copy="${esc(command)}">copy</button></div>` +
|
|
19
|
+
'<div class="dialog-actions"><button class="cmd" type="submit" value="close">close</button></div>' +
|
|
20
|
+
'</form></dialog>'
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates the dialog under `root` on first use, updates its command, and opens it.
|
|
26
|
+
* @param {HTMLElement} root
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @returns {HTMLDialogElement}
|
|
29
|
+
*/
|
|
30
|
+
export function openRegenerateDialog(root, command) {
|
|
31
|
+
let dialog = root.querySelector(`#${REGENERATE_DIALOG_ID}`)
|
|
32
|
+
if (!(dialog instanceof HTMLDialogElement)) {
|
|
33
|
+
const t = document.createElement('template')
|
|
34
|
+
t.innerHTML = regenerateDialogHtml(command)
|
|
35
|
+
root.appendChild(t.content)
|
|
36
|
+
dialog = root.querySelector(`#${REGENERATE_DIALOG_ID}`)
|
|
37
|
+
}
|
|
38
|
+
if (!(dialog instanceof HTMLDialogElement)) {
|
|
39
|
+
throw new Error('regenerate dialog did not render')
|
|
40
|
+
}
|
|
41
|
+
const code = dialog.querySelector('code')
|
|
42
|
+
const copy = dialog.querySelector('[data-copy]')
|
|
43
|
+
if (code) {
|
|
44
|
+
code.textContent = command
|
|
45
|
+
}
|
|
46
|
+
copy?.setAttribute('data-copy', command)
|
|
47
|
+
if (typeof dialog.showModal === 'function' && !dialog.open) {
|
|
48
|
+
dialog.showModal()
|
|
49
|
+
} else {
|
|
50
|
+
dialog.setAttribute('open', '')
|
|
51
|
+
}
|
|
52
|
+
return dialog
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True when `next` carries a canvas that differs from the one the page shows: another head sha,
|
|
57
|
+
* or the same head regenerated (a newer generatedAt).
|
|
58
|
+
* @param {PrBundle} current
|
|
59
|
+
* @param {PrBundle} next
|
|
60
|
+
*/
|
|
61
|
+
export function canvasChanged(current, next) {
|
|
62
|
+
if (next.status !== 'ready' || !next.artifact || !next.canvas) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
if (current.status !== 'ready' || !current.artifact || !current.canvas) {
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
return next.canvas.headSha !== current.canvas.headSha || next.artifact.generatedAt !== current.artifact.generatedAt
|
|
69
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// What the page knows about one PR while it is open: the canvas, the local review state, and
|
|
3
|
+
// what this GitHub login may post. Every change is applied at once and taken back when the
|
|
4
|
+
// server refuses it, so a click is never silently lost.
|
|
5
|
+
/** @typedef {import('./contract-types.js').Capabilities} Capabilities */
|
|
6
|
+
/** @typedef {import('./contract-types.js').FileEntry} FileEntry */
|
|
7
|
+
/** @typedef {import('./contract-types.js').PostCommentInput} PostCommentInput */
|
|
8
|
+
/** @typedef {import('./contract-types.js').PostCommentResponse} PostCommentResponse */
|
|
9
|
+
/** @typedef {import('./contract-types.js').PrState} PrState */
|
|
10
|
+
/** @typedef {import('./contract-types.js').ReviewArtifact} ReviewArtifact */
|
|
11
|
+
/** @typedef {import('./contract-types.js').ReviewSummary} ReviewSummary */
|
|
12
|
+
import { postComment, postReview, putDismissed, putReviewed, putThreadHidden } from './api.js'
|
|
13
|
+
import { sanitizeKey } from './keys.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* putReviewed: typeof putReviewed,
|
|
18
|
+
* putDismissed: typeof putDismissed,
|
|
19
|
+
* putThreadHidden: typeof putThreadHidden,
|
|
20
|
+
* postComment: typeof postComment,
|
|
21
|
+
* postReview: typeof postReview,
|
|
22
|
+
* }} SessionApi
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{
|
|
27
|
+
* prNumber: number,
|
|
28
|
+
* artifact: ReviewArtifact,
|
|
29
|
+
* files: ReadonlyArray<FileEntry>,
|
|
30
|
+
* state: PrState,
|
|
31
|
+
* capabilities: Capabilities,
|
|
32
|
+
* headSha: string,
|
|
33
|
+
* api?: Partial<SessionApi>,
|
|
34
|
+
* onState?: (state: PrState) => void,
|
|
35
|
+
* }} SessionOptions
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @param {Partial<SessionApi> | undefined} overrides */
|
|
39
|
+
function withDefaults(overrides) {
|
|
40
|
+
return { putReviewed, putDismissed, putThreadHidden, postComment, postReview, ...overrides }
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {SessionOptions} options */
|
|
52
|
+
export function createReviewSession(options) {
|
|
53
|
+
const api = withDefaults(options.api)
|
|
54
|
+
/** @type {PrState} */
|
|
55
|
+
let state = options.state
|
|
56
|
+
/** @type {Capabilities} */
|
|
57
|
+
let capabilities = options.capabilities
|
|
58
|
+
const keyToPath = new Map(options.files.map(f => [f.key, f.path]))
|
|
59
|
+
|
|
60
|
+
/** @type {Array<(state: PrState) => void>} */
|
|
61
|
+
const listeners = options.onState === undefined ? [] : [options.onState]
|
|
62
|
+
|
|
63
|
+
/** @param {PrState} next */
|
|
64
|
+
const publish = next => {
|
|
65
|
+
state = next
|
|
66
|
+
for (const fn of listeners) {
|
|
67
|
+
fn(state)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How many changes are waiting for an answer. */
|
|
72
|
+
let inFlight = 0
|
|
73
|
+
/** The newest answer that came back while others were still in flight, and its write count. */
|
|
74
|
+
/** @type {PrState | null} */
|
|
75
|
+
let newestState = null
|
|
76
|
+
let newestRev = 0
|
|
77
|
+
/** The write count of the last answer this page took. */
|
|
78
|
+
let takenRev = 0
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shows the change at once, then reconciles with the server.
|
|
82
|
+
*
|
|
83
|
+
* `apply` and `undo` work on whatever the state is when they run, so a change that fails
|
|
84
|
+
* takes back only itself and leaves a change that landed meanwhile alone. Answers carry the
|
|
85
|
+
* write they came from (`rev`), so the newest one wins however the answers arrive, and it is
|
|
86
|
+
* taken once nothing is in flight.
|
|
87
|
+
* @param {(state: PrState) => PrState} apply
|
|
88
|
+
* @param {(state: PrState) => PrState} undo
|
|
89
|
+
* @param {() => Promise<{ state: PrState }>} request
|
|
90
|
+
*/
|
|
91
|
+
const change = async (apply, undo, request) => {
|
|
92
|
+
inFlight += 1
|
|
93
|
+
publish(apply(state))
|
|
94
|
+
try {
|
|
95
|
+
const answer = await request()
|
|
96
|
+
inFlight -= 1
|
|
97
|
+
takeAnswer(answer.state)
|
|
98
|
+
settle()
|
|
99
|
+
return state
|
|
100
|
+
} catch (err) {
|
|
101
|
+
inFlight -= 1
|
|
102
|
+
// A change that failed was never written, so an answer that is waiting already describes
|
|
103
|
+
// the state without it; only what this page showed has to be taken back.
|
|
104
|
+
publish(undo(state))
|
|
105
|
+
settle()
|
|
106
|
+
throw err
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {PrState} answered */
|
|
111
|
+
const takeAnswer = answered => {
|
|
112
|
+
const rev = answered.rev ?? 0
|
|
113
|
+
if (rev >= newestRev) {
|
|
114
|
+
newestRev = rev
|
|
115
|
+
newestState = answered
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Takes the newest answer once every change has come back, unless an older one arrived. */
|
|
120
|
+
const settle = () => {
|
|
121
|
+
if (inFlight > 0 || newestState === null) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const next = newestState
|
|
125
|
+
newestState = null
|
|
126
|
+
newestRev = 0
|
|
127
|
+
if ((next.rev ?? 0) >= takenRev) {
|
|
128
|
+
takenRev = next.rev ?? 0
|
|
129
|
+
publish(next)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {PrState} current
|
|
135
|
+
* @param {'reviewed' | 'dismissed' | 'hiddenThreads'} field
|
|
136
|
+
* @param {string} key
|
|
137
|
+
* @param {{ at: string, reason?: string | undefined } | true | undefined} value undefined removes the key
|
|
138
|
+
*/
|
|
139
|
+
const withEntry = (current, field, key, value) => {
|
|
140
|
+
const next = { ...current[field] }
|
|
141
|
+
if (value === undefined) {
|
|
142
|
+
delete next[key]
|
|
143
|
+
} else {
|
|
144
|
+
Object.assign(next, { [key]: value })
|
|
145
|
+
}
|
|
146
|
+
return { ...current, [field]: next }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
get state() {
|
|
151
|
+
return state
|
|
152
|
+
},
|
|
153
|
+
get capabilities() {
|
|
154
|
+
return capabilities
|
|
155
|
+
},
|
|
156
|
+
get artifact() {
|
|
157
|
+
return options.artifact
|
|
158
|
+
},
|
|
159
|
+
prNumber: options.prNumber,
|
|
160
|
+
/**
|
|
161
|
+
* Runs `fn` after every change of the local state, the optimistic one included.
|
|
162
|
+
* @param {(state: PrState) => void} fn
|
|
163
|
+
*/
|
|
164
|
+
subscribe(fn) {
|
|
165
|
+
listeners.push(fn)
|
|
166
|
+
return () => {
|
|
167
|
+
const at = listeners.indexOf(fn)
|
|
168
|
+
if (at >= 0) {
|
|
169
|
+
listeners.splice(at, 1)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
/** @param {Capabilities} next */
|
|
174
|
+
setCapabilities(next) {
|
|
175
|
+
capabilities = next
|
|
176
|
+
},
|
|
177
|
+
/** The commit this page was drawn for; every post names it. */
|
|
178
|
+
headSha: options.headSha,
|
|
179
|
+
/** @param {string} key */
|
|
180
|
+
pathForKey(key) {
|
|
181
|
+
return keyToPath.get(key)
|
|
182
|
+
},
|
|
183
|
+
/** @param {string} id */
|
|
184
|
+
isReviewed(id) {
|
|
185
|
+
return state.reviewed[id] === true
|
|
186
|
+
},
|
|
187
|
+
/**
|
|
188
|
+
* @param {string} id
|
|
189
|
+
* @param {boolean} reviewed
|
|
190
|
+
*/
|
|
191
|
+
setReviewed(id, reviewed) {
|
|
192
|
+
const before = state.reviewed[id]
|
|
193
|
+
return change(
|
|
194
|
+
current => withEntry(current, 'reviewed', id, reviewed ? true : undefined),
|
|
195
|
+
current => withEntry(current, 'reviewed', id, before),
|
|
196
|
+
() => api.putReviewed(options.prNumber, id, reviewed, { headSha: options.headSha })
|
|
197
|
+
)
|
|
198
|
+
},
|
|
199
|
+
/**
|
|
200
|
+
* @param {string} fingerprint
|
|
201
|
+
* @param {boolean} dismissed
|
|
202
|
+
*/
|
|
203
|
+
setDismissed(fingerprint, dismissed) {
|
|
204
|
+
const before = state.dismissed[fingerprint]
|
|
205
|
+
const at = new Date().toISOString()
|
|
206
|
+
return change(
|
|
207
|
+
current => withEntry(current, 'dismissed', fingerprint, dismissed ? { at } : undefined),
|
|
208
|
+
current => withEntry(current, 'dismissed', fingerprint, before),
|
|
209
|
+
() => api.putDismissed(options.prNumber, fingerprint, dismissed)
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* @param {number} rootCommentId
|
|
214
|
+
* @param {boolean} hidden
|
|
215
|
+
*/
|
|
216
|
+
setThreadHidden(rootCommentId, hidden) {
|
|
217
|
+
const key = String(rootCommentId)
|
|
218
|
+
const before = state.hiddenThreads[key]
|
|
219
|
+
const at = new Date().toISOString()
|
|
220
|
+
return change(
|
|
221
|
+
current => withEntry(current, 'hiddenThreads', key, hidden ? { at } : undefined),
|
|
222
|
+
current => withEntry(current, 'hiddenThreads', key, before),
|
|
223
|
+
() => api.putThreadHidden(options.prNumber, rootCommentId, hidden)
|
|
224
|
+
)
|
|
225
|
+
},
|
|
226
|
+
/**
|
|
227
|
+
* Posts one comment. The pending item is the caller's; here only the answer is kept.
|
|
228
|
+
* @param {PostCommentInput} input
|
|
229
|
+
* @returns {Promise<PostCommentResponse>}
|
|
230
|
+
*/
|
|
231
|
+
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
|
+
}
|
|
244
|
+
},
|
|
245
|
+
/**
|
|
246
|
+
* @param {'APPROVE' | 'REQUEST_CHANGES'} event
|
|
247
|
+
* @param {string} [body]
|
|
248
|
+
* @returns {Promise<ReviewSummary>}
|
|
249
|
+
*/
|
|
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
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @typedef {ReturnType<typeof createReviewSession>} ReviewSession */
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { cssEscape } from './anchors.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Marks the sidebar link for the section being read.
|
|
6
|
+
* @param {ParentNode} root
|
|
7
|
+
* @param {string} anchorId
|
|
8
|
+
*/
|
|
9
|
+
export function markRailCurrent(root, anchorId) {
|
|
10
|
+
for (const link of root.querySelectorAll('nav.rail a[href]')) {
|
|
11
|
+
if (link.getAttribute('href') === `#${anchorId}`) {
|
|
12
|
+
if (link.getAttribute('aria-current') !== 'location') {
|
|
13
|
+
link.setAttribute('aria-current', 'location')
|
|
14
|
+
}
|
|
15
|
+
} else {
|
|
16
|
+
link.removeAttribute('aria-current')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Follows the section at the upper third of the viewport. Section sizes can change as diffs
|
|
23
|
+
* and diagrams load, so those changes also update the selection.
|
|
24
|
+
* @param {HTMLElement} root
|
|
25
|
+
*/
|
|
26
|
+
export function initScrollSpy(root) {
|
|
27
|
+
const sections = Array.from(root.querySelectorAll('nav.rail a[href^="#"]')).flatMap(link => {
|
|
28
|
+
const id = link.getAttribute('href')?.slice(1) ?? ''
|
|
29
|
+
const section = id === '' ? null : root.querySelector(`#${cssEscape(id)}`)
|
|
30
|
+
return section instanceof HTMLElement ? [section] : []
|
|
31
|
+
})
|
|
32
|
+
let frame = 0
|
|
33
|
+
const update = () => {
|
|
34
|
+
frame = 0
|
|
35
|
+
let active = sections[0]
|
|
36
|
+
for (const section of sections) {
|
|
37
|
+
if (section.getBoundingClientRect().top > window.innerHeight / 3) {
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
active = section
|
|
41
|
+
}
|
|
42
|
+
if (active !== undefined) {
|
|
43
|
+
markRailCurrent(root, active.id)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const schedule = () => {
|
|
47
|
+
if (frame === 0) {
|
|
48
|
+
frame = window.requestAnimationFrame(update)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const observer = new ResizeObserver(schedule)
|
|
52
|
+
for (const section of sections) {
|
|
53
|
+
observer.observe(section)
|
|
54
|
+
}
|
|
55
|
+
window.addEventListener('scroll', schedule, { passive: true })
|
|
56
|
+
window.addEventListener('resize', schedule)
|
|
57
|
+
update()
|
|
58
|
+
return {
|
|
59
|
+
stop() {
|
|
60
|
+
window.removeEventListener('scroll', schedule)
|
|
61
|
+
window.removeEventListener('resize', schedule)
|
|
62
|
+
observer.disconnect()
|
|
63
|
+
window.cancelAnimationFrame(frame)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Picking lines in a diff. The reducer is pure: click selects, clicking the same single line
|
|
3
|
+
// again deselects, shift-click extends, dragging extends and commits when the pointer is
|
|
4
|
+
// released, Esc clears. The DOM side only marks the rows and shows the toolbar.
|
|
5
|
+
/** @typedef {import('./contract-types.js').Side} Side */
|
|
6
|
+
/** @typedef {{ key: string, path: string, side: Side, line: number }} LineRef */
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ key: string, path: string, side: Side, anchor: number, start: number, end: number, dragging: boolean }} Selection
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ type: 'click', target: LineRef }
|
|
12
|
+
* | { type: 'shift-click', target: LineRef }
|
|
13
|
+
* | { type: 'drag-start', target: LineRef }
|
|
14
|
+
* | { type: 'drag-over', target: LineRef }
|
|
15
|
+
* | { type: 'commit' }
|
|
16
|
+
* | { type: 'clear' }} SelectionAction
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { findRow } from './anchors.js'
|
|
20
|
+
import { askButtonHtml } from './ask.js'
|
|
21
|
+
import { esc } from './dom.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {Selection | null} selection
|
|
25
|
+
* @param {LineRef} target
|
|
26
|
+
*/
|
|
27
|
+
function sameSide(selection, target) {
|
|
28
|
+
return selection !== null && selection.key === target.key && selection.side === target.side
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {LineRef} target
|
|
33
|
+
* @param {boolean} dragging
|
|
34
|
+
* @returns {Selection}
|
|
35
|
+
*/
|
|
36
|
+
function single(target, dragging) {
|
|
37
|
+
return {
|
|
38
|
+
key: target.key,
|
|
39
|
+
path: target.path,
|
|
40
|
+
side: target.side,
|
|
41
|
+
anchor: target.line,
|
|
42
|
+
start: target.line,
|
|
43
|
+
end: target.line,
|
|
44
|
+
dragging,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {Selection} selection
|
|
50
|
+
* @param {number} line
|
|
51
|
+
* @param {boolean} dragging
|
|
52
|
+
* @returns {Selection}
|
|
53
|
+
*/
|
|
54
|
+
function extend(selection, line, dragging) {
|
|
55
|
+
return {
|
|
56
|
+
...selection,
|
|
57
|
+
start: Math.min(selection.anchor, line),
|
|
58
|
+
end: Math.max(selection.anchor, line),
|
|
59
|
+
dragging,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {Selection | null} selection
|
|
65
|
+
* @param {SelectionAction} action
|
|
66
|
+
* @returns {Selection | null}
|
|
67
|
+
*/
|
|
68
|
+
export function selectionReducer(selection, action) {
|
|
69
|
+
switch (action.type) {
|
|
70
|
+
case 'click':
|
|
71
|
+
if (
|
|
72
|
+
sameSide(selection, action.target) &&
|
|
73
|
+
selection?.start === action.target.line &&
|
|
74
|
+
selection.end === action.target.line
|
|
75
|
+
) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
return single(action.target, false)
|
|
79
|
+
case 'shift-click':
|
|
80
|
+
return selection !== null && sameSide(selection, action.target)
|
|
81
|
+
? extend(selection, action.target.line, false)
|
|
82
|
+
: single(action.target, false)
|
|
83
|
+
case 'drag-start':
|
|
84
|
+
return single(action.target, true)
|
|
85
|
+
case 'drag-over':
|
|
86
|
+
return selection?.dragging === true && sameSide(selection, action.target)
|
|
87
|
+
? extend(selection, action.target.line, true)
|
|
88
|
+
: selection
|
|
89
|
+
case 'commit':
|
|
90
|
+
return selection === null ? null : { ...selection, dragging: false }
|
|
91
|
+
case 'clear':
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {Selection} selection */
|
|
97
|
+
export function selectionLabel(selection) {
|
|
98
|
+
return selection.start === selection.end
|
|
99
|
+
? `${selection.path} line ${selection.start}`
|
|
100
|
+
: `${selection.path} lines ${selection.start}–${selection.end}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The little bar under a selection: comment on those lines, or ask the chat about them.
|
|
105
|
+
* @param {Selection} selection
|
|
106
|
+
*/
|
|
107
|
+
export function selectionToolbarHtml(selection) {
|
|
108
|
+
const label = selectionLabel(selection)
|
|
109
|
+
return (
|
|
110
|
+
`<tr class="sel-bar" data-decoration="selection"><td class="ln" colspan="3"></td><td class="code x">` +
|
|
111
|
+
`<span class="lbl">${esc(label)}</span>` +
|
|
112
|
+
'<span class="tbtns"><button class="cmd" type="button" data-act="comment-selection" data-needs-post>comment</button>' +
|
|
113
|
+
askButtonHtml({
|
|
114
|
+
kind: 'lines',
|
|
115
|
+
path: selection.path,
|
|
116
|
+
side: selection.side,
|
|
117
|
+
start: selection.start,
|
|
118
|
+
end: selection.end,
|
|
119
|
+
}) +
|
|
120
|
+
'</span></td></tr>'
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Marks the selected rows and puts the toolbar under the last one. Removes the marks of the
|
|
126
|
+
* previous selection first, so calling it again with any selection leaves one set of marks.
|
|
127
|
+
* @param {ParentNode} root
|
|
128
|
+
* @param {Selection | null} selection
|
|
129
|
+
* @returns {HTMLTableRowElement | null} the row the toolbar was added after
|
|
130
|
+
*/
|
|
131
|
+
export function markSelection(root, selection) {
|
|
132
|
+
for (const row of Array.from(root.querySelectorAll('tr.is-selected'))) {
|
|
133
|
+
row.classList.remove('is-selected')
|
|
134
|
+
}
|
|
135
|
+
for (const bar of Array.from(root.querySelectorAll('tr.sel-bar'))) {
|
|
136
|
+
bar.remove()
|
|
137
|
+
}
|
|
138
|
+
if (selection === null) {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
/** @type {HTMLTableRowElement | null} */
|
|
142
|
+
let last = null
|
|
143
|
+
for (let line = selection.start; line <= selection.end; line++) {
|
|
144
|
+
const row = findRow(root, selection.key, selection.side, line)
|
|
145
|
+
if (row) {
|
|
146
|
+
row.classList.add('is-selected')
|
|
147
|
+
last = row
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (last === null || selection.dragging) {
|
|
151
|
+
return last
|
|
152
|
+
}
|
|
153
|
+
const template = document.createElement('template')
|
|
154
|
+
template.innerHTML = `<table><tbody>${selectionToolbarHtml(selection)}</tbody></table>`
|
|
155
|
+
const bar = template.content.querySelector('tr')
|
|
156
|
+
if (bar !== null) {
|
|
157
|
+
last.insertAdjacentElement('afterend', bar)
|
|
158
|
+
}
|
|
159
|
+
return last
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The line a pointer event landed on, read from the row it came from. Null when the event did
|
|
164
|
+
* not start on a line number cell of a diff row.
|
|
165
|
+
* @param {Event} event
|
|
166
|
+
* @param {(key: string) => string | undefined} pathForKey
|
|
167
|
+
* @returns {LineRef | null}
|
|
168
|
+
*/
|
|
169
|
+
export function lineRefFromEvent(event, pathForKey) {
|
|
170
|
+
const target = event.target instanceof Element ? event.target.closest('td.ln') : null
|
|
171
|
+
if (target === null) {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
const row = target.closest('tr')
|
|
175
|
+
const table = row?.closest('table.diff')
|
|
176
|
+
const key = table?.getAttribute('data-key')
|
|
177
|
+
if (!(row && key) || row.classList.contains('hunk') || row.classList.contains('more')) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
// The column decides the side: the left one is the old file, the right one the new file.
|
|
181
|
+
// A deleted line exists only on the left, so it is always the old side.
|
|
182
|
+
const cells = Array.from(row.querySelectorAll('td.ln'))
|
|
183
|
+
const oldText = cells[0]?.textContent?.trim() ?? ''
|
|
184
|
+
const newText = cells[1]?.textContent?.trim() ?? ''
|
|
185
|
+
const clickedOld = cells.indexOf(target) === 0 && oldText !== ''
|
|
186
|
+
const side = row.classList.contains('del') || clickedOld ? 'old' : 'new'
|
|
187
|
+
const line = Number(side === 'old' ? oldText : newText)
|
|
188
|
+
const path = pathForKey(key)
|
|
189
|
+
if (!Number.isInteger(line) || line <= 0 || path === undefined) {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
return { key, path, side, line }
|
|
193
|
+
}
|