v0.27.0 0039612

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	{"push", "last push"},
60	{"name", "name"},
61}
62
63// homeKeys are the keys of ?sort= that sortRepos knows.
64var homeKeys = []string{"push", "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 "push":
75			return a.PushedAt.Before(b.PushedAt)
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 push 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 := "push", true
95	for _, k := range homeKeys {
96		if r.URL.Query().Get("sort") == k {
97			key, desc = k, k == "push"
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{}
107	for _, c := range homeSorts {
108		sl := SortLink{Label: c.label}
109		next := "asc"
110		if c.key == "push" {
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
125	l := s.base()
126	l.Title = s.instance
127	l.Live, l.LiveURL = true, liveURL("home", "")
128	l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
129	return Home(l, p), http.StatusOK, nil
130}
131
132// homeRecent is how many commits a row of the home list shows.
133const homeRecent = 5
134
135// homeWorkers bounds the repositories whose commits and run are read at once.
136const homeWorkers = 8
137
138// homeRows builds the rows of repos, in order: each repository costs a read
139// of its commits and of its latest run, so they are read a few at a time. A
140// repository whose reads fail still has its row.
141func (s *Server) homeRows(ctx context.Context, repos []ports.RepoView, now time.Time) []HomeRow {
142	rows := make([]HomeRow, len(repos))
143	var (
144		sem = make(chan struct{}, homeWorkers)
145		wg  sync.WaitGroup
146	)
147	for i, repo := range repos {
148		sem <- struct{}{}
149		wg.Add(1)
150		go func() {
151			defer func() { <-sem; wg.Done() }()
152			rows[i] = s.homeRowOf(ctx, repo, now, i == 0)
153		}()
154	}
155	wg.Wait()
156	return rows
157}
158
159// homeRowOf is the row of a repository with what the visitor may read of it:
160// the newest commits of its default branch and the state of its latest run.
161func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time, cursor bool) HomeRow {
162	row := homeRow(r, now, cursor, SignedIn(ctx))
163	if r.PushedAt.IsZero() || r.DefaultBranch == "" {
164		row.NoCommits = r.PushedAt.IsZero()
165		return row
166	}
167	var head string
168	row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169	if head != "" {
170		row.CI = s.homeCI(ctx, r, head)
171		row.Stats = s.homeStats(ctx, r, head, now)
172	}
173	return row
174}
175
176// homeCommits reads the newest commits of the default branch. It reports an
177// empty branch; a failed read is logged and gives neither commits nor that
178// note, since the row does not depend on them.
179func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Time) (rows []CommitRow, head string, none bool) {
180	// By its full name, so that a tag of the same name is not read.
181	page, err := s.commitList(ctx, r.Name, "refs/heads/"+r.DefaultBranch, "", homeRecent, "")
182	switch {
183	case errors.Is(err, domain.ErrNotFound):
184		return nil, "", true
185	case err != nil:
186		log := zerowrap.FromCtx(ctx)
187		log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the recent commits of the home page")
188		return nil, "", false
189	}
190	for _, c := range page.Commits[:min(len(page.Commits), homeRecent)] {
191		rows = append(rows, commitRow(r.Name.String(), c, now))
192	}
193	if len(page.Commits) > 0 {
194		head = page.Commits[0].ID.String()
195	}
196	return rows, head, len(rows) == 0
197}
198
199// homeStats are the figures of a row; nil when the read fails (logged), since
200// the row does not depend on them.
201func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
202	id, err := domain.ParseObjectID(head)
203	if err != nil {
204		return nil
205	}
206	st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
207	if err != nil {
208		if !errors.Is(err, domain.ErrNotFound) {
209			log := zerowrap.FromCtx(ctx)
210			log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
211		}
212		return nil
213	}
214	hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
215	if st.Partial {
216		hs.Recent += "+" // a lower bound: the walk stopped at its cap
217	}
218	if t := st.LatestTag; t != nil {
219		hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
220		if !t.CommitID.IsZero() {
221			// By its commit: a branch of the same name would win over the
222			// tag in a tree path.
223			hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
224		}
225	}
226	return hs
227}
228
229// homeCI is the state of the CI on the head of the default branch, as the
230// caller of the request sees it: the combined checks of that commit, read by
231// its id so the cost does not grow with the CI history. nil when the server
232// runs no CI, the caller cannot read it, the commit has no check, or the read
233// fails (checksFor logs it).
234func (s *Server) homeCI(ctx context.Context, r ports.RepoView, head string) *HomeCI {
235	checks := s.checksFor(ctx, r.Name, head)
236	if len(checks) == 0 {
237		return nil
238	}
239	sum := checksSummary(r.Name, head, checks)
240	ci := &HomeCI{Kind: sum.Kind, Label: ciLabel(sum.Kind), Title: sum.Title, Href: repoHref(r.Name.String(), "commit", head)}
241	if run := checks[0].RunID; !slices.ContainsFunc(checks, func(c ports.CheckView) bool { return c.RunID != run }) {
242		ci.Href = runHref(r.Name.String(), run) // one run: straight to it
243	}
244	return ci
245}
246
247// ciLabel is the word next to the mark of the CI state on the home page.
248func ciLabel(kind string) string {
249	switch kind {
250	case "ok":
251		return "passing"
252	case "bad":
253		return "failing"
254	}
255	return "running"
256}
257
258// homeRow is the row of a repository in the home list, without its commits
259// and run. The private badge is for a signed-in visitor: an anonymous one
260// never gets a private repository.
261func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
262	name := r.Name.String()
263	row := HomeRow{
264		ID: "repo-" + name, Name: name, Href: repoHref(name),
265		Desc: r.Description, Topics: r.Topics, Cursor: cursor,
266		Private: signed && r.Visibility == domain.VisibilityPrivate,
267	}
268	if !r.PushedAt.IsZero() {
269		row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
270	}
271	return row
272}
273
274// repoCmds are the repositories as targets of the ":" command line, but the
275// one being viewed.
276func repoCmds(repos []ports.RepoView, except string) []CmdItem {
277	var out []CmdItem
278	for _, r := range repos {
279		if name := r.Name.String(); name != except {
280			out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
281		}
282	}
283	return out
284}

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