1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "net/url"
9 "path"
10
11 "github.com/a-h/templ"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16// commitsPerPage is how many commits one page of history lists.
17const commitsPerPage = 50
18
19// DayGroup is the commits of one day (UTC, by committer date).
20type DayGroup struct {
21 Day string
22 Rows []CommitRow
23}
24
25// CommitsPage is the history of a ref, or of a path at a ref.
26type CommitsPage struct {
27 Ref string
28 // Path is the path the history is limited to, ready to display; "" for
29 // the whole history.
30 Path string
31 Days []DayGroup
32 Count int
33 // Older is the link to the next page, "" on the last page. Newest is the
34 // link back to the first page, "" on the first page: page tokens only go
35 // forward.
36 Older, Newest string
37 // Open links to the file or directory at this ref, when it exists there.
38 Open *Link
39 // Refs are the branch heads and tags among the commits of this page.
40 Refs []Link
41 // Live is true when the page announces new commits of its branch.
42 Live bool
43}
44
45// commits lists the history of a ref, newest first, a page at a time, with
46// an optional path to limit it to. The page token of the use case travels in the
47// "page" query parameter.
48func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
49 d, err := s.loadRepo(ctx, r.PathValue("repo"))
50 if err != nil {
51 return nil, 0, err
52 }
53 sel, ok := d.locate(r.PathValue("rest"))
54 if !ok {
55 return nil, 0, errNotFound
56 }
57 token := r.URL.Query().Get("page")
58 res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token)
59 if err != nil {
60 return nil, 0, err
61 }
62 cs := res.Commits
63 if len(cs) == 0 && token == "" && sel.path != "" {
64 // Git answers an empty history for a path that never existed.
65 return nil, 0, errNotFound
66 }
67
68 p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
69 base := repoHref(d.name(), "commits", sel.name, sel.path)
70 if t := res.NextPageToken; t != "" {
71 p.Older = base + "?page=" + url.QueryEscape(t)
72 }
73 if token != "" {
74 p.Newest = base
75 }
76 s.fillDays(ctx, &p, d, cs)
77 if sel.path != "" && token == "" {
78 p.Open = s.pathLink(ctx, d, sel)
79 }
80
81 l := s.repoLayout(ctx, d, "commits")
82 pinRef(&l, sel)
83 if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
84 // The first page of a branch announces the commits pushed to it.
85 p.Live = true
86 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
87 }
88 l.Crumbs = pathCrumbs(d.name(), "commits", sel)
89 if sel.path == "" {
90 l.Title = "commits · " + d.name() + " · " + s.instance
91 } else {
92 l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
93 }
94 l.HasRail = true
95 return Commits(l, p), http.StatusOK, nil
96}
97
98// maxCheckLookups bounds the commits of one page whose checks are read: each
99// is one read of the store.
100const maxCheckLookups = 100
101
102// fillDays groups the commits of a page by day and lists the refs among them.
103func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) {
104 now := s.cfg.Now()
105 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
106 for i, c := range cs {
107 row := commitRow(d.name(), c, now)
108 row.ID, row.Cursor = "c-"+row.Short, i == 0
109 row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()]
110 if i < maxCheckLookups {
111 row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String()))
112 }
113 day := "unknown date"
114 if t := c.Committer.Time; !t.IsZero() {
115 day = t.UTC().Format("2006-01-02")
116 }
117 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
118 p.Days = append(p.Days, DayGroup{Day: day})
119 }
120 g := &p.Days[len(p.Days)-1]
121 g.Rows = append(g.Rows, row)
122 for _, t := range row.Tags {
123 p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID})
124 }
125 for _, b := range row.Branches {
126 p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID})
127 }
128 }
129}
130
131// pathLink finds what the path of a history is at the ref: a directory
132// (tree) or a file (blob). It is nil when the path is not there, a deleted
133// file for one, or when the listing fails: the link is a convenience.
134func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
135 dir := path.Dir(sel.path)
136 if dir == "." {
137 dir = ""
138 }
139 tree, err := s.getTree(ctx, d.repo.Name, sel.rev, dir, false)
140 if err != nil {
141 return nil
142 }
143 for _, e := range tree.Entries {
144 if e.Path != sel.path {
145 continue
146 }
147 if e.Kind == domain.EntryDirectory {
148 return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
149 }
150 return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)}
151 }
152 return nil
153}