v0.28.0 fca9155

paging.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"encoding/base64"
8	"fmt"
9	"slices"
10	"strconv"
11	"strings"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17const (
18	repoTokenPrefix  = "rp1:"
19	tokenTokenPrefix = "tp1:"
20
21	// maxTokenLen bounds a page token before it is decoded.
22	maxTokenLen = 1024
23)
24
25// Page sizes of the listings.
26const (
27	// DefaultPageSize is the page size when the caller gives none.
28	DefaultPageSize = 50
29	// MaxPageSize is the largest page; a larger request is lowered to it.
30	MaxPageSize = 100
31	// MaxRepoPageSize is the largest page of ListRepos. A repository list is
32	// one scan of the metadata whatever the page, so a caller that wants the
33	// whole list (the web pages) asks for it in one page rather than in
34	// several scans.
35	MaxRepoPageSize = 1000
36	// MaxRefPageSize is the largest page of ListRefs, for the same reason:
37	// each page of refs runs the whole for-each-ref again.
38	MaxRefPageSize = 1000
39)
40
41// MaxCommitSkip bounds how deep into a history a page token may point: each
42// page walks the history from its start with `git log --skip`.
43const MaxCommitSkip = 100_000
44
45// pageSize applies the default and the maximum.
46func pageSize(n int) (int, error) { return pageSizeUpTo(n, MaxPageSize) }
47
48// pageSizeUpTo applies the default and the given maximum.
49func pageSizeUpTo(n, most int) (int, error) {
50	switch {
51	case n < 0:
52		return 0, fmt.Errorf("%w: %d", domain.ErrInvalidPageSize, n)
53	case n == 0:
54		return DefaultPageSize, nil
55	}
56	return min(n, most), nil
57}
58
59func decodeToken(tok, prefix string) (string, bool) {
60	if len(tok) > maxTokenLen {
61		return "", false
62	}
63	raw, err := base64.RawURLEncoding.DecodeString(tok)
64	if err != nil {
65		return "", false
66	}
67	return strings.CutPrefix(string(raw), prefix)
68}
69
70// pageAfter returns the first size items of the ordered items that satisfy
71// after (nil keeps all of them) and, when more follow, the token of the last
72// one returned. Tokens name the last item, so items that appear or disappear
73// between two pages never shift the others.
74func pageAfter[T any](items []T, size int, after func(T) bool, token func(T) string) (page []T, next string) {
75	for _, it := range items {
76		if after != nil && !after(it) {
77			continue
78		}
79		if len(page) == size {
80			return page, token(page[size-1])
81		}
82		page = append(page, it)
83	}
84	return page, ""
85}
86
87// ListPage returns a page of the repositories the principal may read, ordered
88// by name. size is 0 for DefaultPageSize and lowered to MaxRepoPageSize; the page
89// token names the last repository of the previous page, so repositories that
90// appear or disappear between two pages never shift the others.
91func (r *Repos) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error) {
92	n, err := pageSizeUpTo(size, MaxRepoPageSize)
93	if err != nil {
94		return ports.RepoPage{}, err
95	}
96	after, err := decodeRepoToken(token)
97	if err != nil {
98		return ports.RepoPage{}, err
99	}
100	all, err := r.List(ctx, p)
101	if err != nil {
102		return ports.RepoPage{}, err
103	}
104	var page ports.RepoPage
105	page.Repos, page.NextPageToken = pageAfter(all, n,
106		func(repo domain.Repo) bool { return after == "" || repo.Name.String() > after },
107		func(repo domain.Repo) string { return encodeRepoToken(repo.Name) })
108	return page, nil
109}
110
111func encodeRepoToken(last domain.RepoName) string {
112	return base64.RawURLEncoding.EncodeToString([]byte(repoTokenPrefix + last.String()))
113}
114
115// decodeRepoToken returns "" for the empty token.
116func decodeRepoToken(tok string) (string, error) {
117	if tok == "" {
118		return "", nil
119	}
120	raw, ok := decodeToken(tok, repoTokenPrefix)
121	if !ok || raw == "" {
122		return "", fmt.Errorf("%w: repositories", domain.ErrInvalidPageToken)
123	}
124	return raw, nil
125}
126
127// ListPage returns a page of the tokens List returns, newest first (then by
128// id). size and the page token work like Repos.ListPage; the token names the
129// last token of the previous page.
130func (t *Tokens) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error) {
131	n, err := pageSize(size)
132	if err != nil {
133		return ports.TokenPage{}, err
134	}
135	after, err := decodeTokenCursor(token)
136	if err != nil {
137		return ports.TokenPage{}, err
138	}
139	all, err := t.List(ctx, p)
140	if err != nil {
141		return ports.TokenPage{}, err
142	}
143	slices.SortFunc(all, func(a, b domain.Token) int {
144		if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
145			return c
146		}
147		return strings.Compare(b.ID, a.ID)
148	})
149	var page ports.TokenPage
150	page.Tokens, page.NextPageToken = pageAfter(all, n,
151		func(tok domain.Token) bool { return after == nil || tokenAfter(tok, *after) },
152		encodeTokenCursor)
153	return page, nil
154}
155
156type tokenCursor struct {
157	created int64
158	id      string
159}
160
161// tokenAfter reports whether tok comes after the cursor in newest-first order.
162func tokenAfter(tok domain.Token, c tokenCursor) bool {
163	if created := tok.CreatedAt.UnixNano(); created != c.created {
164		return created < c.created
165	}
166	return tok.ID < c.id
167}
168
169func encodeTokenCursor(last domain.Token) string {
170	raw := tokenTokenPrefix + strconv.FormatInt(last.CreatedAt.UnixNano(), 10) + ":" + last.ID
171	return base64.RawURLEncoding.EncodeToString([]byte(raw))
172}
173
174// decodeTokenCursor returns nil for the empty token.
175func decodeTokenCursor(tok string) (*tokenCursor, error) {
176	if tok == "" {
177		return nil, nil
178	}
179	bad := fmt.Errorf("%w: tokens", domain.ErrInvalidPageToken)
180	raw, ok := decodeToken(tok, tokenTokenPrefix)
181	if !ok {
182		return nil, bad
183	}
184	created, id, ok := strings.Cut(raw, ":")
185	n, err := strconv.ParseInt(created, 10, 64)
186	if !ok || err != nil || id == "" {
187		return nil, bad
188	}
189	return &tokenCursor{created: n, id: id}, nil
190}

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 3

Tags 44

↑↓ moveenter openesc close