@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
package/static/js/app.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Boot: read the bootstrap JSON, fetch the bundle and the patches together, render the screen for
|
|
3
|
+
// the bundle's status, and keep the page current: the empty screen polls until a canvas is
|
|
4
|
+
// published, and the regenerate dialog polls until a newer canvas replaces the one on screen.
|
|
5
|
+
/** @typedef {import('./contract-types.js').PrBundle} PrBundle */
|
|
6
|
+
import {
|
|
7
|
+
ApiError,
|
|
8
|
+
fetchBundle,
|
|
9
|
+
fetchPatches,
|
|
10
|
+
fetchSharedCanvas,
|
|
11
|
+
importCanvas,
|
|
12
|
+
pollBundle,
|
|
13
|
+
saveAppearance,
|
|
14
|
+
} from './api.js'
|
|
15
|
+
import { setChatEnabled } from './ask.js'
|
|
16
|
+
import { readChatWidth, renderChatShell, wireChat } from './chat.js'
|
|
17
|
+
import { runCommand, wireCopyCommands } from './commands.js'
|
|
18
|
+
import { initDeepLinks } from './deep-link.js'
|
|
19
|
+
import { initDiagrams } from './diagram.js'
|
|
20
|
+
import { esc, qs } from './dom.js'
|
|
21
|
+
import { exportCanvasZip } from './download.js'
|
|
22
|
+
import { renderEmptyState, renderStaleState, staleBarHtml } from './empty-state.js'
|
|
23
|
+
import { errorCardHtml } from './errors.js'
|
|
24
|
+
import { renderHeader } from './header.js'
|
|
25
|
+
import { wireDropZone } from './import-zone.js'
|
|
26
|
+
import { toast, wireReview } from './interactions.js'
|
|
27
|
+
import { defineLayerElements, pathSet, renderLayers, renderRail, setRenderContext } from './layers.js'
|
|
28
|
+
import { renderOverview } from './overview.js'
|
|
29
|
+
import { wireQuickQuestions } from './quick-questions.js'
|
|
30
|
+
import { canvasChanged, openRegenerateDialog } from './regenerate.js'
|
|
31
|
+
import { createReviewSession } from './review-session.js'
|
|
32
|
+
import { initScrollSpy } from './scroll-spy.js'
|
|
33
|
+
import { openSettingsDialog } from './settings.js'
|
|
34
|
+
import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
|
|
35
|
+
import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
|
|
36
|
+
|
|
37
|
+
/** @typedef {{ prNumber: number, owner: string, repo: string, version: string }} Bootstrap */
|
|
38
|
+
|
|
39
|
+
/** @returns {Bootstrap | null} */
|
|
40
|
+
function readBootstrap() {
|
|
41
|
+
const el = document.getElementById('bootstrap')
|
|
42
|
+
if (!el?.textContent) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return /** @type {Bootstrap} */ (JSON.parse(el.textContent))
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} version
|
|
54
|
+
* @param {PrBundle} [bundle]
|
|
55
|
+
*/
|
|
56
|
+
function footerHtml(version, bundle) {
|
|
57
|
+
const canvas = bundle?.canvas
|
|
58
|
+
? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
|
|
59
|
+
: ''
|
|
60
|
+
return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except GitHub posts you confirm</span></footer>`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {ReadonlyArray<string>} warnings */
|
|
64
|
+
function bannerHtml(warnings) {
|
|
65
|
+
return warnings.length === 0 ? '' : `<div class="banner" role="status">${warnings.map(w => esc(w)).join(' · ')}</div>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown} err
|
|
70
|
+
* @returns {import('./contract-types.js').ErrorEnvelope['error']}
|
|
71
|
+
*/
|
|
72
|
+
function toEnvelopeError(err) {
|
|
73
|
+
if (!(err instanceof ApiError)) {
|
|
74
|
+
return { code: 'INTERNAL', message: String(err) }
|
|
75
|
+
}
|
|
76
|
+
return err.hint === undefined
|
|
77
|
+
? { code: err.code, message: err.message }
|
|
78
|
+
: { code: err.code, message: err.message, hint: err.hint }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
|
|
83
|
+
* parallel fetch can land before the patches exist; one more try after the bundle is enough.
|
|
84
|
+
* @param {number} prNumber
|
|
85
|
+
* @param {Promise<Record<string, string> | null>} started
|
|
86
|
+
* @param {string} [headSha] the canvas commit, when it is not the PR head
|
|
87
|
+
*/
|
|
88
|
+
async function settlePatches(prNumber, started, headSha) {
|
|
89
|
+
const first = await started
|
|
90
|
+
if (first !== null) {
|
|
91
|
+
return first
|
|
92
|
+
}
|
|
93
|
+
const retry = headSha === undefined ? {} : { headSha }
|
|
94
|
+
return fetchPatches(prNumber, retry).then(
|
|
95
|
+
r => r.patches,
|
|
96
|
+
() => null
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class PrAppElement extends HTMLElement {
|
|
101
|
+
/** @type {{ stop: () => void } | null} */
|
|
102
|
+
poller = null
|
|
103
|
+
/** @type {Bootstrap | null} */
|
|
104
|
+
bootstrap = null
|
|
105
|
+
/** @type {import('./theme.js').Theme} */
|
|
106
|
+
theme = 'auto'
|
|
107
|
+
/** @type {import('./skin.js').Skin} */
|
|
108
|
+
skin = 'terminal'
|
|
109
|
+
/** @type {{ stop: () => void } | null} */
|
|
110
|
+
diagrams = null
|
|
111
|
+
/** @type {{ stop: () => void } | null} */
|
|
112
|
+
deepLinks = null
|
|
113
|
+
/** @type {{ stop: () => void } | null} */
|
|
114
|
+
scrollSpy = null
|
|
115
|
+
/** @type {{ stop: () => void } | null} */
|
|
116
|
+
interactions = null
|
|
117
|
+
/** @type {ReturnType<typeof wireChat>} */
|
|
118
|
+
chat = null
|
|
119
|
+
/** @type {{ stop: () => void } | null} */
|
|
120
|
+
quickQuestions = null
|
|
121
|
+
/** The reader asked to read the canvas of the older commit. */
|
|
122
|
+
viewStale = false
|
|
123
|
+
|
|
124
|
+
connectedCallback() {
|
|
125
|
+
// Once per element: the content is re-rendered on every flip, the element itself is not.
|
|
126
|
+
wireCopyCommands(this)
|
|
127
|
+
void this.boot()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
disconnectedCallback() {
|
|
131
|
+
this.stopPolling()
|
|
132
|
+
this.diagrams?.stop()
|
|
133
|
+
this.diagrams = null
|
|
134
|
+
this.deepLinks?.stop()
|
|
135
|
+
this.deepLinks = null
|
|
136
|
+
this.scrollSpy?.stop()
|
|
137
|
+
this.scrollSpy = null
|
|
138
|
+
this.interactions?.stop()
|
|
139
|
+
this.interactions = null
|
|
140
|
+
this.stopChat()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
stopChat() {
|
|
144
|
+
this.chat?.stop()
|
|
145
|
+
this.chat = null
|
|
146
|
+
this.quickQuestions?.stop()
|
|
147
|
+
this.quickQuestions = null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async boot() {
|
|
151
|
+
this.bootstrap = readBootstrap()
|
|
152
|
+
if (!this.bootstrap) {
|
|
153
|
+
this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
this.theme = readTheme(document.documentElement)
|
|
157
|
+
this.skin = readSkin(document.documentElement)
|
|
158
|
+
const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
|
|
159
|
+
r => r.patches,
|
|
160
|
+
() => null
|
|
161
|
+
)
|
|
162
|
+
/** @type {PrBundle} */
|
|
163
|
+
let bundle
|
|
164
|
+
try {
|
|
165
|
+
bundle = await fetchBundle(this.bootstrap.prNumber)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.innerHTML = errorCardHtml(toEnvelopeError(err)) + footerHtml(this.bootstrap.version)
|
|
168
|
+
qs('#retry', this)?.addEventListener('click', () => void this.boot())
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
await this.render(bundle, patchesPromise)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {PrBundle} bundle
|
|
176
|
+
* @param {Promise<Record<string, string> | null>} [patchesPromise]
|
|
177
|
+
*/
|
|
178
|
+
async render(bundle, patchesPromise) {
|
|
179
|
+
const boot = this.bootstrap
|
|
180
|
+
if (!boot) {
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
this.stopPolling()
|
|
184
|
+
this.diagrams?.stop()
|
|
185
|
+
this.diagrams = null
|
|
186
|
+
this.deepLinks?.stop()
|
|
187
|
+
this.deepLinks = null
|
|
188
|
+
this.scrollSpy?.stop()
|
|
189
|
+
this.scrollSpy = null
|
|
190
|
+
this.interactions?.stop()
|
|
191
|
+
this.interactions = null
|
|
192
|
+
this.stopChat()
|
|
193
|
+
const now = new Date()
|
|
194
|
+
const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
|
|
195
|
+
const chatEnabled = bundle.chat.enabled
|
|
196
|
+
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
|
197
|
+
// The renderers read this while they build the cards, so it is set before the first one.
|
|
198
|
+
setChatEnabled(chatEnabled)
|
|
199
|
+
const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
|
|
200
|
+
if (bundle.artifact && showsCanvas) {
|
|
201
|
+
const { artifact } = bundle
|
|
202
|
+
// A stale canvas describes its own commit, so its files and diffs come from that sha.
|
|
203
|
+
const staleSha = bundle.status === 'stale' ? bundle.stale?.canvasHeadSha : undefined
|
|
204
|
+
const files = staleSha === undefined ? bundle.files : artifact.files
|
|
205
|
+
const paths = pathSet(files)
|
|
206
|
+
const patches = await settlePatches(
|
|
207
|
+
boot.prNumber,
|
|
208
|
+
patchesPromise ??
|
|
209
|
+
fetchPatches(boot.prNumber, staleSha === undefined ? {} : { headSha: staleSha }).then(
|
|
210
|
+
r => r.patches,
|
|
211
|
+
() => null
|
|
212
|
+
),
|
|
213
|
+
staleSha
|
|
214
|
+
)
|
|
215
|
+
// The context is set before any <pr-file> connects, so each card renders once, when visible.
|
|
216
|
+
setRenderContext({
|
|
217
|
+
artifact,
|
|
218
|
+
files,
|
|
219
|
+
patches,
|
|
220
|
+
comments: bundle.comments.reviewComments,
|
|
221
|
+
state: bundle.state,
|
|
222
|
+
now,
|
|
223
|
+
})
|
|
224
|
+
defineLayerElements()
|
|
225
|
+
const staleBar = bundle.status === 'stale' && bundle.stale ? staleBarHtml(bundle.stale) : ''
|
|
226
|
+
this.innerHTML =
|
|
227
|
+
header +
|
|
228
|
+
bannerHtml(bundle.warnings) +
|
|
229
|
+
`<div class="layout${chatEnabled ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage) })}</div>` +
|
|
230
|
+
footerHtml(boot.version, bundle)
|
|
231
|
+
// The screen is interactive from here: reviewed state, dismissals, threads, and posting.
|
|
232
|
+
// A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
|
|
233
|
+
// number of that commit means something else on the head a comment would land on.
|
|
234
|
+
const capabilities =
|
|
235
|
+
bundle.status === 'stale'
|
|
236
|
+
? {
|
|
237
|
+
canComment: /** @type {const} */ (false),
|
|
238
|
+
tokenKind: bundle.capabilities.tokenKind,
|
|
239
|
+
login: bundle.capabilities.login,
|
|
240
|
+
reason: 'this canvas is for an older commit; generate one for the current head to post',
|
|
241
|
+
}
|
|
242
|
+
: bundle.capabilities
|
|
243
|
+
const session = createReviewSession({
|
|
244
|
+
prNumber: boot.prNumber,
|
|
245
|
+
artifact,
|
|
246
|
+
files,
|
|
247
|
+
state: bundle.state,
|
|
248
|
+
capabilities,
|
|
249
|
+
headSha: bundle.pr.headSha,
|
|
250
|
+
})
|
|
251
|
+
const interactions = wireReview(this, session, {
|
|
252
|
+
chat: () => this.chat,
|
|
253
|
+
openSettings: el => void openSettingsDialog(this, el),
|
|
254
|
+
})
|
|
255
|
+
this.interactions = interactions
|
|
256
|
+
if (chatEnabled) {
|
|
257
|
+
this.chat = wireChat({
|
|
258
|
+
root: this,
|
|
259
|
+
prNumber: boot.prNumber,
|
|
260
|
+
session,
|
|
261
|
+
onProposed: (what, comment, el) => interactions.onProposedComment(what, comment, el),
|
|
262
|
+
})
|
|
263
|
+
this.quickQuestions = wireQuickQuestions(this, {
|
|
264
|
+
onPick: (context, question) => {
|
|
265
|
+
if (question === null) {
|
|
266
|
+
this.chat?.ask(context)
|
|
267
|
+
} else {
|
|
268
|
+
this.chat?.askQuestion(context, question)
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
if (bundle.status === 'stale') {
|
|
274
|
+
this.startPolling(next => next.status === 'ready')
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
const screen = bundle.status === 'stale' ? renderStaleState(bundle) : renderEmptyState(bundle)
|
|
278
|
+
this.innerHTML =
|
|
279
|
+
header +
|
|
280
|
+
bannerHtml(bundle.warnings) +
|
|
281
|
+
`<div class="layout no-chat"><nav class="rail" aria-label="Review layers"><ul class="tree"><li><a href="#empty-state" aria-current="location"><span class="t">Overview</span><span class="m">no canvas yet</span></a></li></ul></nav><main id="main">${screen}</main></div>` +
|
|
282
|
+
footerHtml(boot.version, bundle)
|
|
283
|
+
// A canvas published from the harness shows up here without a reload.
|
|
284
|
+
this.startPolling(next => next.status === 'ready')
|
|
285
|
+
}
|
|
286
|
+
// Mermaid is fetched only when this screen holds a diagram, and again on a theme flip.
|
|
287
|
+
this.diagrams = initDiagrams(this)
|
|
288
|
+
// The cards are in the page now, so a link in the URL has something to land on.
|
|
289
|
+
this.deepLinks = initDeepLinks(this)
|
|
290
|
+
this.scrollSpy = initScrollSpy(this)
|
|
291
|
+
this.wireCommands(bundle)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Writes the skin or the theme to `.pr-review/settings.yml`.
|
|
296
|
+
* @param {import('./contract-types.js').AppearanceInput} input
|
|
297
|
+
* @param {string} what the word the failure message names
|
|
298
|
+
*/
|
|
299
|
+
save(input, what) {
|
|
300
|
+
void saveAppearance(input).catch(() => toast(this, `could not save the ${what} to settings.yml`))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @param {PrBundle} bundle */
|
|
304
|
+
wireCommands(bundle) {
|
|
305
|
+
// The review screen routes this through the delegated handler; the empty and stale screens
|
|
306
|
+
// have no such handler, so the command is wired here for every screen.
|
|
307
|
+
const settings = qs('#settings', this)
|
|
308
|
+
if (settings instanceof HTMLElement && this.interactions === null) {
|
|
309
|
+
settings.addEventListener('click', () => void openSettingsDialog(this, settings))
|
|
310
|
+
}
|
|
311
|
+
// Both repaint the page under the click; the settings file catches up after the round trip,
|
|
312
|
+
// and each says so when it cannot, because the next load would come back in the old one.
|
|
313
|
+
const toggle = qs('#theme-toggle', this)
|
|
314
|
+
toggle?.addEventListener('click', () => {
|
|
315
|
+
this.theme = nextTheme(this.theme)
|
|
316
|
+
applyTheme(this.theme, document.documentElement)
|
|
317
|
+
toggle.textContent = themeLabel(this.theme)
|
|
318
|
+
this.save({ theme: this.theme }, 'theme')
|
|
319
|
+
})
|
|
320
|
+
const skinToggle = qs('#skin-toggle', this)
|
|
321
|
+
skinToggle?.addEventListener('click', () => {
|
|
322
|
+
this.skin = nextSkin(this.skin)
|
|
323
|
+
applySkin(this.skin, document.documentElement)
|
|
324
|
+
skinToggle.textContent = skinLabel(this.skin)
|
|
325
|
+
this.save({ skin: this.skin }, 'skin')
|
|
326
|
+
})
|
|
327
|
+
const openDialog = () => {
|
|
328
|
+
openRegenerateDialog(this, bundle.skillCommand)
|
|
329
|
+
this.startPolling(next => canvasChanged(bundle, next))
|
|
330
|
+
}
|
|
331
|
+
qs('#regenerate', this)?.addEventListener('click', openDialog)
|
|
332
|
+
qs('#stale-generate', this)?.addEventListener('click', openDialog)
|
|
333
|
+
const boot = this.bootstrap
|
|
334
|
+
if (!boot) {
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
const exportZip = qs('#export-zip', this)
|
|
338
|
+
if (exportZip instanceof HTMLElement) {
|
|
339
|
+
exportZip.addEventListener('click', () => {
|
|
340
|
+
// The canvas on screen is the one to export, which is the older one on the stale screen.
|
|
341
|
+
const headSha = bundle.canvas?.headSha
|
|
342
|
+
void runCommand(exportZip, () => exportCanvasZip(boot.prNumber, headSha ? { headSha } : {}), {
|
|
343
|
+
pendingLabel: 'exporting…',
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
const fetchShared = qs('#fetch-shared', this)
|
|
348
|
+
if (fetchShared instanceof HTMLElement) {
|
|
349
|
+
fetchShared.addEventListener('click', () => {
|
|
350
|
+
void runCommand(
|
|
351
|
+
fetchShared,
|
|
352
|
+
async () => {
|
|
353
|
+
// Imported or not, the answer changes the callout, so the screen is drawn again.
|
|
354
|
+
await fetchSharedCanvas(boot.prNumber)
|
|
355
|
+
await this.reload()
|
|
356
|
+
},
|
|
357
|
+
{ pendingLabel: 'fetching…' }
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
const refresh = qs('#refresh', this)
|
|
362
|
+
if (refresh instanceof HTMLElement) {
|
|
363
|
+
refresh.addEventListener('click', () => {
|
|
364
|
+
void runCommand(refresh, () => this.reload({ refresh: true }), { pendingLabel: 'refreshing…' })
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
const viewStale = qs('#view-stale', this)
|
|
368
|
+
viewStale?.addEventListener('click', () => {
|
|
369
|
+
this.viewStale = true
|
|
370
|
+
void this.render(bundle)
|
|
371
|
+
})
|
|
372
|
+
wireDropZone(this, { prNumber: boot.prNumber, importImpl: importCanvas, onImported: () => void this.reload() })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Re-reads the bundle and re-renders, after an import changed what the server holds. With
|
|
377
|
+
* `refresh` the server asks GitHub again instead of answering from its caches.
|
|
378
|
+
* @param {{ refresh?: boolean }} [opts]
|
|
379
|
+
*/
|
|
380
|
+
async reload(opts = {}) {
|
|
381
|
+
const boot = this.bootstrap
|
|
382
|
+
if (!boot) {
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
const next = await fetchBundle(boot.prNumber, opts.refresh === true ? { refresh: true } : {})
|
|
386
|
+
await this.render(next)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One poller at a time. When `until` holds, the new bundle replaces the screen.
|
|
391
|
+
* @param {(bundle: PrBundle) => boolean} until
|
|
392
|
+
*/
|
|
393
|
+
startPolling(until) {
|
|
394
|
+
const boot = this.bootstrap
|
|
395
|
+
if (!boot) {
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
this.stopPolling()
|
|
399
|
+
this.poller = pollBundle(boot.prNumber, {
|
|
400
|
+
until,
|
|
401
|
+
onBundle: next => {
|
|
402
|
+
if (until(next)) {
|
|
403
|
+
this.poller = null
|
|
404
|
+
void this.render(next)
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
stopPolling() {
|
|
411
|
+
this.poller?.stop()
|
|
412
|
+
this.poller = null
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!customElements.get('pr-app')) {
|
|
417
|
+
customElements.define('pr-app', PrAppElement)
|
|
418
|
+
}
|
package/static/js/ask.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The `[ ask ]` command that sets the AI Chat context. One builder, so a layer card, a file
|
|
3
|
+
// card, an attention point, and the selection toolbar all carry the same attributes and the
|
|
4
|
+
// quick-question menu finds them all.
|
|
5
|
+
import { chatContextAttrs } from './chat-context.js'
|
|
6
|
+
import { esc } from './dom.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the AI Chat pane is on for this screen. The renderers read it rather than threading a
|
|
10
|
+
* flag through every card; `app.js` sets it once per render.
|
|
11
|
+
*/
|
|
12
|
+
let chatEnabled = false
|
|
13
|
+
|
|
14
|
+
/** @param {boolean} on */
|
|
15
|
+
export function setChatEnabled(on) {
|
|
16
|
+
chatEnabled = on
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isChatEnabled() {
|
|
20
|
+
return chatEnabled
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The command, or nothing at all when the pane is off: a page without a chat shows no way to ask.
|
|
25
|
+
* @param {import('./chat-context.js').ChatContext} context
|
|
26
|
+
* @param {{ label?: string, enabled?: boolean }} [opts]
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function askButtonHtml(context, opts = {}) {
|
|
30
|
+
if (!(opts.enabled ?? chatEnabled)) {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
const label = opts.label ?? 'ask'
|
|
34
|
+
return `<button class="cmd" type="button" data-act="ask" data-ask${chatContextAttrs(context)}>${esc(label)}</button>`
|
|
35
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The one thing a chat message is about. One value per message, set-not-toggle: asking about the
|
|
3
|
+
// same target again leaves the context as it is. Both sides use this file: the browser through a
|
|
4
|
+
// module import, the server through src/contract/chat.ts.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ kind: 'pr' }
|
|
8
|
+
* | { kind: 'layer', layerId: string }
|
|
9
|
+
* | { kind: 'file', path: string }
|
|
10
|
+
* | { kind: 'lines', path: string, side: 'new' | 'old', start: number, end: number }
|
|
11
|
+
* | { kind: 'point', fingerprint: string }} ChatContext
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {ChatContext} */
|
|
15
|
+
export const WHOLE_PR = { kind: 'pr' }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when two contexts name the same target, so setting the second one changes nothing.
|
|
19
|
+
* @param {ChatContext} a
|
|
20
|
+
* @param {ChatContext} b
|
|
21
|
+
*/
|
|
22
|
+
export function sameChatContext(a, b) {
|
|
23
|
+
if (a.kind !== b.kind) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
if (a.kind === 'pr') {
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
if (a.kind === 'layer' && b.kind === 'layer') {
|
|
30
|
+
return a.layerId === b.layerId
|
|
31
|
+
}
|
|
32
|
+
if (a.kind === 'file' && b.kind === 'file') {
|
|
33
|
+
return a.path === b.path
|
|
34
|
+
}
|
|
35
|
+
if (a.kind === 'lines' && b.kind === 'lines') {
|
|
36
|
+
return a.path === b.path && a.side === b.side && a.start === b.start && a.end === b.end
|
|
37
|
+
}
|
|
38
|
+
if (a.kind === 'point' && b.kind === 'point') {
|
|
39
|
+
return a.fingerprint === b.fingerprint
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the chip says. The label names the target exactly, so a reader always knows what the next
|
|
46
|
+
* message is about.
|
|
47
|
+
* @param {ChatContext} context
|
|
48
|
+
* @param {(layerId: string) => string | undefined} [layerTitle]
|
|
49
|
+
* @param {(fingerprint: string) => string | undefined} [pointTitle]
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function chatContextLabel(context, layerTitle, pointTitle) {
|
|
53
|
+
switch (context.kind) {
|
|
54
|
+
case 'pr':
|
|
55
|
+
return 'whole PR'
|
|
56
|
+
case 'layer': {
|
|
57
|
+
const title = layerTitle?.(context.layerId)
|
|
58
|
+
return title === undefined ? `layer ${context.layerId}` : `layer · ${title}`
|
|
59
|
+
}
|
|
60
|
+
case 'file':
|
|
61
|
+
return context.path
|
|
62
|
+
case 'lines': {
|
|
63
|
+
const side = context.side === 'old' ? ' (old side)' : ''
|
|
64
|
+
return context.start === context.end
|
|
65
|
+
? `${context.path} line ${context.start}${side}`
|
|
66
|
+
: `${context.path} lines ${context.start}–${context.end}${side}`
|
|
67
|
+
}
|
|
68
|
+
case 'point': {
|
|
69
|
+
const title = pointTitle?.(context.fingerprint)
|
|
70
|
+
return title === undefined ? 'attention point' : `point · ${title}`
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The context a `[ ask ]` element names, read from its data attributes. Returns the whole-PR
|
|
77
|
+
* context for an element that names nothing.
|
|
78
|
+
* @param {Element} el
|
|
79
|
+
* @returns {ChatContext}
|
|
80
|
+
*/
|
|
81
|
+
export function chatContextFromElement(el) {
|
|
82
|
+
const fingerprint = el.getAttribute('data-ask-point')
|
|
83
|
+
if (fingerprint !== null && fingerprint !== '') {
|
|
84
|
+
return { kind: 'point', fingerprint }
|
|
85
|
+
}
|
|
86
|
+
const layerId = el.getAttribute('data-ask-layer')
|
|
87
|
+
if (layerId !== null && layerId !== '') {
|
|
88
|
+
return { kind: 'layer', layerId }
|
|
89
|
+
}
|
|
90
|
+
const path = el.getAttribute('data-ask-path')
|
|
91
|
+
if (path === null || path === '') {
|
|
92
|
+
return WHOLE_PR
|
|
93
|
+
}
|
|
94
|
+
const start = Number(el.getAttribute('data-ask-start'))
|
|
95
|
+
const end = Number(el.getAttribute('data-ask-end'))
|
|
96
|
+
if (!Number.isInteger(start) || start <= 0 || !Number.isInteger(end) || end < start) {
|
|
97
|
+
return { kind: 'file', path }
|
|
98
|
+
}
|
|
99
|
+
return { kind: 'lines', path, side: el.getAttribute('data-ask-side') === 'old' ? 'old' : 'new', start, end }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The data attributes an `[ ask ]` element carries, ready to put in a template string.
|
|
104
|
+
* @param {ChatContext} context
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function chatContextAttrs(context) {
|
|
108
|
+
switch (context.kind) {
|
|
109
|
+
case 'pr':
|
|
110
|
+
return ''
|
|
111
|
+
case 'layer':
|
|
112
|
+
return ` data-ask-layer="${escapeAttr(context.layerId)}"`
|
|
113
|
+
case 'file':
|
|
114
|
+
return ` data-ask-path="${escapeAttr(context.path)}"`
|
|
115
|
+
case 'lines':
|
|
116
|
+
return (
|
|
117
|
+
` data-ask-path="${escapeAttr(context.path)}" data-ask-side="${context.side}"` +
|
|
118
|
+
` data-ask-start="${context.start}" data-ask-end="${context.end}"`
|
|
119
|
+
)
|
|
120
|
+
case 'point':
|
|
121
|
+
return ` data-ask-point="${escapeAttr(context.fingerprint)}"`
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Attribute-safe text. `dom.js` is the browser-only escaper; this file also runs on the server,
|
|
127
|
+
* so it escapes the five characters an attribute value can break on itself.
|
|
128
|
+
* @param {string} value
|
|
129
|
+
*/
|
|
130
|
+
function escapeAttr(value) {
|
|
131
|
+
return value
|
|
132
|
+
.replace(/&/g, '&')
|
|
133
|
+
.replace(/</g, '<')
|
|
134
|
+
.replace(/>/g, '>')
|
|
135
|
+
.replace(/"/g, '"')
|
|
136
|
+
.replace(/'/g, ''')
|
|
137
|
+
}
|