v0.3.0 9fdf386

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		Desc:    d.repo.GetDescription(),
48		AllHref: repoHref(d.name(), "commits", ref),
49		Clone:   Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
50	}
51
52	commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
53		Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
54	}))
55	if err != nil {
56		return nil, 0, err
57	}
58	branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
59	for i, c := range commits.Msg.GetCommits() {
60		row := commitRow(d.name(), c, now)
61		row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
62		p.Recent = append(p.Recent, row)
63	}
64
65	p.Facts = s.facts(ctx, d, now)
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.Status = Status{
77		Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
78		Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
79	}
80	l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline"
81	return Summary(l, p), http.StatusOK, nil
82}
83
84// maxTagDates is the most tags whose commit dates are read to name the
85// latest one; with more, the fact shows the count only.
86const maxTagDates = 20
87
88// facts are the key/value rows of the rail.
89func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
90	fs := []Fact{
91		lastPushFact(d.repo, now),
92		{Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
93	}
94	if n := len(d.tags); n > 0 {
95		val := plural(n, "tag", "tags")
96		if n <= maxTagDates {
97			if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
98				val = name + " · " + c.when + " · " + val
99			}
100		}
101		fs = append(fs, Fact{Key: "tag", Val: val})
102	}
103	if c := d.repo.GetCreatedAt(); c.IsValid() {
104		fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
105	}
106	return fs
107}
108
109// lastPushFact is the "last push" fact of a repository; the live updates
110// render it again.
111func lastPushFact(r *pb.Repo, now time.Time) Fact {
112	last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
113	if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
114		last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
115		last.Time = rfc3339(t.AsTime())
116	}
117	return last
118}
119
120// latestTag is the tag whose commit is the newest.
121func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
122	var name string
123	var best refCommit
124	for _, t := range tags {
125		c, ok := commits[t.GetCommitId()]
126		if ok && c.short != "" && (name == "" || c.at > best.at) {
127			name, best = t.GetShortName(), c
128		}
129	}
130	return name, best
131}
132
133// renderedReadme is the README of a repository, ready for the page.
134type renderedReadme struct {
135	readme  *Readme
136	outline []Heading
137}
138
139// readme finds the README at the root of the tree of the head commit, reads
140// it and renders it. It is nil when the repository has none. Rendering
141// problems degrade to plain text; only API failures are errors.
142func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
143	tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
144	if err != nil {
145		return nil, err
146	}
147	return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries())
148}
149
150// readmeIn picks the README among the entries of the directory dir ("" for
151// the root) of the commit rev, reads it and renders it. Links in it are
152// relative to dir, on the ref ref. It is nil when the directory has none.
153func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) {
154	var best *pb.TreeEntry
155	rank := len(readmeNames)
156	for _, e := range entries {
157		if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
158			continue
159		}
160		for i, n := range readmeNames {
161			if i < rank && strings.EqualFold(e.GetName(), n) {
162				best, rank = e, i
163			}
164		}
165	}
166	if best == nil {
167		return nil, nil
168	}
169	rd := &Readme{Name: best.GetName()}
170	if best.GetSize() > maxReadmeBytes {
171		rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
172		return &renderedReadme{readme: rd}, nil
173	}
174	body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
175	if err != nil {
176		return nil, err
177	}
178	if binary {
179		rd.Note = best.GetName() + " is not text."
180		return &renderedReadme{readme: rd}, nil
181	}
182	name := strings.ToLower(best.GetName())
183	if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
184		h, outline, err := s.md.render(body, repo, ref, dir)
185		if err == nil {
186			rd.HTML = h
187			return &renderedReadme{readme: rd, outline: outline}, nil
188		}
189		log := zerowrap.FromCtx(ctx)
190		log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text")
191	}
192	rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
193	return &renderedReadme{readme: rd}, nil
194}
195
196// readBlob reads a file through the GetBlob stream, at most limit bytes. It
197// reports a binary file without its content.
198func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
199	stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
200	if err != nil {
201		return nil, false, err
202	}
203	defer func() { _ = stream.Close() }()
204	for stream.Receive() {
205		m := stream.Msg()
206		if m.GetInfo().GetBinary() {
207			return nil, true, nil
208		}
209		body = append(body, m.GetChunk()...)
210		if int64(len(body)) > limit {
211			return body[:limit], false, nil
212		}
213	}
214	if err := stream.Err(); err != nil {
215		return nil, false, err
216	}
217	return body, false, nil
218}
219
220// humanSize formats a size in bytes.
221func humanSize(n int64) string {
222	switch {
223	case n < 1024:
224		return itoa(int(n)) + " B"
225	case n < 1<<20:
226		return fmt.Sprintf("%.1f KiB", float64(n)/1024)
227	}
228	return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
229}

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