detached aa7340c

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

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 46

↑↓ moveenter openesc close