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 "bnema.dev/ermite/internal/ports"
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 DiffView
45 // Invisible is true when some line holds an invisible character, shown as a
46 // marker.
47 Invisible bool
48 // Checks is the combined state of the CI checks of the commit.
49 Checks ChecksSummary
50 // Sig is the signature badge, nil when the commit was not checked.
51 Sig *SignatureBadge
52 // Verification is what is known of the signing key, for the details.
53 Verification domain.CommitVerification
54}
55
56// DiffView is how a page lays its diff out: the layouts in the page and the
57// way to switch between them. The commit page and the merge request pages
58// share it.
59type DiffView struct {
60 // View is the layout shown: "unified" or "split". Pinned is true when the
61 // URL chose it, which then wins over the choice remembered by the browser.
62 View string
63 Pinned bool
64 // Both is true when both layouts are in the page, and the toggle works in
65 // the browser; otherwise only View is sent.
66 Both bool
67 ShowUnified, ShowSplit bool
68 UnifiedHref, SplitHref string
69}
70
71// newDiffView reads ?view= and decides which layouts a diff of that many
72// lines sends; the links switch to the other layout.
73func newDiffView(r *http.Request, lines int, unifiedHref, splitHref string) DiffView {
74 v := DiffView{View: "unified", UnifiedHref: unifiedHref, SplitHref: splitHref}
75 switch r.URL.Query().Get("view") {
76 case "split":
77 v.View, v.Pinned = "split", true
78 case "unified":
79 v.Pinned = true
80 }
81 v.Both = lines <= bothViewsLines
82 v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
83 return v
84}
85
86// diffLines counts the lines of the hunks of files, and tells whether some
87// line holds a marker.
88func diffLines(files []DiffFile) (lines int, invisible bool) {
89 for _, f := range files {
90 for _, h := range f.Hunks {
91 lines += len(h.Lines)
92 invisible = invisible || slices.ContainsFunc(h.Lines, hasMarker)
93 }
94 }
95 return lines, invisible
96}
97
98// commit shows a commit: its message, its totals, the changed files and the
99// diff against the first parent. ?view=split selects the split layout.
100func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
101 sha := r.PathValue("sha")
102 if !isCommitID(sha) {
103 return nil, 0, errNotFound
104 }
105 d, err := s.loadRepo(ctx, r.PathValue("repo"))
106 if err != nil {
107 return nil, 0, err
108 }
109 res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
110 if err != nil {
111 return nil, 0, err
112 }
113 c := res.Commit
114 cid := c.ID.String()
115 now := s.cfg.Now()
116
117 p := CommitPage{
118 SHA: cid, Short: short(cid),
119 Subject: visibleText(c.Summary), Body: commitBody(c),
120 Author: person(c.Author, now),
121 Merge: len(c.Parents) > 1,
122 Root: len(c.Parents) == 0,
123 NFiles: res.Stats.FilesChanged,
124 Adds: res.Stats.Additions,
125 Dels: res.Stats.Deletions,
126 Files: s.hl.files(ctx, res.Diff.Files),
127
128 Sig: signatureBadge(c.Verification, true),
129 Verification: c.Verification,
130 }
131 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132 collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
134 p.Committer = &cm
135 }
136 for _, pid := range c.Parents {
137 id := pid.String()
138 p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
139 }
140 for _, b := range d.branches {
141 if b.CommitID.String() == cid {
142 p.Refs = append(p.Refs, b.ShortName)
143 }
144 }
145 for _, t := range d.tags {
146 if t.CommitID.String() == cid {
147 p.Refs = append(p.Refs, "tag: "+t.ShortName)
148 }
149 }
150 p.Note = diffNote(res.Diff)
151 p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
152
153 lines, invisible := diffLines(p.Files)
154 p.Invisible = invisible
155 base := repoHref(d.name(), "commit", cid)
156 p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
157
158 l := s.repoLayout(ctx, d, "commits")
159 l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
160 pinRef(&l, target{rev: cid, name: p.Short, detached: true})
161 history := repoHref(d.name())
162 if d.head != nil {
163 history = repoHref(d.name(), "commits", d.head.ShortName)
164 }
165 l.Crumbs = []Crumb{
166 {Label: d.name(), Href: repoHref(d.name())},
167 {Label: "commit", Href: history},
168 {Label: p.Short, Current: true},
169 }
170 l.HasRail = true
171 l.Lazy = true
172 return Commit(l, p), http.StatusOK, nil
173}
174
175// person converts a signature.
176func person(s domain.Signature, now time.Time) Person {
177 p := Person{Name: visibleText(s.Name)}
178 p.When, p.WhenAt = whenOf(now, s.Time)
179 if !s.Time.IsZero() {
180 p.Abs = absolute(s.Time)
181 }
182 return p
183}
184
185// commitBody is the message without its first line, for display.
186func commitBody(c domain.Commit) string {
187 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
188 _, body, _ := strings.Cut(msg, "\n")
189 return messageText(strings.TrimSpace(body))
190}
191
192// diffNote says what is missing from an incomplete diff, "" for a complete one:
193// files too large for the browser at all. Collapsed files say so on their own
194// line and load on their own; every changed file is listed.
195func diffNote(d domain.Diff) string {
196 gone := 0
197 for _, f := range d.Files {
198 if f.Omitted == domain.OmitUnavailable {
199 gone++
200 }
201 }
202 if gone == 0 {
203 return ""
204 }
205 return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
206}
207
208// commitWindow is how many files of a commit get their hunks read with the
209// page; the files past it are collapsed, and load on their own.
210const commitWindow = 300
211
212// commitDiff reads a commit for its page (and the compare page): every file
213// listed, the hunks of the first commitWindow files read, generated files
214// collapsed. Files past the window are collapsed too. With ?file=i (the link
215// of a page without scripts) file i is read in full and replaces its entry.
216func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
217 res, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: 0, To: commitWindow}})
218 if err != nil {
219 return res, err
220 }
221 for i := commitWindow; i < len(res.Diff.Files); i++ {
222 if f := &res.Diff.Files[i]; !f.Binary && f.Omitted == domain.OmitNone && f.Additions+f.Deletions > 0 {
223 f.Omitted = domain.OmitBudget
224 }
225 }
226 if i, ok := fileParam(r, len(res.Diff.Files)); ok && res.Diff.Files[i].Omitted.Collapsed() {
227 one, err := s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
228 if err != nil {
229 return res, err
230 }
231 if i < len(one.Diff.Files) {
232 res.Diff.Files[i] = one.Diff.Files[i]
233 }
234 }
235 return res, nil
236}
237
238// commitLinks are the addresses a commit page loads its collapsed files from.
239func commitLinks(repo, rev string) fileLinks {
240 return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
241}
242
243// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
244// the one file i of the commit with its hunks, in full, as the one
245// details.dfile the page swaps for its placeholder.
246func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
247 sha := r.PathValue("sha")
248 i, err := parseOne(r)
249 if err != nil || !isCommitID(sha) {
250 return nil, 0, errNotFound
251 }
252 d, err := s.loadRepo(ctx, r.PathValue("repo"))
253 if err != nil {
254 return nil, 0, err
255 }
256 res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
257 if err != nil {
258 return nil, 0, err
259 }
260 if i >= len(res.Diff.Files) {
261 return nil, 0, errNotFound
262 }
263 cid := res.Commit.ID.String()
264 raw := res.Diff.Files[i : i+1]
265 files := s.hl.files(ctx, raw)
266 files[0].ID = "f" + itoa(i+1)
267 contextGaps(files, raw, repoHref(d.name(), "context", cid))
268 files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
269 lines, _ := diffLines(files)
270 return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
271}