v0.5.0 bfab294

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	// 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
209	Box *MergeBox
210	// Live is true when the page announces changes.
211	Live bool
212}
213
214// CompareCommit is a commit of a branch that the target lacks.
215type CompareCommit = CommitRow
216
217// ComparePage is a branch compared with the default branch.
218type ComparePage struct {
219	Source, Target string
220	SourceHref     string
221	TargetHref     string
222	Ahead          []CommitRow
223	AheadText      string
224	CLI            Clone
225	// Note says what the page could not tell.
226	Note string
227	// The diff is the one of the single commit the branch is ahead by.
228	NFiles, Adds, Dels int
229	Files              []DiffFile
230	DiffView
231	Invisible bool
232	HasDiff   bool
233	// Existing is the open merge request for this pair, when there is one.
234	Existing *Link
235	// Create is the form to open one; nil when the reader cannot or there is
236	// one already.
237	Create *CreateForm
238}
239
240// CreateForm is the POST that opens a merge request.
241type CreateForm struct {
242	Action, Source, Target, Title string
243}
244
245// mrBase is the URL of the merge request pages of a repository.
246func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
247
248func mrHref(repo string, n int64, sub ...string) string {
249	return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
250}
251
252// mrNumber reads the number of the path: a positive decimal number, written
253// without sign or leading zeros, else the page is not found.
254func mrNumber(r *http.Request) (int64, error) {
255	s := r.PathValue("n")
256	n, err := strconv.ParseInt(s, 10, 64)
257	if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
258		return 0, errNotFound
259	}
260	return n, nil
261}
262
263func stateName(s pb.MergeRequestState) string {
264	switch s {
265	case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
266		return "open"
267	case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
268		return "merged"
269	case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
270		return "closed"
271	}
272	return "unknown"
273}
274
275func modeName(m pb.MergeMode) string {
276	switch m {
277	case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
278		return "fast-forward"
279	case pb.MergeMode_MERGE_MODE_SQUASH:
280		return "squash"
281	}
282	return ""
283}
284
285// mrRow converts a merge request of a listing.
286func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
287	row := MRRow{
288		Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
289		Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
290		Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
291		Author:   visibleText(m.GetAuthor()),
292	}
293	ts := m.GetUpdatedAt()
294	if !ts.IsValid() {
295		ts = m.GetCreatedAt()
296	}
297	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
298	return row
299}
300
301// mrList lists the merge requests: the open ones, then the merged and closed
302// ones, a page at a time. The decided ones are paged with the page token of
303// the API in "page"; the open ones show on the first page only.
304func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
305	d, err := s.loadRepo(ctx, r.PathValue("repo"))
306	if err != nil {
307		return nil, 0, err
308	}
309	token := r.URL.Query().Get("page")
310	p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
311	p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
312	base := mrBase(d.name())
313
314	if token == "" {
315		tok := ""
316		for i := 0; i < maxOpenPages; i++ {
317			res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
318				Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
319			}))
320			if err != nil {
321				return nil, 0, err
322			}
323			for _, m := range res.Msg.GetMergeRequests() {
324				p.Open = append(p.Open, s.mrRow(d.name(), m))
325			}
326			if tok = res.Msg.GetNextPageToken(); tok == "" {
327				break
328			}
329		}
330		if tok != "" {
331			p.OpenNote = "more open merge requests than are listed here."
332		}
333	}
334
335	tok := token
336	for i := 0; i < maxMRPages; i++ {
337		res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
338			Repo: d.name(), PageSize: pageSize, PageToken: tok,
339		}))
340		if err != nil {
341			return nil, 0, err
342		}
343		for _, m := range res.Msg.GetMergeRequests() {
344			if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
345				p.Done = append(p.Done, s.mrRow(d.name(), m))
346			}
347		}
348		tok = res.Msg.GetNextPageToken()
349		if tok == "" || len(p.Done) >= mrPerPage {
350			break
351		}
352	}
353	if tok != "" {
354		p.Older = base + "?page=" + url.QueryEscape(tok)
355	}
356	if token != "" {
357		p.Newest = base
358	}
359	switch {
360	case len(p.Open) > 0:
361		p.Open[0].Cursor = true
362	case len(p.Done) > 0:
363		p.Done[0].Cursor = true
364	}
365	for _, b := range d.branches {
366		if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches {
367			continue
368		}
369		p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())})
370	}
371
372	l := s.repoLayout(ctx, d, "merge-requests")
373	l.Title = "merge requests · " + d.name() + " · " + s.instance
374	l.Crumbs[0].Current = false
375	l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
376	if token == "" {
377		p.Live = true
378		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
379	}
380	l.HasRail = true
381	return MRList(l, p), http.StatusOK, nil
382}
383
384// mrShow shows a merge request: the whole diff against the target.
385func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
386	return s.mrPage(ctx, r, false)
387}
388
389// mrSince shows the diff since the head the reviewer last marked read.
390func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
391	return s.mrPage(ctx, r, true)
392}
393
394func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
395	n, err := mrNumber(r)
396	if err != nil {
397		return nil, 0, err
398	}
399	if since && !SignedIn(ctx) {
400		// The read position is the owner's: there is nothing to show others.
401		return nil, 0, errNotFound
402	}
403	d, err := s.loadRepo(ctx, r.PathValue("repo"))
404	if err != nil {
405		return nil, 0, err
406	}
407	res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
408		Repo: d.name(), Number: n, SinceLastRead: since,
409	}))
410	if err != nil {
411		return nil, 0, err
412	}
413	m := res.Msg.GetMergeRequest()
414	if m == nil {
415		return nil, 0, errNotFound
416	}
417	now := s.cfg.Now()
418	signed := SignedIn(ctx)
419	open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
420	head := m.GetHeadId()
421	self := mrHref(d.name(), n)
422	if since {
423		self = mrHref(d.name(), n, "since")
424	}
425
426	p := MRPage{
427		Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
428		State: stateName(m.GetState()), Open: open, Signed: signed,
429		Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
430		Author: visibleText(m.GetAuthor()),
431		Desc:   plainParts(m.GetDescription()),
432		Head:   head, HeadShort: short(head),
433		NFiles: int(res.Msg.GetStats().GetFilesChanged()),
434		Adds:   int(res.Msg.GetStats().GetAdditions()),
435		Dels:   int(res.Msg.GetStats().GetDeletions()),
436		Files:  diffFiles(res.Msg.GetDiff().GetFiles()),
437	}
438	p.Opened.Name = p.Author
439	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
440	if t := m.GetCreatedAt(); t.IsValid() {
441		p.Opened.Abs = absolute(t.AsTime())
442	}
443	p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
444	for _, b := range d.branches {
445		switch b.GetShortName() {
446		case m.GetSourceBranch():
447			p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
448		case m.GetTargetBranch():
449			p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
450		}
451	}
452	if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
453		p.MergedShort = short(m.GetMergedCommitId())
454		p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
455		p.MergeMode = modeName(m.GetMergeMode())
456	}
457	if st := d.repo.GetSettings(); st != nil {
458		for _, mode := range st.GetAllowedMergeModes() {
459			if name := modeName(mode); name != "" {
460				p.Modes = append(p.Modes, name)
461			}
462		}
463		p.ChecksBlock = st.GetFailingChecksBlock()
464	}
465	p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
466
467	// The commits of the request, newest first.
468	for _, c := range res.Msg.GetCommits() {
469		row := commitRow(d.name(), c, now)
470		p.Commits = append(p.Commits, MRCommit{
471			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
472		})
473	}
474	lastRead := res.Msg.GetLastReadId()
475	if signed && lastRead != "" {
476		// The commits are newest first: those before the last read head are new.
477		// A head that is not among them (the branch was rewritten) marks none.
478		for i, c := range res.Msg.GetCommits() {
479			if c.GetId() != lastRead {
480				continue
481			}
482			for j := 0; j < i; j++ {
483				p.Commits[j].New = true
484			}
485		}
486	}
487	if res.Msg.GetCommitsTruncated() {
488		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
489	}
490
491	// Since the last read: the navigation, and what the page says about it.
492	if !signed {
493		lastRead = "" // the read position is the owner's
494	}
495	if signed && lastRead != "" {
496		p.Since = &SinceNav{
497			AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
498			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
499		}
500	}
501	if since {
502		switch {
503		case lastRead == "":
504			p.SinceNote = "nothing was marked read yet: this is the whole change."
505		case res.Msg.GetLastReadLost():
506			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
507		case len(p.Files) == 0:
508			p.SinceNote = "nothing changed since " + short(lastRead) + "."
509		default:
510			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
511		}
512	}
513	if p.Since != nil {
514		p.Since.Files = len(p.Files)
515	}
516
517	// Comments: under their line, else in the list below the diff. The forms
518	// are the owner's, on the whole diff of an open request.
519	canWrite := signed && open && !since
520	var on *lineRef
521	if canWrite {
522		on = parseOn(r.URL.Query().Get("on"))
523	}
524	s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on)
525	lines, invisible := diffLines(p.Files)
526	p.Invisible = invisible
527	p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
528	if signed && open {
529		p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead)
530	}
531	if canWrite {
532		p.GeneralForm = &CommentForm{
533			ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
534		}
535	}
536
537	l := s.repoLayout(ctx, d, "merge-requests")
538	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
539	if head != "" {
540		pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
541		if !d.hasBranch(m.GetSourceBranch()) {
542			pinRef(&l, target{rev: head, name: short(head), detached: true})
543		}
544	}
545	l.Crumbs[0].Current = false
546	l.Crumbs = append(l.Crumbs,
547		Crumb{Label: "merge requests", Href: mrBase(d.name())},
548		Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
549	)
550	if since {
551		l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
552	}
553	p.Live = true
554	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))
555	l.HasRail = true
556	return MergeRequest(l, p), http.StatusOK, nil
557}
558
559// hasBranch tells whether the repository has the branch.
560func (d *repoData) hasBranch(name string) bool {
561	for _, b := range d.branches {
562		if b.GetShortName() == name {
563			return true
564		}
565	}
566	return false
567}
568
569// mergeBox is what the owner can do with an open request.
570func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
571	b := &MergeBox{
572		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
573		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
574		CanMarkRead:     head != "" && head != lastRead,
575		CanDeleteBranch: source != d.repo.GetDefaultBranch(),
576	}
577	ff, sq := false, false
578	if st := d.repo.GetSettings(); st != nil {
579		for _, mode := range st.GetAllowedMergeModes() {
580			switch mode {
581			case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
582				ff = true
583				b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
584			case pb.MergeMode_MERGE_MODE_SQUASH:
585				sq = true
586				b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
587			}
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.
640func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
641	now := s.cfg.Now()
642	self := mrHref(repo, n)
643	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
644
645	type lineKey struct {
646		path string
647		side pb.DiffSide
648		line int
649	}
650	index := map[lineKey]*DiffLine{}
651	fileIDs := map[string]string{}
652	for fi := range p.Files {
653		f := &p.Files[fi]
654		fileIDs[f.Key] = f.ID
655		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
656		for hi := range f.Hunks {
657			for li := range f.Hunks[hi].Lines {
658				ln := &f.Hunks[hi].Lines[li]
659				if ln.oldN > 0 && ln.Kind != "add" {
660					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
661					if linkable {
662						ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
663					}
664				}
665				if ln.newN > 0 && ln.Kind != "del" {
666					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
667					if linkable {
668						ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
669					}
670				}
671			}
672		}
673	}
674	find := func(path string, side pb.DiffSide, line int) *DiffLine {
675		if !inline {
676			return nil
677		}
678		return index[lineKey{path, side, line}]
679	}
680
681	for _, c := range comments {
682		v := CommentView{
683			ID: c.GetId(), Author: visibleText(c.GetAuthor()),
684			Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
685			Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
686		}
687		v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
688		if t := c.GetCreatedAt(); t.IsValid() {
689			v.Abs = absolute(t.AsTime())
690		}
691		p.NComments++
692		if !c.GetResolved() {
693			p.NUnresolved++
694		}
695		general := c.GetPath() == ""
696		var ln *DiffLine
697		if !general {
698			ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
699			if ln == nil {
700				side := "new"
701				if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
702					side = "old"
703				}
704				v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
705			}
706		}
707		if canWrite {
708			v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"}
709			if c.GetResolved() {
710				v.Resolve.Label = "Unresolve"
711			}
712		}
713		switch {
714		case ln != nil:
715			ln.Notes = append(ln.Notes, v)
716		case general:
717			p.General = append(p.General, v)
718		default:
719			p.Loose = append(p.Loose, v)
720		}
721	}
722
723	if on != nil {
724		side := pb.DiffSide_DIFF_SIDE_NEW
725		if on.side == "old" {
726			side = pb.DiffSide_DIFF_SIDE_OLD
727		}
728		if ln := find(on.path, side, on.line); ln != nil {
729			ln.Form = &CommentForm{
730				ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
731				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
732				Cancel: self + "#" + fileIDs[on.path], Focus: true,
733			}
734		}
735	}
736}
737
738// userText renders the parts of a plain text: the text escaped, the http(s)
739// addresses as links. Its white space is kept by the style of the container.
740func userText(parts []TextPart) templ.Component {
741	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
742		var b strings.Builder
743		for _, part := range parts {
744			if part.Href != "" {
745				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
746			} else {
747				b.WriteString(html.EscapeString(part.Text))
748			}
749		}
750		_, err := io.WriteString(w, b.String())
751		return err
752	})
753}

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