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/templates/requests.html +237

@@ -0,0 +1,237 @@
1 {{define "requests"}}
2 {{template "header" .}}
3 {{template "repohead" (nav .Name "requests")}}
4
5 <div class="page-head">
6 <div>
7 <h1>Changes</h1>
8 <p class="page-sub">{{.Open}} open · {{.Closed}} closed · {{.Total}} total</p>
9 </div>
10 {{if .CanOpen}}<a class="button" href="/r/{{.Name}}/requests/new">{{icon "plus"}}New change</a>{{end}}
11 </div>
12
13 {{template "change_list" .}}
14 {{template "footer" .}}
15 {{end}}
16
17 {{define "change_list"}}
18 <div id="change-list">
19 <div class="chips" hx-boost="true" hx-target="#change-list" hx-swap="outerHTML" hx-push-url="true">
20 <a class="chip{{if eq .State "open"}} active{{end}}" href="/r/{{.Name}}/requests?state=open{{if .Query}}&amp;q={{urlquery .Query}}{{end}}">Open <span class="chip-count">{{.Open}}</span></a>
21 <a class="chip{{if eq .State "closed"}} active{{end}}" href="/r/{{.Name}}/requests?state=closed{{if .Query}}&amp;q={{urlquery .Query}}{{end}}">Closed <span class="chip-count">{{.Closed}}</span></a>
22 <a class="chip{{if eq .State "all"}} active{{end}}" href="/r/{{.Name}}/requests?state=all{{if .Query}}&amp;q={{urlquery .Query}}{{end}}">All <span class="chip-count">{{.Total}}</span></a>
23 </div>
24
25 <form method="get" action="/r/{{.Name}}/requests" class="filters" hx-get="/r/{{.Name}}/requests" hx-target="#change-list" hx-swap="outerHTML" hx-push-url="true">
26 <input type="hidden" name="state" value="{{.State}}">
27 <input name="q" type="text" placeholder="Search title, description and author" value="{{.Query}}">
28 <button type="submit">{{icon "sliders-horizontal"}}Filter</button>
29 {{if .Filtered}}<a href="/r/{{.Name}}/requests" hx-get="/r/{{.Name}}/requests" hx-target="#change-list" hx-swap="outerHTML" hx-push-url="true">clear</a>{{end}}
30 </form>
31
32 {{if .Rows}}
33 <div class="panel">
34 <table class="change-list">
35 <thead>
36 <tr><th>Status</th><th>Change</th><th>Base ← Head</th><th>Author</th><th>Approvals</th><th>Age</th></tr>
37 </thead>
38 <tbody>
39 {{range .Rows}}
40 <tr>
41 <td class="fit"><span class="state {{.State}}">{{.State}}</span></td>
42 <td>
43 <a class="row-title" href="/r/{{$.Name}}/requests/{{.ID}}">{{.Title}}</a>
44 <div class="row-meta"><span class="sha">#{{short .ID}}</span><span>{{len .Comments}} comment(s)</span></div>
45 </td>
46 <td><code>{{.Base}}</code> <span class="muted">←</span> <span class="sha">{{short .Head}}</span></td>
47 <td class="muted nowrap">{{who .Author}}</td>
48 <td class="muted">{{.Current}}/{{$.Required}}</td>
49 <td class="muted nowrap">{{since .Created.Unix}}</td>
50 </tr>
51 {{end}}
52 </tbody>
53 </table>
54 </div>
55 {{else if and (eq .State "open") .Closed}}
56 <div class="empty-state">
57 <p>No open changes. {{.Closed}} closed.</p>
58 <a href="/r/{{.Name}}/requests?state=closed" hx-get="/r/{{.Name}}/requests?state=closed" hx-target="#change-list" hx-swap="outerHTML" hx-push-url="true">View closed changes</a>
59 </div>
60 {{else if .Filtered}}
61 <div class="empty-state">
62 <p>No changes match this filter.</p>
63 <a href="/r/{{.Name}}/requests" hx-get="/r/{{.Name}}/requests" hx-target="#change-list" hx-swap="outerHTML" hx-push-url="true">Clear filters</a>
64 </div>
65 {{else}}
66 <div class="empty-state">
67 <p>No changes yet.</p>
68 <p class="muted">From a clone of this repository:</p>
69 <pre>vole change new --title "..." --base {{.Base}}</pre>
70 <p class="muted">A change is also just a directory on the <code>vole</code> branch:
71 <code>requests/&lt;ulid&gt;/request.md</code> naming the base branch and the head
72 commit, plus <code>approvals/&lt;account-id&gt;.md</code> from each approver.</p>
73 </div>
74 {{end}}
75 </div>
76 {{end}}
77
78 {{define "request_sidebar"}}
79 <aside class="thread-side" id="request-sidebar">
80 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
81 <section>
82 <h2>{{icon "circle-dot"}}State</h2>
83 <p><span class="state {{.Request.State}}">{{.Request.State}}</span></p>
84 {{if .CanClose}}
85 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/state" hx-post="/r/{{.Name}}/requests/{{.Request.ID}}/state" hx-target="#request-sidebar" hx-swap="outerHTML" hx-disabled-elt="find button[type='submit']">
86 <input type="hidden" name="csrf" value="{{.CSRF}}">
87 <input type="hidden" name="state" value="{{if eq .Request.State "open"}}closed{{else}}open{{end}}">
88 <button class="secondary" type="submit">{{if eq .Request.State "open"}}{{icon "circle-x"}}Close change{{else}}{{icon "rotate-ccw"}}Reopen change{{end}}</button>
89 </form>
90 {{end}}
91 </section>
92
93 <section>
94 <h2>{{icon "git-branch"}}Review</h2>
95 <dl class="meta-list">
96 <dt>Base</dt><dd><code>{{.Request.Base}}</code></dd>
97 <dt>Head</dt><dd><a class="sha" href="/r/{{.Name}}/commit/{{.Request.Head}}">{{short .Request.Head}}</a></dd>
98 </dl>
99 <p class="muted">{{.Current}} of {{.Required}} required approval(s).</p>
100 {{if .CanApprove}}
101 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/approve" hx-post="/r/{{.Name}}/requests/{{.Request.ID}}/approve" hx-target="#request-sidebar" hx-swap="outerHTML" hx-disabled-elt="find button[type='submit']">
102 <input type="hidden" name="csrf" value="{{.CSRF}}">
103 <button type="submit">{{icon "check"}}Approve this head</button>
104 </form>
105 <p class="muted">Approving names the current head; a new head starts over.</p>
106 {{end}}
107 </section>
108
109 <section>
110 <h2>{{icon "check"}}Approvals</h2>
111 {{if .Approvals}}
112 <ul class="approval-list">
113 {{range .Approvals}}
114 <li>
115 <span class="avatar c{{tone .Name}}" aria-hidden="true">{{initial .Name}}</span>
116 <span class="cname">{{.Name}}</span>
117 <span class="muted">{{if .Current}}current{{else}}stale{{end}}</span>
118 </li>
119 {{end}}
120 </ul>
121 {{else}}
122 <p class="muted">No approvals yet. An approval names one commit, so pushing a new head starts over.</p>
123 {{end}}
124 </section>
125
126 <section>
127 <h2>{{icon "circle-check"}}Checks</h2>
128 {{if .Checks}}
129 <ul class="check-list">
130 {{range .Checks}}
131 <li>
132 <span class="status {{.Status}}">{{.Status}}</span>
133 <a href="/r/{{$.Name}}/runs/{{.ID}}">{{.Job}}</a>
134 </li>
135 {{end}}
136 </ul>
137 {{else}}
138 <p class="muted">No CI run for this commit yet.</p>
139 {{end}}
140 </section>
141
142 <section>
143 <h2>{{icon "info"}}Details</h2>
144 <dl class="meta-list">
145 <dt>Author</dt><dd>{{who .Request.Author}}</dd>
146 <dt>Opened</dt><dd>{{since .Request.Created.Unix}}</dd>
147 <dt>Reference</dt><dd class="sha">#{{short .Request.ID}}</dd>
148 <dt>Comments</dt><dd>{{.CommentCount}}</dd>
149 </dl>
150 </section>
151 </aside>
152 {{end}}
153
154 {{define "request_discussion"}}
155 <div id="request-discussion">
156 {{if .ThreadError}}<p class="error">{{.ThreadError}}</p>{{end}}
157 {{with .CommentTarget}}<p class="anchor-note muted">Commenting on <code>{{.Path}}:{{.Line}}</code> ({{.Side}} side) · <a href="/r/{{$.Name}}/requests/{{$.Request.ID}}#request-comment-form">cancel</a></p>{{end}}
158 <h2>{{icon "message-square"}}Discussion <span class="muted">({{len .Comments}})</span></h2>
159 {{range .Comments}}
160 <article class="comment" id="c-{{.ID}}">
161 <header class="comment-head">
162 <span class="avatar c{{tone .Author}}" aria-hidden="true">{{initial (who .Author)}}</span>
163 <span class="comment-author">{{who .Author}}</span>
164 <span class="muted">{{since .Created.Unix}}</span>
165 {{if .Anchor}}<span class="muted">on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side){{if not .Current}} · <span class="conflict">the file has changed since this comment</span>{{end}}</span>{{end}}
166 </header>
167 <div class="comment-body md">{{.Body}}</div>
168 </article>
169 {{else}}
170 <p class="muted">No discussion yet. From a clone of this repository:
171 <code>vole change comment {{.Request.ID}} -m "..."</code></p>
172 {{end}}
173
174 {{if .CanDiscuss}}
175 <form id="request-comment-form" method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/comments" class="form-block" hx-post="/r/{{.Name}}/requests/{{.Request.ID}}/comments" hx-target="#request-discussion" hx-swap="outerHTML" hx-push-url="/r/{{.Name}}/requests/{{.Request.ID}}" hx-disabled-elt="find button[type='submit']">
176 <input type="hidden" name="csrf" value="{{.CSRF}}">
177 {{with .CommentTarget}}<input type="hidden" name="path" value="{{.Path}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}
178 <div class="card form-card">
179 <label>Comment <textarea name="body" required placeholder="Leave a comment. Markdown is welcome."{{if .CommentTarget}} autofocus{{end}}></textarea></label>
180 </div>
181 <div class="form-actions"><button type="submit">{{icon "message-square"}}Comment</button></div>
182 </form>
183 {{end}}
184
185 </div>
186 {{end}}
187
188 {{define "request"}}
189 {{template "header" .}}
190 {{template "repohead" (nav .Name "requests")}}
191
192 <div class="page-head">
193 <div>
194 <h1>{{.Request.Title}}</h1>
195 <p class="page-sub">
196 <span class="state {{.Request.State}}">{{.Request.State}}</span>
197 · <span class="sha">#{{short .Request.ID}}</span>
198 · by {{who .Request.Author}} {{since .Request.Created.Unix}}
199 </p>
200 </div>
201 </div>
202
203 <div class="thread">
204 <div class="thread-main">
205 <article class="comment">
206 <header class="comment-head">
207 <span class="avatar c{{tone .Request.Author}}" aria-hidden="true">{{initial (who .Request.Author)}}</span>
208 <span class="comment-author">{{who .Request.Author}}</span>
209 <span class="muted">opened this {{since .Request.Created.Unix}}</span>
210 </header>
211 <div class="comment-body md">
212 {{if .Description}}{{.Description}}{{else}}<p class="muted">No description.</p>{{end}}
213 </div>
214 </article>
215
216 {{template "request_discussion" .}}
217
218 <h2>{{icon "git-pull-request"}}What would land</h2>
219 {{if .MergeChecked}}{{if .Conflicts}}
220 <p class="conflict">This branch no longer merges into {{.Request.Base}} cleanly:
221 {{range .Conflicts}}<code>{{.}}</code> {{end}}— rebase or merge {{.Request.Base}} before landing it.</p>
222 {{else}}
223 <p class="muted">Merges into {{.Request.Base}} cleanly.</p>
224 {{end}}{{end}}
225 {{if .AlreadyMerged}}
226 <p class="muted">Already merged into {{.Request.Base}}.</p>
227 {{else if .Files}}
228 {{template "diff" .}}
229 {{else}}
230 <p class="muted">No changes, or the head is not reachable from this repository yet.</p>
231 {{end}}
232 </div>
233
234 {{template "request_sidebar" .}}
235 </div>
236 {{template "footer" .}}
237 {{end}}