v0.18.0 0cea275

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

Keyboard shortcuts

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

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

Branches 4

Tags 45

↑↓ moveenter openesc close