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 "connectrpc.com/connect"
12 "github.com/a-h/templ"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15)
16
17// commitsPerPage is how many commits one page of history lists.
18const commitsPerPage = 50
19
20// DayGroup is the commits of one day (UTC, by committer date).
21type DayGroup struct {
22 Day string
23 Rows []CommitRow
24}
25
26// CommitsPage is the history of a ref, or of a path at a ref.
27type CommitsPage struct {
28 Ref string
29 // Path is the path the history is limited to, ready to display; "" for
30 // the whole history.
31 Path string
32 Days []DayGroup
33 Count int
34 // Older is the link to the next page, "" on the last page. Newest is the
35 // link back to the first page, "" on the first page: page tokens only go
36 // forward.
37 Older, Newest string
38 // Open links to the file or directory at this ref, when it exists there.
39 Open *Link
40 // Refs are the branch heads and tags among the commits of this page.
41 Refs []Link
42 // Live is true when the page announces new commits of its branch.
43 Live bool
44}
45
46// commits lists the history of a ref, newest first, a page at a time, with
47// an optional path to limit it to. The page token of the API travels in the
48// "page" query parameter.
49func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) {
50 d, err := s.loadRepo(ctx, r.PathValue("repo"))
51 if err != nil {
52 return nil, 0, err
53 }
54 sel, ok := d.locate(r.PathValue("rest"))
55 if !ok {
56 return nil, 0, errNotFound
57 }
58 token := r.URL.Query().Get("page")
59 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
60 Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token,
61 }))
62 if err != nil {
63 return nil, 0, err
64 }
65 cs := res.Msg.GetCommits()
66 if len(cs) == 0 && token == "" && sel.path != "" {
67 // Git answers an empty history for a path that never existed.
68 return nil, 0, errNotFound
69 }
70
71 p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)}
72 base := repoHref(d.name(), "commits", sel.name, sel.path)
73 if t := res.Msg.GetNextPageToken(); t != "" {
74 p.Older = base + "?page=" + url.QueryEscape(t)
75 }
76 if token != "" {
77 p.Newest = base
78 }
79 s.fillDays(&p, d, cs)
80 if sel.path != "" && token == "" {
81 p.Open = s.pathLink(ctx, d, sel)
82 }
83
84 l := s.repoLayout(ctx, d, "commits")
85 pinRef(&l, sel)
86 if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
87 // The first page of a branch announces the commits pushed to it.
88 p.Live = true
89 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
90 }
91 l.Crumbs = pathCrumbs(d.name(), "commits", sel)
92 if sel.path == "" {
93 l.Title = "commits · " + d.name() + " · " + s.instance
94 } else {
95 l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
96 }
97 what := plural(len(cs), "commit", "commits")
98 if p.Older != "" {
99 what += " · more"
100 }
101 if p.Path != "" {
102 what = "history of " + p.Path + " · " + what
103 }
104 l.Status = Status{
105 Mode: "commits", Context: sel.name + " · " + what,
106 Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs),
107 }
108 l.HasRail, l.RailSum = true, "history · refs on this page"
109 return Commits(l, p), http.StatusOK, nil
110}
111
112// fillDays groups the commits of a page by day and lists the refs among them.
113func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) {
114 now := s.cfg.Now()
115 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
116 for i, c := range cs {
117 row := commitRow(d.name(), c, now)
118 row.ID, row.Cursor = "c-"+row.Short, i == 0
119 row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()]
120 day := "unknown date"
121 if t := c.GetCommitter().GetTime(); t.IsValid() {
122 day = t.AsTime().UTC().Format("2006-01-02")
123 }
124 if n := len(p.Days); n == 0 || p.Days[n-1].Day != day {
125 p.Days = append(p.Days, DayGroup{Day: day})
126 }
127 g := &p.Days[len(p.Days)-1]
128 g.Rows = append(g.Rows, row)
129 for _, t := range row.Tags {
130 p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID})
131 }
132 for _, b := range row.Branches {
133 p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID})
134 }
135 }
136}
137
138// pathLink finds what the path of a history is at the ref: a directory
139// (tree) or a file (blob). It is nil when the path is not there, a deleted
140// file for one, or when the listing fails: the link is a convenience.
141func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link {
142 dir := path.Dir(sel.path)
143 if dir == "." {
144 dir = ""
145 }
146 tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir}))
147 if err != nil {
148 return nil
149 }
150 for _, e := range tree.Msg.GetEntries() {
151 if e.GetPath() != sel.path {
152 continue
153 }
154 if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY {
155 return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)}
156 }
157 return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)}
158 }
159 return nil
160}