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/overview.go +251

@@ -0,0 +1,251 @@
1 package web
2
3 // The repository overview: the page a visitor lands on before they have chosen
4 // a file or an issue. It answers "what is this, who works on it, and what
5 // happened lately" from git and the queue, with no stored counters to go stale.
6
7 import (
8 "html/template"
9 "net/http"
10 "path"
11 "sort"
12 "strings"
13
14 "github.com/rodent-software/vole/ci/runs"
15 "github.com/rodent-software/vole/core/git"
16 "github.com/rodent-software/vole/forge/repo"
17 )
18
19 type languageShare struct {
20 Name string
21 Bytes int64
22 Percent int
23 }
24
25 type contributorView struct {
26 Name string
27 Commits int
28 Initial string
29 Tone int
30 }
31
32 func (w *Web) Overview(rw http.ResponseWriter, r *http.Request) {
33 m, ok := w.open(rw, r)
34 if !ok {
35 return
36 }
37 g := m.Git()
38 ref := m.DefaultBranch
39 commit, _ := g.RevParse(ref)
40
41 var size int64
42 var commits []git.Commit
43 var languages []languageShare
44 if commit != "" {
45 commits, _ = g.Log(commit, 5)
46 if blobs, err := g.TreeBlobs(commit); err == nil {
47 for _, b := range blobs {
48 size += b.Size
49 }
50 languages = languageShares(blobs)
51 }
52 }
53
54 var tags []git.Tag
55 if list, err := g.Tags(); err == nil {
56 if len(list) > 5 {
57 list = list[:5]
58 }
59 tags = list
60 }
61
62 var contributors []contributorView
63 if list, err := g.Contributors(commit); err == nil {
64 shown := list
65 if len(shown) > 8 {
66 shown = shown[:8]
67 }
68 for _, c := range shown {
69 contributors = append(contributors, contributorView{
70 Name: c.Name, Commits: c.Commits, Initial: initial(c.Name), Tone: tone(c.Name),
71 })
72 }
73 }
74
75 var recentRuns []*runs.Run
76 if w.Runs != nil {
77 recentRuns, _ = w.Runs.Recent(m.Name, 5)
78 }
79
80 readme, readmeName := w.readmeAt(m, commit)
81 site := ""
82 if w.Cfg.PagesDomain != "" {
83 site = "http://" + m.Name + "." + w.Cfg.PagesDomain
84 }
85
86 w.render(rw, "overview", struct {
87 Page
88 Name string
89 Ref string
90 Commit string
91 CloneURL string
92 SSHURL string
93 Site string
94 Readme template.HTML
95 ReadmeName string
96 Size int64
97 Languages []languageShare
98 Commits []git.Commit
99 Runs []*runs.Run
100 Tags []git.Tag
101 Contributors []contributorView
102 }{
103 Page: w.page(r, m.Name),
104 Name: m.Name,
105 Ref: ref,
106 Commit: commit,
107 CloneURL: cloneURL(r, m.Name),
108 SSHURL: sshURL(w.Cfg.SSHAddr, m.Name),
109 Site: site,
110 Readme: readme,
111 ReadmeName: readmeName,
112 Size: size,
113 Languages: languages,
114 Commits: commits,
115 Runs: recentRuns,
116 Tags: tags,
117 Contributors: contributors,
118 }, 0)
119 }
120
121 // readmeAt returns the rendered README and the name it was found under. The name
122 // is shown above the body: a rendered file with no filename is harder to trust.
123 func (w *Web) readmeAt(m *repo.Meta, commit string) (template.HTML, string) {
124 if commit == "" {
125 return "", ""
126 }
127 for _, name := range []string{"README.md", "readme.md", "Readme.md", "README.markdown"} {
128 if data, err := m.Git().BlobAt(commit, name); err == nil {
129 return w.renderMarkdown(m.Name, string(data)), name
130 }
131 }
132 return "", ""
133 }
134
135 // initial is the one-letter avatar label. Names are display names, so the first
136 // rune is enough; an empty name gets "?" rather than an empty circle.
137 func initial(name string) string {
138 for _, r := range strings.TrimSpace(name) {
139 return strings.ToUpper(string(r))
140 }
141 return "?"
142 }
143
144 // tone picks one of the avatar colour classes deterministically, so a person
145 // keeps the same colour on every page and between visits.
146 func tone(name string) int {
147 var sum uint32
148 for _, r := range name {
149 sum = sum*31 + uint32(r)
150 }
151 return int(sum%7) + 1
152 }
153
154 // languageShares groups the current tree by file extension and returns the
155 // largest groups. It is a rough map, not a linguist: the point is to show what a
156 // repository is mostly made of, not to classify every generated file.
157 func languageShares(blobs map[string]git.Blob) []languageShare {
158 byName := map[string]int64{}
159 var total int64
160 for _, b := range blobs {
161 name := languageFor(b.Path)
162 byName[name] += b.Size
163 total += b.Size
164 }
165 if total == 0 {
166 return nil
167 }
168 list := make([]languageShare, 0, len(byName))
169 for name, n := range byName {
170 pct := int(n * 100 / total)
171 if pct == 0 && n > 0 {
172 pct = 1
173 }
174 list = append(list, languageShare{Name: name, Bytes: n, Percent: pct})
175 }
176 sort.Slice(list, func(i, j int) bool {
177 if list[i].Bytes != list[j].Bytes {
178 return list[i].Bytes > list[j].Bytes
179 }
180 return list[i].Name < list[j].Name
181 })
182 if len(list) > 6 {
183 other := languageShare{Name: "Other"}
184 for _, l := range list[6:] {
185 other.Bytes += l.Bytes
186 other.Percent += l.Percent
187 }
188 list = append(list[:6], other)
189 }
190 return list
191 }
192
193 func languageFor(name string) string {
194 switch strings.ToLower(path.Ext(name)) {
195 case ".go":
196 return "Go"
197 case ".js", ".mjs", ".cjs":
198 return "JavaScript"
199 case ".ts", ".tsx":
200 return "TypeScript"
201 case ".jsx":
202 return "JavaScript"
203 case ".py":
204 return "Python"
205 case ".rs":
206 return "Rust"
207 case ".rb":
208 return "Ruby"
209 case ".java":
210 return "Java"
211 case ".kt", ".kts":
212 return "Kotlin"
213 case ".c", ".h":
214 return "C"
215 case ".cc", ".cpp", ".hpp":
216 return "C++"
217 case ".cs":
218 return "C#"
219 case ".sh", ".bash", ".zsh":
220 return "Shell"
221 case ".md", ".markdown":
222 return "Markdown"
223 case ".html", ".htm":
224 return "HTML"
225 case ".css", ".scss", ".sass":
226 return "CSS"
227 case ".json":
228 return "JSON"
229 case ".yml", ".yaml":
230 return "YAML"
231 case ".toml":
232 return "TOML"
233 case ".sql":
234 return "SQL"
235 case ".proto":
236 return "Protocol Buffers"
237 case ".lua":
238 return "Lua"
239 case ".php":
240 return "PHP"
241 case ".swift":
242 return "Swift"
243 case ".ex", ".exs":
244 return "Elixir"
245 case ".zig":
246 return "Zig"
247 case "":
248 return "Other"
249 }
250 return "Other"
251 }