@vintasoftware/pr-review-canvas 0.4.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 +27 -8
- package/docs/reference.md +247 -62
- package/package.json +1 -1
- package/pr-review.config.example.yml +38 -4
- package/prompts/generation-format.md +120 -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 +13 -4
- package/src/acpx/acpx.ts +98 -5
- package/src/acpx/models.ts +43 -0
- package/src/chat/chat-manager.ts +27 -1
- package/src/cli.ts +58 -1
- package/src/commands.ts +5 -1
- package/src/contract/api.ts +26 -1
- package/src/contract/canvas-manifest.ts +5 -0
- package/src/contract/generation-context.ts +52 -1
- 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/reviews.ts +10 -1
- package/src/contract/settings.ts +5 -0
- package/src/contract/state.ts +23 -12
- package/src/contract/validation.ts +1 -0
- package/src/github/post-review.ts +62 -6
- package/src/gitlab/post-review.ts +48 -9
- package/src/gitlab/publish-drafts.ts +69 -0
- package/src/host/client.ts +3 -2
- package/src/host/host.ts +23 -5
- package/src/project-config.ts +15 -2
- package/src/review/carry-marks.ts +131 -0
- package/src/review/doctor.ts +60 -26
- package/src/review/incremental.ts +107 -0
- package/src/review/normalize.ts +14 -4
- package/src/review/prepare.ts +47 -0
- package/src/review/prompt.ts +112 -5
- package/src/review/publish.ts +1 -0
- package/src/review/test-paths.ts +44 -4
- package/src/review/validate-folds.ts +348 -23
- package/src/review/validate.ts +10 -1
- package/src/server/bundle.ts +14 -2
- package/src/server/html.ts +4 -4
- package/src/server/routes/chat-routes.ts +15 -6
- package/src/server/routes/pages.ts +4 -1
- package/src/server/routes/review-routes.ts +202 -42
- package/src/store/canvas-store.ts +3 -0
- package/src/store/settings-store.ts +9 -1
- package/src/store/state-store.ts +69 -4
- package/src/upgrade.ts +338 -0
- package/static/js/api.js +55 -1
- package/static/js/app.js +28 -7
- package/static/js/chat-panel.js +32 -9
- package/static/js/chat.js +27 -4
- package/static/js/code-folds.js +171 -44
- package/static/js/composer.js +109 -4
- package/static/js/contract-types.d.ts +4 -0
- package/static/js/diff-decorations.js +67 -1
- package/static/js/empty-state.js +17 -0
- package/static/js/fold-levels.js +176 -0
- package/static/js/header.js +36 -9
- package/static/js/interactions.js +273 -44
- package/static/js/keyboard.js +4 -1
- package/static/js/keys.js +12 -0
- package/static/js/layers.js +292 -29
- package/static/js/nav.js +22 -4
- package/static/js/pending.js +161 -0
- package/static/js/points.js +69 -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 +106 -27
- package/static/js/settings.js +53 -23
- package/static/js/signoff.js +75 -5
- 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/header.css +21 -0
- package/static/styles/pending.css +102 -0
- package/static/styles/review.css +4 -0
- package/static/styles.css +1 -0
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
|
/**
|
package/static/js/composer.js
CHANGED
|
@@ -20,6 +20,8 @@ import { noPostingTitle, postToLabel } from './host.js'
|
|
|
20
20
|
* inReplyToId?: number,
|
|
21
21
|
* pointFingerprint?: string,
|
|
22
22
|
* body?: string,
|
|
23
|
+
* pendingActive?: boolean,
|
|
24
|
+
* pendingId?: string,
|
|
23
25
|
* }} ComposerOptions
|
|
24
26
|
*/
|
|
25
27
|
|
|
@@ -33,6 +35,8 @@ function dataAttributes(opts) {
|
|
|
33
35
|
['data-start-line', opts.startLine],
|
|
34
36
|
['data-in-reply-to', opts.inReplyToId],
|
|
35
37
|
['data-fingerprint', opts.pointFingerprint],
|
|
38
|
+
['data-pending-id', opts.pendingId],
|
|
39
|
+
['data-pending-active', opts.pendingActive === true ? '1' : '0'],
|
|
36
40
|
]
|
|
37
41
|
return pairs
|
|
38
42
|
.filter(([, value]) => value !== undefined)
|
|
@@ -41,7 +45,57 @@ function dataAttributes(opts) {
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
|
-
* The
|
|
48
|
+
* The commands under the box, which are the ones the forge's own page offers.
|
|
49
|
+
*
|
|
50
|
+
* With no review open, a comment on a diff line can go either way: into a review it starts, or
|
|
51
|
+
* straight out on its own. Once a review is open it can only join it. Posting one comment on its
|
|
52
|
+
* own would publish it while the rest of the review is still held back, so that way is closed for
|
|
53
|
+
* as long as something is waiting, and the drafts are submitted together.
|
|
54
|
+
*
|
|
55
|
+
* A draft being edited has neither: it is already in the review, so it is only saved.
|
|
56
|
+
* @param {ComposerOptions} opts
|
|
57
|
+
*/
|
|
58
|
+
function commandsHtml(opts) {
|
|
59
|
+
const cancel = '<button class="cmd" type="button" data-act="composer-cancel">cancel</button>'
|
|
60
|
+
if (opts.pendingId !== undefined) {
|
|
61
|
+
return `<button class="cmd fill" type="button" data-act="pending-save">save</button>${cancel}`
|
|
62
|
+
}
|
|
63
|
+
const post = (lead = false) =>
|
|
64
|
+
`<button class="cmd${lead ? ' fill' : ''}" type="button" data-act="composer-post" data-needs-post>${postToLabel()}</button>`
|
|
65
|
+
if (opts.kind !== 'inline') {
|
|
66
|
+
return `${post(true)}${cancel}`
|
|
67
|
+
}
|
|
68
|
+
if (opts.pendingActive === true) {
|
|
69
|
+
return `<button class="cmd fill" type="button" data-act="composer-queue">add review comment</button>${cancel}`
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
`<button class="cmd fill" type="button" data-act="composer-queue">start a review</button>` +
|
|
73
|
+
post() +
|
|
74
|
+
cancel
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Keep an open inline composer's commands in step when another action starts a review.
|
|
79
|
+
* @param {ParentNode} root
|
|
80
|
+
* @param {boolean} active
|
|
81
|
+
*/
|
|
82
|
+
export function refreshComposerCommands(root, active) {
|
|
83
|
+
for (const box of root.querySelectorAll('.composer-box[data-kind="inline"]')) {
|
|
84
|
+
if (
|
|
85
|
+
box.hasAttribute('data-pending-id') ||
|
|
86
|
+
box.hasAttribute('data-posting') ||
|
|
87
|
+
(box.getAttribute('data-pending-active') === '1') === active
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
const actions = box.querySelector('.composer-actions')
|
|
91
|
+
if (actions !== null)
|
|
92
|
+
actions.innerHTML = commandsHtml({ id: box.id, label: '', kind: 'inline', pendingActive: active })
|
|
93
|
+
box.setAttribute('data-pending-active', active ? '1' : '0')
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The box itself. The textarea carries the draft; the commands act on the nearest
|
|
45
99
|
* `.composer-box` ancestor.
|
|
46
100
|
* @param {ComposerOptions} opts
|
|
47
101
|
*/
|
|
@@ -51,8 +105,7 @@ export function composerHtml(opts) {
|
|
|
51
105
|
`<label class="sr" for="${esc(opts.id)}-t">${esc(opts.label)}</label>` +
|
|
52
106
|
previewControlsHtml() +
|
|
53
107
|
`<textarea id="${esc(opts.id)}-t" rows="3" placeholder="${esc(opts.label)}">${esc(opts.body ?? '')}</textarea>` +
|
|
54
|
-
`<div class="composer-actions"
|
|
55
|
-
'<button class="cmd" type="button" data-act="composer-cancel">cancel</button></div></div>'
|
|
108
|
+
`<div class="composer-actions">${commandsHtml(opts)}</div></div>`
|
|
56
109
|
)
|
|
57
110
|
}
|
|
58
111
|
|
|
@@ -81,6 +134,24 @@ export function composerBody(box) {
|
|
|
81
134
|
return textarea instanceof HTMLTextAreaElement ? textarea.value.trim() : ''
|
|
82
135
|
}
|
|
83
136
|
|
|
137
|
+
/**
|
|
138
|
+
* What this composer would add to the pending review, or null when it is empty or is not a
|
|
139
|
+
* comment on a diff line. Only inline comments can wait: a reply and a PR-level comment have
|
|
140
|
+
* no place in a forge review's comment list.
|
|
141
|
+
* @param {Element} box
|
|
142
|
+
* @returns {Omit<import('./contract-types.js').AddPendingInput, 'headSha'> | null}
|
|
143
|
+
*/
|
|
144
|
+
export function composerPendingInput(box) {
|
|
145
|
+
const input = composerInput(box)
|
|
146
|
+
return input === null || input.kind !== 'inline' ? null : stripKind(input)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {Extract<PostCommentInput, { kind: 'inline' }>} input */
|
|
150
|
+
function stripKind(input) {
|
|
151
|
+
const { kind: _kind, ...rest } = input
|
|
152
|
+
return rest
|
|
153
|
+
}
|
|
154
|
+
|
|
84
155
|
/**
|
|
85
156
|
* The request that posts what this composer holds.
|
|
86
157
|
* @param {Element} box
|
|
@@ -131,8 +202,41 @@ export function focusComposer(root, id) {
|
|
|
131
202
|
return textarea
|
|
132
203
|
}
|
|
133
204
|
|
|
205
|
+
/** What a composer stands in for is marked with this while the box is open. */
|
|
206
|
+
const CONCEALED = 'data-composer-concealed'
|
|
207
|
+
|
|
208
|
+
/** The parts of a concealed host that the box stands in for. */
|
|
209
|
+
const CONCEALED_PARTS = ':scope > .prose, :scope > .tbtns'
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Hides what a composer edits while the box is open, so the reader sees one of the two rather
|
|
213
|
+
* than the draft and its editor at once. The mark is what lets any close put it back.
|
|
214
|
+
* @param {Element} host the element the composer was appended to
|
|
215
|
+
*/
|
|
216
|
+
export function concealForComposer(host) {
|
|
217
|
+
host.setAttribute(CONCEALED, '')
|
|
218
|
+
for (const part of Array.from(host.querySelectorAll(CONCEALED_PARTS))) {
|
|
219
|
+
part.setAttribute('hidden', '')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Puts back everything a composer was concealing. Closing is the only way a box goes away, so
|
|
225
|
+
* doing this here covers cancel, Escape, and opening another box alike.
|
|
226
|
+
* @param {ParentNode} root
|
|
227
|
+
*/
|
|
228
|
+
export function revealConcealed(root) {
|
|
229
|
+
for (const host of Array.from(root.querySelectorAll(`[${CONCEALED}]`))) {
|
|
230
|
+
host.removeAttribute(CONCEALED)
|
|
231
|
+
for (const part of Array.from(host.querySelectorAll(CONCEALED_PARTS))) {
|
|
232
|
+
part.removeAttribute('hidden')
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
134
237
|
/**
|
|
135
|
-
* Removes every open composer under `root`, so only one is open at a time
|
|
238
|
+
* Removes every open composer under `root`, so only one is open at a time, and reveals whatever
|
|
239
|
+
* they were standing in for.
|
|
136
240
|
* @param {ParentNode} root
|
|
137
241
|
*/
|
|
138
242
|
export function closeComposers(root) {
|
|
@@ -142,6 +246,7 @@ export function closeComposers(root) {
|
|
|
142
246
|
;(row ?? box).remove()
|
|
143
247
|
closed++
|
|
144
248
|
}
|
|
249
|
+
revealConcealed(root)
|
|
145
250
|
return closed
|
|
146
251
|
}
|
|
147
252
|
|
|
@@ -13,6 +13,7 @@ export type {
|
|
|
13
13
|
PrBundle,
|
|
14
14
|
PublicHost,
|
|
15
15
|
ReviewBodyResponse,
|
|
16
|
+
ReviewBootstrap,
|
|
16
17
|
ReviewSummary,
|
|
17
18
|
SharedCanvasFetchResponse,
|
|
18
19
|
SharedCanvasInfo,
|
|
@@ -37,6 +38,7 @@ export type {
|
|
|
37
38
|
CodeFold,
|
|
38
39
|
Diagram,
|
|
39
40
|
FileEntry,
|
|
41
|
+
FoldLevel,
|
|
40
42
|
Hunk,
|
|
41
43
|
Layer,
|
|
42
44
|
LayerFile,
|
|
@@ -60,3 +62,5 @@ export type {
|
|
|
60
62
|
Theme,
|
|
61
63
|
} from '../../src/contract/settings.js'
|
|
62
64
|
export type { PrState } from '../../src/contract/state.js'
|
|
65
|
+
export type { AddPendingInput, PendingComment } from '../../src/contract/pending.js'
|
|
66
|
+
export type { ReviewEvent } from '../../src/contract/reviews.js'
|
|
@@ -9,6 +9,7 @@ import { findRow, nearestRow } from './anchors.js'
|
|
|
9
9
|
import { viewCommentHtml } from './comment-link.js'
|
|
10
10
|
import { esc, fragment, avatarHtml, timeAgo } from './dom.js'
|
|
11
11
|
import { renderMarkdown } from './markdown.js'
|
|
12
|
+
import { pendingRowHtml } from './pending.js'
|
|
12
13
|
import { pointRowHtml } from './points.js'
|
|
13
14
|
|
|
14
15
|
const DECORATION = 'data-decoration'
|
|
@@ -146,6 +147,66 @@ export function insertThreadRow(card, key, t, opts) {
|
|
|
146
147
|
return true
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The drafts of one file, drawn under the lines they comment on. A draft whose line is not on the
|
|
152
|
+
* page (a fold, or a file drawn short) goes under the nearest row, like every other decoration.
|
|
153
|
+
* @param {HTMLElement} card
|
|
154
|
+
* @param {string} key
|
|
155
|
+
* @param {ReadonlyArray<import('./contract-types.js').PendingComment>} drafts
|
|
156
|
+
* @param {Date} now
|
|
157
|
+
* @returns {{ placed: number, missed: number }}
|
|
158
|
+
*/
|
|
159
|
+
export function insertPendingRows(card, key, drafts, now) {
|
|
160
|
+
let placed = 0
|
|
161
|
+
let missed = 0
|
|
162
|
+
for (const draft of drafts) {
|
|
163
|
+
const near = nearestRow(card, key, draft.side, draft.line)
|
|
164
|
+
if (near === null) {
|
|
165
|
+
missed++
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const row = firstRow(pendingRowHtml([draft], now))
|
|
169
|
+
row.setAttribute(DECORATION, 'pending')
|
|
170
|
+
row.dataset['pendingId'] = draft.id
|
|
171
|
+
row.dataset['pendingBody'] = draft.body
|
|
172
|
+
if (near.approx) row.classList.add('is-approx')
|
|
173
|
+
let after = near.row
|
|
174
|
+
while (
|
|
175
|
+
after.nextElementSibling instanceof HTMLTableRowElement &&
|
|
176
|
+
after.nextElementSibling.getAttribute(DECORATION) === 'pending'
|
|
177
|
+
) {
|
|
178
|
+
after = after.nextElementSibling
|
|
179
|
+
}
|
|
180
|
+
after.insertAdjacentElement('afterend', row)
|
|
181
|
+
placed++
|
|
182
|
+
}
|
|
183
|
+
return { placed, missed }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Update draft rows without replacing thread replies, annotations, or active editors.
|
|
187
|
+
* @param {HTMLElement} card
|
|
188
|
+
* @param {string} key
|
|
189
|
+
* @param {ReadonlyArray<import('./contract-types.js').PendingComment>} drafts
|
|
190
|
+
* @param {Date} now
|
|
191
|
+
*/
|
|
192
|
+
export function refreshPendingRows(card, key, drafts, now) {
|
|
193
|
+
const remaining = new Map(drafts.map(p => [p.id, p]))
|
|
194
|
+
for (const row of card.querySelectorAll('tr[data-decoration="pending"]')) {
|
|
195
|
+
const draft = remaining.get(row.getAttribute('data-pending-id') ?? '')
|
|
196
|
+
if (draft === undefined) {
|
|
197
|
+
row.remove()
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
remaining.delete(draft.id)
|
|
201
|
+
if (row.getAttribute('data-pending-body') !== draft.body) {
|
|
202
|
+
const prose = row.querySelector('.pending-cmt > .prose')
|
|
203
|
+
if (prose !== null) prose.innerHTML = renderMarkdown(draft.body, { github: true })
|
|
204
|
+
row.setAttribute('data-pending-body', draft.body)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
insertPendingRows(card, key, [...remaining.values()], now)
|
|
208
|
+
}
|
|
209
|
+
|
|
149
210
|
/**
|
|
150
211
|
* @param {string} html
|
|
151
212
|
* @returns {HTMLTableRowElement}
|
|
@@ -166,7 +227,7 @@ function firstRow(html) {
|
|
|
166
227
|
* right after the anchor row, so they are added in reverse).
|
|
167
228
|
* @param {HTMLElement} card
|
|
168
229
|
* @param {string} key
|
|
169
|
-
* @param {{ annotations: ReadonlyArray<Annotation>, points: ReadonlyArray<Point>, threads: ReadonlyArray<Thread>, paths: ReadonlySet<string>, now: Date, state?: import('./contract-types.js').PrState, posted?: ReadonlyMap<string, string>, hiddenThreads?: ReadonlySet<number> }} data
|
|
230
|
+
* @param {{ annotations: ReadonlyArray<Annotation>, points: ReadonlyArray<Point>, threads: ReadonlyArray<Thread>, pending?: ReadonlyArray<import('./contract-types.js').PendingComment>, paths: ReadonlySet<string>, now: Date, state?: import('./contract-types.js').PrState, posted?: ReadonlyMap<string, string>, hiddenThreads?: ReadonlySet<number> }} data
|
|
170
231
|
* @returns {{ placed: number, missed: number }}
|
|
171
232
|
*/
|
|
172
233
|
export function applyDecorations(card, key, data) {
|
|
@@ -185,6 +246,11 @@ export function applyDecorations(card, key, data) {
|
|
|
185
246
|
missed++
|
|
186
247
|
}
|
|
187
248
|
}
|
|
249
|
+
// Drafts are added before the threads, so under one line they end up after them: the comment
|
|
250
|
+
// that is already on the forge reads first, the one still being written reads last.
|
|
251
|
+
const drafts = insertPendingRows(card, key, data.pending ?? [], data.now)
|
|
252
|
+
placed += drafts.placed
|
|
253
|
+
missed += drafts.missed
|
|
188
254
|
for (const t of [...data.threads].reverse()) {
|
|
189
255
|
count(
|
|
190
256
|
insertThreadRow(card, key, t, { now: data.now, hidden: data.hiddenThreads?.has(t.root.id) ?? false })
|
package/static/js/empty-state.js
CHANGED
|
@@ -208,6 +208,23 @@ export function carriedOverBarHtml(carried) {
|
|
|
208
208
|
)
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The note above a canvas that some of the reviewer's progress followed onto. Without it, the
|
|
213
|
+
* already-ticked layers look like a bug. The sha is the canvas the marks were made on, which may
|
|
214
|
+
* be several generations back when the reviewer marked nothing on the canvases in between.
|
|
215
|
+
* @param {string} markedCanvasSha
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
export function marksCarriedBarHtml(markedCanvasSha) {
|
|
219
|
+
return (
|
|
220
|
+
'<div class="stale-bar carried-over-bar" role="status"><strong>Review progress carried over.</strong> ' +
|
|
221
|
+
esc(
|
|
222
|
+
`You marked these on the canvas of ${markedCanvasSha.slice(0, 7)}; a file whose diff has not changed since keeps its mark, and a layer keeps its own when it still holds exactly those files.`
|
|
223
|
+
) +
|
|
224
|
+
'</div>'
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
/**
|
|
212
229
|
* The `stale` screen: the same card as the empty state, plus the two ways forward.
|
|
213
230
|
* @param {PrBundle} bundle
|