detached 388d3be

repo.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"slices"
8	"sort"
9	"strings"
10	"sync"
11	"time"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17// maxRefs bounds the refs one page reads. A repository with more has its
18// lists cut, and the pages say so.
19const maxRefs = 1000
20
21// repoData is what the pages of one repository start from: its metadata and
22// its refs, read once per request.
23type repoData struct {
24	repo     ports.RepoView
25	branches []domain.Ref
26	tags     []domain.Ref
27	// cut is true when the repository has more refs than maxRefs.
28	cut bool
29	// head is the branch the repository opens on: its default branch, or the
30	// first one when the default does not exist. nil for an empty repository.
31	head *domain.Ref
32}
33
34// repoView reads a repository as the caller of the request may see it, with
35// the Git state of its summary. A missing or private repository is
36// domain.ErrNotFound.
37func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38	repo, err := s.repoRecord(ctx, name)
39	if err != nil {
40		return ports.RepoView{}, err
41	}
42	return s.cfg.Repos.View(ctx, repo)
43}
44
45// repoRecord reads the repository record only, without Git: its name,
46// visibility and settings (the defaults when it has none).
47//
48//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
49func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50	repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
51	if err != nil {
52		return domain.Repo{}, err
53	}
54	if repo.Settings.IsZero() {
55		repo.Settings = domain.DefaultRepoSettings()
56	}
57	return repo, nil
58}
59
60// loadRepo reads a repository and its refs. A missing, private or invalidly
61// named repository is a domain.ErrNotFound / invalid name error, which the
62// caller turns into the 404 page.
63func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) {
64	name, err := domain.ParseRepoName(rawName)
65	if err != nil {
66		return nil, err
67	}
68	view, err := s.repoView(ctx, name)
69	if err != nil {
70		return nil, err
71	}
72	d := &repoData{repo: view}
73	// Each page of ListRefs runs the whole for-each-ref again: the refs are
74	// asked in one page of up to maxRefs (the use case lowers it to its cap,
75	// and the token then leads to the rest).
76	token, n := "", 0
77	for {
78		page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{
79			Kinds:    []domain.RefKind{domain.RefKindBranch, domain.RefKindTag},
80			PageSize: maxRefs, PageToken: token,
81		})
82		if err != nil {
83			return nil, err
84		}
85		for _, r := range page.Refs {
86			switch r.Kind {
87			case domain.RefKindBranch:
88				d.branches = append(d.branches, r)
89			case domain.RefKindTag:
90				d.tags = append(d.tags, r)
91			}
92			n++
93		}
94		if token = page.NextPageToken; token == "" {
95			break
96		}
97		if n >= maxRefs {
98			d.cut = true
99			break
100		}
101	}
102	for i := range d.branches {
103		if d.branches[i].ShortName == d.repo.DefaultBranch {
104			d.head = &d.branches[i]
105		}
106	}
107	if d.head == nil && len(d.branches) > 0 {
108		d.head = &d.branches[0]
109	}
110	return d, nil
111}
112
113// allRefs is the branches then the tags.
114func (d *repoData) allRefs() []domain.Ref {
115	return append(append([]domain.Ref(nil), d.branches...), d.tags...)
116}
117
118// name is the repository name.
119func (d *repoData) name() string { return d.repo.Name.String() }
120
121// openMRCount is the number of open merge requests shown on the tab, empty
122// when there are none or they cannot be counted (the tab still works).
123func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
124	n, err := s.cfg.MergeRequests.CountOpen(ctx, principal(ctx), name)
125	if err != nil || n == 0 {
126		return ""
127	}
128	return itoa(n)
129}
130
131// layout is the Layout of the pages of a repository: the sections, the ref
132// switcher on the head ref and the ":" targets. The caller sets the title,
133// the crumbs, the status and the rail.
134//
135//hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here
136func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout {
137	l := s.base()
138	l.Repo = d.name()
139	def := d.repo.DefaultBranch
140	if d.head != nil {
141		def = d.head.ShortName
142		l.Ref, l.RefPath = def, def
143		l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String())
144		l.setFinder(l.SHA, def)
145	}
146	mk := func(key, label, href, id string) Section {
147		return Section{Key: key, Label: label, Href: href, Current: section == id}
148	}
149	l.Sections = []Section{
150		mk("s", "Summary", repoHref(d.name()), "summary"),
151		mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
152		mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
153		mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
154	}
155	if s.cfg.MergeRequests != nil {
156		mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")
157		mrs.Count = s.openMRCount(ctx, d.repo.Name)
158		l.Sections = append(l.Sections, mrs)
159	}
160	if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
161		l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162	}
163	l.Sections = s.withActions(l.Sections, d.name(), section)
164	l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
165
166	l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
167	for _, sec := range l.Sections {
168		l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label), Href: sec.Href})
169	}
170	if l.Ref != "" {
171		for _, b := range d.branches {
172			it := RefItem{Name: b.ShortName, Href: repoHref(d.name(), "tree", b.ShortName), Current: b.ShortName == d.head.ShortName}
173			l.Heads = append(l.Heads, it)
174			l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href})
175		}
176		for _, t := range d.tags {
177			it := RefItem{Name: t.ShortName, Href: repoHref(d.name(), "tree", t.ShortName)}
178			l.Tags = append(l.Tags, it)
179			l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href})
180		}
181	}
182	// Other repositories, when the list is reachable; the page does not
183	// depend on it.
184	if repos, err := s.listRepos(ctx); err == nil {
185		l.Cmd = append(l.Cmd, repoCmds(repos, d.name())...)
186	}
187	return l
188}
189
190// withActions adds the Actions tab when the server runs CI.
191func (s *Server) withActions(sections []Section, repo, current string) []Section {
192	if s.cfg.Runs == nil {
193		return sections
194	}
195	return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"})
196}
197
198// cloneURL is the HTTPS clone URL of a repository.
199func (s *Server) cloneURL(name string) string {
200	return s.cfg.PublicURL + "/" + name + ".git"
201}
202
203// refCommit is what a ref shows: the commit it designates.
204type refCommit struct {
205	short, subject, when, whenAt string
206	at                           int64 // unix time, to sort tags
207}
208
209// commitsOf reads the head commit of each ref, a few at a time. A ref whose
210// commit cannot be read (a tag on a tree, say) has no entry.
211func (s *Server) commitsOf(ctx context.Context, repo domain.RepoName, refs []domain.Ref) map[string]refCommit {
212	const workers = 8
213	var (
214		mu  sync.Mutex
215		out = make(map[string]refCommit, len(refs))
216		sem = make(chan struct{}, workers)
217		wg  sync.WaitGroup
218	)
219	now := s.cfg.Now()
220	for _, r := range refs {
221		if r.CommitID.IsZero() {
222			continue
223		}
224		id := r.CommitID.String()
225		mu.Lock()
226		_, done := out[id]
227		if !done {
228			out[id] = refCommit{} // reserved: one lookup per commit
229		}
230		mu.Unlock()
231		if done {
232			continue
233		}
234		sem <- struct{}{}
235		wg.Add(1)
236		go func() {
237			defer func() { <-sem; wg.Done() }()
238			// By id, not by name: a branch and a tag may share a name.
239			res, err := s.commitList(ctx, repo, id, "", 1, "")
240			var rc refCommit
241			if err == nil && len(res.Commits) == 1 {
242				row := commitRow(repo.String(), res.Commits[0], now)
243				rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt
244				rc.at = unixSeconds(res.Commits[0].Committer.Time)
245			}
246			mu.Lock()
247			out[id] = rc
248			mu.Unlock()
249		}()
250	}
251	wg.Wait()
252	return out
253}
254
255// whenOf is the relative and the machine form of a time; both are "" when
256// the time is unknown (the zero time).
257func whenOf(now time.Time, t time.Time) (when, at string) {
258	if t.IsZero() {
259		return "", ""
260	}
261	return ago(now, t), rfc3339(t)
262}
263
264// unixSeconds is the Unix time of t in seconds, 0 for the zero time.
265func unixSeconds(t time.Time) int64 {
266	if t.IsZero() {
267		return 0
268	}
269	return t.Unix()
270}
271
272// commitRow is the row every list of commits starts from: the short id, the
273// subject, the author and the date of the committer, all fit to display. The
274// caller adds what is specific to its list.
275func commitRow(repo string, c domain.Commit, now time.Time) CommitRow {
276	row := CommitRow{
277		Short: short(c.ID.String()), Subject: visibleText(c.Summary),
278		CHref:  repoHref(repo, "commit", c.ID.String()),
279		Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1,
280		Sig: signatureBadge(c.Verification, false),
281	}
282	row.When, row.WhenAt = whenOf(now, c.Committer.Time)
283	return row
284}
285
286// namesAt maps commit ids to the names of the refs that designate them.
287func namesAt(refs []domain.Ref) map[string][]string {
288	m := map[string][]string{}
289	for _, r := range refs {
290		m[r.CommitID.String()] = append(m[r.CommitID.String()], r.ShortName)
291	}
292	for _, v := range m {
293		sort.Strings(v)
294	}
295	return m
296}
297
298// emptyState is the page of a repository without a branch.
299func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage) {
300	l := s.repoLayout(ctx, d, "summary")
301	l.Title = d.name() + " · " + s.instance
302	l.Sections = nil
303	l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
304	branch := d.repo.DefaultBranch
305	return l, StatePage{
306		Code: "empty", Title: d.name() + " is empty",
307		Lines: []string{"no commits yet. push a branch to create one."},
308		Clone: &Clone{ID: "clone-" + d.name(), Lines: []string{
309			"git remote add origin " + s.cloneURL(d.name()),
310			"git push -u origin " + branch,
311		}},
312		Back: []Link{{Label: "home", Href: "/"}},
313	}
314}

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 4

Tags 45

↑↓ moveenter openesc close