v0.5.0 bfab294

browse.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"crypto/sha256"
8	"encoding/base64"
9	"encoding/hex"
10	"fmt"
11	"io"
12	"slices"
13	"strconv"
14	"strings"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// Browse are the read use cases over the Git data of a repository: refs,
21// trees, blobs, history and commits. Every method applies the access rule
22// first: a repository the principal may not read, or that does not exist,
23// answers domain.ErrNotFound before the Git reader is touched.
24type Browse struct {
25	meta   ports.RepoMetaStore
26	reader ports.RepoReader
27}
28
29// NewBrowse builds the read use cases over their ports.
30func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
31	return &Browse{meta: meta, reader: reader}
32}
33
34// authorize returns nil if p may read the repository.
35func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) error {
36	repo, err := lookupRepo(ctx, b.meta, name)
37	if err != nil {
38		return err
39	}
40	if !canRead(p, repo) {
41		return domain.ErrNotFound
42	}
43	return nil
44}
45
46// ListRefsParams selects the refs to list.
47type ListRefsParams struct {
48	// Kinds restricts the result; empty means every kind.
49	Kinds []domain.RefKind
50	// PageSize is 0 for the default, and lowered to MaxPageSize.
51	PageSize int
52	// PageToken is the NextPageToken of the previous page, or empty.
53	PageToken string
54}
55
56// RefPage is a page of refs.
57type RefPage struct {
58	Refs []domain.Ref
59	// NextPageToken is empty on the last page.
60	NextPageToken string
61}
62
63func kindRank(k domain.RefKind) int {
64	switch k {
65	case domain.RefKindHead:
66		return 0
67	case domain.RefKindBranch:
68		return 1
69	}
70	return 2
71}
72
73// ListRefs lists HEAD, the branches and the tags, in that order, then by
74// name. The page token names the last ref of the previous page, so refs that
75// appear or disappear between two pages never shift the others.
76func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRefsParams) (RefPage, error) {
77	size, err := pageSize(params.PageSize)
78	if err != nil {
79		return RefPage{}, err
80	}
81	for _, k := range params.Kinds {
82		if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
83			return RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
84		}
85	}
86	after, err := decodeRefToken(params.PageToken)
87	if err != nil {
88		return RefPage{}, err
89	}
90	if err := b.authorize(ctx, p, repo); err != nil {
91		return RefPage{}, err
92	}
93	all, err := b.reader.ListRefs(ctx, repo)
94	if err != nil {
95		return RefPage{}, err
96	}
97
98	var wanted []domain.Ref
99	for _, r := range all {
100		if len(params.Kinds) == 0 || slices.Contains(params.Kinds, r.Kind) {
101			wanted = append(wanted, r)
102		}
103	}
104	var page RefPage
105	page.Refs, page.NextPageToken = pageAfter(wanted, size,
106		func(r domain.Ref) bool { return after == nil || refAfter(r, *after) },
107		encodeRefToken)
108	return page, nil
109}
110
111// refAfter reports whether r sorts after the ref the token names.
112func refAfter(r domain.Ref, last refCursor) bool {
113	if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
114		return kr > lr
115	}
116	return r.Name > last.name
117}
118
119// refCursor is the decoded page token of ListRefs.
120type refCursor struct {
121	kind domain.RefKind
122	name string
123}
124
125const (
126	refTokenPrefix    = "r1:"
127	commitTokenPrefix = "c1:"
128)
129
130func encodeRefToken(last domain.Ref) string {
131	return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
132}
133
134// decodeRefToken returns nil for the empty token.
135func decodeRefToken(tok string) (*refCursor, error) {
136	if tok == "" {
137		return nil, nil
138	}
139	raw, ok := decodeToken(tok, refTokenPrefix)
140	if !ok {
141		return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
142	}
143	kind, name, ok := strings.Cut(raw, ":")
144	k := domain.RefKind(kind)
145	if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
146		return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
147	}
148	return &refCursor{kind: k, name: name}, nil
149}
150
151// ResolveRevision turns a revision into the commit it designates.
152func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
153	if err := b.authorize(ctx, p, repo); err != nil {
154		return domain.ResolvedRevision{}, err
155	}
156	return b.reader.ResolveRevision(ctx, repo, rev)
157}
158
159// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
160// leading ref and the path inside it.
161func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
162	if err := b.authorize(ctx, p, repo); err != nil {
163		return domain.RefPath{}, err
164	}
165	return b.reader.ResolveRefPath(ctx, repo, path)
166}
167
168// GetTree lists a directory at a revision. With includeLastCommit, entries get
169// their last commit, for at most domain.MaxLastCommitEntries of them.
170func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
171	if err := b.authorize(ctx, p, repo); err != nil {
172		return domain.Tree{}, err
173	}
174	return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
175}
176
177// OpenBlob returns the description of a file and a reader of its content. The
178// caller closes the reader.
179func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
180	if err := b.authorize(ctx, p, repo); err != nil {
181		return domain.BlobInfo{}, nil, err
182	}
183	return b.reader.OpenBlob(ctx, repo, rev, path)
184}
185
186// ListCommitsParams selects a page of history.
187type ListCommitsParams struct {
188	// Revision is where the history starts; the zero value is HEAD. Ignored
189	// when PageToken is set: the token pins the commit the first page started
190	// from, so every page walks the same history even if a branch moves.
191	Revision domain.Revision
192	// Path limits the history to the commits that changed it. It must be the
193	// same on every page of a listing.
194	Path domain.TreePath
195	// PageSize is 0 for the default, and lowered to MaxPageSize.
196	PageSize int
197	// PageToken is the NextPageToken of the previous page, or empty.
198	PageToken string
199}
200
201// CommitPage is a page of history, newest first.
202type CommitPage struct {
203	Commits []domain.Commit
204	// NextPageToken is empty on the last page.
205	NextPageToken string
206}
207
208// ListCommits returns a page of history. The page token holds the commit the
209// listing started from, how many commits were already returned and a digest
210// of the path; a token used with another path is invalid.
211func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (CommitPage, error) {
212	size, err := pageSize(params.PageSize)
213	if err != nil {
214		return CommitPage{}, err
215	}
216	q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
217	if params.PageToken != "" {
218		head, skip, err := decodeCommitToken(params.PageToken, params.Path)
219		if err != nil {
220			return CommitPage{}, err
221		}
222		q.Revision, q.Skip = domain.RevisionOf(head), skip
223	}
224	if err := b.authorize(ctx, p, repo); err != nil {
225		return CommitPage{}, err
226	}
227	res, err := b.reader.ListCommits(ctx, repo, q)
228	if err != nil {
229		return CommitPage{}, err
230	}
231	out := CommitPage{Commits: res.Commits}
232	if res.HasMore {
233		out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
234	}
235	return out, nil
236}
237
238func pathDigest(path domain.TreePath) string {
239	sum := sha256.Sum256([]byte(path.String()))
240	return hex.EncodeToString(sum[:6])
241}
242
243func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
244	raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
245	return base64.RawURLEncoding.EncodeToString([]byte(raw))
246}
247
248func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
249	bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
250	raw, ok := decodeToken(tok, commitTokenPrefix)
251	if !ok {
252		return domain.ObjectID{}, 0, bad
253	}
254	parts := strings.Split(raw, ":")
255	if len(parts) != 3 {
256		return domain.ObjectID{}, 0, bad
257	}
258	head, err := domain.ParseObjectID(parts[0])
259	skip, err2 := strconv.Atoi(parts[1])
260	if err != nil || err2 != nil || skip < 0 || skip > MaxCommitSkip || parts[2] != pathDigest(path) {
261		return domain.ObjectID{}, 0, bad
262	}
263	return head, skip, nil
264}
265
266// GetCommit returns a commit with its statistics and its diff against the
267// first parent, within the default diff limits.
268func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
269	if err := b.authorize(ctx, p, repo); err != nil {
270		return domain.CommitDetail{}, err
271	}
272	return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
273}

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