@vintasoftware/pr-review-canvas 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -31
- package/docs/reference.md +396 -101
- package/package.json +9 -4
- package/pr-review.config.example.yml +44 -4
- package/prompts/chat-seed.md +3 -0
- package/prompts/generation-format.md +123 -26
- package/prompts/generation-strict-incremental.md +53 -0
- package/prompts/generation-strict.md +1 -27
- package/prompts/generation-surfacing-incremental.md +56 -0
- package/prompts/generation-surfacing.md +1 -58
- package/prompts/judging-strict.md +27 -0
- package/prompts/judging-surfacing.md +58 -0
- package/skills/pr-review-canvas/SKILL.md +81 -42
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/canvas/comment.ts +24 -0
- package/src/canvas/import.ts +30 -9
- package/src/canvas/name.ts +1 -0
- package/src/canvas/zip.ts +21 -1
- package/src/chat/chat-manager.ts +67 -40
- package/src/chat/threads.ts +27 -26
- package/src/cli.ts +70 -8
- package/src/commands.ts +50 -17
- package/src/config.ts +14 -26
- package/src/contract/api.ts +58 -2
- package/src/contract/canvas-manifest.ts +7 -0
- package/src/contract/comments.ts +5 -0
- package/src/contract/discovery.ts +5 -2
- package/src/contract/generation-context.ts +76 -2
- package/src/contract/keys.ts +1 -0
- package/src/contract/pending.ts +49 -0
- package/src/contract/review-artifact.ts +50 -7
- package/src/contract/review-key.ts +51 -0
- package/src/contract/reviews.ts +26 -0
- package/src/contract/settings.ts +7 -0
- package/src/contract/state.ts +53 -22
- package/src/contract/validation.ts +1 -0
- package/src/git/environment.mjs +27 -0
- package/src/git/git.ts +109 -9
- package/src/git/local-target.ts +138 -0
- package/src/git/patch-lines.ts +34 -2
- package/src/git/pr-refs.ts +36 -0
- package/src/github/attachments.ts +9 -257
- package/src/github/canvas-comment.ts +22 -0
- package/src/github/capabilities.ts +3 -41
- package/src/github/comments.ts +3 -24
- package/src/github/post-comment.ts +3 -36
- package/src/github/post-review.ts +64 -23
- package/src/github/pr.ts +6 -87
- package/src/github/threads.ts +2 -2
- package/src/gitlab/attachments.ts +40 -0
- package/src/gitlab/canvas-comment.ts +26 -0
- package/src/gitlab/capabilities.ts +64 -0
- package/src/gitlab/comments.ts +164 -0
- package/src/gitlab/mr.ts +115 -0
- package/src/gitlab/post-comment.ts +111 -0
- package/src/gitlab/post-review.ts +93 -0
- package/src/gitlab/project.ts +13 -0
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/attachments.ts +293 -0
- package/src/host/capabilities.ts +38 -0
- package/src/host/client.ts +246 -0
- package/src/host/host.ts +154 -0
- package/src/host/pr.ts +51 -0
- package/src/host/remote.ts +42 -0
- package/src/project-config.ts +26 -0
- package/src/review/carry-marks.ts +131 -0
- package/src/review/carry-over.ts +79 -0
- package/src/review/doctor.ts +82 -38
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +111 -10
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +62 -10
- package/src/{github → review}/review-body.ts +17 -5
- package/src/review/skill-command.ts +5 -3
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +349 -24
- package/src/review/validate.ts +14 -5
- package/src/server/bundle.ts +324 -111
- package/src/server/context.ts +10 -8
- package/src/server/errors.ts +30 -8
- package/src/server/html.ts +35 -11
- package/src/server/routes/api.ts +63 -26
- package/src/server/routes/chat-routes.ts +91 -44
- package/src/server/routes/pages.ts +26 -9
- package/src/server/routes/review-routes.ts +278 -67
- package/src/store/canvas-store.ts +93 -55
- package/src/store/data-dir.ts +2 -1
- package/src/store/derived-store.ts +41 -27
- package/src/store/pr-store.ts +23 -14
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +105 -36
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +87 -25
- package/static/js/app.js +50 -15
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +30 -6
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +132 -17
- package/static/js/contract-types.d.ts +7 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/diff-renderer.js +1 -1
- package/static/js/download.js +1 -1
- package/static/js/empty-state.js +102 -18
- package/static/js/errors.js +22 -6
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +65 -15
- package/static/js/host.js +40 -0
- package/static/js/import-zone.js +1 -1
- package/static/js/interactions.js +274 -46
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +295 -32
- package/static/js/links.js +3 -3
- package/static/js/markdown.js +28 -1
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +70 -9
- package/static/js/progress.js +4 -5
- package/static/js/quick-questions.js +15 -2
- package/static/js/reading-level.js +97 -0
- package/static/js/review-session.js +113 -29
- package/static/js/settings.js +55 -24
- package/static/js/signoff.js +79 -10
- package/static/js/skin.js +2 -2
- package/static/styles/chat-panel.css +22 -24
- package/static/styles/chat.css +4 -0
- package/static/styles/commands.css +6 -0
- package/static/styles/header.css +21 -0
- package/static/styles/panels.css +4 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review-actions.css +1 -0
- package/static/styles/review.css +4 -0
- package/static/styles/skin-github.css +7 -1
- package/static/styles.css +1 -0
- package/src/github/gh.ts +0 -211
package/static/js/app.js
CHANGED
|
@@ -13,28 +13,43 @@ import {
|
|
|
13
13
|
saveAppearance,
|
|
14
14
|
} from './api.js'
|
|
15
15
|
import { setChatEnabled } from './ask.js'
|
|
16
|
-
import { readChatWidth, renderChatShell, wireChat } from './chat.js'
|
|
16
|
+
import { readChatMinimized, readChatWidth, renderChatShell, wireChat } from './chat.js'
|
|
17
17
|
import { runCommand, wireCopyCommands } from './commands.js'
|
|
18
18
|
import { initDeepLinks } from './deep-link.js'
|
|
19
19
|
import { initDiagrams } from './diagram.js'
|
|
20
20
|
import { esc, qs } from './dom.js'
|
|
21
21
|
import { exportCanvasZip } from './download.js'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
carriedOverBarHtml,
|
|
24
|
+
marksCarriedBarHtml,
|
|
25
|
+
renderEmptyState,
|
|
26
|
+
renderStaleState,
|
|
27
|
+
staleBarHtml,
|
|
28
|
+
} from './empty-state.js'
|
|
23
29
|
import { errorCardHtml } from './errors.js'
|
|
24
30
|
import { renderHeader } from './header.js'
|
|
25
31
|
import { wireDropZone } from './import-zone.js'
|
|
26
32
|
import { toast, wireReview } from './interactions.js'
|
|
27
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
defineLayerElements,
|
|
35
|
+
pathSet,
|
|
36
|
+
renderLayers,
|
|
37
|
+
renderRail,
|
|
38
|
+
setFoldLevel,
|
|
39
|
+
setRenderContext,
|
|
40
|
+
} from './layers.js'
|
|
28
41
|
import { renderOverview } from './overview.js'
|
|
29
42
|
import { wireQuickQuestions } from './quick-questions.js'
|
|
30
43
|
import { canvasChanged, openRegenerateDialog } from './regenerate.js'
|
|
31
44
|
import { createReviewSession } from './review-session.js'
|
|
32
45
|
import { initScrollSpy } from './scroll-spy.js'
|
|
33
46
|
import { openSettingsDialog } from './settings.js'
|
|
34
|
-
import { applySkin, nextSkin, readSkin, skinLabel } from './skin.js'
|
|
47
|
+
import { applySkin, DEFAULT_SKIN, nextSkin, readSkin, skinLabel } from './skin.js'
|
|
35
48
|
import { applyTheme, nextTheme, readTheme, themeLabel } from './theme.js'
|
|
49
|
+
import { hostLabel, setHost } from './host.js'
|
|
36
50
|
|
|
37
|
-
/** @typedef {
|
|
51
|
+
/** @typedef {import('./contract-types.js').ReviewKey} ReviewKey */
|
|
52
|
+
/** @typedef {import('./contract-types.js').ReviewBootstrap} Bootstrap */
|
|
38
53
|
|
|
39
54
|
/** @returns {Bootstrap | null} */
|
|
40
55
|
function readBootstrap() {
|
|
@@ -57,7 +72,7 @@ function footerHtml(version, bundle) {
|
|
|
57
72
|
const canvas = bundle?.canvas
|
|
58
73
|
? `<span>canvas <span class="mono">${esc(bundle.canvas.headSha.slice(0, 7))}</span> · ${esc(bundle.canvas.source)}</span>`
|
|
59
74
|
: ''
|
|
60
|
-
return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except
|
|
75
|
+
return `<footer><span>pr-review ${esc(version)}</span>${canvas}<span>localhost only · nothing leaves this machine except ${esc(hostLabel())} posts you confirm</span></footer>`
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** @param {ReadonlyArray<string>} warnings */
|
|
@@ -83,7 +98,7 @@ function toEnvelopeError(err) {
|
|
|
83
98
|
/**
|
|
84
99
|
* The patches for the bundle. The first visit builds derived/ while the bundle resolves, so the
|
|
85
100
|
* parallel fetch can land before the patches exist; one more try after the bundle is enough.
|
|
86
|
-
* @param {
|
|
101
|
+
* @param {ReviewKey} prNumber
|
|
87
102
|
* @param {Promise<Record<string, string> | null>} started
|
|
88
103
|
* @param {string} [headSha] the canvas commit, when it is not the PR head
|
|
89
104
|
*/
|
|
@@ -107,7 +122,7 @@ export class PrAppElement extends HTMLElement {
|
|
|
107
122
|
/** @type {import('./theme.js').Theme} */
|
|
108
123
|
theme = 'auto'
|
|
109
124
|
/** @type {import('./skin.js').Skin} */
|
|
110
|
-
skin =
|
|
125
|
+
skin = DEFAULT_SKIN
|
|
111
126
|
/** @type {{ stop: () => void } | null} */
|
|
112
127
|
diagrams = null
|
|
113
128
|
/** @type {{ stop: () => void } | null} */
|
|
@@ -155,8 +170,12 @@ export class PrAppElement extends HTMLElement {
|
|
|
155
170
|
this.innerHTML = errorCardHtml({ code: 'INTERNAL', message: 'missing bootstrap data' })
|
|
156
171
|
return
|
|
157
172
|
}
|
|
173
|
+
setHost(this.bootstrap.host)
|
|
158
174
|
this.theme = readTheme(document.documentElement)
|
|
159
175
|
this.skin = readSkin(document.documentElement)
|
|
176
|
+
// The reading level the canvas opens at comes from the settings file with the page, so the
|
|
177
|
+
// first draw hides what the reader asked for.
|
|
178
|
+
setFoldLevel(this, this.bootstrap.foldLevel)
|
|
160
179
|
const patchesPromise = fetchPatches(this.bootstrap.prNumber).then(
|
|
161
180
|
r => r.patches,
|
|
162
181
|
() => null
|
|
@@ -196,6 +215,7 @@ export class PrAppElement extends HTMLElement {
|
|
|
196
215
|
const header = renderHeader(bundle, { host: location.host, theme: this.theme, skin: this.skin, now })
|
|
197
216
|
const chatEnabled = bundle.chat.enabled
|
|
198
217
|
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
|
218
|
+
const chatMinimized = chatEnabled && readChatMinimized(storage)
|
|
199
219
|
// The renderers read this while they build the cards, so it is set before the first one.
|
|
200
220
|
setChatEnabled(chatEnabled)
|
|
201
221
|
const showsCanvas = bundle.artifact !== undefined && (bundle.status === 'ready' || this.viewStale)
|
|
@@ -215,20 +235,29 @@ export class PrAppElement extends HTMLElement {
|
|
|
215
235
|
staleSha
|
|
216
236
|
)
|
|
217
237
|
// The context is set before any <pr-file> connects, so each card renders once, when visible.
|
|
238
|
+
const headSha = staleSha ?? bundle.pr.headSha
|
|
218
239
|
setRenderContext({
|
|
219
240
|
artifact,
|
|
220
241
|
files,
|
|
221
242
|
patches,
|
|
243
|
+
headSha,
|
|
222
244
|
comments: bundle.comments.reviewComments,
|
|
223
245
|
state: bundle.state,
|
|
224
246
|
now,
|
|
225
247
|
})
|
|
226
248
|
defineLayerElements()
|
|
227
|
-
const staleBar =
|
|
249
|
+
const staleBar =
|
|
250
|
+
bundle.status === 'stale' && bundle.stale
|
|
251
|
+
? staleBarHtml(bundle.stale, bundle.local)
|
|
252
|
+
: bundle.carriedOver
|
|
253
|
+
? carriedOverBarHtml(bundle.carriedOver)
|
|
254
|
+
: ''
|
|
255
|
+
const marksBar =
|
|
256
|
+
bundle.marksCarriedFrom === undefined ? '' : marksCarriedBarHtml(bundle.marksCarriedFrom)
|
|
228
257
|
this.innerHTML =
|
|
229
258
|
header +
|
|
230
259
|
bannerHtml(bundle.warnings) +
|
|
231
|
-
`<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>` +
|
|
260
|
+
`<div class="layout${chatEnabled && !chatMinimized ? '' : ' no-chat'}">${renderRail(artifact, bundle.state)}<main id="main">${staleBar}${marksBar}${renderOverview(bundle, { paths, now })}${renderLayers(artifact, files, bundle.state, bundle.comments.reviewComments, headSha)}</main>${renderChatShell({ enabled: chatEnabled, width: readChatWidth(storage), minimized: chatMinimized })}</div>` +
|
|
232
261
|
footerHtml(boot.version, bundle)
|
|
233
262
|
// The screen is interactive from here: reviewed state, dismissals, threads, and posting.
|
|
234
263
|
// A stale canvas shows the diff of an older commit, so nothing is posted from it: a line
|
|
@@ -249,6 +278,8 @@ export class PrAppElement extends HTMLElement {
|
|
|
249
278
|
state: bundle.state,
|
|
250
279
|
capabilities,
|
|
251
280
|
headSha: bundle.pr.headSha,
|
|
281
|
+
// The marks are keyed to the canvas on the page, so every mark names it.
|
|
282
|
+
...(bundle.canvas === undefined ? {} : { canvasSha: bundle.canvas.headSha }),
|
|
252
283
|
})
|
|
253
284
|
const interactions = wireReview(this, session, {
|
|
254
285
|
chat: () => this.chat,
|
|
@@ -371,11 +402,15 @@ export class PrAppElement extends HTMLElement {
|
|
|
371
402
|
this.viewStale = true
|
|
372
403
|
void this.render(bundle)
|
|
373
404
|
})
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
405
|
+
// Importing a canvas a teammate attached is a pull request thing; local work has no thread
|
|
406
|
+
// to attach one to, and the local screens draw no drop zone.
|
|
407
|
+
if (typeof boot.prNumber === 'number') {
|
|
408
|
+
wireDropZone(this, {
|
|
409
|
+
prNumber: boot.prNumber,
|
|
410
|
+
importImpl: importCanvas,
|
|
411
|
+
onImported: () => void this.reload(),
|
|
412
|
+
})
|
|
413
|
+
}
|
|
379
414
|
}
|
|
380
415
|
|
|
381
416
|
/**
|
package/static/js/chat-panel.js
CHANGED
|
@@ -1,42 +1,65 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
/** Keeps the same chat mounted while switching between the sidebar and floating panel.
|
|
4
|
+
*
|
|
5
|
+
* The chat minimizes at every width. Narrow screens float it over the canvas, so minimizing
|
|
6
|
+
* closes the dialog; wide screens dock it in the layout grid, so minimizing drops the column.
|
|
7
|
+
* Either way the launcher brings back the same pane with its draft and transcript intact.
|
|
8
|
+
*
|
|
4
9
|
* @param {HTMLElement} root
|
|
5
10
|
* @param {HTMLElement} pane
|
|
11
|
+
* @param {{ minimized?: boolean, onMinimizedChange?: (minimized: boolean) => void }} [opts]
|
|
6
12
|
*/
|
|
7
|
-
export function wireChatPanel(root, pane) {
|
|
13
|
+
export function wireChatPanel(root, pane, opts = {}) {
|
|
8
14
|
const dialog = /** @type {HTMLDialogElement} */ (root.querySelector('#chat-dialog'))
|
|
9
15
|
const launcher = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-launcher'))
|
|
10
16
|
const minimize = /** @type {HTMLButtonElement} */ (root.querySelector('#chat-minimize'))
|
|
17
|
+
const layout = root.querySelector('.layout')
|
|
11
18
|
const narrow = window.matchMedia('(max-width: 1360px)')
|
|
12
19
|
const mobile = window.matchMedia('(max-width: 600px)')
|
|
13
|
-
|
|
20
|
+
/** Floating panels start minimized; a docked one starts the way the reader last left it. */
|
|
21
|
+
let floating = false
|
|
22
|
+
let docked = !opts.minimized
|
|
23
|
+
|
|
24
|
+
const shown = () => (narrow.matches ? floating : docked)
|
|
14
25
|
|
|
15
26
|
function sync() {
|
|
16
27
|
if (dialog.open) dialog.close()
|
|
17
28
|
if (narrow.matches) {
|
|
18
29
|
dialog.append(pane)
|
|
19
|
-
|
|
30
|
+
pane.hidden = false
|
|
31
|
+
if (floating) {
|
|
20
32
|
if (mobile.matches) dialog.showModal()
|
|
21
33
|
else dialog.show()
|
|
22
34
|
}
|
|
23
35
|
} else {
|
|
24
36
|
dialog.before(pane)
|
|
37
|
+
pane.hidden = !docked
|
|
25
38
|
}
|
|
26
|
-
|
|
27
|
-
launcher.
|
|
39
|
+
layout?.classList.toggle('no-chat', !narrow.matches && !docked)
|
|
40
|
+
launcher.setAttribute('aria-expanded', String(shown()))
|
|
41
|
+
launcher.hidden = shown()
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
function open() {
|
|
31
|
-
if (
|
|
32
|
-
|
|
45
|
+
if (!shown()) {
|
|
46
|
+
if (narrow.matches) floating = true
|
|
47
|
+
else {
|
|
48
|
+
docked = true
|
|
49
|
+
opts.onMinimizedChange?.(false)
|
|
50
|
+
}
|
|
33
51
|
sync()
|
|
34
52
|
}
|
|
35
|
-
pane
|
|
53
|
+
// The composer sits low in a tall sticky pane, so plain focus() would scroll the canvas to it.
|
|
54
|
+
pane.querySelector('textarea')?.focus({ preventScroll: true })
|
|
36
55
|
}
|
|
37
56
|
|
|
38
57
|
function close() {
|
|
39
|
-
|
|
58
|
+
if (narrow.matches) floating = false
|
|
59
|
+
else {
|
|
60
|
+
docked = false
|
|
61
|
+
opts.onMinimizedChange?.(true)
|
|
62
|
+
}
|
|
40
63
|
sync()
|
|
41
64
|
launcher.focus()
|
|
42
65
|
}
|
package/static/js/chat.js
CHANGED
|
@@ -23,15 +23,17 @@ import { postedCommentUrl, viewCommentHtml } from './comment-link.js'
|
|
|
23
23
|
import { esc, qs } from './dom.js'
|
|
24
24
|
import { getRenderContext } from './layers.js'
|
|
25
25
|
import { renderMarkdown } from './markdown.js'
|
|
26
|
+
import { postToLabel } from './host.js'
|
|
26
27
|
import { splitChatAnswer, targetsFromFiles } from './proposed-comment.js'
|
|
27
28
|
|
|
28
29
|
export const CHAT_WIDTH_KEY = 'pr-review.chat-width'
|
|
30
|
+
export const CHAT_MINIMIZED_KEY = 'pr-review.chat-minimized'
|
|
29
31
|
export const CHAT_WIDTH_MIN = 280
|
|
30
32
|
export const CHAT_WIDTH_MAX = 560
|
|
31
33
|
export const CHAT_WIDTH_DEFAULT = 340
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* @param {{ enabled: boolean, width?: number }} opts
|
|
36
|
+
* @param {{ enabled: boolean, width?: number, minimized?: boolean }} opts
|
|
35
37
|
* @returns {string} '' when AI Chat is disabled
|
|
36
38
|
*/
|
|
37
39
|
export function renderChatShell(opts) {
|
|
@@ -39,8 +41,10 @@ export function renderChatShell(opts) {
|
|
|
39
41
|
return ''
|
|
40
42
|
}
|
|
41
43
|
const width = clampWidth(opts.width ?? CHAT_WIDTH_DEFAULT)
|
|
44
|
+
// The launcher starts hidden and `wireChatPanel` reveals it where it belongs, so a docked
|
|
45
|
+
// chat never paints a launcher over itself on the first frame.
|
|
42
46
|
return (
|
|
43
|
-
|
|
47
|
+
`<aside class="chat" aria-labelledby="chat-h"${opts.minimized ? ' hidden' : ''}>` +
|
|
44
48
|
`<button class="handle" type="button" id="chat-handle" role="separator" aria-orientation="vertical" aria-label="Resize AI Chat" aria-valuenow="${width}" aria-valuemin="${CHAT_WIDTH_MIN}" aria-valuemax="${CHAT_WIDTH_MAX}"></button>` +
|
|
45
49
|
'<div class="chat-h"><h2 id="chat-h">AI Chat</h2><label class="sr" for="thread">Thread</label>' +
|
|
46
50
|
'<select id="thread"></select>' +
|
|
@@ -59,7 +63,7 @@ export function renderChatShell(opts) {
|
|
|
59
63
|
'<span class="muted small">enter to send</span></div></form>' +
|
|
60
64
|
'</aside>' +
|
|
61
65
|
'<dialog class="chat-dialog" id="chat-dialog" aria-labelledby="chat-h"></dialog>' +
|
|
62
|
-
'<button class="chat-launcher" id="chat-launcher" type="button" aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
|
|
66
|
+
'<button class="chat-launcher" id="chat-launcher" type="button" hidden aria-controls="chat-dialog" aria-expanded="false">AI Chat</button>'
|
|
63
67
|
)
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -101,6 +105,23 @@ export function writeChatWidth(storage, width) {
|
|
|
101
105
|
storage?.setItem(CHAT_WIDTH_KEY, String(clampWidth(width)))
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Only a wide screen stores this: a floating chat always starts minimized.
|
|
110
|
+
* @param {Storage | null} storage
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function readChatMinimized(storage) {
|
|
114
|
+
return (storage === null ? null : storage.getItem(CHAT_MINIMIZED_KEY)) === '1'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {Storage | null} storage
|
|
119
|
+
* @param {boolean} minimized
|
|
120
|
+
*/
|
|
121
|
+
export function writeChatMinimized(storage, minimized) {
|
|
122
|
+
storage?.setItem(CHAT_MINIMIZED_KEY, minimized ? '1' : '0')
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
/** The questions the quick menu offers, and the one that just focuses the box. */
|
|
105
126
|
export const QUICK_QUESTIONS = [
|
|
106
127
|
'Suggestion to solve this?',
|
|
@@ -113,7 +134,7 @@ export const QUICK_QUESTIONS = [
|
|
|
113
134
|
/**
|
|
114
135
|
* @typedef {{
|
|
115
136
|
* root: HTMLElement,
|
|
116
|
-
* prNumber:
|
|
137
|
+
* prNumber: import('./contract-types.js').ReviewKey,
|
|
117
138
|
* session: ReviewSession,
|
|
118
139
|
* storage?: Storage | null,
|
|
119
140
|
* api?: Partial<ChatApi>,
|
|
@@ -179,7 +200,7 @@ export function proposedCommentHtml(comment, id, postedUrl) {
|
|
|
179
200
|
`<div class="prose">${renderMarkdown(comment.body)}</div>` +
|
|
180
201
|
'<span class="tbtns">' +
|
|
181
202
|
(postedUrl === undefined
|
|
182
|
-
? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post
|
|
203
|
+
? `<button class="cmd fill" type="button" data-act="proposed-post" data-proposed="${esc(id)}" data-needs-post>${postToLabel()}</button>`
|
|
183
204
|
: viewCommentHtml(postedUrl, true)) +
|
|
184
205
|
`<button class="cmd" type="button" data-act="proposed-edit" data-proposed="${esc(id)}" data-needs-post>edit</button>` +
|
|
185
206
|
`<button class="cmd" type="button" data-copy="${esc(comment.body)}">copy</button>` +
|
|
@@ -748,7 +769,10 @@ export function wireChat(options) {
|
|
|
748
769
|
}
|
|
749
770
|
|
|
750
771
|
const stopResize = wireResize(pane, root, storage, applyWidth)
|
|
751
|
-
const panel = wireChatPanel(root, pane
|
|
772
|
+
const panel = wireChatPanel(root, pane, {
|
|
773
|
+
minimized: readChatMinimized(storage),
|
|
774
|
+
onMinimizedChange: minimized => writeChatMinimized(storage, minimized),
|
|
775
|
+
})
|
|
752
776
|
|
|
753
777
|
form.addEventListener('submit', onSubmit)
|
|
754
778
|
root.addEventListener('click', onClick)
|
package/static/js/code-folds.js
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
// Rows a reviewer can hide: the canvas folds the model named, and the folds the diff renderer
|
|
3
3
|
// marked (import-only and whitespace-only changes, and moved blocks). Both open again when a
|
|
4
|
-
// deep link lands inside them.
|
|
4
|
+
// deep link lands inside them. Every model fold is wired once, when the diff is drawn; the
|
|
5
|
+
// reader's level only decides which of them are active, so changing it never rebuilds the table
|
|
6
|
+
// and an open composer or an expanded fold survives. The renderer's own folds hide in every mode.
|
|
5
7
|
import { findRow } from './anchors.js'
|
|
8
|
+
import { foldsForLevel } from './fold-levels.js'
|
|
6
9
|
|
|
7
10
|
/** @typedef {import('./contract-types.js').CodeFold} CodeFold */
|
|
11
|
+
/** @typedef {import('./contract-types.js').FoldLevel} FoldLevel */
|
|
12
|
+
|
|
13
|
+
/** Rows that keep their code open at every level. */
|
|
14
|
+
const PINNED = '[data-decoration="point"], [data-decoration="thread"]'
|
|
15
|
+
/** Annotation band rows and the note row under them; only an aggressive fold may hide these. */
|
|
16
|
+
const ANNOTATED = '.ann, [data-decoration="note"]'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One model fold on a drawn table. `rows` are fixed when the diff is drawn; `hiddenBefore` is how
|
|
20
|
+
* each row looked before any fold touched it, so a fold the level turns off gives it back.
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* fold: CodeFold,
|
|
23
|
+
* rows: HTMLTableRowElement[],
|
|
24
|
+
* hiddenBefore: boolean[],
|
|
25
|
+
* summaries: Set<HTMLTableRowElement>,
|
|
26
|
+
* header: HTMLTableRowElement,
|
|
27
|
+
* toggle: HTMLButtonElement,
|
|
28
|
+
* active: boolean,
|
|
29
|
+
* expanded: boolean,
|
|
30
|
+
* }} WiredFold
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The folds wired on each drawn diff. A redraw builds a new diff, so it starts a new list. */
|
|
34
|
+
const wiredFolds = /** @type {WeakMap<Element, WiredFold[]>} */ (new WeakMap())
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The drawn diff of a card, one table per hunk, or null before it draws.
|
|
38
|
+
* @param {HTMLElement} card
|
|
39
|
+
*/
|
|
40
|
+
function drawnDiff(card) {
|
|
41
|
+
return card.querySelector('.diff-wrap')
|
|
42
|
+
}
|
|
8
43
|
|
|
9
44
|
/**
|
|
10
45
|
* @param {Element | null} summary
|
|
@@ -27,7 +62,8 @@ function coveredFoldSummary(summary, rows) {
|
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
/**
|
|
30
|
-
* Finds the rows between both anchors. A
|
|
65
|
+
* Finds the rows between both anchors. A range holding an attention point or a discussion stays
|
|
66
|
+
* open at every level; a range holding an annotation stays open unless the fold is aggressive.
|
|
31
67
|
* @param {HTMLElement} card
|
|
32
68
|
* @param {string} key
|
|
33
69
|
* @param {CodeFold} fold
|
|
@@ -47,18 +83,30 @@ function foldRows(card, key, fold) {
|
|
|
47
83
|
|
|
48
84
|
const siblings = Array.from(first.parentElement.children)
|
|
49
85
|
const from = siblings.indexOf(first)
|
|
50
|
-
|
|
86
|
+
let to = siblings.indexOf(last)
|
|
51
87
|
|
|
52
88
|
if (to < from) {
|
|
53
89
|
return []
|
|
54
90
|
}
|
|
55
91
|
|
|
56
|
-
const
|
|
57
|
-
const hasDiscussion = rows.some(row => row.matches('.ann, [data-decoration], [data-code-fold], .code-fold'))
|
|
92
|
+
const aggressive = fold.level === 'aggressive'
|
|
58
93
|
|
|
59
|
-
|
|
94
|
+
// An annotation's note row sits under the last line it covers, so an aggressive fold that ends
|
|
95
|
+
// on that line takes the note with it rather than leaving it stranded above open code.
|
|
96
|
+
if (aggressive) {
|
|
97
|
+
while (siblings[to + 1]?.matches('[data-decoration="note"]') === true) {
|
|
98
|
+
to++
|
|
99
|
+
}
|
|
100
|
+
}
|
|
60
101
|
|
|
61
|
-
|
|
102
|
+
const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
|
|
103
|
+
const trailing = siblings[to + 1]
|
|
104
|
+
const blocked =
|
|
105
|
+
rows.some(row => row.matches(PINNED)) ||
|
|
106
|
+
(!aggressive && rows.some(row => row.matches(ANNOTATED))) ||
|
|
107
|
+
trailing?.matches(aggressive ? PINNED : '[data-decoration]') === true
|
|
108
|
+
|
|
109
|
+
if (blocked) {
|
|
62
110
|
return []
|
|
63
111
|
}
|
|
64
112
|
|
|
@@ -70,6 +118,20 @@ function foldRows(card, key, fold) {
|
|
|
70
118
|
return rows
|
|
71
119
|
}
|
|
72
120
|
|
|
121
|
+
/**
|
|
122
|
+
* What the toggle says. An aggressive fold that hides an annotation shows the annotation instead
|
|
123
|
+
* of its own title, so the reader still reads the explanation of the code it replaces. The
|
|
124
|
+
* validator lets such a fold cover one whole annotation at most, so there is one note to show.
|
|
125
|
+
* @param {readonly HTMLTableRowElement[]} rows
|
|
126
|
+
* @param {CodeFold} fold
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function foldLabel(rows, fold) {
|
|
130
|
+
const note = rows.find(row => row.matches('[data-decoration="note"]'))
|
|
131
|
+
const text = note?.querySelector('.prose')?.textContent?.trim()
|
|
132
|
+
return text === undefined || text === '' ? fold.title : text
|
|
133
|
+
}
|
|
134
|
+
|
|
73
135
|
/**
|
|
74
136
|
* Inserts the fold's title above its first row.
|
|
75
137
|
* @param {HTMLTableRowElement} first
|
|
@@ -91,63 +153,128 @@ function createToggle(first, title) {
|
|
|
91
153
|
header.appendChild(cell)
|
|
92
154
|
first.before(header)
|
|
93
155
|
|
|
94
|
-
return toggle
|
|
156
|
+
return { header, toggle }
|
|
95
157
|
}
|
|
96
158
|
|
|
97
159
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param {
|
|
101
|
-
* @param {string} title
|
|
160
|
+
* Draws one fold as the level and the reader left it. An inactive fold gives its rows back as
|
|
161
|
+
* they were; an active one hides them behind its title until the reader expands it.
|
|
162
|
+
* @param {WiredFold} wired
|
|
102
163
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
rows
|
|
110
|
-
.map(row => row.id)
|
|
111
|
-
.filter(Boolean)
|
|
112
|
-
.join(' ')
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
const setExpanded = (/** @type {boolean} */ expanded) => {
|
|
116
|
-
toggle.setAttribute('aria-expanded', String(expanded))
|
|
117
|
-
|
|
118
|
-
for (const row of rows) {
|
|
119
|
-
row.hidden = !expanded || foldSummaries.has(row)
|
|
164
|
+
function paintFold(wired) {
|
|
165
|
+
wired.header.hidden = !wired.active
|
|
166
|
+
wired.toggle.setAttribute('aria-expanded', String(wired.active && wired.expanded))
|
|
167
|
+
for (const [index, row] of wired.rows.entries()) {
|
|
168
|
+
if (wired.active) {
|
|
169
|
+
row.hidden = !wired.expanded || wired.summaries.has(row)
|
|
120
170
|
row.setAttribute('data-code-fold', '')
|
|
171
|
+
} else {
|
|
172
|
+
row.hidden = wired.hiddenBefore[index] === true
|
|
173
|
+
row.removeAttribute('data-code-fold')
|
|
121
174
|
}
|
|
122
175
|
}
|
|
176
|
+
}
|
|
123
177
|
|
|
124
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Points a drawn card's folds at a level: the outermost folds that hide at it become active and
|
|
180
|
+
* start folded, and the rest give their rows back. A fold that stays active keeps whatever the
|
|
181
|
+
* reader did with it. A card with a thread or a pending draft keeps all of its code open, so none
|
|
182
|
+
* is active. Inactive
|
|
183
|
+
* folds paint first, so a row shared with an active outer or inner fold ends up as the active
|
|
184
|
+
* one says.
|
|
185
|
+
* @param {HTMLElement} card
|
|
186
|
+
* @param {FoldLevel} level
|
|
187
|
+
* @param {boolean} discussed whether a thread or a pending draft sits in the card's chunks
|
|
188
|
+
*/
|
|
189
|
+
export function setCodeFoldLevel(card, level, discussed) {
|
|
190
|
+
const diff = drawnDiff(card)
|
|
191
|
+
const wired = diff === null ? [] : (wiredFolds.get(diff) ?? [])
|
|
192
|
+
const active = new Set(
|
|
193
|
+
discussed
|
|
194
|
+
? []
|
|
195
|
+
: foldsForLevel(
|
|
196
|
+
wired.map(w => w.fold),
|
|
197
|
+
level
|
|
198
|
+
)
|
|
199
|
+
)
|
|
125
200
|
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
|
|
201
|
+
for (const w of wired) {
|
|
202
|
+
const now = active.has(w.fold)
|
|
203
|
+
if (now && !w.active) {
|
|
204
|
+
w.expanded = false
|
|
130
205
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
206
|
+
w.active = now
|
|
207
|
+
}
|
|
208
|
+
const inactive = wired.filter(w => !w.active)
|
|
209
|
+
for (const w of [...inactive, ...wired.filter(fold => fold.active)]) {
|
|
210
|
+
paintFold(w)
|
|
211
|
+
}
|
|
134
212
|
}
|
|
135
213
|
|
|
136
214
|
/**
|
|
137
|
-
*
|
|
215
|
+
* Wires every model fold of a freshly drawn diff, then applies the reader's level. The rows of
|
|
216
|
+
* every fold are found before any title row goes in, so nested folds each see only diff rows.
|
|
217
|
+
* Ranges with attention points stay open, and so do ranges with annotations unless the fold is
|
|
218
|
+
* aggressive. A second call on the same diff does nothing.
|
|
138
219
|
* @param {HTMLElement} card
|
|
139
220
|
* @param {string} key
|
|
140
221
|
* @param {readonly CodeFold[]} folds
|
|
222
|
+
* @param {FoldLevel} level the reader's level
|
|
223
|
+
* @param {boolean} discussed whether a thread or a pending draft sits in the card's chunks
|
|
141
224
|
*/
|
|
142
|
-
export function applyCodeFolds(card, key, folds) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
225
|
+
export function applyCodeFolds(card, key, folds, level, discussed) {
|
|
226
|
+
const diff = drawnDiff(card)
|
|
227
|
+
if (diff === null || wiredFolds.has(diff)) {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
146
230
|
|
|
147
|
-
|
|
148
|
-
|
|
231
|
+
const found = folds.map(fold => ({ fold, rows: foldRows(card, key, fold) }))
|
|
232
|
+
/** @type {WiredFold[]} */
|
|
233
|
+
const wired = []
|
|
234
|
+
for (const { fold, rows } of found) {
|
|
235
|
+
const first = rows[0]
|
|
236
|
+
if (first === undefined) {
|
|
237
|
+
continue
|
|
149
238
|
}
|
|
239
|
+
const { header, toggle } = createToggle(first, foldLabel(rows, fold))
|
|
240
|
+
toggle.setAttribute(
|
|
241
|
+
'aria-controls',
|
|
242
|
+
rows
|
|
243
|
+
.map(row => row.id)
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.join(' ')
|
|
246
|
+
)
|
|
247
|
+
const w = {
|
|
248
|
+
fold,
|
|
249
|
+
rows,
|
|
250
|
+
hiddenBefore: rows.map(row => row.hidden),
|
|
251
|
+
summaries: new Set(rows.filter(row => coveredFoldSummary(row, rows))),
|
|
252
|
+
header,
|
|
253
|
+
toggle,
|
|
254
|
+
active: false,
|
|
255
|
+
expanded: false,
|
|
256
|
+
}
|
|
257
|
+
toggle.addEventListener('click', () => {
|
|
258
|
+
w.expanded = !w.expanded
|
|
259
|
+
paintFold(w)
|
|
260
|
+
})
|
|
261
|
+
const table = first.closest('table')
|
|
262
|
+
table?.addEventListener('reveal-code', event => {
|
|
263
|
+
const target = event.target
|
|
264
|
+
if (
|
|
265
|
+
w.active &&
|
|
266
|
+
target instanceof Node &&
|
|
267
|
+
(target === table || rows.some(row => row.contains(target)))
|
|
268
|
+
) {
|
|
269
|
+
w.expanded = true
|
|
270
|
+
paintFold(w)
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
wired.push(w)
|
|
150
274
|
}
|
|
275
|
+
|
|
276
|
+
wiredFolds.set(diff, wired)
|
|
277
|
+
setCodeFoldLevel(card, level, discussed)
|
|
151
278
|
}
|
|
152
279
|
|
|
153
280
|
/**
|