v0.25.0 4492065

mr.go RawHistoryFolder

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

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