vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Build out change review and threaded discussions
f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b
Build out change review and threaded discussions - Add Conversation, Files and Runs tabs to change requests. Files uses the code-browser shape with a changed-file tree and one selected-file diff; Runs lists the head commit's workflow results. - Show anchored review comments beneath the line, with a collapsed excerpt read from the exact blob that was reviewed, and return posted anchors to their line note. - Add comment threads: any comment can be replied to, the UI flattens the descendants under one root, reply context links back to the parent, and reply targets feed a single composer at the foot of the discussion. - Collapse reply groups while keeping the root visible, and scroll to the composer when Reply is selected. - Add comment context menus with Reply, Copy text, Copy link, Edit and Delete. Add web edit/delete routes with author or admin checks; deletion leaves a tombstone so replies keep their thread. - Support --reply-to in the issue and change CLI commands, and treat a missing parent as a tombstone rather than an invalid tree. - Fix the diff comment gutter, action button colours, reply labels, and keep the Files tree and diff pane aligned. Update metadata validation, web templates, CSS, JavaScript, tests, and the CLI/change-request/issue docs.
server/web/static/app.js +169−4
| @@ -36,6 +36,58 @@ | |||
| 36 | 36 | }); | |
| 37 | 37 | })(); | |
| 38 | 38 | ||
| 39 | // One discussion composer per page. Reply buttons only select their target; the | ||
| 40 | // visible "Replying to ..." chip can be cleared without losing the draft. | ||
| 41 | (() => { | ||
| 42 | document.addEventListener('click', (event) => { | ||
| 43 | const pick = event.target.closest('[data-reply-to]'); | ||
| 44 | if (pick) { | ||
| 45 | event.preventDefault(); | ||
| 46 | const form = document.querySelector('.discussion-composer'); | ||
| 47 | if (!form) return; | ||
| 48 | const input = form.querySelector('[data-reply-input]'); | ||
| 49 | const box = form.querySelector('[data-reply-target]'); | ||
| 50 | const name = form.querySelector('[data-reply-name]'); | ||
| 51 | if (!input || !box || !name) return; | ||
| 52 | input.value = pick.dataset.replyTo || ''; | ||
| 53 | name.textContent = pick.dataset.replyAuthor || ''; | ||
| 54 | box.hidden = false; | ||
| 55 | const textarea = form.querySelector('textarea[name="body"]'); | ||
| 56 | form.scrollIntoView({ behavior: 'smooth', block: 'end' }); | ||
| 57 | if (textarea) textarea.focus({ preventScroll: true }); | ||
| 58 | return; | ||
| 59 | } | ||
| 60 | const clear = event.target.closest('[data-reply-clear]'); | ||
| 61 | if (!clear) return; | ||
| 62 | const form = clear.closest('.discussion-composer'); | ||
| 63 | if (!form) return; | ||
| 64 | const input = form.querySelector('[data-reply-input]'); | ||
| 65 | const box = form.querySelector('[data-reply-target]'); | ||
| 66 | const name = form.querySelector('[data-reply-name]'); | ||
| 67 | if (input) input.value = ''; | ||
| 68 | if (name) name.textContent = ''; | ||
| 69 | if (box) box.hidden = true; | ||
| 70 | }); | ||
| 71 | })(); | ||
| 72 | |||
| 73 | // Inline review comments: the composer is inserted beneath a diff line. Cancel | ||
| 74 | // removes it, Escape closes it, and opening another line keeps one composer. | ||
| 75 | (() => { | ||
| 76 | const close = (row) => { if (row) row.remove(); }; | ||
| 77 | document.addEventListener('click', (event) => { | ||
| 78 | if (event.target.closest('[data-inline-comment-cancel]')) { | ||
| 79 | close(event.target.closest('tr.inline-comment')); | ||
| 80 | } else if (event.target.closest('[data-inline-comment-add]')) { | ||
| 81 | document.querySelectorAll('tr.inline-comment').forEach(close); | ||
| 82 | } | ||
| 83 | }); | ||
| 84 | document.addEventListener('keydown', (event) => { | ||
| 85 | if (event.key !== 'Escape') return; | ||
| 86 | const form = event.target.closest('.inline-comment-form'); | ||
| 87 | if (form) close(form.closest('tr.inline-comment')); | ||
| 88 | }); | ||
| 89 | })(); | ||
| 90 | |||
| 39 | 91 | // Label token input. Without JavaScript the text field still submits labels as a | |
| 40 | 92 | // comma-separated value; with it, labels become removable chips and existing | |
| 41 | 93 | // labels are offered as suggestions. | |
| @@ -145,12 +197,123 @@ | |||
| 145 | 197 | document.body.addEventListener('htmx:afterSwap', (event) => init(event.target)); | |
| 146 | 198 | })(); | |
| 147 | 199 | ||
| 200 | // Comment card menus: one small menu holds Reply, Copy, Edit and Delete. The | ||
| 201 | // edit form is part of the card, so opening it does not navigate away. | ||
| 202 | (() => { | ||
| 203 | const closeMenus = (except) => { | ||
| 204 | document.querySelectorAll('[data-comment-menu]').forEach((menu) => { | ||
| 205 | const list = menu.querySelector('[data-comment-menu-list]'); | ||
| 206 | const toggle = menu.querySelector('[data-comment-menu-toggle]'); | ||
| 207 | if (!list || list === except) return; | ||
| 208 | list.hidden = true; | ||
| 209 | if (toggle) toggle.setAttribute('aria-expanded', 'false'); | ||
| 210 | }); | ||
| 211 | }; | ||
| 212 | const copy = (value) => { | ||
| 213 | if (navigator.clipboard) { | ||
| 214 | navigator.clipboard.writeText(value).catch(() => {}); | ||
| 215 | return; | ||
| 216 | } | ||
| 217 | const area = document.createElement('textarea'); | ||
| 218 | area.value = value; | ||
| 219 | document.body.appendChild(area); | ||
| 220 | area.select(); | ||
| 221 | document.execCommand('copy'); | ||
| 222 | area.remove(); | ||
| 223 | }; | ||
| 224 | document.addEventListener('click', (event) => { | ||
| 225 | const toggle = event.target.closest('[data-comment-menu-toggle]'); | ||
| 226 | if (toggle) { | ||
| 227 | event.preventDefault(); | ||
| 228 | const menu = toggle.closest('[data-comment-menu]'); | ||
| 229 | const list = menu && menu.querySelector('[data-comment-menu-list]'); | ||
| 230 | if (!list) return; | ||
| 231 | const open = list.hidden; | ||
| 232 | closeMenus(list); | ||
| 233 | list.hidden = !open; | ||
| 234 | toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); | ||
| 235 | return; | ||
| 236 | } | ||
| 237 | |||
| 238 | const item = event.target.closest('[data-comment-menu-list] button, [data-comment-menu-list] a'); | ||
| 239 | if (item) { | ||
| 240 | if (item.hasAttribute('data-comment-edit')) { | ||
| 241 | const article = item.closest('.comment'); | ||
| 242 | const form = article && article.querySelector('[data-comment-edit-form]'); | ||
| 243 | if (article && form) { | ||
| 244 | article.classList.add('editing'); | ||
| 245 | form.hidden = false; | ||
| 246 | const textarea = form.querySelector('textarea'); | ||
| 247 | if (textarea) textarea.focus(); | ||
| 248 | } | ||
| 249 | } else if (item.hasAttribute('data-copy-link')) { | ||
| 250 | const id = item.getAttribute('data-copy-link'); | ||
| 251 | copy(window.location.href.split('#')[0] + '#c-' + id); | ||
| 252 | } | ||
| 253 | if (item.type !== 'submit') closeMenus(); | ||
| 254 | return; | ||
| 255 | } | ||
| 256 | |||
| 257 | if (!event.target.closest('[data-comment-menu]')) closeMenus(); | ||
| 258 | const cancel = event.target.closest('[data-comment-edit-cancel]'); | ||
| 259 | if (cancel) { | ||
| 260 | const article = cancel.closest('.comment'); | ||
| 261 | const form = article && article.querySelector('[data-comment-edit-form]'); | ||
| 262 | if (article && form) { | ||
| 263 | article.classList.remove('editing'); | ||
| 264 | form.hidden = true; | ||
| 265 | } | ||
| 266 | } | ||
| 267 | }); | ||
| 268 | document.addEventListener('submit', (event) => { | ||
| 269 | if (event.target.closest('[data-comment-menu]')) closeMenus(); | ||
| 270 | }); | ||
| 271 | document.addEventListener('keydown', (event) => { | ||
| 272 | if (event.key === 'Escape') closeMenus(); | ||
| 273 | }); | ||
| 274 | })(); | ||
| 275 | |||
| 276 | // A hash can point at a reply inside a collapsed thread. Open its ancestors | ||
| 277 | // before the browser tries to scroll, and repeat on click so a second click on | ||
| 278 | // the same "replying to" link still finds the comment. | ||
| 279 | (() => { | ||
| 280 | const reveal = (id) => { | ||
| 281 | if (!id) return; | ||
| 282 | const node = document.getElementById(id); | ||
| 283 | if (!node) return; | ||
| 284 | let details = node.closest('details'); | ||
| 285 | while (details) { | ||
| 286 | details.open = true; | ||
| 287 | details = details.parentElement && details.parentElement.closest('details'); | ||
| 288 | } | ||
| 289 | if (node.matches('details')) node.open = true; | ||
| 290 | requestAnimationFrame(() => node.scrollIntoView({ block: 'center' })); | ||
| 291 | }; | ||
| 292 | const fromHash = () => { | ||
| 293 | let id = window.location.hash.slice(1); | ||
| 294 | if (!id) return; | ||
| 295 | try { id = decodeURIComponent(id); } catch (_) {} | ||
| 296 | reveal(id); | ||
| 297 | }; | ||
| 298 | window.addEventListener('hashchange', fromHash); | ||
| 299 | window.addEventListener('load', fromHash); | ||
| 300 | document.addEventListener('click', (event) => { | ||
| 301 | const link = event.target.closest('a[href^="#"]'); | ||
| 302 | if (!link) return; | ||
| 303 | let id = link.getAttribute('href').slice(1); | ||
| 304 | if (!id) return; | ||
| 305 | try { id = decodeURIComponent(id); } catch (_) {} | ||
| 306 | reveal(id); | ||
| 307 | }); | ||
| 308 | fromHash(); | ||
| 309 | })(); | ||
| 310 | |||
| 148 | 311 | // Code browser: keep the file tree's active row and expanded ancestors in sync | |
| 149 | 312 | // when HTMX replaces the main pane. The full-page render already resolves this | |
| 150 | 313 | // server-side; this only closes the gap for fragment navigation. | |
| 151 | 314 | (() => { | |
| 152 | const sync = (main) => { | ||
| 153 | const tree = document.getElementById('file-tree'); | ||
| 315 | const sync = (treeID, main) => { | ||
| 316 | const tree = document.getElementById(treeID); | ||
| 154 | 317 | if (!tree || !main) return; | |
| 155 | 318 | const path = main.dataset.path || ''; | |
| 156 | 319 | tree.querySelectorAll('.active').forEach((el) => el.classList.remove('active')); | |
| @@ -167,8 +330,10 @@ | |||
| 167 | 330 | }; | |
| 168 | 331 | document.body.addEventListener('htmx:afterSwap', (event) => { | |
| 169 | 332 | const main = event.target; | |
| 170 | if (!main || main.id !== 'code-main') return; | ||
| 171 | sync(main); | ||
| 333 | if (!main) return; | ||
| 334 | const treeID = main.id === 'code-main' ? 'file-tree' : main.id === 'review-main' ? 'review-tree' : ''; | ||
| 335 | if (!treeID) return; | ||
| 336 | sync(treeID, main); | ||
| 172 | 337 | // If the old file had scrolled the page down, the next file should start at | |
| 173 | 338 | // its own top rather than midway through. | |
| 174 | 339 | if (main.getBoundingClientRect().top < 60) { | |