v0.15.0 8a6cfec

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

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 3

Tags 44

↑↓ moveenter openesc close