v0.33.0 cf26365

home.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"slices"
10	"sort"
11	"strings"
12	"sync"
13	"time"
14
15	"github.com/a-h/templ"
16	"github.com/bnema/zerowrap"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// maxRepos bounds the repositories one page lists.
23const maxRepos = 1000
24
25// pageSize is the page size asked from the commit lists: the largest the use
26// case accepts for them.
27const pageSize = 100
28
29// listRepos reads the repositories the visitor may see, ordered by name.
30// Every page of ListRepos scans the whole repository list and reads the HEAD
31// of each repository of the page, so the list is asked in one page of up to
32// maxRepos: one scan in the common case, whatever the cap of the use case is
33// (a lower one only means more pages, followed by their token).
34func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
35	var repos []ports.RepoView
36	token := ""
37	for len(repos) < maxRepos {
38		page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token)
39		if err != nil {
40			return nil, err
41		}
42		for _, repo := range page.Repos {
43			view, err := s.cfg.Repos.View(ctx, repo)
44			if err != nil {
45				return nil, err
46			}
47			repos = append(repos, view)
48		}
49		if token = page.NextPageToken; token == "" {
50			break
51		}
52	}
53	return repos, nil
54}
55
56// homeSorts are the orders the home list offers, in order. The query also
57// accepts "desc" and "branch", which the page does not link to.
58var homeSorts = []struct{ key, label string }{
59	{"activity", "last activity"},
60	{"name", "name"},
61}
62
63// homeKeys are the keys of ?sort= that sortRepos knows.
64var homeKeys = []string{"activity", "name", "desc", "branch"}
65
66// sortRepos orders repos by the column key; ties keep the order by name.
67func sortRepos(repos []ports.RepoView, key string, desc bool) {
68	less := func(a, b ports.RepoView) bool {
69		switch key {
70		case "desc":
71			return strings.ToLower(a.Description) < strings.ToLower(b.Description)
72		case "branch":
73			return a.DefaultBranch < b.DefaultBranch
74		case "activity":
75			return repoActiveAt(a).Before(repoActiveAt(b))
76		}
77		return a.Name.String() < b.Name.String()
78	}
79	sort.SliceStable(repos, func(i, j int) bool {
80		if desc {
81			return less(repos[j], repos[i])
82		}
83		return less(repos[i], repos[j])
84	})
85}
86
87// home lists the repositories: /?sort=<col>&dir=asc|desc, newest activity first
88// by default.
89func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
90	repos, err := s.listRepos(ctx)
91	if err != nil {
92		return nil, 0, err
93	}
94	key, desc := "activity", true
95	for _, k := range homeKeys {
96		if r.URL.Query().Get("sort") == k {
97			key, desc = k, k == "activity"
98		}
99	}
100	if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
101		desc = d == "desc"
102	}
103	sortRepos(repos, key, desc)
104
105	now := s.cfg.Now()
106	p := HomePage{Sort: key, Desc: desc}
107	for _, c := range homeSorts {
108		sl := SortLink{Label: c.label}
109		next := "asc"
110		if c.key == "activity" {
111			next = "desc"
112		}
113		if c.key == key {
114			sl.Mark, sl.State = "▲", "ascending, select for descending"
115			next = "desc"
116			if desc {
117				sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc"
118			}
119		}
120		sl.Href = "/?sort=" + c.key + "&dir=" + next
121		p.Sorts = append(p.Sorts, sl)
122	}
123	p.Rows = s.homeRows(ctx, repos, now)
124	l := s.base()
125	if n, show := s.inboxCount(ctx); show {
126		l.Inbox = &InboxButton{Href: inboxPath, Count: n}
127	}
128	l.Title = s.instance
129	l.Live, l.LiveURL = true, liveURL("home", "")
130	l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
131	return Home(l, p), http.StatusOK, nil
132}
133
134// homeRecent is how many commits a row of the home list shows.
135const homeRecent = 5
136
137// homeWorkers bounds the repositories whose commits and run are read at once.
138const homeWorkers = 8
139
140// homeRows builds the rows of repos, in order: each repository costs a read
141// of its commits and of its latest run, so they are read a few at a time. A
142// repository whose reads fail still has its row.
143func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
144	rows := make([]HomeRow, len(repos))
145	var (
146		sem = make(chan struct{}, homeWorkers)
147		wg  sync.WaitGroup
148	)
149	for i, repo := range repos {
150		sem <- struct{}{}
151		wg.Add(1)
152		go func() {
153			defer func() { <-sem; wg.Done() }()
154			rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
155		}()
156	}
157	wg.Wait()
158	return rows
159}
160
161// homeRowOf is the row of a repository with what the visitor may read of it:
162// the newest commits of its default branch and the state of its latest run.
163func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
164	row := homeRow(r, now, cursor, SignedIn(ctx))
165	if r.PushedAt.IsZero() || r.DefaultBranch == "" {
166		row.NoCommits = r.PushedAt.IsZero()
167		return row
168	}
169	var head string
170	row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
171	if head != "" {
172		row.CI = s.homeCI(ctx, r, head)
173		row.Stats = s.homeStats(ctx, r, head, now)
174	}
175	return row
176}
177
178// homeCommits reads the newest commits of the default branch. It reports an
179// empty branch; a failed read is logged and gives neither commits nor that
180// note, since the row does not depend on them.
181func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
182	// By its full name, so that a tag of the same name is not read.
183	page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
184	switch {
185	case errors.Is(err, domain.ErrNotFound):
186		return nil, "", true
187	case err != nil:
188		log := zerowrap.FromCtx(ctx)
189		log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
190		return nil, "", false
191	}
192	for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
193		rows = append(rows, commitRow(r.Name.String(), c, now))
194	}
195	if len(page.Commits) > 0 {
196		head = page.Commits[0].ID.String()
197	}
198	return rows, head, len(rows) == 0
199}
200
201// homeStats are the figures of a row; nil when the read fails (logged), since
202// the row does not depend on them.
203func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
204	id, err := domain.ParseObjectID(head)
205	if err != nil {
206		return nil
207	}
208	st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
209	if err != nil {
210		if !errors.Is(err, domain.ErrNotFound) {
211			log := zerowrap.FromCtx(ctx)
212			log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
213		}
214		return nil
215	}
216	hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
217	if st.Partial {
218		hs.Recent += "+" // a lower bound: the walk stopped at its cap
219	}
220	if t := st.LatestTag; t != nil {
221		hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
222		if !t.CommitID.IsZero() {
223			// By its commit: a branch of the same name would win over the
224			// tag in a tree path.
225			hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
226		}
227	}
228	return hs
229}
230
231// homeCI is the state of the CI on the head of the default branch, as the
232// caller of the request sees it: the combined checks of that commit, read by
233// its id so the cost does not grow with the CI history. nil when the server
234// runs no CI, the caller cannot read it, the commit has no check, or the read
235// fails (checksFor logs it).
236func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
237	checks := s.checksFor(ctx, r.Name, head)
238	if len(checks) == 0 {
239		return nil
240	}
241	sum := checksSummary(r.Name, head, checks)
242	ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
243	if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
244		ci.Href = runHref(r.Name.String(), run) // one run: straight to it
245	}
246	return ci
247}
248
249// ciLabel is the word next to the mark of the CI state on the home page.
250func ciLabel(kind string) string {
251	switch kind {
252	case "ok":
253		return "passing"
254	case "bad":
255		return "failing"
256	}
257	return "running"
258}
259
260// homeRow is the row of a repository in the home list, without its commits
261// and run. The private badge is for a signed-in visitor: an anonymous one
262// never gets a private repository.
263func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
264	name := r.Name.String()
265	row := HomeRow{
266		ID: "repo-" + name, Name: name, Href: repoHref(name),
267		Desc: r.Description, Topics: r.Topics, Cursor: cursor,
268		Private: signed && r.Visibility == domain.VisibilityPrivate,
269	}
270	if at := repoActiveAt(r); !at.IsZero() {
271		row.Active, row.ActiveAt = ago(now, at), rfc3339(at)
272	}
273	return row
274}
275
276// repoCmds are the repositories as targets of the ":" command line, but the
277// one being viewed.
278func repoCmds(repos []ports.RepoView, except string) []CmdItem {
279	var out []CmdItem
280	for _, r := range repos {
281		if name := r.Name.String(); name != except {
282			out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
283		}
284	}
285	return out
286}
287
288// repoActiveAt falls back for repositories without recorded activity.
289func repoActiveAt(r ports.RepoView) time.Time {
290	if !r.ActiveAt.IsZero() {
291		return r.ActiveAt
292	}
293	if r.PushedAt.After(r.CreatedAt) {
294		return r.PushedAt
295	}
296	return r.CreatedAt
297}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close