v0.19.0 55a688e

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	p.Opened.Name = p.Author
480	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt)
481	if !m.CreatedAt.IsZero() {
482		p.Opened.Abs = absolute(m.CreatedAt)
483	}
484	p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt)
485	for _, b := range d.branches {
486		switch b.ShortName {
487		case m.Source:
488			p.SourceHref = repoHref(d.name(), "tree", b.ShortName)
489		case m.Target:
490			p.TargetHref = repoHref(d.name(), "tree", b.ShortName)
491		}
492	}
493	if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() {
494		p.MergedShort = short(m.MergedCommitID.String())
495		p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
496		p.MergeMode = modeName(m.MergeMode)
497	}
498	for _, mode := range d.repo.Settings.AllowedMergeModes {
499		if name := modeName(mode); name != "" {
500			p.Modes = append(p.Modes, name)
501		}
502	}
503	p.ChecksBlock = d.repo.Settings.FailingChecksBlock
504	if head != "" {
505		checks := s.checksFor(ctx, d.repo.Name, head)
506		p.Checks = checksSection(d.repo.Repo, checks)
507		p.ChecksSum = checksSummary(d.repo.Name, head, checks)
508		if !open {
509			p.Checks.Note = ""
510		}
511	}
512	p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
513	countNotes(p.Files, res.Comments)
514	if !res.DiffBaseID.IsZero() {
515		lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head))
516	}
517	p.From = from
518
519	// The commits of the request, newest first.
520	for _, c := range res.Commits {
521		row := commitRow(d.name(), c, now)
522		p.Commits = append(p.Commits, MRCommit{
523			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
524		})
525	}
526	lastRead := res.LastReadID.String()
527	if signed && lastRead != "" {
528		// The commits are newest first: those before the last read head are new.
529		// A head that is not among them (the branch was rewritten) marks none.
530		for i, c := range res.Commits {
531			if c.ID.String() != lastRead {
532				continue
533			}
534			for j := 0; j < i; j++ {
535				p.Commits[j].New = true
536			}
537		}
538	}
539	if res.CommitsTruncated {
540		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
541	}
542
543	// Since the last read: the navigation, and what the page says about it.
544	if !signed {
545		lastRead = "" // the read position is the owner's
546	}
547	if signed && lastRead != "" {
548		p.Since = &SinceNav{
549			AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
550			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
551		}
552	}
553	if since {
554		switch {
555		case lastRead == "":
556			p.SinceNote = "nothing was marked read yet: this is the whole change."
557		case res.LastReadLost:
558			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
559		case len(p.Files) == 0:
560			p.SinceNote = "nothing changed since " + short(lastRead) + "."
561		default:
562			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
563		}
564	}
565	if p.Since != nil {
566		p.Since.Files = len(p.Files)
567	}
568
569	// Comments: under their line, else in the list below the diff. The forms
570	// are the owner's, on the whole diff of an open request.
571	canWrite := signed && open && !since
572	var on *lineRef
573	if canWrite {
574		on = parseOn(r.URL.Query().Get("on"))
575	}
576	s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on)
577	lines, invisible := diffLines(p.Files)
578	p.Invisible = invisible
579	p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
580	if signed && open {
581		p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
582		if m.IsDraft() {
583			p.Box.ReadyAction = mrHref(d.name(), n, "ready")
584		}
585	}
586	if canWrite {
587		p.GeneralForm = &CommentForm{
588			ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
589		}
590	}
591
592	l := s.repoLayout(ctx, d, "merge-requests")
593	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
594	if head != "" {
595		pinRef(&l, target{rev: head, name: m.Source})
596		if !d.hasBranch(m.Source) {
597			pinRef(&l, target{rev: head, name: short(head), detached: true})
598		}
599	}
600	l.Crumbs[0].Current = false
601	l.Crumbs = append(l.Crumbs,
602		Crumb{Label: "merge requests", Href: mrBase(d.name())},
603		Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
604	)
605	if since {
606		l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
607	}
608	p.Live = true
609	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))
610	l.HasRail = true
611	l.MR = true
612	return MergeRequest(l, p), http.StatusOK, nil
613}
614
615// hasBranch tells whether the repository has the branch.
616func (d *repoData) hasBranch(name string) bool {
617	for _, b := range d.branches {
618		if b.ShortName == name {
619			return true
620		}
621	}
622	return false
623}
624
625// mergeBox is what the owner can do with an open request.
626func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
627	b := &MergeBox{
628		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
629		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
630		CanMarkRead:     head != "" && head != lastRead,
631		CanDeleteBranch: source != d.repo.DefaultBranch,
632	}
633	ff, sq := false, false
634	for _, mode := range d.repo.Settings.AllowedMergeModes {
635		switch mode {
636		case domain.MergeModeFastForward:
637			ff = true
638			b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
639		case domain.MergeModeSquash:
640			sq = true
641			b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
642		}
643	}
644	if head != "" {
645		cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
646		b.CLI = Clone{ID: "cli-merge"}
647		if ff {
648			b.CLI.Lines = append(b.CLI.Lines, cmd)
649		}
650		if sq {
651			b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
652		}
653	}
654	return b
655}
656
657// shellWord quotes s for a shell unless it is made of safe characters, which
658// a repository name always is.
659func shellWord(s string) string {
660	for _, c := range []byte(s) {
661		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
662			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
663		}
664	}
665	return s
666}
667
668// lineRef designates a line of the diff in the query of the comment form:
669// "?on=new:12:path/to/file".
670type lineRef struct {
671	side string
672	line int
673	path string
674}
675
676func parseOn(s string) *lineRef {
677	side, rest, ok := strings.Cut(s, ":")
678	if !ok || (side != "new" && side != "old") {
679		return nil
680	}
681	num, path, ok := strings.Cut(rest, ":")
682	line, err := strconv.Atoi(num)
683	if !ok || err != nil || line < 1 || path == "" {
684		return nil
685	}
686	return &lineRef{side: side, line: line, path: path}
687}
688
689// placeComments puts every comment under its line when the diff shown has it,
690// else in the list below the diff. The comments name lines of the diff against
691// the target, so a page that shows another diff (since the last read) lists
692// them all below, with their line. A reader who can write gets the line numbers
693// as links to the form of their line; on designates the line whose form shows.
694//
695//hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only
696func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) {
697	now := s.cfg.Now()
698	self := mrHref(repo, n)
699	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
700
701	type lineKey struct {
702		path string
703		side domain.DiffSide
704		line int
705	}
706	index := map[lineKey]*DiffLine{}
707	fileIDs := map[string]string{}
708	fileAt := map[string]int{}
709	for fi := range p.Files {
710		f := &p.Files[fi]
711		fileIDs[f.Key] = f.ID
712		fileAt[f.Key] = fi
713		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
714		// The form of a line shows on the page of the window of its file.
715		on := self + "?on="
716		if w := windowStart(fi); w > 0 {
717			on = self + "?from=" + itoa(w) + "&on="
718		}
719		for hi := range f.Hunks {
720			for li := range f.Hunks[hi].Lines {
721				ln := &f.Hunks[hi].Lines[li]
722				if ln.oldN > 0 && ln.Kind != "add" {
723					index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln
724					if linkable {
725						ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
726					}
727				}
728				if ln.newN > 0 && ln.Kind != "del" {
729					index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln
730					if linkable {
731						ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
732					}
733				}
734			}
735		}
736	}
737	find := func(path string, side domain.DiffSide, line int) *DiffLine {
738		if !inline {
739			return nil
740		}
741		return index[lineKey{path, side, line}]
742	}
743
744	for _, c := range comments {
745		cid := strconv.FormatInt(c.ID, 10)
746		v := CommentView{
747			ID: cid, Author: s.who(c.Author),
748			Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
749			Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
750		}
751		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
752		if !c.CreatedAt.IsZero() {
753			v.Abs = absolute(c.CreatedAt)
754		}
755		p.NComments++
756		if !c.Resolved {
757			p.NUnresolved++
758		}
759		general := c.Path == ""
760		fi, listed := fileAt[c.Path]
761		if !general && listed && p.Files[fi].Lazy {
762			// Its file comes with its window, and the comment with it.
763			continue
764		}
765		var ln *DiffLine
766		if !general {
767			ln = find(c.Path, c.Side, c.Line)
768			if ln == nil {
769				side := "new"
770				if c.Side == domain.SideOld {
771					side = "old"
772				}
773				v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")"
774			}
775		}
776		if canWrite {
777			v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"}
778			if !general && listed {
779				v.Resolve.From = windowStart(fi)
780			}
781			if c.Resolved {
782				v.Resolve.Label = "Unresolve"
783			}
784		}
785		switch {
786		case ln != nil:
787			ln.Notes = append(ln.Notes, v)
788		case general:
789			p.General = append(p.General, v)
790		case p.LooseInFiles && listed:
791			p.Files[fi].Loose = append(p.Files[fi].Loose, v)
792		default:
793			p.Loose = append(p.Loose, v)
794		}
795	}
796
797	if on != nil {
798		side := domain.SideNew
799		if on.side == "old" {
800			side = domain.SideOld
801		}
802		if ln := find(on.path, side, on.line); ln != nil {
803			ln.Form = &CommentForm{
804				ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
805				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
806				Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true,
807				From: windowStart(fileAt[on.path]),
808			}
809		}
810	}
811}
812
813// userText renders the parts of a plain text: the text escaped, the http(s)
814// addresses as links. Its white space is kept by the style of the container.
815func userText(parts []TextPart) templ.Component {
816	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
817		var b strings.Builder
818		for _, part := range parts {
819			if part.Href != "" {
820				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
821			} else {
822				b.WriteString(html.EscapeString(part.Text))
823			}
824		}
825		_, err := io.WriteString(w, b.String())
826		return err
827	})
828}

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