1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "net/url"
10 "strconv"
11 "strings"
12
13 "github.com/a-h/templ"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// The diff of a merge request is read a window of files at a time. The page
20// reads the files of one window (the first, or the one ?from= names) with
21// their hunks and lists the others as placeholders; a placeholder holds the
22// address of its window, which the browser fetches when it comes near the
23// screen and swaps in (mr.js). The window carries the base and the head the
24// page shows: when they moved, the use case refuses (ErrConflict) and the
25// placeholder offers to reload instead of mixing two diffs in one page.
26
27// MRFilesPage is a window of files of a merge request, for /files: the files
28// of the window, rendered like in the page, and nothing else.
29type MRFilesPage struct {
30 Files []DiffFile
31 DiffView
32}
33
34// windowStart is the first file of the window that holds file i.
35func windowStart(i int) int { return i - i%mrWindow }
36
37// withFrom adds the window of a page to its address: the page of the files
38// [from, from+mrWindow) when from is not the first window.
39func withFrom(href string, from int) string {
40 if from <= 0 {
41 return href
42 }
43 sep := "?"
44 if strings.Contains(href, "?") {
45 sep = "&"
46 }
47 return href + sep + "from=" + itoa(from)
48}
49
50// formFrom reads the window a form was posted from (a hidden field "from"):
51// 0 when absent or not a window start, so that a redirect never carries
52// anything else.
53func formFrom(r *http.Request) int {
54 s := r.PostFormValue("from")
55 n, err := strconv.Atoi(s)
56 if err != nil || n <= 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
57 return 0
58 }
59 return n
60}
61
62// parseFrom reads ?from=: the start of a window, 0 when absent. Anything else
63// (not a number, negative, not a window start) is not a page.
64func parseFrom(r *http.Request) (int, error) {
65 s := r.URL.Query().Get("from")
66 if s == "" {
67 return 0, nil
68 }
69 n, err := strconv.Atoi(s)
70 if err != nil || n < 0 || n%mrWindow != 0 || strconv.Itoa(n) != s {
71 return 0, errNotFound
72 }
73 return n, nil
74}
75
76// lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
77// a placeholder that loads their window. src is the URL of /files with the
78// base and the head of the diff; self the page, for the links of a page
79// without scripts.
80func lazyFiles(files []DiffFile, from int, src string) {
81 for i := range files {
82 f := &files[i]
83 if i >= from && i < from+mrWindow {
84 continue
85 }
86 f.Lazy = true
87 f.From = windowStart(i)
88 f.Src = src + "&from=" + itoa(f.From)
89 f.Height = lazyHeight(f.Add + f.Del)
90 f.Open = !f.Bin && f.Add+f.Del > 0
91 }
92}
93
94// lazyHeight is the size class of the placeholder of a file with n changed
95// lines: about the room its diff takes, so that the page does not jump much
96// when it comes in.
97func lazyHeight(n int) int {
98 switch {
99 case n <= 10:
100 return 1
101 case n <= 50:
102 return 2
103 case n <= 200:
104 return 3
105 default:
106 return 4
107 }
108}
109
110// filesSrc is the address of the windows of a diff.
111func filesSrc(repo string, n int64, since bool, base, head string) string {
112 q := url.Values{}
113 if since {
114 q.Set("since", "1")
115 }
116 q.Set("base", base)
117 q.Set("head", head)
118 return mrHref(repo, n, "files") + "?" + q.Encode()
119}
120
121// countNotes sets, on each file, the number of comments on its lines and the
122// ids of those comments.
123func countNotes(files []DiffFile, comments []domain.Comment) {
124 at := make(map[string]int, len(files))
125 for i, f := range files {
126 at[f.Key] = i
127 }
128 ids := make([][]string, len(files))
129 for _, c := range comments {
130 i, ok := at[c.Path]
131 if c.Path == "" || !ok {
132 continue
133 }
134 if c.ReplyTo == 0 && !c.Deleted {
135 files[i].Notes++
136 }
137 if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
138 files[i].OpenNotes++
139 }
140 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
141 }
142 for i := range files {
143 files[i].Comments = strings.Join(ids[i], " ")
144 }
145}
146
147// splitPath is the last element of a path and the directory before it.
148func splitPath(p string) (name, dir string) {
149 i := strings.LastIndexByte(p, '/')
150 if i < 0 {
151 return visibleText(p), ""
152 }
153 return visibleText(p[i+1:]), visibleText(p[:i])
154}
155
156// mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]:
157// the files of a window with their hunks and their comments, for a
158// placeholder of the page. A base or a head that is not the one of the diff
159// any more answers 409: the page says to reload.
160func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
161 n, err := mrNumber(r)
162 if err != nil {
163 return nil, 0, err
164 }
165 q := r.URL.Query()
166 since := q.Get("since") == "1"
167 if since && !SignedIn(ctx) {
168 return nil, 0, errNotFound
169 }
170 from, err := parseFrom(r)
171 if err != nil {
172 return nil, 0, err
173 }
174 base, errB := domain.ParseObjectID(q.Get("base"))
175 head, errH := domain.ParseObjectID(q.Get("head"))
176 if errB != nil || errH != nil {
177 return nil, 0, errNotFound
178 }
179 d, err := s.loadRepo(ctx, r.PathValue("repo"))
180 if err != nil {
181 return nil, 0, err
182 }
183 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
184 SinceLastRead: since, DiffOnly: true, Base: base, Head: head,
185 Files: &ports.FileWindow{From: from, To: from + mrWindow},
186 })
187 if errors.Is(err, domain.ErrConflict) {
188 return MRFilesMoved(), http.StatusConflict, nil
189 }
190 if err != nil {
191 return nil, 0, err
192 }
193 if from >= len(res.Diff.Files) {
194 return nil, 0, errNotFound
195 }
196 m := res.MergeRequest
197 signed := SignedIn(ctx)
198 canWrite := signed && m.State == domain.MRStateOpen && !since
199 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
200 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
201 s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
202 window := p.Files[from:min(from+mrWindow, len(p.Files))]
203 // The layouts follow the page: it sends ?view= (the layout shown) and
204 // ?layouts=both when it holds both layouts and switches them itself, so
205 // that every window of the page renders the same way.
206 self := mrHref(d.name(), n)
207 if since {
208 self = mrHref(d.name(), n, "since")
209 }
210 lines, _ := diffLines(window)
211 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
212 switch r.URL.Query().Get("layouts") {
213 case "both":
214 v.Both = true
215 case "one":
216 v.Both = false
217 }
218 v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
219 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
220}