@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.
Files changed (83) hide show
  1. package/README.md +27 -8
  2. package/docs/reference.md +247 -62
  3. package/package.json +1 -1
  4. package/pr-review.config.example.yml +38 -4
  5. package/prompts/generation-format.md +120 -26
  6. package/prompts/generation-strict-incremental.md +53 -0
  7. package/prompts/generation-strict.md +1 -27
  8. package/prompts/generation-surfacing-incremental.md +56 -0
  9. package/prompts/generation-surfacing.md +1 -58
  10. package/prompts/judging-strict.md +27 -0
  11. package/prompts/judging-surfacing.md +58 -0
  12. package/skills/pr-review-canvas/SKILL.md +13 -4
  13. package/src/acpx/acpx.ts +98 -5
  14. package/src/acpx/models.ts +43 -0
  15. package/src/chat/chat-manager.ts +27 -1
  16. package/src/cli.ts +58 -1
  17. package/src/commands.ts +5 -1
  18. package/src/contract/api.ts +26 -1
  19. package/src/contract/canvas-manifest.ts +5 -0
  20. package/src/contract/generation-context.ts +52 -1
  21. package/src/contract/keys.ts +1 -0
  22. package/src/contract/pending.ts +49 -0
  23. package/src/contract/review-artifact.ts +50 -7
  24. package/src/contract/reviews.ts +10 -1
  25. package/src/contract/settings.ts +5 -0
  26. package/src/contract/state.ts +23 -12
  27. package/src/contract/validation.ts +1 -0
  28. package/src/github/post-review.ts +62 -6
  29. package/src/gitlab/post-review.ts +48 -9
  30. package/src/gitlab/publish-drafts.ts +69 -0
  31. package/src/host/client.ts +3 -2
  32. package/src/host/host.ts +23 -5
  33. package/src/project-config.ts +15 -2
  34. package/src/review/carry-marks.ts +131 -0
  35. package/src/review/doctor.ts +60 -26
  36. package/src/review/incremental.ts +107 -0
  37. package/src/review/normalize.ts +14 -4
  38. package/src/review/prepare.ts +47 -0
  39. package/src/review/prompt.ts +112 -5
  40. package/src/review/publish.ts +1 -0
  41. package/src/review/test-paths.ts +44 -4
  42. package/src/review/validate-folds.ts +348 -23
  43. package/src/review/validate.ts +10 -1
  44. package/src/server/bundle.ts +14 -2
  45. package/src/server/html.ts +4 -4
  46. package/src/server/routes/chat-routes.ts +15 -6
  47. package/src/server/routes/pages.ts +4 -1
  48. package/src/server/routes/review-routes.ts +202 -42
  49. package/src/store/canvas-store.ts +3 -0
  50. package/src/store/settings-store.ts +9 -1
  51. package/src/store/state-store.ts +69 -4
  52. package/src/upgrade.ts +338 -0
  53. package/static/js/api.js +55 -1
  54. package/static/js/app.js +28 -7
  55. package/static/js/chat-panel.js +32 -9
  56. package/static/js/chat.js +27 -4
  57. package/static/js/code-folds.js +171 -44
  58. package/static/js/composer.js +109 -4
  59. package/static/js/contract-types.d.ts +4 -0
  60. package/static/js/diff-decorations.js +67 -1
  61. package/static/js/empty-state.js +17 -0
  62. package/static/js/fold-levels.js +176 -0
  63. package/static/js/header.js +36 -9
  64. package/static/js/interactions.js +273 -44
  65. package/static/js/keyboard.js +4 -1
  66. package/static/js/keys.js +12 -0
  67. package/static/js/layers.js +292 -29
  68. package/static/js/nav.js +22 -4
  69. package/static/js/pending.js +161 -0
  70. package/static/js/points.js +69 -9
  71. package/static/js/progress.js +4 -5
  72. package/static/js/quick-questions.js +15 -2
  73. package/static/js/reading-level.js +97 -0
  74. package/static/js/review-session.js +106 -27
  75. package/static/js/settings.js +53 -23
  76. package/static/js/signoff.js +75 -5
  77. package/static/js/skin.js +2 -2
  78. package/static/styles/chat-panel.css +22 -24
  79. package/static/styles/chat.css +4 -0
  80. package/static/styles/header.css +21 -0
  81. package/static/styles/pending.css +102 -0
  82. package/static/styles/review.css +4 -0
  83. package/static/styles.css +1 -0
@@ -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 decorated or overlapping range stays open.
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
- const to = siblings.indexOf(last)
86
+ let to = siblings.indexOf(last)
51
87
 
52
88
  if (to < from) {
53
89
  return []
54
90
  }
55
91
 
56
- const rows = siblings.slice(from, to + 1).filter(row => row instanceof HTMLTableRowElement)
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
- const hasThread = Boolean(first.closest('table')?.querySelector('[data-decoration="thread"]'))
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
- if (hasDiscussion || hasThread || last.nextElementSibling?.matches('[data-decoration]')) {
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
- * Keeps the original rows in the DOM so comments and deep links retain their anchors.
99
- * @param {HTMLTableRowElement} first
100
- * @param {HTMLTableRowElement[]} rows
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 wireFold(first, rows, title) {
104
- const toggle = createToggle(first, title)
105
- const table = first.closest('table')
106
- const foldSummaries = new Set(rows.filter(row => coveredFoldSummary(row, rows)))
107
- toggle.setAttribute(
108
- 'aria-controls',
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
- toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true'))
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
- table?.addEventListener('reveal-code', event => {
127
- const target = event.target
128
- if (target instanceof Node && (target === table || rows.some(row => row.contains(target)))) {
129
- setExpanded(true)
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
- setExpanded(false)
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
- * Adds title-only folds; ranges with annotations, attention points, or discussions stay open.
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
- for (const fold of folds) {
144
- const rows = foldRows(card, key, fold)
145
- const first = rows[0]
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
- if (first !== undefined) {
148
- wireFold(first, rows, fold.title)
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
  /**
@@ -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 box itself. The textarea carries the draft; the two commands act on the nearest
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"><button class="cmd fill" type="button" data-act="composer-post" data-needs-post>${postToLabel()}</button>` +
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 })
@@ -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