v0.36.0 1fb2421

mr_files.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close