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// parseOne reads ?one=1&from=i of a one-file fragment: the index i of the
77// file, any non-negative number.
78func parseOne(r *http.Request) (int, error) {
79 q := r.URL.Query()
80 s := q.Get("from")
81 n, err := strconv.Atoi(s)
82 if q.Get("one") != "1" || err != nil || n < 0 || strconv.Itoa(n) != s {
83 return 0, errNotFound
84 }
85 return n, nil
86}
87
88// lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows
89// a placeholder that loads their window. src is the URL of /files with the
90// base and the head of the diff; self the page, for the links of a page
91// without scripts.
92func lazyFiles(files []DiffFile, from int, src string) {
93 for i := range files {
94 f := &files[i]
95 if i >= from && i < from+mrWindow {
96 continue
97 }
98 f.Lazy = true
99 f.From = windowStart(i)
100 f.Src = src + "&from=" + itoa(f.From)
101 f.Height = lazyHeight(f.Add + f.Del)
102 f.Open = !f.Bin && f.Add+f.Del > 0
103 }
104}
105
106// lazyHeight is the size class of the placeholder of a file with n changed
107// lines: about the room its diff takes, so that the page does not jump much
108// when it comes in.
109func lazyHeight(n int) int {
110 switch {
111 case n <= 10:
112 return 1
113 case n <= 50:
114 return 2
115 case n <= 200:
116 return 3
117 default:
118 return 4
119 }
120}
121
122// filesSrc is the address of the windows of a diff.
123func filesSrc(repo string, n int64, since bool, base, head string) string {
124 q := url.Values{}
125 if since {
126 q.Set("since", "1")
127 }
128 q.Set("base", base)
129 q.Set("head", head)
130 return mrHref(repo, n, "files") + "?" + q.Encode()
131}
132
133// countNotes sets, on each file, the number of comments on its lines and the
134// ids of those comments.
135func countNotes(files []DiffFile, comments []domain.Comment) {
136 at := make(map[string]int, len(files))
137 for i, f := range files {
138 at[f.Key] = i
139 }
140 ids := make([][]string, len(files))
141 for _, c := range comments {
142 i, ok := at[c.Path]
143 if c.Path == "" || !ok {
144 continue
145 }
146 if c.ReplyTo == 0 && !c.Deleted {
147 files[i].Notes++
148 }
149 if c.ReplyTo == 0 && !c.Deleted && !c.Resolved {
150 files[i].OpenNotes++
151 }
152 ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
153 }
154 for i := range files {
155 files[i].Comments = strings.Join(ids[i], " ")
156 }
157}
158
159// splitPath is the last element of a path and the directory before it.
160func splitPath(p string) (name, dir string) {
161 i := strings.LastIndexByte(p, '/')
162 if i < 0 {
163 return visibleText(p), ""
164 }
165 return visibleText(p[i+1:]), visibleText(p[:i])
166}
167
168// mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]:
169// the files of a window with their hunks and their comments, for a
170// placeholder of the page. A base or a head that is not the one of the diff
171// any more answers 409: the page says to reload.
172func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
173 n, err := mrNumber(r)
174 if err != nil {
175 return nil, 0, err
176 }
177 q := r.URL.Query()
178 since := q.Get("since") == "1"
179 if since && !SignedIn(ctx) {
180 return nil, 0, errNotFound
181 }
182 one := q.Get("one") == "1"
183 var from int
184 if one {
185 from, err = parseOne(r)
186 } else {
187 from, err = parseFrom(r)
188 }
189 if err != nil {
190 return nil, 0, err
191 }
192 base, errB := domain.ParseObjectID(q.Get("base"))
193 head, errH := domain.ParseObjectID(q.Get("head"))
194 if errB != nil || errH != nil {
195 return nil, 0, errNotFound
196 }
197 d, err := s.loadRepo(ctx, r.PathValue("repo"))
198 if err != nil {
199 return nil, 0, err
200 }
201 win := &ports.FileWindow{From: from, To: from + mrWindow}
202 if one {
203 win = &ports.FileWindow{From: from, To: from + 1, Full: true}
204 }
205 res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
206 SinceLastRead: since, DiffOnly: true, Base: base, Head: head, Files: win,
207 })
208 if errors.Is(err, domain.ErrConflict) {
209 return MRFilesMoved(), http.StatusConflict, nil
210 }
211 if err != nil {
212 return nil, 0, err
213 }
214 if from >= len(res.Diff.Files) {
215 return nil, 0, errNotFound
216 }
217 m := res.MergeRequest
218 signed := SignedIn(ctx)
219 canWrite := signed && m.State == domain.MRStateOpen && !since
220 p := MRPage{Files: s.hl.files(ctx, res.Diff.Files), LooseInFiles: true, NoteAuthor: domain.AuthorName(principal(ctx))}
221 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head.String()))
222 links := fileLinks{src: filesSrc(d.name(), n, since, base.String(), head.String()), from: from, repo: d.name(), rev: head.String()}
223 if one {
224 // A one-file read holds file from alone, at index from: the load
225 // addresses of a window do not apply, only the raw link.
226 p.Files[from].Raw = rawLink(res.Diff.Files[from], links)
227 } else {
228 collapseFiles(p.Files, res.Diff.Files, links)
229 }
230 s.placeComments(ctx, &p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil)
231 window := p.Files[from:min(from+mrWindow, len(p.Files))]
232 if one {
233 window = p.Files[from : from+1]
234 }
235 // The layouts follow the page: it sends ?view= (the layout shown) and
236 // ?layouts=both when it holds both layouts and switches them itself, so
237 // that every window of the page renders the same way.
238 self := mrHref(d.name(), n)
239 if since {
240 self = mrHref(d.name(), n, "since")
241 }
242 lines, _ := diffLines(window)
243 v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
244 v.pageLayouts(r)
245 return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil
246}