v0.10.1 e4c85c7

commit.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/http"
8	"slices"
9	"strings"
10	"time"
11
12	"github.com/a-h/templ"
13
14	"bnema.dev/ermite/internal/domain"
15)
16
17// bothViewsLines is the largest diff, in lines, that is sent in both the
18// unified and the split layout so that the toggle needs no round trip. A
19// larger one is sent in the layout asked for only, and the toggle is a link.
20const bothViewsLines = 4000
21
22// Person is an author or a committer with the date of the action.
23type Person struct {
24	Name, When, WhenAt, Abs string
25}
26
27// CommitPage is a commit with its diff.
28type CommitPage struct {
29	SHA, Short, Subject, Body string
30	Author                    Person
31	// Committer is nil when it is the author, at the same time.
32	Committer *Person
33	Parents   []Link
34	Merge     bool
35	Root      bool
36	Refs      []string
37	// NFiles, Adds and Dels are the totals of the commit, including the files
38	// that the diff omits.
39	NFiles, Adds, Dels int
40	Files              []DiffFile
41	// Note says the diff is incomplete.
42	Note string
43	DiffView
44	// Invisible is true when some line holds an invisible character, shown as a
45	// marker.
46	Invisible bool
47	// Checks is the combined state of the CI checks of the commit.
48	Checks ChecksSummary
49}
50
51// DiffView is how a page lays its diff out: the layouts in the page and the
52// way to switch between them. The commit page and the merge request pages
53// share it.
54type DiffView struct {
55	// View is the layout shown: "unified" or "split". Pinned is true when the
56	// URL chose it, which then wins over the choice remembered by the browser.
57	View   string
58	Pinned bool
59	// Both is true when both layouts are in the page, and the toggle works in
60	// the browser; otherwise only View is sent.
61	Both                   bool
62	ShowUnified, ShowSplit bool
63	UnifiedHref, SplitHref string
64}
65
66// newDiffView reads ?view= and decides which layouts a diff of that many
67// lines sends; the links switch to the other layout.
68func newDiffView(r *http.Request, lines int, unifiedHref, splitHref string) DiffView {
69	v := DiffView{View: "unified", UnifiedHref: unifiedHref, SplitHref: splitHref}
70	switch r.URL.Query().Get("view") {
71	case "split":
72		v.View, v.Pinned = "split", true
73	case "unified":
74		v.Pinned = true
75	}
76	v.Both = lines <= bothViewsLines
77	v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
78	return v
79}
80
81// diffLines counts the lines of the hunks of files, and tells whether some
82// line holds a marker.
83func diffLines(files []DiffFile) (lines int, invisible bool) {
84	for _, f := range files {
85		for _, h := range f.Hunks {
86			lines += len(h.Lines)
87			invisible = invisible || slices.ContainsFunc(h.Lines, hasMarker)
88		}
89	}
90	return lines, invisible
91}
92
93// commit shows a commit: its message, its totals, the changed files and the
94// diff against the first parent. ?view=split selects the split layout.
95//
96//hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here
97func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
98	sha := r.PathValue("sha")
99	if !isCommitID(sha) {
100		return nil, 0, errNotFound
101	}
102	d, err := s.loadRepo(ctx, r.PathValue("repo"))
103	if err != nil {
104		return nil, 0, err
105	}
106	res, err := s.getCommit(ctx, d.repo.Name, sha)
107	if err != nil {
108		return nil, 0, err
109	}
110	c := res.Commit
111	cid := c.ID.String()
112	now := s.cfg.Now()
113
114	p := CommitPage{
115		SHA: cid, Short: short(cid),
116		Subject: visibleText(c.Summary), Body: commitBody(c),
117		Author: person(c.Author, now),
118		Merge:  len(c.Parents) > 1,
119		Root:   len(c.Parents) == 0,
120		NFiles: res.Stats.FilesChanged,
121		Adds:   res.Stats.Additions,
122		Dels:   res.Stats.Deletions,
123		Files:  diffFiles(res.Diff.Files),
124	}
125	if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
126		p.Committer = &cm
127	}
128	for _, pid := range c.Parents {
129		id := pid.String()
130		p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
131	}
132	for _, b := range d.branches {
133		if b.CommitID.String() == cid {
134			p.Refs = append(p.Refs, b.ShortName)
135		}
136	}
137	for _, t := range d.tags {
138		if t.CommitID.String() == cid {
139			p.Refs = append(p.Refs, "tag: "+t.ShortName)
140		}
141	}
142	p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
143	p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
144
145	lines, invisible := diffLines(p.Files)
146	p.Invisible = invisible
147	base := repoHref(d.name(), "commit", cid)
148	p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
149
150	l := s.repoLayout(ctx, d, "commits")
151	l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
152	pinRef(&l, target{rev: cid, name: p.Short, detached: true})
153	history := repoHref(d.name())
154	if d.head != nil {
155		history = repoHref(d.name(), "commits", d.head.ShortName)
156	}
157	l.Crumbs = []Crumb{
158		{Label: d.name(), Href: repoHref(d.name())},
159		{Label: "commit", Href: history},
160		{Label: p.Short, Current: true},
161	}
162	l.HasRail = true
163	return Commit(l, p), http.StatusOK, nil
164}
165
166// person converts a signature.
167func person(s domain.Signature, now time.Time) Person {
168	p := Person{Name: visibleText(s.Name)}
169	p.When, p.WhenAt = whenOf(now, s.Time)
170	if !s.Time.IsZero() {
171		p.Abs = absolute(s.Time)
172	}
173	return p
174}
175
176// commitBody is the message without its first line, for display.
177func commitBody(c domain.Commit) string {
178	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
179	_, body, _ := strings.Cut(msg, "\n")
180	return messageText(strings.TrimSpace(body))
181}
182
183// diffNote says what is missing from an incomplete diff, "" for a complete one.
184func diffNote(d domain.Diff, shown, total int) string {
185	if !d.Truncated {
186		return ""
187	}
188	var parts []string
189	if shown < total {
190		parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
191	}
192	cut := 0
193	for _, f := range d.Files {
194		if f.Truncated {
195			cut++
196		}
197	}
198	if cut > 0 {
199		parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
200	}
201	return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
202}

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