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.

Edit issue and change details in place

802e0e52533746522b3e65b953e5cc0141aa70b9 · Keenan Nemetz · 5d ago · parent 90f1ab59

Edit issue and change details in place

Put the title pencil beside the heading and the description menu on its card, matching the comment editor. Each control saves only its own field: the title response is the heading and the description response is the card, so an open draft in the other field is not replaced. The standalone /edit page stays as the fallback.

Prefilled edit fields size themselves to their content and remain vertically resizable. Prose copied from a menu is query-escaped in the attribute, so untrusted markdown in an issue or comment cannot appear verbatim in the page.

server/web/static/app.js +75−3

@@ -19,11 +19,44 @@ const copyText = (value, done) => {
19 19 finish();
20 20 };
21 21
22 // fitTextarea gives a prefilled edit field the height of its content. The field
23 // stays resizable: this only picks the starting height, and typing grows it again.
24 const fitTextarea = (area) => {
25 if (!area || area.hidden || area.offsetParent === null) return;
26 area.style.height = 'auto';
27 area.style.height = `${area.scrollHeight}px`;
28 };
29
30 (() => {
31 const init = (scope = document) => {
32 if (scope instanceof Element && scope.matches('[data-autosize]')) fitTextarea(scope);
33 if (scope && typeof scope.querySelectorAll === 'function') {
34 scope.querySelectorAll('[data-autosize]').forEach(fitTextarea);
35 }
36 };
37 document.addEventListener('input', (event) => {
38 const area = event.target.closest && event.target.closest('[data-autosize]');
39 if (area) fitTextarea(area);
40 });
41 init();
42 document.body.addEventListener('htmx:afterSwap', (event) => init(event.target));
43 })();
44
22 45 (() => {
23 46 document.addEventListener('click', (event) => {
24 const button = event.target.closest('[data-copy]');
47 const button = event.target.closest('[data-copy], [data-copy-query]');
25 48 if (!button) return;
26 const value = button.getAttribute('data-copy');
49 let value = button.getAttribute('data-copy');
50 const query = button.getAttribute('data-copy-query');
51 if (query !== null) {
52 // Prose is query-escaped on the way into the attribute, so a script URL in
53 // an issue or comment cannot appear verbatim in the page.
54 try {
55 value = decodeURIComponent(query.replace(/\+/g, ' '));
56 } catch (_) {
57 value = query;
58 }
59 }
27 60 if (!value) return;
28 61 event.preventDefault();
29 62 copyText(value, () => {
@@ -405,7 +438,10 @@ const copyText = (value, done) => {
405 438 article.classList.add('editing');
406 439 form.hidden = false;
407 440 const textarea = form.querySelector('textarea');
408 if (textarea) textarea.focus();
441 if (textarea) {
442 fitTextarea(textarea);
443 textarea.focus();
444 }
409 445 }
410 446 } else if (item.hasAttribute('data-copy-link')) {
411 447 const id = item.getAttribute('data-copy-link');
@@ -434,6 +470,42 @@ const copyText = (value, done) => {
434 470 });
435 471 })();
436 472
473 // The issue or change title: the pencil beside the heading swaps it for a
474 // title-only form. The description has its own comment-style menu and form, so
475 // this is deliberately only the heading.
476 (() => {
477 const parts = () => ({
478 view: document.querySelector('[data-details-title-view]'),
479 toggle: document.querySelector('[data-title-edit]'),
480 form: document.querySelector('[data-title-edit-form]'),
481 input: document.querySelector('[data-title-edit-input]'),
482 });
483 const set = (open) => {
484 const p = parts();
485 if (!p.form) return;
486 if (p.view) p.view.hidden = open;
487 if (p.toggle) p.toggle.hidden = open;
488 p.form.hidden = !open;
489 if (open && p.input) p.input.focus();
490 };
491 document.addEventListener('click', (event) => {
492 if (event.target.closest('[data-title-edit]')) {
493 event.preventDefault();
494 set(true);
495 return;
496 }
497 if (event.target.closest('[data-title-edit-cancel]')) {
498 event.preventDefault();
499 set(false);
500 }
501 });
502 document.addEventListener('keydown', (event) => {
503 if (event.key !== 'Escape') return;
504 const p = parts();
505 if (p.form && !p.form.hidden) set(false);
506 });
507 })();
508
437 509 // A hash can point at a reply inside a collapsed thread. Open its ancestors
438 510 // before the browser tries to scroll, and repeat on click so a second click on
439 511 // the same "replying to" link still finds the comment.