v0.6.0 d5cc8fe

summary.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"fmt"
8	"html"
9	"html/template"
10	"net/http"
11	"strings"
12	"time"
13
14	"connectrpc.com/connect"
15	"github.com/a-h/templ"
16	"github.com/bnema/zerowrap"
17
18	pb "bnema.dev/ermite-api/gen/ermite"
19)
20
21// maxReadmeBytes is the largest README that is rendered in the page; a
22// larger one is replaced by a note.
23const maxReadmeBytes = 512 << 10
24
25// recentCommits is how many commits the rail of the summary lists.
26const recentCommits = 5
27
28// readmeNames are the README file names in order of preference (compared
29// lowercase): Markdown first.
30var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
31
32// summary is the page of a repository: description, README, and in the rail
33// the clone URL, the facts, the recent commits and the README outline.
34func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35	d, err := s.loadRepo(ctx, r.PathValue("repo"))
36	if err != nil {
37		return nil, 0, err
38	}
39	if d.head == nil {
40		l, st := s.emptyState(ctx, d)
41		return State(l, st), http.StatusOK, nil
42	}
43	ref := d.head.GetShortName()
44	now := s.cfg.Now()
45
46	p := SummaryPage{
47		AllHref: repoHref(d.name(), "commits", ref),
48		Clone:   Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
49	}
50
51	commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
52		Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
53	}))
54	if err != nil {
55		return nil, 0, err
56	}
57	branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
58	for i, c := range commits.Msg.GetCommits() {
59		row := commitRow(d.name(), c, now)
60		row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
61		p.Recent = append(p.Recent, row)
62	}
63
64	p.Facts = s.facts(d, now)
65	p.Latest = s.latest(ctx, d)
66
67	if rd, err := s.readme(ctx, d, ref); err != nil {
68		return nil, 0, err
69	} else if rd != nil {
70		p.Readme, p.Outline = rd.readme, rd.outline
71	}
72
73	l := s.repoLayout(ctx, d, "summary")
74	l.Title = d.name() + " · " + s.instance
75	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
76	l.HasRail = true
77	l.Lede = d.repo.GetDescription()
78	return Summary(l, p), http.StatusOK, nil
79}
80
81// maxTagDates is the most tags whose commit dates are read to name the
82// latest one; with more, the rail shows the count only.
83const maxTagDates = 20
84
85// latest is the newest tag of the repository for the rail, nil without tags.
86func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
87	n := len(d.tags)
88	if n == 0 {
89		return nil
90	}
91	lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
92	if n <= maxTagDates {
93		if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
94			lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
95			lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
96		}
97	}
98	return lt
99}
100
101// facts are the key/value rows of the rail.
102func (s *Server) facts(d *repoData, now time.Time) []Fact {
103	fs := []Fact{
104		lastPushFact(d.repo, now),
105		{Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
106	}
107	if c := d.repo.GetCreatedAt(); c.IsValid() {
108		fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
109	}
110	return fs
111}
112
113// lastPushFact is the "last push" fact of a repository; the live updates
114// render it again.
115func lastPushFact(r *pb.Repo, now time.Time) Fact {
116	last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
117	if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
118		last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
119		last.Time = rfc3339(t.AsTime())
120	}
121	return last
122}
123
124// latestTag is the tag whose commit is the newest.
125func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
126	var name string
127	var best refCommit
128	for _, t := range tags {
129		c, ok := commits[t.GetCommitId()]
130		if ok && c.short != "" && (name == "" || c.at > best.at) {
131			name, best = t.GetShortName(), c
132		}
133	}
134	return name, best
135}
136
137// renderedReadme is the README of a repository, ready for the page.
138type renderedReadme struct {
139	readme  *Readme
140	outline []Heading
141}
142
143// readme finds the README at the root of the tree of the head commit, reads
144// it and renders it. It is nil when the repository has none. Rendering
145// problems degrade to plain text; only API failures are errors.
146func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
147	tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
148	if err != nil {
149		return nil, err
150	}
151	return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries())
152}
153
154// readmeIn picks the README among the entries of the directory dir ("" for
155// the root) of the commit rev, reads it and renders it. Links in it are
156// relative to dir, on the ref ref. It is nil when the directory has none.
157func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) {
158	var best *pb.TreeEntry
159	rank := len(readmeNames)
160	for _, e := range entries {
161		if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
162			continue
163		}
164		for i, n := range readmeNames {
165			if i < rank && strings.EqualFold(e.GetName(), n) {
166				best, rank = e, i
167			}
168		}
169	}
170	if best == nil {
171		return nil, nil
172	}
173	rd := &Readme{Name: best.GetName()}
174	if best.GetSize() > maxReadmeBytes {
175		rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
176		return &renderedReadme{readme: rd}, nil
177	}
178	body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
179	if err != nil {
180		return nil, err
181	}
182	if binary {
183		rd.Note = best.GetName() + " is not text."
184		return &renderedReadme{readme: rd}, nil
185	}
186	name := strings.ToLower(best.GetName())
187	if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
188		h, outline, err := s.md.render(body, repo, ref, dir)
189		if err == nil {
190			rd.HTML = h
191			return &renderedReadme{readme: rd, outline: outline}, nil
192		}
193		log := zerowrap.FromCtx(ctx)
194		log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text")
195	}
196	rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197	return &renderedReadme{readme: rd}, nil
198}
199
200// readBlob reads a file through the GetBlob stream, at most limit bytes. It
201// reports a binary file without its content.
202func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
203	stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
204	if err != nil {
205		return nil, false, err
206	}
207	defer func() { _ = stream.Close() }()
208	for stream.Receive() {
209		m := stream.Msg()
210		if m.GetInfo().GetBinary() {
211			return nil, true, nil
212		}
213		body = append(body, m.GetChunk()...)
214		if int64(len(body)) > limit {
215			return body[:limit], false, nil
216		}
217	}
218	if err := stream.Err(); err != nil {
219		return nil, false, err
220	}
221	return body, false, nil
222}
223
224// humanSize formats a size in bytes.
225func humanSize(n int64) string {
226	switch {
227	case n < 1024:
228		return itoa(int(n)) + " B"
229	case n < 1<<20:
230		return fmt.Sprintf("%.1f KiB", float64(n)/1024)
231	}
232	return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
233}

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