1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/a-h/templ"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// maxRepos bounds the repositories one page lists.
19const maxRepos = 1000
20
21// pageSize is the page size asked from the commit lists: the largest the use
22// case accepts for them.
23const pageSize = 100
24
25// listRepos reads the repositories the visitor may see, ordered by name.
26// Every page of ListRepos scans the whole repository list and reads the HEAD
27// of each repository of the page, so the list is asked in one page of up to
28// maxRepos: one scan in the common case, whatever the cap of the use case is
29// (a lower one only means more pages, followed by their token).
30func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) {
31 var repos []ports.RepoView
32 token := ""
33 for len(repos) < maxRepos {
34 page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token)
35 if err != nil {
36 return nil, err
37 }
38 for _, repo := range page.Repos {
39 view, err := s.cfg.Repos.View(ctx, repo)
40 if err != nil {
41 return nil, err
42 }
43 repos = append(repos, view)
44 }
45 if token = page.NextPageToken; token == "" {
46 break
47 }
48 }
49 return repos, nil
50}
51
52// homeSorts are the orders the home list offers, in order. The query also
53// accepts "desc" and "branch", which the page does not link to.
54var homeSorts = []struct{ key, label string }{
55 {"push", "last push"},
56 {"name", "name"},
57}
58
59// homeKeys are the keys of ?sort= that sortRepos knows.
60var homeKeys = []string{"push", "name", "desc", "branch"}
61
62// sortRepos orders repos by the column key; ties keep the order by name.
63func sortRepos(repos []ports.RepoView, key string, desc bool) {
64 less := func(a, b ports.RepoView) bool {
65 switch key {
66 case "desc":
67 return strings.ToLower(a.Description) < strings.ToLower(b.Description)
68 case "branch":
69 return a.DefaultBranch < b.DefaultBranch
70 case "push":
71 return a.PushedAt.Before(b.PushedAt)
72 }
73 return a.Name.String() < b.Name.String()
74 }
75 sort.SliceStable(repos, func(i, j int) bool {
76 if desc {
77 return less(repos[j], repos[i])
78 }
79 return less(repos[i], repos[j])
80 })
81}
82
83// home lists the repositories: /?sort=<col>&dir=asc|desc, newest push first
84// by default.
85func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, int, error) {
86 repos, err := s.listRepos(ctx)
87 if err != nil {
88 return nil, 0, err
89 }
90 key, desc := "push", true
91 for _, k := range homeKeys {
92 if r.URL.Query().Get("sort") == k {
93 key, desc = k, k == "push"
94 }
95 }
96 if d := r.URL.Query().Get("dir"); d == "asc" || d == "desc" {
97 desc = d == "desc"
98 }
99 sortRepos(repos, key, desc)
100
101 now := s.cfg.Now()
102 signed := SignedIn(ctx)
103 p := HomePage{}
104 for _, c := range homeSorts {
105 sl := SortLink{Label: c.label}
106 next := "asc"
107 if c.key == "push" {
108 next = "desc"
109 }
110 if c.key == key {
111 sl.Mark, sl.State = "▲", "ascending, select for descending"
112 next = "desc"
113 if desc {
114 sl.Mark, sl.State, next = "▼", "descending, select for ascending", "asc"
115 }
116 }
117 sl.Href = "/?sort=" + c.key + "&dir=" + next
118 p.Sorts = append(p.Sorts, sl)
119 }
120 for i, repo := range repos {
121 p.Rows = append(p.Rows, homeRow(repo, now, i == 0, signed))
122 }
123
124 l := s.base()
125 l.Title = s.instance
126 l.Live, l.LiveURL = true, liveURL("home", "")
127 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
128 return Home(l, p), http.StatusOK, nil
129}
130
131// homeRow is the card of a repository in the home list. The private badge is
132// for a signed-in visitor: an anonymous one never gets a private repository.
133func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
134 name := r.Name.String()
135 row := HomeRow{
136 ID: "repo-" + name, Name: name, Href: repoHref(name),
137 Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,
138 Private: signed && r.Visibility == domain.VisibilityPrivate,
139 }
140 if !r.PushedAt.IsZero() {
141 row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
142 }
143 return row
144}
145
146// repoCmds are the repositories as targets of the ":" command line, but the
147// one being viewed.
148func repoCmds(repos []ports.RepoView, except string) []CmdItem {
149 var out []CmdItem
150 for _, r := range repos {
151 if name := r.Name.String(); name != except {
152 out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)})
153 }
154 }
155 return out
156}