v0.3.0 9fdf386

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	"connectrpc.com/connect"
15	"github.com/a-h/templ"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18)
19
20// The merge request pages (ADR 0017). They read through MergeRequestService
21// and GitService 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 API are read whole.
48	mrPerPage = 50
49	// maxMRPages bounds the API pages 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}
151
152// ModeOption is a merge mode the repository allows.
153type ModeOption struct{ Value, Label string }
154
155// SinceNav switches between the whole diff and the diff since the last read.
156type SinceNav struct {
157	AllHref, SinceHref string
158	Files              int
159	ReadShort          string
160	HeadShort          string
161	Current            bool
162}
163
164// MRPage is a merge request with its diff and comments.
165type MRPage struct {
166	Number         string
167	Title          string
168	State          string
169	Open           bool
170	Signed         bool
171	Source, Target string
172	SourceHref     string
173	TargetHref     string
174	Author         string
175	Opened         Person
176	Updated        string
177	UpdatedAt      string
178	Desc           []TextPart
179	Head           string
180	HeadShort      string
181	MergedShort    string
182	MergedHref     string
183	MergeMode      string
184
185	Since     *SinceNav
186	SinceNote string
187
188	NFiles, Adds, Dels int
189	Files              []DiffFile
190	DiffView
191	Note      string
192	Invisible bool
193
194	// General are the comments that are not on a line, Loose those on a line
195	// that the diff shown does not have.
196	General, Loose []CommentView
197	NComments      int
198	NUnresolved    int
199	GeneralForm    *CommentForm
200
201	Commits     []MRCommit
202	CommitsNote string
203	Modes       []string
204	ChecksBlock bool
205
206	Box *MergeBox
207	// Live is true when the page announces changes.
208	Live bool
209}
210
211// CompareCommit is a commit of a branch that the target lacks.
212type CompareCommit = CommitRow
213
214// ComparePage is a branch compared with the default branch.
215type ComparePage struct {
216	Source, Target string
217	SourceHref     string
218	TargetHref     string
219	Ahead          []CommitRow
220	AheadText      string
221	CLI            Clone
222	// Note says what the page could not tell.
223	Note string
224	// The diff is the one of the single commit the branch is ahead by.
225	NFiles, Adds, Dels int
226	Files              []DiffFile
227	DiffView
228	Invisible bool
229	HasDiff   bool
230	// Existing is the open merge request for this pair, when there is one.
231	Existing *Link
232	// Create is the form to open one; nil when the reader cannot or there is
233	// one already.
234	Create *CreateForm
235}
236
237// CreateForm is the POST that opens a merge request.
238type CreateForm struct {
239	Action, Source, Target, Title string
240}
241
242// mrBase is the URL of the merge request pages of a repository.
243func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
244
245func mrHref(repo string, n int64, sub ...string) string {
246	return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
247}
248
249// mrNumber reads the number of the path: a positive decimal number, written
250// without sign or leading zeros, else the page is not found.
251func mrNumber(r *http.Request) (int64, error) {
252	s := r.PathValue("n")
253	n, err := strconv.ParseInt(s, 10, 64)
254	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
255		return 0, errNotFound
256	}
257	return n, nil
258}
259
260func stateName(s pb.MergeRequestState) string {
261	switch s {
262	case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
263		return "open"
264	case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
265		return "merged"
266	case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
267		return "closed"
268	}
269	return "unknown"
270}
271
272func modeName(m pb.MergeMode) string {
273	switch m {
274	case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
275		return "fast-forward"
276	case pb.MergeMode_MERGE_MODE_SQUASH:
277		return "squash"
278	}
279	return ""
280}
281
282// mrRow converts a merge request of a listing.
283func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
284	row := MRRow{
285		Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
286		Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
287		Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
288		Author:   visibleText(m.GetAuthor()),
289	}
290	ts := m.GetUpdatedAt()
291	if !ts.IsValid() {
292		ts = m.GetCreatedAt()
293	}
294	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
295	return row
296}
297
298// mrList lists the merge requests: the open ones, then the merged and closed
299// ones, a page at a time. The decided ones are paged with the page token of
300// the API in "page"; the open ones show on the first page only.
301func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
302	d, err := s.loadRepo(ctx, r.PathValue("repo"))
303	if err != nil {
304		return nil, 0, err
305	}
306	token := r.URL.Query().Get("page")
307	p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
308	p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
309	base := mrBase(d.name())
310
311	if token == "" {
312		tok := ""
313		for i := 0; i < maxOpenPages; i++ {
314			res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
315				Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
316			}))
317			if err != nil {
318				return nil, 0, err
319			}
320			for _, m := range res.Msg.GetMergeRequests() {
321				p.Open = append(p.Open, s.mrRow(d.name(), m))
322			}
323			if tok = res.Msg.GetNextPageToken(); tok == "" {
324				break
325			}
326		}
327		if tok != "" {
328			p.OpenNote = "more open merge requests than are listed here."
329		}
330	}
331
332	tok := token
333	for i := 0; i < maxMRPages; i++ {
334		res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
335			Repo: d.name(), PageSize: pageSize, PageToken: tok,
336		}))
337		if err != nil {
338			return nil, 0, err
339		}
340		for _, m := range res.Msg.GetMergeRequests() {
341			if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
342				p.Done = append(p.Done, s.mrRow(d.name(), m))
343			}
344		}
345		tok = res.Msg.GetNextPageToken()
346		if tok == "" || len(p.Done) >= mrPerPage {
347			break
348		}
349	}
350	if tok != "" {
351		p.Older = base + "?page=" + url.QueryEscape(tok)
352	}
353	if token != "" {
354		p.Newest = base
355	}
356	switch {
357	case len(p.Open) > 0:
358		p.Open[0].Cursor = true
359	case len(p.Done) > 0:
360		p.Done[0].Cursor = true
361	}
362	for _, b := range d.branches {
363		if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches {
364			continue
365		}
366		p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())})
367	}
368
369	l := s.repoLayout(ctx, d, "merge-requests")
370	l.Title = "merge requests · " + d.name() + " · " + s.instance
371	l.Crumbs[0].Current = false
372	l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
373	if token == "" {
374		p.Live = true
375		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
376	}
377	rows := len(p.Open) + len(p.Done)
378	l.Status = Status{
379		Mode: "merge requests", Context: itoa(len(p.Open)) + " open · " + itoa(len(p.Done)) + " decided",
380		Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: rows,
381	}
382	l.HasRail, l.RailSum = true, "states · open one"
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
396func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
397	n, err := mrNumber(r)
398	if err != nil {
399		return nil, 0, err
400	}
401	if since && !SignedIn(ctx) {
402		// The read position is the owner's: there is nothing to show others.
403		return nil, 0, errNotFound
404	}
405	d, err := s.loadRepo(ctx, r.PathValue("repo"))
406	if err != nil {
407		return nil, 0, err
408	}
409	res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
410		Repo: d.name(), Number: n, SinceLastRead: since,
411	}))
412	if err != nil {
413		return nil, 0, err
414	}
415	m := res.Msg.GetMergeRequest()
416	if m == nil {
417		return nil, 0, errNotFound
418	}
419	now := s.cfg.Now()
420	signed := SignedIn(ctx)
421	open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
422	head := m.GetHeadId()
423	self := mrHref(d.name(), n)
424	if since {
425		self = mrHref(d.name(), n, "since")
426	}
427
428	p := MRPage{
429		Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
430		State: stateName(m.GetState()), Open: open, Signed: signed,
431		Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
432		Author: visibleText(m.GetAuthor()),
433		Desc:   plainParts(m.GetDescription()),
434		Head:   head, HeadShort: short(head),
435		NFiles: int(res.Msg.GetStats().GetFilesChanged()),
436		Adds:   int(res.Msg.GetStats().GetAdditions()),
437		Dels:   int(res.Msg.GetStats().GetDeletions()),
438		Files:  diffFiles(res.Msg.GetDiff().GetFiles()),
439	}
440	p.Opened.Name = p.Author
441	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
442	if t := m.GetCreatedAt(); t.IsValid() {
443		p.Opened.Abs = absolute(t.AsTime())
444	}
445	p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
446	for _, b := range d.branches {
447		switch b.GetShortName() {
448		case m.GetSourceBranch():
449			p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
450		case m.GetTargetBranch():
451			p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
452		}
453	}
454	if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
455		p.MergedShort = short(m.GetMergedCommitId())
456		p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
457		p.MergeMode = modeName(m.GetMergeMode())
458	}
459	if st := d.repo.GetSettings(); st != nil {
460		for _, mode := range st.GetAllowedMergeModes() {
461			if name := modeName(mode); name != "" {
462				p.Modes = append(p.Modes, name)
463			}
464		}
465		p.ChecksBlock = st.GetFailingChecksBlock()
466	}
467	p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
468
469	// The commits of the request, newest first.
470	for _, c := range res.Msg.GetCommits() {
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.Msg.GetLastReadId()
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.Msg.GetCommits() {
481			if c.GetId() != lastRead {
482				continue
483			}
484			for j := 0; j < i; j++ {
485				p.Commits[j].New = true
486			}
487		}
488	}
489	if res.Msg.GetCommitsTruncated() {
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.Msg.GetLastReadLost():
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.Msg.GetComments(), 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, 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.GetSourceBranch()})
543		if !d.hasBranch(m.GetSourceBranch()) {
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.GetSourceBranch()))
557	l.Status = Status{
558		Mode: "merge request", Context: p.Number + " · " + p.Source + " → " + p.Target + " · " + p.State,
559		Hints: "n next note · d split · u unified · x collapse all · ? help",
560	}
561	l.HasRail, l.RailSum = true, "merge · checks · commits · files"
562	return MergeRequest(l, p), http.StatusOK, nil
563}
564
565// hasBranch tells whether the repository has the branch.
566func (d *repoData) hasBranch(name string) bool {
567	for _, b := range d.branches {
568		if b.GetShortName() == name {
569			return true
570		}
571	}
572	return false
573}
574
575// mergeBox is what the owner can do with an open request.
576func (s *Server) mergeBox(d *repoData, n int64, head, lastRead string) *MergeBox {
577	b := &MergeBox{
578		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
579		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
580		CanMarkRead: head != "" && head != lastRead,
581	}
582	ff, sq := false, false
583	if st := d.repo.GetSettings(); st != nil {
584		for _, mode := range st.GetAllowedMergeModes() {
585			switch mode {
586			case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
587				ff = true
588				b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
589			case pb.MergeMode_MERGE_MODE_SQUASH:
590				sq = true
591				b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
592			}
593		}
594	}
595	if head != "" {
596		cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
597		b.CLI = Clone{ID: "cli-merge"}
598		if ff {
599			b.CLI.Lines = append(b.CLI.Lines, cmd)
600		}
601		if sq {
602			b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
603		}
604	}
605	return b
606}
607
608// shellWord quotes s for a shell unless it is made of safe characters, which
609// a repository name always is.
610func shellWord(s string) string {
611	for _, c := range []byte(s) {
612		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
613			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
614		}
615	}
616	return s
617}
618
619// lineRef designates a line of the diff in the query of the comment form:
620// "?on=new:12:path/to/file".
621type lineRef struct {
622	side string
623	line int
624	path string
625}
626
627func parseOn(s string) *lineRef {
628	side, rest, ok := strings.Cut(s, ":")
629	if !ok || (side != "new" && side != "old") {
630		return nil
631	}
632	num, path, ok := strings.Cut(rest, ":")
633	line, err := strconv.Atoi(num)
634	if !ok || err != nil || line < 1 || path == "" {
635		return nil
636	}
637	return &lineRef{side: side, line: line, path: path}
638}
639
640// placeComments puts every comment under its line when the diff shown has it,
641// else in the list below the diff. The comments name lines of the diff against
642// the target, so a page that shows another diff (since the last read) lists
643// them all below, with their line. A reader who can write gets the line numbers
644// as links to the form of their line; on designates the line whose form shows.
645func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
646	now := s.cfg.Now()
647	self := mrHref(repo, n)
648	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
649
650	type lineKey struct {
651		path string
652		side pb.DiffSide
653		line int
654	}
655	index := map[lineKey]*DiffLine{}
656	fileIDs := map[string]string{}
657	for fi := range p.Files {
658		f := &p.Files[fi]
659		fileIDs[f.Key] = f.ID
660		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
661		for hi := range f.Hunks {
662			for li := range f.Hunks[hi].Lines {
663				ln := &f.Hunks[hi].Lines[li]
664				if ln.oldN > 0 && ln.Kind != "add" {
665					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
666					if linkable {
667						ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
668					}
669				}
670				if ln.newN > 0 && ln.Kind != "del" {
671					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
672					if linkable {
673						ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
674					}
675				}
676			}
677		}
678	}
679	find := func(path string, side pb.DiffSide, line int) *DiffLine {
680		if !inline {
681			return nil
682		}
683		return index[lineKey{path, side, line}]
684	}
685
686	for _, c := range comments {
687		v := CommentView{
688			ID: c.GetId(), Author: visibleText(c.GetAuthor()),
689			Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
690			Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
691		}
692		v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
693		if t := c.GetCreatedAt(); t.IsValid() {
694			v.Abs = absolute(t.AsTime())
695		}
696		p.NComments++
697		if !c.GetResolved() {
698			p.NUnresolved++
699		}
700		general := c.GetPath() == ""
701		var ln *DiffLine
702		if !general {
703			ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
704			if ln == nil {
705				side := "new"
706				if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
707					side = "old"
708				}
709				v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
710			}
711		}
712		if canWrite {
713			v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "[resolve]"}
714			if c.GetResolved() {
715				v.Resolve.Label = "[unresolve]"
716			}
717		}
718		switch {
719		case ln != nil:
720			ln.Notes = append(ln.Notes, v)
721		case general:
722			p.General = append(p.General, v)
723		default:
724			p.Loose = append(p.Loose, v)
725		}
726	}
727
728	if on != nil {
729		side := pb.DiffSide_DIFF_SIDE_NEW
730		if on.side == "old" {
731			side = pb.DiffSide_DIFF_SIDE_OLD
732		}
733		if ln := find(on.path, side, on.line); ln != nil {
734			ln.Form = &CommentForm{
735				ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
736				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
737				Cancel: self + "#" + fileIDs[on.path], Focus: true,
738			}
739		}
740	}
741}
742
743// userText renders the parts of a plain text: the text escaped, the http(s)
744// addresses as links. Its white space is kept by the style of the container.
745func userText(parts []TextPart) templ.Component {
746	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
747		var b strings.Builder
748		for _, part := range parts {
749			if part.Href != "" {
750				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
751			} else {
752				b.WriteString(html.EscapeString(part.Text))
753			}
754		}
755		_, err := io.WriteString(w, b.String())
756		return err
757	})
758}

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