v0.29.0 55fcc8f

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	"bnema.dev/ermite/internal/ports"
16)
17
18// bothViewsLines is the largest diff, in lines, that is sent in both the
19// unified and the split layout so that the toggle needs no round trip. A
20// larger one is sent in the layout asked for only, and the toggle is a link.
21const bothViewsLines = 4000
22
23// Person is an author or a committer with the date of the action.
24type Person struct {
25	Name, When, WhenAt, Abs string
26}
27
28// CommitPage is a commit with its diff.
29type CommitPage struct {
30	SHA, Short, Subject, Body string
31	Author                    Person
32	// Committer is nil when it is the author, at the same time.
33	Committer *Person
34	Parents   []Link
35	Merge     bool
36	Root      bool
37	Refs      []string
38	// NFiles, Adds and Dels are the totals of the commit, including the files
39	// that the diff omits.
40	NFiles, Adds, Dels int
41	Files              []DiffFile
42	// Note says the diff is incomplete.
43	Note string
44	DiffView
45	// Invisible is true when some line holds an invisible character, shown as a
46	// marker.
47	Invisible bool
48	// Checks is the combined state of the CI checks of the commit.
49	Checks ChecksSummary
50	// Sig is the signature badge, nil when the commit was not checked.
51	Sig *SignatureBadge
52	// Verification is what is known of the signing key, for the details.
53	Verification domain.CommitVerification
54}
55
56// DiffView is how a page lays its diff out: the layouts in the page and the
57// way to switch between them. The commit page and the merge request pages
58// share it.
59type DiffView struct {
60	// View is the layout shown: "unified" or "split". Pinned is true when the
61	// URL chose it, which then wins over the choice remembered by the browser.
62	View   string
63	Pinned bool
64	// Both is true when both layouts are in the page, and the toggle works in
65	// the browser; otherwise only View is sent.
66	Both                   bool
67	ShowUnified, ShowSplit bool
68	UnifiedHref, SplitHref string
69}
70
71// newDiffView reads ?view= and decides which layouts a diff of that many
72// lines sends; the links switch to the other layout.
73func newDiffView(r *http.Request, lines int, unifiedHref, splitHref string) DiffView {
74	v := DiffView{View: "unified", UnifiedHref: unifiedHref, SplitHref: splitHref}
75	switch r.URL.Query().Get("view") {
76	case "split":
77		v.View, v.Pinned = "split", true
78	case "unified":
79		v.Pinned = true
80	}
81	v.Both = lines <= bothViewsLines
82	v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
83	return v
84}
85
86// diffLines counts the lines of the hunks of files, and tells whether some
87// line holds a marker.
88func diffLines(files []DiffFile) (lines int, invisible bool) {
89	for _, f := range files {
90		for _, h := range f.Hunks {
91			lines += len(h.Lines)
92			invisible = invisible || slices.ContainsFunc(h.Lines, hasMarker)
93		}
94	}
95	return lines, invisible
96}
97
98// commit shows a commit: its message, its totals, the changed files and the
99// diff against the first parent. ?view=split selects the split layout.
100func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
101	sha := r.PathValue("sha")
102	if !isCommitID(sha) {
103		return nil, 0, errNotFound
104	}
105	d, err := s.loadRepo(ctx, r.PathValue("repo"))
106	if err != nil {
107		return nil, 0, err
108	}
109	res, err := s.commitDiff(ctx, r, d.repo.Name, sha)
110	if err != nil {
111		return nil, 0, err
112	}
113	c := res.Commit
114	cid := c.ID.String()
115	now := s.cfg.Now()
116
117	p := CommitPage{
118		SHA: cid, Short: short(cid),
119		Subject: visibleText(c.Summary), Body: commitBody(c),
120		Author: person(c.Author, now),
121		Merge:  len(c.Parents) > 1,
122		Root:   len(c.Parents) == 0,
123		NFiles: res.Stats.FilesChanged,
124		Adds:   res.Stats.Additions,
125		Dels:   res.Stats.Deletions,
126		Files:  s.hl.files(ctx, res.Diff.Files),
127
128		Sig:          signatureBadge(c.Verification, true),
129		Verification: c.Verification,
130	}
131	contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
132	collapseFiles(p.Files, res.Diff.Files, commitLinks(d.name(), cid))
133	if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
134		p.Committer = &cm
135	}
136	for _, pid := range c.Parents {
137		id := pid.String()
138		p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
139	}
140	for _, b := range d.branches {
141		if b.CommitID.String() == cid {
142			p.Refs = append(p.Refs, b.ShortName)
143		}
144	}
145	for _, t := range d.tags {
146		if t.CommitID.String() == cid {
147			p.Refs = append(p.Refs, "tag: "+t.ShortName)
148		}
149	}
150	p.Note = diffNote(res.Diff)
151	p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
152
153	lines, invisible := diffLines(p.Files)
154	p.Invisible = invisible
155	base := repoHref(d.name(), "commit", cid)
156	p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
157
158	l := s.repoLayout(ctx, d, "commits")
159	l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
160	pinRef(&l, target{rev: cid, name: p.Short, detached: true})
161	history := repoHref(d.name())
162	if d.head != nil {
163		history = repoHref(d.name(), "commits", d.head.ShortName)
164	}
165	l.Crumbs = []Crumb{
166		{Label: d.name(), Href: repoHref(d.name())},
167		{Label: "commit", Href: history},
168		{Label: p.Short, Current: true},
169	}
170	l.HasRail = true
171	l.Lazy = true
172	return Commit(l, p), http.StatusOK, nil
173}
174
175// person converts a signature.
176func person(s domain.Signature, now time.Time) Person {
177	p := Person{Name: visibleText(s.Name)}
178	p.When, p.WhenAt = whenOf(now, s.Time)
179	if !s.Time.IsZero() {
180		p.Abs = absolute(s.Time)
181	}
182	return p
183}
184
185// commitBody is the message without its first line, for display.
186func commitBody(c domain.Commit) string {
187	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
188	_, body, _ := strings.Cut(msg, "\n")
189	return messageText(strings.TrimSpace(body))
190}
191
192// diffNote says what is missing from an incomplete diff, "" for a complete one:
193// files too large for the browser at all. Collapsed files say so on their own
194// line and load on their own; every changed file is listed.
195func diffNote(d domain.Diff) string {
196	gone := 0
197	for _, f := range d.Files {
198		if f.Omitted == domain.OmitUnavailable {
199			gone++
200		}
201	}
202	if gone == 0 {
203		return ""
204	}
205	return "this diff is incomplete: " + plural(gone, "file is", "files are") + " too large to show. the totals above count everything; clone the repository to read the rest."
206}
207
208// commitDiff reads a commit for its page (and the compare page): every file
209// listed, the hunks of the first files read, generated files and files past
210// the window collapsed (ports.GetCommitOptions.Page). With ?file=i (the link
211// of a page without scripts) file i is read in full and replaces its entry.
212func (s *Server) commitDiff(ctx context.Context, r *http.Request, repo domain.RepoName, rev string) (domain.CommitDetail, error) {
213	return s.getCommit(ctx, repo, rev, ports.GetCommitOptions{Page: true, Load: fileParam(r)})
214}
215
216// commitLinks are the addresses a commit page loads its collapsed files from.
217func commitLinks(repo, rev string) fileLinks {
218	return fileLinks{src: repoHref(repo, "commit", rev, "files"), repo: repo, rev: rev}
219}
220
221// commitFiles is GET /<repo>/commit/<sha>/files?one=1&from=i[&view=&layouts=]:
222// the one file i of the commit with its hunks, in full, as the one
223// details.dfile the page swaps for its placeholder.
224func (s *Server) commitFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) {
225	sha := r.PathValue("sha")
226	i, err := parseOne(r)
227	if err != nil || !isCommitID(sha) {
228		return nil, 0, errNotFound
229	}
230	d, err := s.loadRepo(ctx, r.PathValue("repo"))
231	if err != nil {
232		return nil, 0, err
233	}
234	res, err := s.getCommit(ctx, d.repo.Name, sha, ports.GetCommitOptions{Files: &ports.FileWindow{From: i, To: i + 1, Full: true}})
235	if err != nil {
236		return nil, 0, err
237	}
238	if i >= len(res.Diff.Files) {
239		return nil, 0, errNotFound
240	}
241	cid := res.Commit.ID.String()
242	raw := res.Diff.Files[i : i+1]
243	files := s.hl.files(ctx, raw)
244	files[0].ID = "f" + itoa(i+1)
245	contextGaps(files, raw, repoHref(d.name(), "context", cid))
246	files[0].Raw = rawLink(raw[0], commitLinks(d.name(), cid))
247	lines, _ := diffLines(files)
248	return MRFiles(MRFilesPage{Files: files, DiffView: fragmentView(r, lines)}), http.StatusOK, nil
249}

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