v0.25.1 7fabed9

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// 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		files[i].Notes++
135		if !c.Resolved {
136			files[i].OpenNotes++
137		}
138		ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10))
139	}
140	for i := range files {
141		files[i].Comments = strings.Join(ids[i], " ")
142	}
143}
144
145// splitPath is the last element of a path and the directory before it.
146func splitPath(p string) (name, dir string) {
147	i := strings.LastIndexByte(p, '/')
148	if i < 0 {
149		return visibleText(p), ""
150	}
151	return visibleText(p[i+1:]), visibleText(p[:i])
152}
153
154// mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]:
155// the files of a window with their hunks and their comments, for a
156// placeholder of the page. A base or a head that is not the one of the diff
157// any more answers 409: the page says to reload.
158//
159//hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here
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}
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}

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 2

Tags 45

↑↓ moveenter openesc close