v0.20.0 032b9ef

mr.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"html"
8	"io"
9	"net/http"
10	"net/url"
11	"strconv"
12	"strings"
13
14	"github.com/a-h/templ"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// The merge request pages (ADR 0017). They read through MergeRequestService
21// and BrowseService as the visitor of the request: anonymous sees the merge
22// requests of public repositories, the signed-in owner those of every
23// repository, and only the owner gets the forms (mr_post.go handles them).
24//
25// Routes:
26//
27//	GET  /<repo>/merge-requests              the list, open ones first
28//	GET  /<repo>/merge-requests/<n>          the request with its diff
29//	GET  /<repo>/merge-requests/<n>/since    the diff since the last read
30//	GET  /<repo>/compare/<branch>            a branch against the default branch
31//	GET  /<repo>/merge-requests/<n>/files    a window of files of the diff
32//
33// The page lists every changed file and reads the hunks of the first window
34// of files only (mrWindow); the files after it are placeholders that the
35// browser fills in, a window per request, as they come near the screen
36// (/files?from=&base=&head=). Without scripts each placeholder links to the
37// page of its window (?from=), which shows the hunks of that window.
38//
39// Reading marks nothing as read. MarkRead stores state, and a GET must stay
40// safe: a prefetcher, a link preview or a reload of the owner's tab would move
41// the read position, and the "since your last read" diff would lose what the
42// owner never saw. So the owner marks a head read with a POST button
43// ("[mark read]", /<n>/read), which carries the head the page showed; the
44// button only shows when that head is not the one already read. The since page
45// is the owner's alone (the position is the owner's), and answers 404 to
46// anyone else.
47//
48// Every page here is rendered through page(), so for the owner it is
49// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
50// forms and the CLI lines exist only in the owner's copy.
51
52const (
53	// mrWindow is how many files of a merge request one read gets the hunks
54	// of: the page shows the first window and loads the others.
55	mrWindow = 25
56	// mrPerPage is how many decided merge requests a page of the list shows,
57	// at least; pages of the use case are read whole.
58	mrPerPage = 50
59	// maxMRPages bounds the pages of the use case one list page reads.
60	maxMRPages = 20
61	// maxOpenPages bounds the pages read for the open merge requests, which
62	// the first list page shows in full.
63	maxOpenPages = 5
64	// maxComparePages bounds how many pages of history the compare page reads
65	// to find where the branch left the target.
66	maxComparePages = 5
67	// railBranches is how many branches the list offers to compare.
68	railBranches = 15
69)
70
71// MRRow is one merge request in the list.
72type MRRow struct {
73	Number   string
74	Href     string
75	Title    string
76	Branches string
77	Author   string
78	State    string
79	When     string
80	WhenAt   string
81	Cursor   bool
82}
83
84// BranchLink is a branch the list offers to open a merge request for.
85type BranchLink struct{ Name, Href string }
86
87// MRListPage is the list of the merge requests of a repository.
88type MRListPage struct {
89	Open, Done []MRRow
90	// OpenNote says the open list was cut.
91	OpenNote string
92	// Older and Newest are the paging links of the decided requests.
93	Older, Newest string
94	Branches      []BranchLink
95	Default       string
96	CLI           Clone
97	// Live is true when the page announces changes.
98	Live bool
99}
100
101// CommentView is a comment of a merge request, ready to show.
102type CommentView struct {
103	ID       string
104	Author   string
105	When     string
106	WhenAt   string
107	Abs      string
108	Parts    []TextPart
109	Resolved bool
110	// Outdated is true when the head moved since the comment was written, so
111	// its line may have moved too.
112	Outdated bool
113	// Where names the line of a comment that is not under a line of the diff
114	// shown ("path:12 (new)").
115	Where string
116	// Resolve is the form that resolves or unresolves it; nil when the reader
117	// cannot.
118	Resolve *ResolveForm
119}
120
121// ResolveForm is the POST that changes the resolved state of a comment.
122type ResolveForm struct {
123	Action string
124	// Value is the state the form sets.
125	Value bool
126	Label string
127	// From is the window of the page the comment is on (CommentForm.From).
128	From int
129}
130
131// CommentForm is the POST that adds a comment.
132type CommentForm struct {
133	ID     string
134	Action string
135	Title  string
136	// Path, Line and Side designate the line; empty for a general comment.
137	Path, Line, Side string
138	Cancel           string
139	Focus            bool
140	// From is the window of the page the form is on (see mrWindow), sent
141	// back so that the answer returns to it; 0 for the first.
142	From int
143}
144
145// MRCommit is a commit of the request.
146type MRCommit struct {
147	Short, Href, Subject, Author, When, WhenAt string
148	// New is true for a commit pushed after the head the reviewer last read.
149	New bool
150}
151
152// MergeBox is what the owner does with an open request.
153type MergeBox struct {
154	MergeAction string
155	CloseAction string
156	// ReadyAction takes a draft out of draft; empty when it is not one: a
157	// draft shows it in place of the merge form.
158	ReadyAction string
159	ReadAction  string
160	Head        string
161	Modes       []ModeOption
162	// HeadGone is true when the source branch no longer exists: there is
163	// nothing to merge.
164	HeadGone bool
165	CLI      Clone
166	// CanMarkRead is true when the head differs from the one last read.
167	CanMarkRead bool
168	// CanDeleteBranch offers to delete the source after the merge: it is not
169	// the default branch. The server checks again, with the open requests.
170	CanDeleteBranch bool
171}
172
173// ModeOption is a merge mode the repository allows.
174type ModeOption struct{ Value, Label string }
175
176// SinceNav switches between the whole diff and the diff since the last read.
177type SinceNav struct {
178	AllHref, SinceHref string
179	Files              int
180	ReadShort          string
181	HeadShort          string
182	Current            bool
183}
184
185// MRPage is a merge request with its diff and comments.
186type MRPage struct {
187	Number         string
188	Title          string
189	State          string
190	Open           bool
191	Signed         bool
192	Source, Target string
193	SourceHref     string
194	TargetHref     string
195	Author         string
196	Opened         Person
197	Updated        string
198	UpdatedAt      string
199	Desc           []TextPart
200	Head           string
201	HeadShort      string
202	MergedShort    string
203	MergedHref     string
204	MergeMode      string
205
206	Since     *SinceNav
207	SinceNote string
208
209	NFiles, Adds, Dels int
210	Files              []DiffFile
211	DiffView
212	Note      string
213	Invisible bool
214
215	// General are the comments that are not on a line, Loose those on a line
216	// that the diff shown does not have. With LooseInFiles (a window of files
217	// loaded later) the loose comments of a listed file go under the file
218	// instead (DiffFile.Loose).
219	General, Loose []CommentView
220	LooseInFiles   bool
221	NComments      int
222	NUnresolved    int
223	GeneralForm    *CommentForm
224
225	Commits     []MRCommit
226	CommitsNote string
227	Modes       []string
228	ChecksBlock bool
229	// Checks are the CI checks of the head of the request, ChecksSum their
230	// state in the bar above the diff.
231	Checks    ChecksSection
232	ChecksSum ChecksSummary
233
234	Box *MergeBox
235	// Live is true when the page announces changes.
236	Live bool
237	// From is the first file of the window the page read the hunks of (?from=).
238	From int
239}
240
241// CompareCommit is a commit of a branch that the target lacks.
242type CompareCommit = CommitRow
243
244// ComparePage is a branch compared with the default branch.
245type ComparePage struct {
246	Source, Target string
247	SourceHref     string
248	TargetHref     string
249	Ahead          []CommitRow
250	AheadText      string
251	CLI            Clone
252	// Note says what the page could not tell.
253	Note string
254	// The diff is the one of the single commit the branch is ahead by.
255	NFiles, Adds, Dels int
256	Files              []DiffFile
257	DiffView
258	Invisible bool
259	HasDiff   bool
260	// Existing is the open merge request for this pair, when there is one.
261	Existing *Link
262	// Create is the form to open one; nil when the reader cannot or there is
263	// one already.
264	Create *CreateForm
265}
266
267// CreateForm is the POST that opens a merge request.
268type CreateForm struct {
269	Action, Source, Target, Title string
270}
271
272// mrBase is the URL of the merge request pages of a repository.
273func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
274
275func mrHref(repo string, n int64, sub ...string) string {
276	return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
277}
278
279// mrNumber reads the number of the path: a positive decimal number, written
280// without sign or leading zeros, else the page is not found.
281func mrNumber(r *http.Request) (int64, error) {
282	s := r.PathValue("n")
283	n, err := strconv.ParseInt(s, 10, 64)
284	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
285		return 0, errNotFound
286	}
287	return n, nil
288}
289
290// mrStateName is the state a page shows: "draft" for an open request whose
291// title marks it so.
292func mrStateName(m domain.MergeRequest) string {
293	if m.IsOpen() && m.IsDraft() {
294		return "draft"
295	}
296	return stateName(m.State)
297}
298
299func stateName(s domain.MRState) string {
300	switch s {
301	case domain.MRStateOpen:
302		return "open"
303	case domain.MRStateMerged:
304		return "merged"
305	case domain.MRStateClosed:
306		return "closed"
307	}
308	return "unknown"
309}
310
311func modeName(m domain.MergeMode) string {
312	switch m {
313	case domain.MergeModeFastForward:
314		return "fast-forward"
315	case domain.MergeModeSquash:
316		return "squash"
317	}
318	return ""
319}
320
321// mrRow converts a merge request of a listing.
322func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
323	row := MRRow{
324		Number: "#" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
325		Title: visibleText(m.Title.String()), State: mrStateName(m),
326		Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
327		Author:   s.who(m.Author),
328	}
329	ts := m.UpdatedAt
330	if ts.IsZero() {
331		ts = m.CreatedAt
332	}
333	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
334	return row
335}
336
337// mrList lists the merge requests: the open ones, then the merged and closed
338// ones, a page at a time. The decided ones are paged with the page token of
339// the use case in "page"; the open ones show on the first page only.
340func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
341	d, err := s.loadRepo(ctx, r.PathValue("repo"))
342	if err != nil {
343		return nil, 0, err
344	}
345	token := r.URL.Query().Get("page")
346	p := MRListPage{Default: visibleText(d.repo.DefaultBranch)}
347	p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
348	base := mrBase(d.name())
349
350	if token == "" {
351		tok := ""
352		for i := 0; i < maxOpenPages; i++ {
353			res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
354				State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok,
355			})
356			if err != nil {
357				return nil, 0, err
358			}
359			for _, m := range res.MergeRequests {
360				p.Open = append(p.Open, s.mrRow(d.name(), m))
361			}
362			if tok = res.NextPageToken; tok == "" {
363				break
364			}
365		}
366		if tok != "" {
367			p.OpenNote = "more open merge requests than are listed here."
368		}
369	}
370
371	tok := token
372	for i := 0; i < maxMRPages; i++ {
373		res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{
374			PageSize: pageSize, PageToken: tok,
375		})
376		if err != nil {
377			return nil, 0, err
378		}
379		for _, m := range res.MergeRequests {
380			if m.State != domain.MRStateOpen {
381				p.Done = append(p.Done, s.mrRow(d.name(), m))
382			}
383		}
384		tok = res.NextPageToken
385		if tok == "" || len(p.Done) >= mrPerPage {
386			break
387		}
388	}
389	if tok != "" {
390		p.Older = base + "?page=" + url.QueryEscape(tok)
391	}
392	if token != "" {
393		p.Newest = base
394	}
395	switch {
396	case len(p.Open) > 0:
397		p.Open[0].Cursor = true
398	case len(p.Done) > 0:
399		p.Done[0].Cursor = true
400	}
401	for _, b := range d.branches {
402		if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches {
403			continue
404		}
405		p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)})
406	}
407
408	l := s.repoLayout(ctx, d, "merge-requests")
409	l.Title = "merge requests · " + d.name() + " · " + s.instance
410	l.Crumbs[0].Current = false
411	l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
412	if token == "" {
413		p.Live = true
414		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
415	}
416	l.HasRail = true
417	return MRList(l, p), http.StatusOK, nil
418}
419
420// mrShow shows a merge request: the whole diff against the target.
421func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
422	return s.mrPage(ctx, r, false)
423}
424
425// mrSince shows the diff since the head the reviewer last marked read.
426func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
427	return s.mrPage(ctx, r, true)
428}
429
430//hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here
431func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
432	n, err := mrNumber(r)
433	if err != nil {
434		return nil, 0, err
435	}
436	if since && !SignedIn(ctx) {
437		// The read position is the owner's: there is nothing to show others.
438		return nil, 0, errNotFound
439	}
440	d, err := s.loadRepo(ctx, r.PathValue("repo"))
441	if err != nil {
442		return nil, 0, err
443	}
444	from, err := parseFrom(r)
445	if err != nil {
446		return nil, 0, err
447	}
448	res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{
449		SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow},
450	})
451	if err != nil {
452		return nil, 0, err
453	}
454	if from > 0 && from >= len(res.Diff.Files) {
455		return nil, 0, errNotFound
456	}
457	m := res.MergeRequest
458	now := s.cfg.Now()
459	signed := SignedIn(ctx)
460	open := m.State == domain.MRStateOpen
461	head := m.HeadID.String()
462	self := mrHref(d.name(), n)
463	if since {
464		self = mrHref(d.name(), n, "since")
465	}
466
467	p := MRPage{
468		Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
469		State: mrStateName(m), Open: open, Signed: signed,
470		Source: visibleText(m.Source), Target: visibleText(m.Target),
471		Author: s.who(m.Author),
472		Desc:   plainParts(m.Description.String()),
473		Head:   head, HeadShort: short(head),
474		NFiles: res.Stats.FilesChanged,
475		Adds:   res.Stats.Additions,
476		Dels:   res.Stats.Deletions,
477		Files:  diffFiles(res.Diff.Files),
478	}
479	contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head))
480	p.Opened.Name = p.Author
481	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
482	if !m.CreatedAt.IsZero() {
483		p.Opened.Abs = absolute(m.CreatedAt)
484	}
485	p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
486	for _, b := range d.branches {
487		switch b.ShortName {
488		case m.Source:
489			p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
490		case m.Target:
491			p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
492		}
493	}
494	if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
495		p.MergedShort = short(m.MergedCommitID.String())
496		p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
497		p.MergeMode = modeName(m.MergeMode)
498	}
499	for _, mode := range d.repo.Settings.AllowedMergeModes {
500		if name := modeName(mode); name != "" {
501			p.Modes = append(p.Modes, name)
502		}
503	}
504	p.ChecksBlock = d.repo.Settings.FailingChecksBlock
505	if head != "" {
506		checks := s.checksFor(ctx, d.repo.Name, head)
507		p.Checks = checksSection(d.repo.Repo, checks)
508		p.ChecksSum = checksSummary(d.repo.Name, head, checks)
509		if !open {
510			p.Checks.Note = ""
511		}
512	}
513	p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
514	countNotes(p.Files, res.Comments)
515	if !res.DiffBaseID.IsZero() {
516		lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
517	}
518	p.From = from
519
520	// The commits of the request, newest first.
521	for _, c := range res.Commits {
522		row := commitRow(d.name(), c, now)
523		p.Commits = append(p.Commits, MRCommit{
524			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
525		})
526	}
527	lastRead := res.LastReadID.String()
528	if signed && lastRead != "" {
529		// The commits are newest first: those before the last read head are new.
530		// A head that is not among them (the branch was rewritten) marks none.
531		for i, c := range res.Commits {
532			if c.ID.String() != lastRead {
533				continue
534			}
535			for j := 0; j < i; j++ {
536				p.Commits[j].New = true
537			}
538		}
539	}
540	if res.CommitsTruncated {
541		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
542	}
543
544	// Since the last read: the navigation, and what the page says about it.
545	if !signed {
546		lastRead = "" // the read position is the owner's
547	}
548	if signed && lastRead != "" {
549		p.Since = &SinceNav{
550			AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
551			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
552		}
553	}
554	if since {
555		switch {
556		case lastRead == "":
557			p.SinceNote = "nothing was marked read yet: this is the whole change."
558		case res.LastReadLost:
559			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
560		case len(p.Files) == 0:
561			p.SinceNote = "nothing changed since " + short(lastRead) + "."
562		default:
563			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
564		}
565	}
566	if p.Since != nil {
567		p.Since.Files = len(p.Files)
568	}
569
570	// Comments: under their line, else in the list below the diff. The forms
571	// are the owner's, on the whole diff of an open request.
572	canWrite := signed && open && !since
573	var on *lineRef
574	if canWrite {
575		on = parseOn(r.URL.Query().Get("on"))
576	}
577	s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
578	lines, invisible := diffLines(p.Files)
579	p.Invisible = invisible
580	p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
581	if signed && open {
582		p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
583		if m.IsDraft() {
584			p.Box.ReadyAction = mrHref(d.name(), n, "ready")
585		}
586	}
587	if canWrite {
588		p.GeneralForm = &CommentForm{
589			ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
590		}
591	}
592
593	l := s.repoLayout(ctx, d, "merge-requests")
594	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
595	if head != "" {
596		pinRef(&l, target{rev: head, name: m.Source})
597		if !d.hasBranch(m.Source) {
598			pinRef(&l, target{rev: head, name: short(head), detached: true})
599		}
600	}
601	l.Crumbs[0].Current = false
602	l.Crumbs = append(l.Crumbs,
603		Crumb{Label: "merge requests", Href: mrBase(d.name())},
604		Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
605	)
606	if since {
607		l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
608	}
609	p.Live = true
610	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
611	l.HasRail = true
612	l.MR = true
613	return MergeRequest(l, p), http.StatusOK, nil
614}
615
616// hasBranch tells whether the repository has the branch.
617func (d *repoData) hasBranch(name string) bool {
618	for _, b := range d.branches {
619		if b.ShortName == name {
620			return true
621		}
622	}
623	return false
624}
625
626// mergeBox is what the owner can do with an open request.
627func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
628	b := &MergeBox{
629		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
630		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
631		CanMarkRead:     head != "" && head != lastRead,
632		CanDeleteBranch: source != d.repo.DefaultBranch,
633	}
634	ff, sq := false, false
635	for _, mode := range d.repo.Settings.AllowedMergeModes {
636		switch mode {
637		case domain.MergeModeFastForward:
638			ff = true
639			b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
640		case domain.MergeModeSquash:
641			sq = true
642			b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
643		}
644	}
645	if head != "" {
646		cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
647		b.CLI = Clone{ID: "cli-merge"}
648		if ff {
649			b.CLI.Lines = append(b.CLI.Lines, cmd)
650		}
651		if sq {
652			b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
653		}
654	}
655	return b
656}
657
658// shellWord quotes s for a shell unless it is made of safe characters, which
659// a repository name always is.
660func shellWord(s string) string {
661	for _, c := range []byte(s) {
662		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
663			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
664		}
665	}
666	return s
667}
668
669// lineRef designates a line of the diff in the query of the comment form:
670// "?on=new:12:path/to/file".
671type lineRef struct {
672	side string
673	line int
674	path string
675}
676
677func parseOn(s string) *lineRef {
678	side, rest, ok := strings.Cut(s, ":")
679	if !ok || (side != "new" && side != "old") {
680		return nil
681	}
682	num, path, ok := strings.Cut(rest, ":")
683	line, err := strconv.Atoi(num)
684	if !ok || err != nil || line < 1 || path == "" {
685		return nil
686	}
687	return &lineRef{side: side, line: line, path: path}
688}
689
690// placeComments puts every comment under its line when the diff shown has it,
691// else in the list below the diff. The comments name lines of the diff against
692// the target, so a page that shows another diff (since the last read) lists
693// them all below, with their line. A reader who can write gets the line numbers
694// as links to the form of their line; on designates the line whose form shows.
695//
696//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
697func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
698	now := s.cfg.Now()
699	self := mrHref(repo, n)
700	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
701
702	type lineKey struct {
703		path string
704		side domain.DiffSide
705		line int
706	}
707	index := map[lineKey]*DiffLine{}
708	fileIDs := map[string]string{}
709	fileAt := map[string]int{}
710	for fi := range p.Files {
711		f := &p.Files[fi]
712		fileIDs[f.Key] = f.ID
713		fileAt[f.Key] = fi
714		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
715		// The form of a line shows on the page of the window of its file.
716		on := self + "?on="
717		if w := windowStart(fi); w > 0 {
718			on = self + "?from=" + itoa(w) + "&on="
719		}
720		for hi := range f.Hunks {
721			for li := range f.Hunks[hi].Lines {
722				ln := &f.Hunks[hi].Lines[li]
723				if ln.oldN > 0 && ln.Kind != "add" {
724					index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
725					if linkable {
726						ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
727					}
728				}
729				if ln.newN > 0 && ln.Kind != "del" {
730					index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
731					if linkable {
732						ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
733					}
734				}
735			}
736		}
737	}
738	find := func(path string, side domain.DiffSide, line int) *DiffLine {
739		if !inline {
740			return nil
741		}
742		return index[lineKey{path, side, line}]
743	}
744
745	for _, c := range comments {
746		cid := strconv.FormatInt(c.ID, 10)
747		v := CommentView{
748			ID: cid, Author: s.who(c.Author),
749			Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
750			Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
751		}
752		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
753		if !c.CreatedAt.IsZero() {
754			v.Abs = absolute(c.CreatedAt)
755		}
756		p.NComments++
757		if !c.Resolved {
758			p.NUnresolved++
759		}
760		general := c.Path == ""
761		fi, listed := fileAt[c.Path]
762		if !general && listed && p.Files[fi].Lazy {
763			// Its file comes with its window, and the comment with it.
764			continue
765		}
766		var ln *DiffLine
767		if !general {
768			ln = find(c.Path, c.Side, c.Line)
769			if ln == nil {
770				side := "new"
771				if c.Side == domain.SideOld {
772					side = "old"
773				}
774				v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
775			}
776		}
777		if canWrite {
778			v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
779			if !general && listed {
780				v.Resolve.From = windowStart(fi)
781			}
782			if c.Resolved {
783				v.Resolve.Label = "Unresolve"
784			}
785		}
786		switch {
787		case ln != nil:
788			ln.Notes = append(ln.Notes, v)
789		case general:
790			p.General = append(p.General, v)
791		case p.LooseInFiles && listed:
792			p.Files[fi].Loose = append(p.Files[fi].Loose, v)
793		default:
794			p.Loose = append(p.Loose, v)
795		}
796	}
797
798	if on != nil {
799		side := domain.SideNew
800		if on.side == "old" {
801			side = domain.SideOld
802		}
803		if ln := find(on.path, side, on.line); ln != nil {
804			ln.Form = &CommentForm{
805				ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
806				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
807				Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
808				From: windowStart(fileAt[on.path]),
809			}
810		}
811	}
812}
813
814// userText renders the parts of a plain text: the text escaped, the http(s)
815// addresses as links. Its white space is kept by the style of the container.
816func userText(parts []TextPart) templ.Component {
817	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
818		var b strings.Builder
819		for _, part := range parts {
820			if part.Href != "" {
821				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
822			} else {
823				b.WriteString(html.EscapeString(part.Text))
824			}
825		}
826		_, err := io.WriteString(w, b.String())
827		return err
828	})
829}

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