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 "github.com/a-h/templ"
13
14 "bnema.dev/ermite/internal/domain"
15)
16
17// bothViewsLines is the largest diff, in lines, that is sent in both the
18// unified and the split layout so that the toggle needs no round trip. A
19// larger one is sent in the layout asked for only, and the toggle is a link.
20const bothViewsLines = 4000
21
22// Person is an author or a committer with the date of the action.
23type Person struct {
24 Name, When, WhenAt, Abs string
25}
26
27// CommitPage is a commit with its diff.
28type CommitPage struct {
29 SHA, Short, Subject, Body string
30 Author Person
31 // Committer is nil when it is the author, at the same time.
32 Committer *Person
33 Parents []Link
34 Merge bool
35 Root bool
36 Refs []string
37 // NFiles, Adds and Dels are the totals of the commit, including the files
38 // that the diff omits.
39 NFiles, Adds, Dels int
40 Files []DiffFile
41 // Note says the diff is incomplete.
42 Note string
43 DiffView
44 // Invisible is true when some line holds an invisible character, shown as a
45 // marker.
46 Invisible bool
47 // Checks is the combined state of the CI checks of the commit.
48 Checks ChecksSummary
49}
50
51// DiffView is how a page lays its diff out: the layouts in the page and the
52// way to switch between them. The commit page and the merge request pages
53// share it.
54type DiffView struct {
55 // View is the layout shown: "unified" or "split". Pinned is true when the
56 // URL chose it, which then wins over the choice remembered by the browser.
57 View string
58 Pinned bool
59 // Both is true when both layouts are in the page, and the toggle works in
60 // the browser; otherwise only View is sent.
61 Both bool
62 ShowUnified, ShowSplit bool
63 UnifiedHref, SplitHref string
64}
65
66// newDiffView reads ?view= and decides which layouts a diff of that many
67// lines sends; the links switch to the other layout.
68func newDiffView(r *http.Request, lines int, unifiedHref, splitHref string) DiffView {
69 v := DiffView{View: "unified", UnifiedHref: unifiedHref, SplitHref: splitHref}
70 switch r.URL.Query().Get("view") {
71 case "split":
72 v.View, v.Pinned = "split", true
73 case "unified":
74 v.Pinned = true
75 }
76 v.Both = lines <= bothViewsLines
77 v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
78 return v
79}
80
81// diffLines counts the lines of the hunks of files, and tells whether some
82// line holds a marker.
83func diffLines(files []DiffFile) (lines int, invisible bool) {
84 for _, f := range files {
85 for _, h := range f.Hunks {
86 lines += len(h.Lines)
87 invisible = invisible || slices.ContainsFunc(h.Lines, hasMarker)
88 }
89 }
90 return lines, invisible
91}
92
93// commit shows a commit: its message, its totals, the changed files and the
94// diff against the first parent. ?view=split selects the split layout.
95//
96//hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here
97func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
98 sha := r.PathValue("sha")
99 if !isCommitID(sha) {
100 return nil, 0, errNotFound
101 }
102 d, err := s.loadRepo(ctx, r.PathValue("repo"))
103 if err != nil {
104 return nil, 0, err
105 }
106 res, err := s.getCommit(ctx, d.repo.Name, sha)
107 if err != nil {
108 return nil, 0, err
109 }
110 c := res.Commit
111 cid := c.ID.String()
112 now := s.cfg.Now()
113
114 p := CommitPage{
115 SHA: cid, Short: short(cid),
116 Subject: visibleText(c.Summary), Body: commitBody(c),
117 Author: person(c.Author, now),
118 Merge: len(c.Parents) > 1,
119 Root: len(c.Parents) == 0,
120 NFiles: res.Stats.FilesChanged,
121 Adds: res.Stats.Additions,
122 Dels: res.Stats.Deletions,
123 Files: diffFiles(res.Diff.Files),
124 }
125 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
126 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
127 p.Committer = &cm
128 }
129 for _, pid := range c.Parents {
130 id := pid.String()
131 p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
132 }
133 for _, b := range d.branches {
134 if b.CommitID.String() == cid {
135 p.Refs = append(p.Refs, b.ShortName)
136 }
137 }
138 for _, t := range d.tags {
139 if t.CommitID.String() == cid {
140 p.Refs = append(p.Refs, "tag: "+t.ShortName)
141 }
142 }
143 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
144 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
145
146 lines, invisible := diffLines(p.Files)
147 p.Invisible = invisible
148 base := repoHref(d.name(), "commit", cid)
149 p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
150
151 l := s.repoLayout(ctx, d, "commits")
152 l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
153 pinRef(&l, target{rev: cid, name: p.Short, detached: true})
154 history := repoHref(d.name())
155 if d.head != nil {
156 history = repoHref(d.name(), "commits", d.head.ShortName)
157 }
158 l.Crumbs = []Crumb{
159 {Label: d.name(), Href: repoHref(d.name())},
160 {Label: "commit", Href: history},
161 {Label: p.Short, Current: true},
162 }
163 l.HasRail = true
164 return Commit(l, p), http.StatusOK, nil
165}
166
167// person converts a signature.
168func person(s domain.Signature, now time.Time) Person {
169 p := Person{Name: visibleText(s.Name)}
170 p.When, p.WhenAt = whenOf(now, s.Time)
171 if !s.Time.IsZero() {
172 p.Abs = absolute(s.Time)
173 }
174 return p
175}
176
177// commitBody is the message without its first line, for display.
178func commitBody(c domain.Commit) string {
179 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
180 _, body, _ := strings.Cut(msg, "\n")
181 return messageText(strings.TrimSpace(body))
182}
183
184// diffNote says what is missing from an incomplete diff, "" for a complete one.
185func diffNote(d domain.Diff, shown, total int) string {
186 if !d.Truncated {
187 return ""
188 }
189 var parts []string
190 if shown < total {
191 parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
192 }
193 cut := 0
194 for _, f := range d.Files {
195 if f.Truncated {
196 cut++
197 }
198 }
199 if cut > 0 {
200 parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
201 }
202 return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
203}