1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 "connectrpc.com/connect"
13 "github.com/a-h/templ"
14
15 pb "bnema.dev/ermite-api/gen/ermite"
16)
17
18// bothViewsLines is the largest diff, in lines, that is sent in both the
19// unified and the split layout so that the toggle needs no round trip. A
20// larger one is sent in the layout asked for only, and the toggle is a link.
21const bothViewsLines = 4000
22
23// Person is an author or a committer with the date of the action.
24type Person struct {
25 Name, When, WhenAt, Abs string
26}
27
28// CommitPage is a commit with its diff.
29type CommitPage struct {
30 SHA, Short, Subject, Body string
31 Author Person
32 // Committer is nil when it is the author, at the same time.
33 Committer *Person
34 Parents []Link
35 Merge bool
36 Root bool
37 Refs []string
38 // NFiles, Adds and Dels are the totals of the commit, including the files
39 // that the diff omits.
40 NFiles, Adds, Dels int
41 Files []DiffFile
42 // Note says the diff is incomplete.
43 Note string
44 // View is the layout shown: "unified" or "split". Pinned is true when the
45 // URL chose it, which then wins over the choice remembered by the browser.
46 View string
47 Pinned bool
48 // Both is true when both layouts are in the page, and the toggle works in
49 // the browser; otherwise only View is sent.
50 Both bool
51 ShowUnified, ShowSplit bool
52 UnifiedHref, SplitHref string
53 // Invisible is true when some line holds an invisible character, shown as a
54 // marker.
55 Invisible bool
56}
57
58// commit shows a commit: its message, its totals, the changed files and the
59// diff against the first parent. ?view=split selects the split layout.
60func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
61 sha := r.PathValue("sha")
62 if !isCommitID(sha) {
63 return nil, 0, errNotFound
64 }
65 d, err := s.loadRepo(ctx, r.PathValue("repo"))
66 if err != nil {
67 return nil, 0, err
68 }
69 res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha}))
70 if err != nil {
71 return nil, 0, err
72 }
73 c := res.Msg.GetCommit()
74 if c == nil {
75 return nil, 0, errNotFound
76 }
77 stats := res.Msg.GetStats()
78 now := s.cfg.Now()
79
80 p := CommitPage{
81 SHA: c.GetId(), Short: short(c.GetId()),
82 Subject: visibleText(c.GetSummary()), Body: commitBody(c),
83 Author: person(c.GetAuthor(), now),
84 Merge: len(c.GetParentIds()) > 1,
85 Root: len(c.GetParentIds()) == 0,
86 NFiles: int(stats.GetFilesChanged()),
87 Adds: int(stats.GetAdditions()),
88 Dels: int(stats.GetDeletions()),
89 Files: diffFiles(res.Msg.GetDiff().GetFiles()),
90 View: "unified",
91 }
92 if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() {
93 p.Committer = &cm
94 }
95 for _, id := range c.GetParentIds() {
96 p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
97 }
98 for _, b := range d.branches {
99 if b.GetCommitId() == c.GetId() {
100 p.Refs = append(p.Refs, b.GetShortName())
101 }
102 }
103 for _, t := range d.tags {
104 if t.GetCommitId() == c.GetId() {
105 p.Refs = append(p.Refs, "tag: "+t.GetShortName())
106 }
107 }
108 p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
109
110 lines := 0
111 for _, f := range p.Files {
112 for _, h := range f.Hunks {
113 lines += len(h.Lines)
114 p.Invisible = p.Invisible || slices.ContainsFunc(h.Lines, hasMarker)
115 }
116 }
117 switch r.URL.Query().Get("view") {
118 case "split":
119 p.View, p.Pinned = "split", true
120 case "unified":
121 p.Pinned = true
122 }
123 p.Both = lines <= bothViewsLines
124 p.ShowUnified, p.ShowSplit = p.Both || p.View == "unified", p.Both || p.View == "split"
125 base := repoHref(d.name(), "commit", c.GetId())
126 p.UnifiedHref, p.SplitHref = base+"?view=unified", base+"?view=split"
127
128 l := s.repoLayout(ctx, d, "commits")
129 l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
130 pinRef(&l, target{rev: c.GetId(), name: p.Short, detached: true})
131 history := repoHref(d.name())
132 if d.head != nil {
133 history = repoHref(d.name(), "commits", d.head.GetShortName())
134 }
135 l.Crumbs = []Crumb{
136 {Label: d.name(), Href: repoHref(d.name())},
137 {Label: "commit", Href: history},
138 {Label: p.Short, Current: true},
139 }
140 l.Status = Status{
141 Mode: "commit", Context: p.Short + " · " + p.totals(),
142 Hints: "d split · u unified · x collapse all · ? help",
143 }
144 l.HasRail, l.RailSum = true, plural(len(p.Files), "changed file", "changed files")
145 return Commit(l, p), http.StatusOK, nil
146}
147
148// totals is "3 files · +12 -4".
149func (p CommitPage) totals() string {
150 return plural(p.NFiles, "file", "files") + " · +" + itoa(p.Adds) + " -" + itoa(p.Dels)
151}
152
153// person converts a signature.
154func person(s *pb.Signature, now time.Time) Person {
155 p := Person{Name: visibleText(s.GetName())}
156 p.When, p.WhenAt = whenOf(now, s.GetTime())
157 if t := s.GetTime(); t.IsValid() {
158 p.Abs = absolute(t.AsTime())
159 }
160 return p
161}
162
163// commitBody is the message without its first line, for display.
164func commitBody(c *pb.Commit) string {
165 msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n")
166 _, body, _ := strings.Cut(msg, "\n")
167 return messageText(strings.TrimSpace(body))
168}
169
170// diffNote says what is missing from an incomplete diff, "" for a complete one.
171func diffNote(d *pb.Diff, shown, total int) string {
172 if !d.GetTruncated() {
173 return ""
174 }
175 var parts []string
176 if shown < total {
177 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
178 }
179 cut := 0
180 for _, f := range d.GetFiles() {
181 if f.GetTruncated() {
182 cut++
183 }
184 }
185 if cut > 0 {
186 parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
187 }
188 return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
189}