Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {