@vintasoftware/pr-review-canvas 0.1.0

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