refs.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/url"
8	"strings"
9
10	"bnema.dev/ermite/internal/domain"
11)
12
13// resolveRef finds the ref a URL path starts with and the path that follows.
14// Ref names may contain "/" ("feature/x"), so the longest prefix of the path
15// that is the short name of a branch or tag wins. A branch wins over a tag of
16// the same name. rest has no leading slash and is "" when the path is the ref
17// alone. ok is false when no ref matches; the caller may then try a commit id.
18//
19//hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here
20func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) {
21	names := refsByName(refs)
22	p = strings.Trim(p, "/")
23	for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") {
24		if r, found := names[p[:end]]; found {
25			return r, strings.TrimPrefix(p[end:], "/"), true
26		}
27	}
28	return domain.Ref{}, "", false
29}
30
31// refsByName indexes the branches and tags by short name. A branch wins over
32// a tag of the same name.
33func refsByName(refs []domain.Ref) map[string]domain.Ref {
34	names := make(map[string]domain.Ref, len(refs))
35	for _, r := range refs {
36		k := r.Kind
37		if k != domain.RefKindBranch && k != domain.RefKindTag {
38			continue
39		}
40		cur, dup := names[r.ShortName]
41		if !dup || cur.Kind != domain.RefKindBranch {
42			names[r.ShortName] = r
43		}
44	}
45	return names
46}
47
48// escapePath escapes every segment of a slash-separated path for a URL path,
49// keeping the slashes.
50func escapePath(p string) string {
51	segs := strings.Split(p, "/")
52	for i, s := range segs {
53		segs[i] = url.PathEscape(s)
54	}
55	return strings.Join(segs, "/")
56}
57
58// repoHref is the URL of a page of a repository: /<repo>/<kind>/<ref>/<path>.
59// kind, ref and path may be empty.
60func repoHref(repo string, parts ...string) string {
61	var b strings.Builder
62	b.WriteString("/" + url.PathEscape(repo))
63	for _, p := range parts {
64		if p != "" {
65			b.WriteString("/" + escapePath(strings.Trim(p, "/")))
66		}
67	}
68	return b.String()
69}
70
71// target is what the URL of a tree, blob, raw or commits page designates: a
72// commit, the name it was reached by, and a path in its tree.
73type target struct {
74	// rev is what the use cases are asked for: the id of the commit of a ref,
75	// or the commit id of the URL itself.
76	rev string
77	// name is how the URL spells the ref: a branch or a tag, or the commit id
78	// when detached.
79	name string
80	path string
81	// detached is true when the URL names a commit and not a ref.
82	detached bool
83	// commit is the full id of the commit when the page has learnt it from
84	// the API and rev is an abbreviation; "" otherwise.
85	commit string
86}
87
88// commitID is the full id of the commit of the target when it is known, and
89// rev otherwise.
90func (t target) commitID() string {
91	if t.commit != "" {
92		return t.commit
93	}
94	return t.rev
95}
96
97// resolveCommit sets the full id of a target named by an abbreviated commit id,
98// with one ResolveRevision. The page renders without it (it only lacks the file
99// finder), so a failure is not one.
100//
101//hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here
102func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) {
103	if !t.detached || isFullID(t.rev) {
104		return
105	}
106	res, err := s.resolveRevision(ctx, repo, t.rev)
107	if err != nil {
108		return
109	}
110	t.commit = res.CommitID.String()
111}
112
113// locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the
114// longest prefix of the path that is the short name of a branch or a tag
115// (names may contain "/"); a branch wins over a tag of the same name. An
116// empty rest designates the head of the repository. Whether the commit or
117// the path exists is for the use cases to say.
118func (d *repoData) locate(rest string) (target, bool) {
119	rest = strings.Trim(rest, "/")
120	if rest == "" {
121		if d.head == nil {
122			return target{}, false
123		}
124		return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true
125	}
126	if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() {
127		return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true
128	}
129	first, p, _ := strings.Cut(rest, "/")
130	if isCommitID(first) {
131		return target{rev: first, name: first, path: p, detached: true}, true
132	}
133	return target{}, false
134}
135
136// isCommitID reports whether s can be a commit id or an abbreviation of one
137// (7 to 40 lowercase hexadecimal digits).
138func isCommitID(s string) bool {
139	if len(s) < 7 || len(s) > 40 {
140		return false
141	}
142	for _, c := range []byte(s) {
143		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
144			return false
145		}
146	}
147	return true
148}
149
150// isBranchHead reports whether t is a branch at its tip, as opposed to a tag
151// or a commit id.
152func (d *repoData) isBranchHead(t target) bool {
153	if t.detached || !isFullID(t.rev) {
154		return false
155	}
156	for _, b := range d.branches {
157		if b.ShortName == t.name && b.CommitID.String() == t.rev {
158			return true
159		}
160	}
161	return false
162}
163
164// pinRef points the ref switcher of the layout at what a page shows: a
165// branch or a tag, which is then the current one of the lists, or a commit,
166// which makes none current. It points the file finder at the commit too.
167func pinRef(l *Layout, t target) {
168	if t.detached {
169		l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), ""
170		if len(t.rev) == 40 {
171			l.SHA = t.rev
172		}
173		l.setFinder(t.commitID(), t.rev)
174		for i := range l.Heads {
175			l.Heads[i].Current = false
176		}
177		for i := range l.Tags {
178			l.Tags[i].Current = false
179		}
180		return
181	}
182	l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev)
183	l.setFinder(t.rev, t.name)
184	for i := range l.Heads {
185		l.Heads[i].Current = l.Heads[i].Name == t.name
186	}
187	for i := range l.Tags {
188		l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name)
189	}
190}
191
192// setFinder points the file finder at a commit: the list of its paths when sha
193// is a full commit id, and the blob pages of ref (a ref name or a commit id)
194// its results open. Without a full id there is nothing to search and the
195// page has no finder data.
196func (l *Layout) setFinder(sha, ref string) {
197	l.PathsURL, l.BlobBase = "", ""
198	if l.Repo == "" || ref == "" || !isFullID(sha) {
199		return
200	}
201	l.PathsURL = repoHref(l.Repo, "paths", sha)
202	l.BlobBase = repoHref(l.Repo, "blob", ref)
203}
204
205func hasName(items []RefItem, name string) bool {
206	for _, it := range items {
207		if it.Name == name {
208			return true
209		}
210	}
211	return false
212}
213
214// pathCrumbs is the path in the header: repo, kind ("tree", "blob" or
215// "commits"), ref, then the segments of the path. The directories link to
216// their tree; the last segment links to the file for a blob, and to nothing
217// for a history.
218func pathCrumbs(repo, kind string, t target) []Crumb {
219	home := repoHref(repo, "tree", t.name)
220	if kind == "commits" {
221		home = repoHref(repo, "commits", t.name)
222	}
223	cs := []Crumb{
224		{Label: repo, Href: repoHref(repo)},
225		{Label: kind, Href: home},
226		{Label: t.name, Href: home, Current: t.path == ""},
227	}
228	if t.path == "" {
229		return cs
230	}
231	segs := strings.Split(t.path, "/")
232	for i, seg := range segs {
233		last := i == len(segs)-1
234		c := Crumb{Label: visibleText(seg), Current: last}
235		switch {
236		case !last || kind == "tree":
237			c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/"))
238		case kind == "blob":
239			c.Href = repoHref(repo, "blob", t.name, t.path)
240		}
241		cs = append(cs, c)
242	}
243	return cs
244}

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