v0.1.0 3f267b5

paging.go RawHistoryFolder

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

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 44

↑↓ moveenter openesc close