v0.4.0 3027a40

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	rows := len(p.Open) + len(p.Done)
381	l.Status = Status{
382		Mode: "merge requests", Context: itoa(len(p.Open)) + " open · " + itoa(len(p.Done)) + " decided",
383		Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: rows,
384	}
385	l.HasRail, l.RailSum = true, "states · open one"
386	return MRList(l, p), http.StatusOK, nil
387}
388
389// mrShow shows a merge request: the whole diff against the target.
390func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
391	return s.mrPage(ctx, r, false)
392}
393
394// mrSince shows the diff since the head the reviewer last marked read.
395func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
396	return s.mrPage(ctx, r, true)
397}
398
399func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
400	n, err := mrNumber(r)
401	if err != nil {
402		return nil, 0, err
403	}
404	if since && !SignedIn(ctx) {
405		// The read position is the owner's: there is nothing to show others.
406		return nil, 0, errNotFound
407	}
408	d, err := s.loadRepo(ctx, r.PathValue("repo"))
409	if err != nil {
410		return nil, 0, err
411	}
412	res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
413		Repo: d.name(), Number: n, SinceLastRead: since,
414	}))
415	if err != nil {
416		return nil, 0, err
417	}
418	m := res.Msg.GetMergeRequest()
419	if m == nil {
420		return nil, 0, errNotFound
421	}
422	now := s.cfg.Now()
423	signed := SignedIn(ctx)
424	open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
425	head := m.GetHeadId()
426	self := mrHref(d.name(), n)
427	if since {
428		self = mrHref(d.name(), n, "since")
429	}
430
431	p := MRPage{
432		Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
433		State: stateName(m.GetState()), Open: open, Signed: signed,
434		Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
435		Author: visibleText(m.GetAuthor()),
436		Desc:   plainParts(m.GetDescription()),
437		Head:   head, HeadShort: short(head),
438		NFiles: int(res.Msg.GetStats().GetFilesChanged()),
439		Adds:   int(res.Msg.GetStats().GetAdditions()),
440		Dels:   int(res.Msg.GetStats().GetDeletions()),
441		Files:  diffFiles(res.Msg.GetDiff().GetFiles()),
442	}
443	p.Opened.Name = p.Author
444	p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
445	if t := m.GetCreatedAt(); t.IsValid() {
446		p.Opened.Abs = absolute(t.AsTime())
447	}
448	p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
449	for _, b := range d.branches {
450		switch b.GetShortName() {
451		case m.GetSourceBranch():
452			p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
453		case m.GetTargetBranch():
454			p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
455		}
456	}
457	if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
458		p.MergedShort = short(m.GetMergedCommitId())
459		p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
460		p.MergeMode = modeName(m.GetMergeMode())
461	}
462	if st := d.repo.GetSettings(); st != nil {
463		for _, mode := range st.GetAllowedMergeModes() {
464			if name := modeName(mode); name != "" {
465				p.Modes = append(p.Modes, name)
466			}
467		}
468		p.ChecksBlock = st.GetFailingChecksBlock()
469	}
470	p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
471
472	// The commits of the request, newest first.
473	for _, c := range res.Msg.GetCommits() {
474		row := commitRow(d.name(), c, now)
475		p.Commits = append(p.Commits, MRCommit{
476			Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
477		})
478	}
479	lastRead := res.Msg.GetLastReadId()
480	if signed && lastRead != "" {
481		// The commits are newest first: those before the last read head are new.
482		// A head that is not among them (the branch was rewritten) marks none.
483		for i, c := range res.Msg.GetCommits() {
484			if c.GetId() != lastRead {
485				continue
486			}
487			for j := 0; j < i; j++ {
488				p.Commits[j].New = true
489			}
490		}
491	}
492	if res.Msg.GetCommitsTruncated() {
493		p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
494	}
495
496	// Since the last read: the navigation, and what the page says about it.
497	if !signed {
498		lastRead = "" // the read position is the owner's
499	}
500	if signed && lastRead != "" {
501		p.Since = &SinceNav{
502			AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
503			ReadShort: short(lastRead), HeadShort: short(head), Current: since,
504		}
505	}
506	if since {
507		switch {
508		case lastRead == "":
509			p.SinceNote = "nothing was marked read yet: this is the whole change."
510		case res.Msg.GetLastReadLost():
511			p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
512		case len(p.Files) == 0:
513			p.SinceNote = "nothing changed since " + short(lastRead) + "."
514		default:
515			p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
516		}
517	}
518	if p.Since != nil {
519		p.Since.Files = len(p.Files)
520	}
521
522	// Comments: under their line, else in the list below the diff. The forms
523	// are the owner's, on the whole diff of an open request.
524	canWrite := signed && open && !since
525	var on *lineRef
526	if canWrite {
527		on = parseOn(r.URL.Query().Get("on"))
528	}
529	s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on)
530	lines, invisible := diffLines(p.Files)
531	p.Invisible = invisible
532	p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
533	if signed && open {
534		p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead)
535	}
536	if canWrite {
537		p.GeneralForm = &CommentForm{
538			ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
539		}
540	}
541
542	l := s.repoLayout(ctx, d, "merge-requests")
543	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
544	if head != "" {
545		pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
546		if !d.hasBranch(m.GetSourceBranch()) {
547			pinRef(&l, target{rev: head, name: short(head), detached: true})
548		}
549	}
550	l.Crumbs[0].Current = false
551	l.Crumbs = append(l.Crumbs,
552		Crumb{Label: "merge requests", Href: mrBase(d.name())},
553		Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
554	)
555	if since {
556		l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
557	}
558	p.Live = true
559	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))
560	l.Status = Status{
561		Mode: "merge request", Context: p.Number + " · " + p.Source + " → " + p.Target + " · " + p.State,
562		Hints: "n next note · d split · u unified · x collapse all · ? help",
563	}
564	l.HasRail, l.RailSum = true, "merge · checks · commits · files"
565	return MergeRequest(l, p), http.StatusOK, nil
566}
567
568// hasBranch tells whether the repository has the branch.
569func (d *repoData) hasBranch(name string) bool {
570	for _, b := range d.branches {
571		if b.GetShortName() == name {
572			return true
573		}
574	}
575	return false
576}
577
578// mergeBox is what the owner can do with an open request.
579func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
580	b := &MergeBox{
581		MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
582		ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
583		CanMarkRead:     head != "" && head != lastRead,
584		CanDeleteBranch: source != d.repo.GetDefaultBranch(),
585	}
586	ff, sq := false, false
587	if st := d.repo.GetSettings(); st != nil {
588		for _, mode := range st.GetAllowedMergeModes() {
589			switch mode {
590			case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
591				ff = true
592				b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
593			case pb.MergeMode_MERGE_MODE_SQUASH:
594				sq = true
595				b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
596			}
597		}
598	}
599	if head != "" {
600		cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
601		b.CLI = Clone{ID: "cli-merge"}
602		if ff {
603			b.CLI.Lines = append(b.CLI.Lines, cmd)
604		}
605		if sq {
606			b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
607		}
608	}
609	return b
610}
611
612// shellWord quotes s for a shell unless it is made of safe characters, which
613// a repository name always is.
614func shellWord(s string) string {
615	for _, c := range []byte(s) {
616		if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
617			return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
618		}
619	}
620	return s
621}
622
623// lineRef designates a line of the diff in the query of the comment form:
624// "?on=new:12:path/to/file".
625type lineRef struct {
626	side string
627	line int
628	path string
629}
630
631func parseOn(s string) *lineRef {
632	side, rest, ok := strings.Cut(s, ":")
633	if !ok || (side != "new" && side != "old") {
634		return nil
635	}
636	num, path, ok := strings.Cut(rest, ":")
637	line, err := strconv.Atoi(num)
638	if !ok || err != nil || line < 1 || path == "" {
639		return nil
640	}
641	return &lineRef{side: side, line: line, path: path}
642}
643
644// placeComments puts every comment under its line when the diff shown has it,
645// else in the list below the diff. The comments name lines of the diff against
646// the target, so a page that shows another diff (since the last read) lists
647// them all below, with their line. A reader who can write gets the line numbers
648// as links to the form of their line; on designates the line whose form shows.
649func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
650	now := s.cfg.Now()
651	self := mrHref(repo, n)
652	resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
653
654	type lineKey struct {
655		path string
656		side pb.DiffSide
657		line int
658	}
659	index := map[lineKey]*DiffLine{}
660	fileIDs := map[string]string{}
661	for fi := range p.Files {
662		f := &p.Files[fi]
663		fileIDs[f.Key] = f.ID
664		linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
665		for hi := range f.Hunks {
666			for li := range f.Hunks[hi].Lines {
667				ln := &f.Hunks[hi].Lines[li]
668				if ln.oldN > 0 && ln.Kind != "add" {
669					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
670					if linkable {
671						ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
672					}
673				}
674				if ln.newN > 0 && ln.Kind != "del" {
675					index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
676					if linkable {
677						ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
678					}
679				}
680			}
681		}
682	}
683	find := func(path string, side pb.DiffSide, line int) *DiffLine {
684		if !inline {
685			return nil
686		}
687		return index[lineKey{path, side, line}]
688	}
689
690	for _, c := range comments {
691		v := CommentView{
692			ID: c.GetId(), Author: visibleText(c.GetAuthor()),
693			Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
694			Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
695		}
696		v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
697		if t := c.GetCreatedAt(); t.IsValid() {
698			v.Abs = absolute(t.AsTime())
699		}
700		p.NComments++
701		if !c.GetResolved() {
702			p.NUnresolved++
703		}
704		general := c.GetPath() == ""
705		var ln *DiffLine
706		if !general {
707			ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
708			if ln == nil {
709				side := "new"
710				if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
711					side = "old"
712				}
713				v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
714			}
715		}
716		if canWrite {
717			v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "[resolve]"}
718			if c.GetResolved() {
719				v.Resolve.Label = "[unresolve]"
720			}
721		}
722		switch {
723		case ln != nil:
724			ln.Notes = append(ln.Notes, v)
725		case general:
726			p.General = append(p.General, v)
727		default:
728			p.Loose = append(p.Loose, v)
729		}
730	}
731
732	if on != nil {
733		side := pb.DiffSide_DIFF_SIDE_NEW
734		if on.side == "old" {
735			side = pb.DiffSide_DIFF_SIDE_OLD
736		}
737		if ln := find(on.path, side, on.line); ln != nil {
738			ln.Form = &CommentForm{
739				ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
740				Title:  "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
741				Cancel: self + "#" + fileIDs[on.path], Focus: true,
742			}
743		}
744	}
745}
746
747// userText renders the parts of a plain text: the text escaped, the http(s)
748// addresses as links. Its white space is kept by the style of the container.
749func userText(parts []TextPart) templ.Component {
750	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
751		var b strings.Builder
752		for _, part := range parts {
753			if part.Href != "" {
754				b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
755			} else {
756				b.WriteString(html.EscapeString(part.Text))
757			}
758		}
759		_, err := io.WriteString(w, b.String())
760		return err
761	})
762}

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