v0.3.0 9fdf386

repo.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"slices"
8	"sort"
9	"sync"
10	"time"
11
12	"connectrpc.com/connect"
13	"google.golang.org/protobuf/types/known/timestamppb"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
16)
17
18// maxRefs bounds the refs one page reads. A repository with more has its
19// lists cut, and the pages say so.
20const maxRefs = 1000
21
22// repoData is what the pages of one repository start from: its metadata and
23// its refs, read once per request.
24type repoData struct {
25	repo     *pb.Repo
26	branches []*pb.Ref
27	tags     []*pb.Ref
28	// cut is true when the repository has more refs than maxRefs.
29	cut bool
30	// head is the branch the repository opens on: its default branch, or the
31	// first one when the default does not exist. nil for an empty repository.
32	head *pb.Ref
33}
34
35// loadRepo reads a repository and its refs. A missing, private or invalidly
36// named repository is a not_found / invalid_argument error of the API, which
37// the caller turns into the 404 page.
38func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) {
39	res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
40	if err != nil {
41		return nil, err
42	}
43	d := &repoData{repo: res.Msg.GetRepo()}
44	token, n := "", 0
45	for {
46		page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{
47			Repo: name, PageSize: pageSize, PageToken: token,
48			Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG},
49		}))
50		if err != nil {
51			return nil, err
52		}
53		for _, r := range page.Msg.GetRefs() {
54			switch r.GetKind() {
55			case pb.RefKind_REF_KIND_BRANCH:
56				d.branches = append(d.branches, r)
57			case pb.RefKind_REF_KIND_TAG:
58				d.tags = append(d.tags, r)
59			}
60			n++
61		}
62		if token = page.Msg.GetNextPageToken(); token == "" {
63			break
64		}
65		if n >= maxRefs {
66			d.cut = true
67			break
68		}
69	}
70	for _, b := range d.branches {
71		if b.GetShortName() == d.repo.GetDefaultBranch() {
72			d.head = b
73		}
74	}
75	if d.head == nil && len(d.branches) > 0 {
76		d.head = d.branches[0]
77	}
78	return d, nil
79}
80
81// allRefs is the branches then the tags.
82func (d *repoData) allRefs() []*pb.Ref {
83	return append(append([]*pb.Ref(nil), d.branches...), d.tags...)
84}
85
86// name is the repository name.
87func (d *repoData) name() string { return d.repo.GetName() }
88
89// layout is the Layout of the pages of a repository: the sections, the ref
90// switcher on the head ref and the ":" targets. The caller sets the title,
91// the crumbs, the status and the rail.
92func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
93	l := s.base()
94	l.Repo = d.name()
95	def := d.repo.GetDefaultBranch()
96	if d.head != nil {
97		def = d.head.GetShortName()
98		l.Ref, l.RefPath = def, def
99		l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
100	}
101	mk := func(key, post, href, id string) Section {
102		return Section{Key: key, Post: post, Href: href, Current: section == id}
103	}
104	l.Sections = []Section{
105		mk("s", "ummary", repoHref(d.name()), "summary"),
106		mk("t", "ree", repoHref(d.name(), "tree", def), "tree"),
107		mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),
108		mk("r", "efs", repoHref(d.name(), "refs"), "refs"),
109	}
110	if s.cfg.MergeRequests != nil {
111		l.Sections = append(l.Sections, mk("m", "erge requests", mrBase(d.name()), "merge-requests"))
112	}
113	l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
114
115	l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
116	for _, sec := range l.Sections {
117		l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href})
118	}
119	if l.Ref != "" {
120		for _, b := range d.branches {
121			it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head}
122			l.Heads = append(l.Heads, it)
123			l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
124		}
125		for _, t := range d.tags {
126			it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())}
127			l.Tags = append(l.Tags, it)
128			l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
129		}
130	}
131	// Other repositories, when the list is reachable; the page does not
132	// depend on it.
133	if repos, err := s.listRepos(ctx); err == nil {
134		l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
135	}
136	return l
137}
138
139// cloneURL is the HTTPS clone URL of a repository.
140func (s *Server) cloneURL(name string) string {
141	return s.cfg.PublicURL + "/" + name + ".git"
142}
143
144// refCommit is what a ref shows: the commit it designates.
145type refCommit struct {
146	short, subject, when, whenAt string
147	at                           int64 // unix time, to sort tags
148}
149
150// commitsOf reads the head commit of each ref, a few at a time. A ref whose
151// commit cannot be read (a tag on a tree, say) has no entry.
152func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit {
153	const workers = 8
154	var (
155		mu  sync.Mutex
156		out = make(map[string]refCommit, len(refs))
157		sem = make(chan struct{}, workers)
158		wg  sync.WaitGroup
159	)
160	now := s.cfg.Now()
161	for _, r := range refs {
162		id := r.GetCommitId()
163		if id == "" {
164			continue
165		}
166		mu.Lock()
167		_, done := out[id]
168		if !done {
169			out[id] = refCommit{} // reserved: one lookup per commit
170		}
171		mu.Unlock()
172		if done {
173			continue
174		}
175		sem <- struct{}{}
176		wg.Add(1)
177		go func() {
178			defer func() { <-sem; wg.Done() }()
179			// By id, not by name: a branch and a tag may share a name.
180			res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1}))
181			var rc refCommit
182			if err == nil && len(res.Msg.GetCommits()) == 1 {
183				row := commitRow(repo, res.Msg.GetCommits()[0], now)
184				rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
185				rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds()
186			}
187			mu.Lock()
188			out[id] = rc
189			mu.Unlock()
190		}()
191	}
192	wg.Wait()
193	return out
194}
195
196// whenOf is the relative and the machine form of a time; both are "" when
197// the time is unknown.
198func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) {
199	if !ts.IsValid() {
200		return "", ""
201	}
202	return ago(now, ts.AsTime()), rfc3339(ts.AsTime())
203}
204
205// commitRow is the row every list of commits starts from: the short id, the
206// subject, the author and the date of the committer, all fit to display. The
207// caller adds what is specific to its list.
208func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow {
209	row := CommitRow{
210		Short: short(c.GetId()), Subject: visibleText(c.GetSummary()),
211		CHref:  repoHref(repo, "commit", c.GetId()),
212		Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1,
213	}
214	row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime())
215	return row
216}
217
218// namesAt maps commit ids to the names of the refs that designate them.
219func namesAt(refs []*pb.Ref) map[string][]string {
220	m := map[string][]string{}
221	for _, r := range refs {
222		m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName())
223	}
224	for _, v := range m {
225		sort.Strings(v)
226	}
227	return m
228}
229
230// emptyState is the page of a repository without a branch.
231func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
232	l := s.repoLayout(ctx, d, "summary")
233	l.Title = d.name() + " · " + s.instance
234	l.Sections = nil
235	l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
236	l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"}
237	branch := d.repo.GetDefaultBranch()
238	return l, StatePage{
239		Code: "empty", Title: d.name() + " is empty",
240		Lines: []string{"no commits yet. push a branch to create one."},
241		Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
242			"git remote add origin " + s.cloneURL(d.name()),
243			"git push -u origin " + branch,
244		}},
245		Back: []Link{{Label: "home", Href: "/"}},
246	}
247}

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