v0.32.0 53e25a3

summary.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"html"
10	"html/template"
11	"io"
12	"net/http"
13	"strings"
14	"time"
15
16	"github.com/a-h/templ"
17	"github.com/bnema/zerowrap"
18
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23// maxReadmeBytes is the largest README that is rendered in the page; a
24// larger one is replaced by a note.
25const maxReadmeBytes = 512 << 10
26
27// recentCommits is how many commits the rail of the summary lists.
28const recentCommits = 5
29
30// recentRuns is how many runs the rail of the summary lists.
31const recentRuns = 5
32
33// readmeNames are the README file names in order of preference (compared
34// lowercase): Markdown first.
35var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
36
37// summary is the page of a repository: description, README, and in the rail
38// the clone URL, the facts, the recent commits and the README outline.
39func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
40	d, err := s.loadRepo(ctx, r.PathValue("repo"))
41	if err != nil {
42		return nil, 0, err
43	}
44	if d.head == nil {
45		l, st := s.emptyState(ctx, d)
46		return State(l, st), http.StatusOK, nil
47	}
48	ref := d.head.ShortName
49	now := s.cfg.Now()
50
51	p := SummaryPage{
52		Mirror:  s.mirrorBadge(ctx, d.repo.Name),
53		AllHref: repoHref(d.name(), "commits", ref),
54		Clone:   Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
55	}
56
57	commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "")
58	if err != nil {
59		return nil, 0, err
60	}
61	branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62	for i, c := range commits.Commits {
63		id := c.ID.String()
64		row := commitRow(d.name(), c, now)
65		row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
66		p.Recent = append(p.Recent, row)
67	}
68
69	p.Facts = s.facts(d, now)
70	p.Latest = s.latest(ctx, d)
71	p.Actions = s.recentActions(ctx, d.repo.Name, now)
72
73	if rd, err := s.readme(ctx, d, ref); err != nil {
74		return nil, 0, err
75	} else if rd != nil {
76		p.Readme, p.Outline = rd.readme, rd.outline
77	}
78
79	l := s.repoLayout(ctx, d, "summary")
80	l.Title = d.name() + " · " + s.instance
81	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
82	l.HasRail = true
83	l.Lede = d.repo.Description
84	return Summary(l, p), http.StatusOK, nil
85}
86
87// maxTagDates is the most tags whose commit dates are read to name the
88// latest one; with more, the rail shows the count only.
89const maxTagDates = 20
90
91// latest is the newest tag of the repository for the rail, nil without tags.
92func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
93	n := len(d.tags)
94	if n == 0 {
95		return nil
96	}
97	lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
98	if n <= maxTagDates {
99		if name, c := latestTag(d.tags, s.commitsOf(ctx, d.repo.Name, d.tags)); name != "" {
100			lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
101			lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
102		}
103	}
104	return lt
105}
106
107// facts are the key/value rows of the rail.
108func (s *Server) facts(d *repoData, now time.Time) []Fact {
109	fs := []Fact{
110		lastPushFact(d.repo, now),
111		{Key: "branch", Val: d.head.ShortName + " · " + plural(len(d.branches), "branch", "branches")},
112	}
113	if c := d.repo.CreatedAt; !c.IsZero() {
114		fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)})
115	}
116	return fs
117}
118
119// lastPushFact is the "last push" fact of a repository; the live updates
120// render it again.
121func lastPushFact(r ports.RepoView, now time.Time) Fact {
122	last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()}
123	if t := r.PushedAt; !t.IsZero() {
124		last.Val = ago(now, t) + " · " + absolute(t)
125		last.Time = rfc3339(t)
126	}
127	return last
128}
129
130// latestTag is the tag whose commit is the newest.
131func latestTag(tags []domain.Ref, commits map[string]refCommit) (string, refCommit) {
132	var name string
133	var best refCommit
134	for _, t := range tags {
135		c, ok := commits[t.CommitID.String()]
136		if ok && c.short != "" && (name == "" || c.at > best.at) {
137			name, best = t.ShortName, c
138		}
139	}
140	return name, best
141}
142
143// renderedReadme is the README of a repository, ready for the page.
144type renderedReadme struct {
145	readme  *Readme
146	outline []Heading
147}
148
149// readme finds the README at the root of the tree of the head commit, reads
150// it and renders it. It is nil when the repository has none. Rendering
151// problems degrade to plain text; only use case failures are errors.
152func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
153	rev := d.head.CommitID.String()
154	tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
155	if err != nil {
156		return nil, err
157	}
158	return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)
159}
160
161// readmeIn picks the README among the entries of the directory dir ("" for
162// the root) of the commit rev, reads it and renders it. Links in it are
163// relative to dir, on the ref ref. It is nil when the directory has none.
164//
165//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
166func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
167	var best *domain.TreeEntry
168	rank := len(readmeNames)
169	for i := range entries {
170		e := &entries[i]
171		if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
172			continue
173		}
174		for i, n := range readmeNames {
175			if i < rank && strings.EqualFold(e.Name, n) {
176				best, rank = e, i
177			}
178		}
179	}
180	if best == nil {
181		return nil, nil
182	}
183	rd := &Readme{Name: best.Name}
184	if best.Size > maxReadmeBytes {
185		rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
186		return &renderedReadme{readme: rd}, nil
187	}
188	body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes)
189	if err != nil {
190		return nil, err
191	}
192	if binary {
193		rd.Note = best.Name + " is not text."
194		return &renderedReadme{readme: rd}, nil
195	}
196	name := strings.ToLower(best.Name)
197	if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
198		h, outline, err := s.md.render(body, repo.String(), ref, dir)
199		if err == nil {
200			rd.HTML = h
201			return &renderedReadme{readme: rd, outline: outline}, nil
202		}
203		log := zerowrap.FromCtx(ctx)
204		log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text")
205	}
206	rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
207	return &renderedReadme{readme: rd}, nil
208}
209
210// readBlob reads a file, at most limit bytes. It reports a binary file
211// without its content.
212func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) {
213	info, rc, err := s.openBlobAt(ctx, repo, rev, path)
214	if err != nil {
215		return nil, false, err
216	}
217	defer func() { _ = rc.Close() }()
218	if info.Binary {
219		return nil, true, nil
220	}
221	body, err = io.ReadAll(io.LimitReader(rc, limit+1))
222	if err != nil {
223		return nil, false, err
224	}
225	if int64(len(body)) > limit {
226		return body[:limit], false, nil
227	}
228	return body, false, nil
229}
230
231// humanSize formats a size in bytes.
232func humanSize(n int64) string {
233	switch {
234	case n < 1024:
235		return itoa(int(n)) + " B"
236	case n < 1<<20:
237		return fmt.Sprintf("%.1f KiB", float64(n)/1024)
238	case n < 1<<30:
239		return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
240	}
241	return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30))
242}
243
244// recentActions is the newest runs for the rail, as the caller of the request
245// sees them (the permission of the actions page). It is nil when the server
246// runs no CI or the caller cannot read it; a failure of the read is logged and
247// the page goes on without, since the code does not depend on CI.
248func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
249	if s.cfg.Runs == nil {
250		return nil
251	}
252	a, err := s.listRecentActions(ctx, repo, now)
253	if err != nil {
254		if !errors.Is(err, domain.ErrNotFound) {
255			log := zerowrap.FromCtx(ctx)
256			log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
257		}
258		return nil
259	}
260	return &a
261}
262
263// listRecentActions reads the newest runs of a repository.
264func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
265	page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
266	if err != nil {
267		return RecentActions{}, err
268	}
269	a := RecentActions{AllHref: actionsBase(repo.String())}
270	for _, run := range page.Runs {
271		a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
272	}
273	return a, nil
274}

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