detached 27e453c

compare.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/http"
8
9	"github.com/a-h/templ"
10
11	"bnema.dev/ermite/internal/domain"
12	"bnema.dev/ermite/internal/ports"
13)
14
15// compare shows a branch against the default branch of the repository: the
16// commits it is ahead by and, for the owner, the form that opens a merge
17// request.
18//
19// The API has no "diff between two branches" call outside a merge request, so
20// the page shows the diff only when it is exact without one: the branch is
21// ahead by a single commit that sits on the tip of the target (that commit's
22// own diff is then the whole change). Otherwise it lists the commits, which
23// link to their own diffs, and says the whole diff comes with the merge
24// request.
25func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
26	d, err := s.loadRepo(ctx, r.PathValue("repo"))
27	if err != nil {
28		return nil, 0, err
29	}
30	src, rest, ok := resolveRef(d.branches, r.PathValue("rest"))
31	if !ok || rest != "" || d.head == nil {
32		return nil, 0, errNotFound
33	}
34	tgt := *d.head
35	name := d.name()
36
37	p := ComparePage{
38		Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName),
39		SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName),
40		CLI: Clone{ID: "cli-create", Lines: []string{
41			"ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title <title>",
42		}},
43	}
44
45	same := src.ShortName == tgt.ShortName
46	var ahead []domain.Commit
47	exact, behind := false, false
48	if same {
49		p.Note = "this is the default branch: there is nothing to compare it with."
50		p.AheadText = "0 commits"
51	} else {
52		var comparison ports.CompareResult
53		comparison, err = s.cfg.MergeRequests.Compare(ctx, principal(ctx), d.repo.Name, tgt.CommitID, src.CommitID)
54		ahead, exact, behind = comparison.Ahead, comparison.Exact, comparison.Behind
55		if err != nil {
56			return nil, 0, err
57		}
58		now := s.cfg.Now()
59		for _, c := range ahead {
60			p.Ahead = append(p.Ahead, commitRow(name, c, now))
61		}
62		p.AheadText = plural(len(ahead), "commit", "commits")
63		if !exact {
64			p.AheadText += " or more"
65		}
66		switch {
67		case !exact:
68			p.Note = "the branch left " + p.Target + " too long ago to tell where: only the newest commits are listed."
69		case len(ahead) == 0:
70			p.Note = "nothing to merge: " + p.Target + " already has every commit of " + p.Source + "."
71		case behind:
72			p.Note = p.Target + " has moved on since the branch started: a merge request can only be merged after " +
73				p.Source + " is rebased on " + p.Target + " and pushed again."
74		}
75	}
76
77	// The exact diff: one commit on top of the tip of the target.
78	if exact && !behind && len(ahead) == 1 {
79		res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String())
80		if err != nil {
81			return nil, 0, err
82		}
83		p.HasDiff = true
84		p.Files = s.hl.files(ctx, res.Diff.Files)
85		contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", res.Commit.ID.String()))
86		p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions
87		if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" {
88			p.Note = n
89		}
90		lines, invisible := diffLines(p.Files)
91		p.Invisible = invisible
92		self := repoHref(name, "compare", src.ShortName)
93		p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
94	} else if len(ahead) > 1 && exact {
95		if p.Note == "" {
96			p.Note = "the whole diff of the branch is on its merge request; each commit links to its own."
97		}
98	}
99
100	// An open request for the same pair, or the form to open one.
101	var existing *domain.MergeRequest
102	if !same {
103		existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName)
104		if err != nil {
105			return nil, 0, err
106		}
107	}
108	switch {
109	case existing != nil:
110		p.Existing = &Link{Label: "#" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
111	case SignedIn(ctx) && !same && len(ahead) > 0:
112		title := ""
113		if len(ahead) == 1 {
114			title = ahead[0].Summary
115		} else {
116			title = ahead[len(ahead)-1].Summary
117		}
118		p.Create = &CreateForm{
119			Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName,
120			Title: cutRunes(title, 120),
121		}
122	}
123
124	l := s.repoLayout(ctx, d, "merge-requests")
125	l.Title = "compare " + p.Source + " · " + name + " · " + s.instance
126	pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName})
127	l.Crumbs[0].Current = false
128	l.Crumbs = append(l.Crumbs,
129		Crumb{Label: "merge requests", Href: mrBase(name)},
130		Crumb{Label: "compare", Current: false},
131		Crumb{Label: p.Source, Current: true},
132	)
133	l.HasRail = true
134	return Compare(l, p), http.StatusOK, nil
135}
136
137// openRequestFor finds the open merge request with this source and target.
138func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) {
139	token := ""
140	for range maxOpenPages {
141		res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{
142			State: domain.MRStateOpen, PageSize: pageSize, PageToken: token,
143		})
144		if err != nil {
145			return nil, err
146		}
147		for i := range res.MergeRequests {
148			if m := &res.MergeRequests[i]; m.Source == source && m.Target == target {
149				return m, nil
150			}
151		}
152		if token = res.NextPageToken; token == "" {
153			break
154		}
155	}
156	return nil, nil
157}
158
159// cutRunes shortens s to at most n characters.
160func cutRunes(s string, n int) string {
161	r := []rune(s)
162	if len(r) <= n {
163		return s
164	}
165	return string(r[:n])
166}

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