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 the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/web/static/app.js +146
| @@ -0,0 +1,146 @@ | |||
| 1 | // Small progressive enhancements. The pages work without this file: it adds | ||
| 2 | // clipboard copy, select-to-submit, and a confirmation on destructive forms. | ||
| 3 | (() => { | ||
| 4 | document.addEventListener('click', (event) => { | ||
| 5 | const button = event.target.closest('[data-copy]'); | ||
| 6 | if (!button) return; | ||
| 7 | const value = button.getAttribute('data-copy'); | ||
| 8 | if (!value) return; | ||
| 9 | event.preventDefault(); | ||
| 10 | const done = () => { | ||
| 11 | const original = button.textContent; | ||
| 12 | button.textContent = 'Copied'; | ||
| 13 | setTimeout(() => { button.textContent = original; }, 1200); | ||
| 14 | }; | ||
| 15 | if (navigator.clipboard) { | ||
| 16 | navigator.clipboard.writeText(value).then(done, () => {}); | ||
| 17 | } else { | ||
| 18 | const area = document.createElement('textarea'); | ||
| 19 | area.value = value; | ||
| 20 | document.body.appendChild(area); | ||
| 21 | area.select(); | ||
| 22 | document.execCommand('copy'); | ||
| 23 | area.remove(); | ||
| 24 | done(); | ||
| 25 | } | ||
| 26 | }); | ||
| 27 | |||
| 28 | document.addEventListener('change', (event) => { | ||
| 29 | const select = event.target.closest('[data-autosubmit]'); | ||
| 30 | if (select && select.form) select.form.submit(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | document.addEventListener('submit', (event) => { | ||
| 34 | const message = event.target.getAttribute('data-confirm'); | ||
| 35 | if (message && !window.confirm(message)) event.preventDefault(); | ||
| 36 | }); | ||
| 37 | })(); | ||
| 38 | |||
| 39 | // Label token input. Without JavaScript the text field still submits labels as a | ||
| 40 | // comma-separated value; with it, labels become removable chips and existing | ||
| 41 | // labels are offered as suggestions. | ||
| 42 | (() => { | ||
| 43 | const setup = (root) => { | ||
| 44 | const tokens = root.querySelector('[data-label-tokens]'); | ||
| 45 | const entry = root.querySelector('[data-label-entry]'); | ||
| 46 | const suggest = root.querySelector('[data-label-suggest]'); | ||
| 47 | if (!tokens || !entry || !suggest) return; | ||
| 48 | const options = Array.from(root.querySelectorAll('[data-label-option]')); | ||
| 49 | const itemName = root.dataset.labelItem || 'label'; | ||
| 50 | |||
| 51 | const selected = () => new Set( | ||
| 52 | Array.from(tokens.querySelectorAll(`input[type="hidden"][name="${itemName}"]`)).map((i) => i.value), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const notify = () => { | ||
| 56 | const form = root.closest('form'); | ||
| 57 | if (form) form.dispatchEvent(new Event('token-change', { bubbles: true })); | ||
| 58 | }; | ||
| 59 | |||
| 60 | const filter = () => { | ||
| 61 | const query = entry.value.trim().toLowerCase(); | ||
| 62 | const have = selected(); | ||
| 63 | let visible = false; | ||
| 64 | for (const option of options) { | ||
| 65 | const label = option.dataset.labelOption || ''; | ||
| 66 | const show = !have.has(label) && (!query || label.toLowerCase().includes(query)); | ||
| 67 | option.hidden = !show; | ||
| 68 | if (show) visible = true; | ||
| 69 | } | ||
| 70 | suggest.hidden = !visible || document.activeElement !== entry; | ||
| 71 | }; | ||
| 72 | |||
| 73 | const add = (value, focus = true, announce = true) => { | ||
| 74 | let changed = false; | ||
| 75 | for (const part of String(value).split(',')) { | ||
| 76 | const label = part.trim(); | ||
| 77 | if (!label || selected().has(label)) continue; | ||
| 78 | changed = true; | ||
| 79 | const token = document.createElement('span'); | ||
| 80 | token.className = 'label-token'; | ||
| 81 | token.dataset.labelToken = label; | ||
| 82 | token.append(document.createTextNode(label)); | ||
| 83 | const remove = document.createElement('button'); | ||
| 84 | remove.type = 'button'; | ||
| 85 | remove.className = 'label-remove'; | ||
| 86 | remove.setAttribute('aria-label', `Remove ${label}`); | ||
| 87 | remove.textContent = '×'; | ||
| 88 | remove.addEventListener('click', () => { token.remove(); filter(); notify(); }); | ||
| 89 | const hidden = document.createElement('input'); | ||
| 90 | hidden.type = 'hidden'; | ||
| 91 | hidden.name = itemName; | ||
| 92 | hidden.value = label; | ||
| 93 | token.append(remove, hidden); | ||
| 94 | tokens.insertBefore(token, entry); | ||
| 95 | } | ||
| 96 | entry.value = ''; | ||
| 97 | filter(); | ||
| 98 | if (announce && changed) notify(); | ||
| 99 | if (focus) entry.focus(); | ||
| 100 | }; | ||
| 101 | |||
| 102 | const initial = entry.value; | ||
| 103 | entry.value = ''; | ||
| 104 | if (initial.trim()) add(initial, false, false); | ||
| 105 | |||
| 106 | tokens.addEventListener('click', (event) => { | ||
| 107 | if (event.target === tokens) entry.focus(); | ||
| 108 | }); | ||
| 109 | entry.addEventListener('input', filter); | ||
| 110 | entry.addEventListener('focus', filter); | ||
| 111 | entry.addEventListener('keydown', (event) => { | ||
| 112 | if (event.key === 'Enter' || event.key === ',') { | ||
| 113 | event.preventDefault(); | ||
| 114 | add(entry.value); | ||
| 115 | } else if (event.key === 'Backspace' && entry.value === '') { | ||
| 116 | const last = tokens.querySelector('.label-token:last-of-type'); | ||
| 117 | if (last) { | ||
| 118 | last.remove(); | ||
| 119 | filter(); | ||
| 120 | notify(); | ||
| 121 | } | ||
| 122 | } else if (event.key === 'Escape') { | ||
| 123 | suggest.hidden = true; | ||
| 124 | } | ||
| 125 | }); | ||
| 126 | entry.addEventListener('blur', () => { | ||
| 127 | if (entry.value.trim() !== '') add(entry.value, false); | ||
| 128 | window.setTimeout(() => { suggest.hidden = true; }, 120); | ||
| 129 | }); | ||
| 130 | for (const option of options) { | ||
| 131 | option.addEventListener('mousedown', (event) => event.preventDefault()); | ||
| 132 | option.addEventListener('click', () => { add(option.dataset.labelOption); entry.focus(); }); | ||
| 133 | } | ||
| 134 | filter(); | ||
| 135 | }; | ||
| 136 | |||
| 137 | const init = (scope = document) => { | ||
| 138 | if (scope instanceof Element && scope.matches('[data-label-input]')) setup(scope); | ||
| 139 | if (scope && typeof scope.querySelectorAll === 'function') { | ||
| 140 | scope.querySelectorAll('[data-label-input]').forEach(setup); | ||
| 141 | } | ||
| 142 | }; | ||
| 143 | init(); | ||
| 144 | // HTMX replaces a sidebar section with a fresh token input; wire it up again. | ||
| 145 | document.body.addEventListener('htmx:afterSwap', (event) => init(event.target)); | ||
| 146 | })(); | ||