v0.26.0 df1db36

mr.go RawHistoryFolder

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

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