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 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 })();