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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Build a landing page and simplify the forge chrome

074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8

Build a landing page and simplify the forge chrome

The instance root is now a landing page rather than the full activity feed:
forge identity, project cards with top language, last commit, issue/change
counts and latest release, a short activity rail, and a first-run empty state.
The full feed moves to /activity with type filters and pagination.

Feeds now cover issues, changes, comments, and releases only; commits and CI
runs already have their own pages. Configuration gains instance_name and
instance_tagline.

Navigation: the crowded global bar becomes Runners, Activity, search, and an
account menu holding Admin, Status, and Log out. Repository pages show the name
and description above the tabs at the home-page header scale, while tab-page
titles stay small. Top-level pages keep the larger header. Drop the workflow
run chip, assigned-issues strip, repository size stat, and footer Status link.

server/web/home.go +348

@@ -0,0 +1,348 @@
1 package web
2
3 // The landing page and the instance-wide activity feed.
4 //
5 // The landing page answers three questions in one screen: what this forge is,
6 // what is in it, and what happened lately. Repositories are the main column; the
7 // merged feed is a rail beside them. Both come from one read per repository, so
8 // listing a project does not cost a second walk over its history.
9
10 import (
11 "net/http"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/rodent-software/vole/core/git"
18 "github.com/rodent-software/vole/forge/access"
19 "github.com/rodent-software/vole/forge/index"
20 )
21
22 const (
23 // homeEventLimit is what fits in the rail without turning the landing page
24 // back into a feed. The full feed has its own page at /activity.
25 homeEventLimit = 10
26 // instanceEventLimit bounds /activity after the per-repository feeds are
27 // merged; instanceEventPage is what one page of it shows.
28 instanceEventLimit = 200
29 instanceEventPage = 50
30 )
31
32 // instanceInfo is the identity the landing page shows. It is derived from the
33 // configuration rather than hard-coded, so an operator can name their forge
34 // without changing the wordmark.
35 type instanceInfo struct {
36 Name string
37 Tagline string
38 }
39
40 // homeVitals are the signs of life on the landing page. Every number is derived
41 // from what the viewer can read: a private repository's activity is not counted.
42 type homeVitals struct {
43 Repos int
44 Runners int
45 ShowRunners bool
46 LastActivity time.Time
47 }
48
49 // repoCard is one repository in the project grid. It is a summary of the same
50 // activity feed the rail uses, not a second source of truth.
51 type repoCard struct {
52 Name string
53 Description string
54 Branch string
55 Private bool
56 Commit *git.Commit
57 LastTag *git.Tag
58 OpenIssues int
59 OpenChanges int
60 Language string
61 LangClass string
62 Updated time.Time
63 }
64
65 // pagerView is the state of one page in the instance feed. Prev and Next are 0
66 // when there is no page in that direction.
67 type pagerView struct {
68 Number int
69 Pages int
70 Prev int
71 Next int
72 }
73
74 type homeData struct {
75 Page
76 Instance instanceInfo
77 Vitals homeVitals
78 Cards []repoCard
79 Events []activityEvent
80 OpenRegistration bool
81 }
82
83 type instanceActivityData struct {
84 Page
85 Kind string
86 Events []activityEvent
87 Pager pagerView
88 }
89
90 // IndexPage serves GET /{$}: the landing page, or repository search results when
91 // q is set. Search results reuse the project cards so a match looks like a
92 // project rather than a table row.
93 func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) {
94 query := strings.TrimSpace(r.URL.Query().Get("q"))
95 entries, err := w.visibleRepos(r, query)
96 if err != nil {
97 w.serverError(rw, r, "index unavailable: "+err.Error())
98 return
99 }
100
101 page := w.page(r, "Repositories")
102 page.Query = query
103 data := homeData{
104 Page: page,
105 Instance: w.instanceInfo(r),
106 OpenRegistration: w.Cfg != nil && w.Cfg.OpenRegistration,
107 }
108
109 var events []activityEvent
110 data.Cards, events = w.repoCards(entries)
111 if query != "" {
112 sort.SliceStable(data.Cards, func(i, j int) bool { return data.Cards[i].Name < data.Cards[j].Name })
113 } else {
114 sortCards(data.Cards)
115 if len(events) > homeEventLimit {
116 events = events[:homeEventLimit]
117 }
118 data.Events = events
119 data.Vitals = w.homeVitals(r, entries, events)
120 }
121 w.render(rw, "index", data, 0)
122 }
123
124 // InstanceActivity serves GET /activity: the whole feed, with the type filter
125 // that would make the landing page rail too heavy.
126 func (w *Web) InstanceActivity(rw http.ResponseWriter, r *http.Request) {
127 kind := normalizeActivityKind(r.URL.Query().Get("kind"))
128 entries, err := w.visibleRepos(r, "")
129 if err != nil {
130 w.serverError(rw, r, "index unavailable: "+err.Error())
131 return
132 }
133 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
134 events, pager := paginate(w.instanceEvents(entries, kind), number, instanceEventPage)
135 w.render(rw, "instance_activity", instanceActivityData{
136 Page: w.page(r, "Activity"), Kind: kind, Events: events, Pager: pager,
137 }, 0)
138 }
139
140 // visibleRepos returns the repositories the viewer may read, optionally narrowed
141 // by a name/description search. A repository the viewer cannot read is absent,
142 // the same answer as a repository that does not exist.
143 func (w *Web) visibleRepos(r *http.Request, query string) ([]index.Entry, error) {
144 entries, err := w.Index.List()
145 if err != nil {
146 return nil, err
147 }
148 needle := strings.ToLower(strings.TrimSpace(query))
149 var visible []index.Entry
150 for _, e := range entries {
151 m, err := w.Repos.Open(e.Name)
152 if err != nil {
153 continue
154 }
155 if !w.canRead(r, m) {
156 continue
157 }
158 if needle != "" &&
159 !strings.Contains(strings.ToLower(e.Name), needle) &&
160 !strings.Contains(strings.ToLower(e.Description), needle) {
161 continue
162 }
163 visible = append(visible, e)
164 }
165 return visible, nil
166 }
167
168 // repoCards reads each repository once and returns its card plus its events with
169 // the repository named on them. It replaces the separate card and feed passes so
170 // the landing page does not walk each repository twice.
171 func (w *Web) repoCards(entries []index.Entry) ([]repoCard, []activityEvent) {
172 cards := make([]repoCard, 0, len(entries))
173 var events []activityEvent
174 for _, e := range entries {
175 m, err := w.Repos.Open(e.Name)
176 if err != nil {
177 continue
178 }
179 feed := w.repoActivity(m, "", "", "")
180 card := repoCard{
181 Name: e.Name, Description: e.Description, Branch: m.DefaultBranch,
182 Private: true, Commit: feed.LastCommit, LastTag: feed.LastTag,
183 OpenIssues: feed.OpenIssues, OpenChanges: feed.OpenChanges,
184 }
185 if feed.LastCommit != nil {
186 if tree, err := m.Git().LsTreeR(feed.LastCommit.SHA); err == nil {
187 card.Language, card.LangClass = topLanguage(tree)
188 }
189 }
190 // Visibility is the same ACL rule the repository header uses, so the
191 // lock/globe on a card cannot disagree with whether an anonymous clone
192 // works.
193 if acl, err := m.ACL(); err == nil {
194 card.Private = !acl.Can("", access.Read)
195 }
196 if len(feed.Events) > 0 {
197 card.Updated = feed.Events[0].Time
198 } else if feed.LastCommit != nil {
199 card.Updated = time.Unix(feed.LastCommit.When, 0).UTC()
200 }
201 cards = append(cards, card)
202 for _, ev := range feed.Events {
203 ev.Repo = m.Name
204 events = append(events, ev)
205 }
206 }
207 sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) })
208 return cards, events
209 }
210
211 // instanceEvents merges the visible repositories' feeds for one kind. It is
212 // bounded after the merge: a per-repository cap would hide a quiet repository's
213 // only event on an instance with a busy neighbour.
214 func (w *Web) instanceEvents(entries []index.Entry, kind string) []activityEvent {
215 var events []activityEvent
216 for _, e := range entries {
217 m, err := w.Repos.Open(e.Name)
218 if err != nil {
219 continue
220 }
221 for _, ev := range w.repoActivity(m, kind, "", "").Events {
222 ev.Repo = m.Name
223 events = append(events, ev)
224 }
225 }
226 sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) })
227 if len(events) > instanceEventLimit {
228 events = events[:instanceEventLimit]
229 }
230 return events
231 }
232
233 // paginate slices one page out of the merged feed. A page number outside the
234 // range is clamped to the nearest page, so an old link or a hand-edited URL
235 // still renders something rather than an empty page.
236 func paginate(events []activityEvent, number, perPage int) ([]activityEvent, pagerView) {
237 if perPage < 1 || len(events) == 0 {
238 return nil, pagerView{Number: 1}
239 }
240 pages := (len(events) + perPage - 1) / perPage
241 if number < 1 {
242 number = 1
243 }
244 if number > pages {
245 number = pages
246 }
247 start := (number - 1) * perPage
248 end := min(start+perPage, len(events))
249 p := pagerView{Number: number, Pages: pages}
250 if number > 1 {
251 p.Prev = number - 1
252 }
253 if number < pages {
254 p.Next = number + 1
255 }
256 return events[start:end], p
257 }
258
259 // topLanguage guesses what a repository is mostly made of from the file names in
260 // its head tree. Counting files rather than bytes is deliberate: one generated
261 // binary or lock file should not decide the label, and the card only needs a
262 // hint. Unknown types are ignored unless the repository contains nothing else.
263 func topLanguage(tree []git.Entry) (name, class string) {
264 counts := map[string]int{}
265 sample := map[string]string{}
266 for _, e := range tree {
267 if e.Type != "blob" {
268 continue
269 }
270 lang := languageFor(e.Name)
271 counts[lang]++
272 if _, ok := sample[lang]; !ok {
273 sample[lang] = e.Name
274 }
275 }
276 best := ""
277 for lang, n := range counts {
278 if lang == "Other" {
279 continue
280 }
281 if best == "" || n > counts[best] || (n == counts[best] && lang < best) {
282 best = lang
283 }
284 }
285 if best == "" {
286 return "", ""
287 }
288 return best, fileClass(sample[best])
289 }
290
291 // sortCards puts the repository that changed most recently first. A repository
292 // with no history sorts last, by name, so a fresh instance still has a stable
293 // order.
294 func sortCards(cards []repoCard) {
295 sort.SliceStable(cards, func(i, j int) bool {
296 a, b := cards[i].Updated, cards[j].Updated
297 if !a.Equal(b) {
298 return a.After(b)
299 }
300 return cards[i].Name < cards[j].Name
301 })
302 }
303
304 // homeVitals counts the connected runners the viewer is allowed to see. A
305 // session building a private repository is not counted, matching the runners
306 // page: naming it would confirm that the repository exists.
307 func (w *Web) homeVitals(r *http.Request, entries []index.Entry, events []activityEvent) homeVitals {
308 v := homeVitals{Repos: len(entries), ShowRunners: w.Runs != nil}
309 if len(events) > 0 {
310 v.LastActivity = events[0].Time
311 }
312 if w.Runs == nil {
313 return v
314 }
315 sessions, err := w.Runs.Sessions(time.Now().UTC().Add(-presenceWindow))
316 if err != nil {
317 return v
318 }
319 for _, s := range sessions {
320 if s.Repo == "" {
321 v.Runners++
322 continue
323 }
324 m, err := w.Repos.Open(s.Repo)
325 if err == nil && w.canRead(r, m) {
326 v.Runners++
327 }
328 }
329 return v
330 }
331
332 // instanceInfo names the forge. The address is already in the browser bar, so
333 // the page does not repeat it.
334 func (w *Web) instanceInfo(_ *http.Request) instanceInfo {
335 info := instanceInfo{
336 Name: "Vole",
337 Tagline: "Code, issues, changes, and CI, hosted here.",
338 }
339 if w.Cfg != nil {
340 if v := strings.TrimSpace(w.Cfg.InstanceName); v != "" {
341 info.Name = v
342 }
343 if v := strings.TrimSpace(w.Cfg.InstanceTagline); v != "" {
344 info.Tagline = v
345 }
346 }
347 return info
348 }